diff --git a/t/Hydra/Controller/User/ldap.t b/t/Hydra/Controller/User/ldap.t new file mode 100644 index 00000000..64da6112 --- /dev/null +++ b/t/Hydra/Controller/User/ldap.t @@ -0,0 +1,105 @@ +use strict; +use warnings; +use Setup; +use LDAPContext; +use Test2::V0; +use Catalyst::Test (); +use HTTP::Request::Common; +use JSON::MaybeXS; + +my $ldap = LDAPContext->new(); +my $users = { + unrelated => $ldap->add_user("unrelated_user"), + admin => $ldap->add_user("admin_user"), + not_admin => $ldap->add_user("not_admin_user"), + many_roles => $ldap->add_user("many_roles"), +}; + +$ldap->add_group("hydra_admin", $users->{"admin"}->{"username"}); +$ldap->add_group("hydra-admin", $users->{"not_admin"}->{"username"}); + +$ldap->add_group("hydra_create-projects", $users->{"many_roles"}->{"username"}); +$ldap->add_group("hydra_restart-jobs", $users->{"many_roles"}->{"username"}); +$ldap->add_group("hydra_bump-to-front", $users->{"many_roles"}->{"username"}); +$ldap->add_group("hydra_cancel-build", $users->{"many_roles"}->{"username"}); + +my $hydra_ldap_config = "${\$ldap->tmpdir()}/hydra_ldap_config.yaml"; +LDAPContext::write_file($hydra_ldap_config, <server_url()}" + ldap_server_options: + timeout: 30 + debug: 0 + binddn: "cn=root,dc=example" + bindpw: notapassword + start_tls: 0 + start_tls_options: + verify: none + user_basedn: "ou=users,dc=example" + user_filter: "(&(objectClass=inetOrgPerson)(cn=%s))" + user_scope: one + user_field: cn + user_search_options: + deref: always + use_roles: 1 + role_basedn: "ou=groups,dc=example" + role_filter: "(&(objectClass=groupOfNames)(member=%s))" + role_scope: one + role_field: cn + role_value: dn + role_search_options: + deref: always +YAML + +$ENV{'HYDRA_LDAP_CONFIG'} = $hydra_ldap_config; +my $ctx = test_context(); + +Catalyst::Test->import('Hydra'); + +subtest "Valid login attempts" => sub { + my %users_to_roles = ( + unrelated => [], + admin => ["admin"], + not_admin => [], + many_roles => [ "create-projects", "restart-jobs", "bump-to-front", "cancel-build" ], + ); + for my $username (keys %users_to_roles) { + my $user = $users->{$username}; + my $roles = $users_to_roles{$username}; + + subtest "Verifying $username" => sub { + my $req = request(POST '/login', + Referer => 'http://localhost/', + Accept => 'application/json', + Content => { + username => $user->{"username"}, + password => $user->{"password"} + } + ); + + is($req->code, 302, "The login redirects"); + my $data = decode_json($req->content()); + is($data->{"username"}, $user->{"username"}, "Username matches"); + is($data->{"emailaddress"}, $user->{"email"}, "Email matches"); + is([sort @{$data->{"userroles"}}], [sort @$roles], "Roles match"); + }; + } +}; + +# Logging in with an invalid user is rejected +is(request(POST '/login', + Referer => 'http://localhost/', + Content => { + username => 'alice', + password => 'foobar' + } +)->code, 403, "Logging in with invalid credentials does not work"); + + + +done_testing; diff --git a/t/lib/LDAPContext.pm b/t/lib/LDAPContext.pm new file mode 100644 index 00000000..87a71854 --- /dev/null +++ b/t/lib/LDAPContext.pm @@ -0,0 +1,234 @@ +use strict; +use warnings; + +package LDAPContext; +use POSIX qw(SIGKILL); +use File::Path qw(make_path); +use WWW::Form::UrlEncoded::PP qw(); +use Hydra::Helper::Exec; + +# Set up an LDAP server to run during the test. +# +# It creates a top level organization and structure, and provides +# add_user and add_group. +# +# The server is automatically terminated when the class is dropped. +sub new { + my ($class) = @_; + + my $root = File::Temp->newdir(); + mkdir $root; + + my $pid_file = "$root/slapd.pid"; + + my $slapd_dir = "$root/slap.d"; + mkdir $slapd_dir; + + my $db_dir = "$root/db"; + mkdir $db_dir; + + my $socket = "$root/slapd.socket"; + + my $self = { + _tmpdir => $root, + _db_dir => $db_dir, + _openldap_source => $ENV{"OPENLDAP_ROOT"}, + _pid_file => $pid_file, + _slapd_dir => $slapd_dir, + _socket => $socket, + }; + + my $blessed = bless $self, $class; + $blessed->start(); + + return $blessed; +} + +# Create a user with a specific email address and password +# +# Hash Parameters: +# +# * password: The clear text password, will be hashed when stored in the DB. +# * email: The user's email address. Defaults to $name@example.net +# +# Return: a hash of parameters for the user +# +# * username: The user's provided $name +# * password: The plaintext password, generated if not provided in the arguments +# * hashed_password: The hashed password +# * email: Their email address +sub add_user { + my ($self, $name, %opts) = @_; + + my $email = $opts{'email'} // "$name\@example"; + my $password = $opts{'password'} // rand_chars(); + + my ($res, $stdout, $stderr) = captureStdoutStderr(1, ("slappasswd", "-s", $password)); + if ($res) { + die "Failed to execute slappasswd ($res): $stderr, $stdout"; + } + my $hashedPassword = $stdout; + $hashedPassword =~ s/^\s+|\s+$//g; # Trim whitespace + + $self->load_ldif("dc=example", < $name, + email => $email, + password => $password, + hashed_password => $hashedPassword, + }; +} + +# Create a group with a specific name and members +sub add_group { + my ($self, $name, @users) = @_; + + my $members = join "\n", (map "member: cn=$_,ou=users,dc=example", @users); + + $self->load_ldif("dc=example", <{"_pid_file"}} + +dn: cn=schema,cn=config +cn: schema +objectClass: olcSchemaConfig + +include: file://${\$self->{"_openldap_source"}}/etc/schema/core.ldif +include: file://${\$self->{"_openldap_source"}}/etc/schema/cosine.ldif +include: file://${\$self->{"_openldap_source"}}/etc/schema/inetorgperson.ldif +include: file://${\$self->{"_openldap_source"}}/etc/schema/nis.ldif + +dn: olcDatabase={1}mdb,cn=config +objectClass: olcDatabaseConfig +objectClass: olcMdbConfig +olcDatabase: {1}mdb +olcDbDirectory: ${\$self->{"_db_dir"}} +olcRootDN: cn=root,dc=example +olcRootPW: notapassword +olcSuffix: dc=example +EOF +} + +sub _makeBootstrapOrganization { + my ($self) = @_; + # This has been copied from the generated config used by the + # ldap test in the flake.nix. + return <load_ldif("cn=config", $self->_makeBootstrapConfig()); + $self->load_ldif("dc=example", $self->_makeBootstrapOrganization()); + + $self->_spawn() +} + +sub validateConfig { + my ($self) = @_; + + expectOkay(1, ("slaptest", "-u", "-F", $self->{"_slapd_dir"})); +} + +sub _spawn { + my ($self) = @_; + + my $pid = fork; + die "When starting the LDAP server:failed to fork." if not defined $pid; + + if ($pid == 0) { + exec("${\$self->{'_openldap_source'}}/libexec/slapd", + # "-d", "conns", "-d", "filter", "-d", "config", + "-F", $self->{"_slapd_dir"}, "-h", $self->server_url()) or die "Could not start slapd"; + } else { + $self->{"_pid"} = $pid; + } +} + +sub server_url { + my ($self) = @_; + + my $encoded_socket_path = WWW::Form::UrlEncoded::PP::url_encode($self->{"_socket"}); + + return "ldapi://$encoded_socket_path"; +} + +sub tmpdir { + my ($self) = @_; + + return $self->{"_tmpdir"}; +} + +sub load_ldif { + my ($self, $suffix, $content) = @_; + + my $path = "${\$self->{'_tmpdir'}}/load.ldif"; + write_file($path, $content); + expectOkay(1, ("slapadd", "-F", $self->{"_slapd_dir"}, "-b", $suffix, "-l", $path)); + $self->validateConfig(); +} + +sub DESTROY +{ + my ($self) = @_; + if ($self->{"_pid"}) { + kill SIGKILL, $self->{"_pid"}; + } +} + +sub write_file { + my ($path, $text) = @_; + open(my $fh, '>', $path) or die "Could not open file '$path' $!"; + print $fh $text || ""; + close $fh; +} + +sub rand_chars { + return sprintf("t%08X", rand(0xFFFFFFFF)); +} + +1;