package lldap_test import ( "context" "errors" "testing" "github.com/go-ldap/ldap/v3" "keycape/internal/adapters/lldap" "keycape/internal/domain" ) // --------------------------------------------------------------------------- // Mock LDAP connection // --------------------------------------------------------------------------- // mockConn implements lldap.LDAPConn for test injection. type mockConn struct { bindFn func(username, password string) error searchFn func(req *ldap.SearchRequest) (*ldap.SearchResult, error) closed bool } func (m *mockConn) Bind(username, password string) error { if m.bindFn != nil { return m.bindFn(username, password) } return nil } func (m *mockConn) Search(req *ldap.SearchRequest) (*ldap.SearchResult, error) { if m.searchFn != nil { return m.searchFn(req) } return &ldap.SearchResult{}, nil } func (m *mockConn) Close() error { m.closed = true return nil } // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- // testConfig returns a minimal Config suitable for tests. func testConfig() lldap.Config { return lldap.Config{ URL: "ldap://lldap:389", BindDN: "cn=admin,dc=netkingdom,dc=local", BindPW: "secret", BaseDN: "dc=netkingdom,dc=local", } } // singleEntryResult builds a SearchResult with one entry for LookupUser tests. func singleEntryResult(dn, uid, cn, sn, mail string, memberOfs []string) *ldap.SearchResult { attrs := []*ldap.EntryAttribute{ {Name: "uid", Values: []string{uid}}, {Name: "cn", Values: []string{cn}}, {Name: "sn", Values: []string{sn}}, {Name: "mail", Values: []string{mail}}, } if len(memberOfs) > 0 { attrs = append(attrs, &ldap.EntryAttribute{Name: "memberOf", Values: memberOfs}) } return &ldap.SearchResult{ Entries: []*ldap.Entry{ {DN: dn, Attributes: attrs}, }, } } // makeAdapter returns an LDAPAdapter using the exported NewForTest constructor. // We use the package-level helper exported for testing. func makeAdapter(cfg lldap.Config, conn lldap.LDAPConn) *lldap.LDAPAdapter { return lldap.NewForTest(cfg, func(_ string) (lldap.LDAPConn, error) { return conn, nil }) } // --------------------------------------------------------------------------- // LookupUser // --------------------------------------------------------------------------- func TestLookupUser_Success(t *testing.T) { dn := "uid=alice,ou=users,dc=netkingdom,dc=local" conn := &mockConn{ searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) { return singleEntryResult( dn, "alice", "Alice Liddell", "Liddell", "alice@example.com", []string{"cn=admins,ou=groups,dc=netkingdom,dc=local"}, ), nil }, } adapter := makeAdapter(testConfig(), conn) user, err := adapter.LookupUser(context.Background(), "alice") if err != nil { t.Fatalf("unexpected error: %v", err) } if user.Username != "alice" { t.Errorf("Username: want %q, got %q", "alice", user.Username) } if user.DisplayName != "Alice Liddell" { t.Errorf("DisplayName: want %q, got %q", "Alice Liddell", user.DisplayName) } if user.Email != "alice@example.com" { t.Errorf("Email: want %q, got %q", "alice@example.com", user.Email) } if user.ID != dn { t.Errorf("ID: want %q, got %q", dn, user.ID) } if len(user.Groups) != 1 || user.Groups[0] != "admins" { t.Errorf("Groups: want [admins], got %v", user.Groups) } } func TestLookupUser_MapsExplicitTenantAdminGroup(t *testing.T) { conn := &mockConn{ searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) { return singleEntryResult( "uid=bernd,ou=people,dc=test,dc=local", "bernd", "Bernd", "Worsch", "bernd.worsch@binky-hedgehog.com", []string{ "cn=tenant:friendly:binky:users,ou=groups,dc=test,dc=local", "cn=tenant:friendly:binky:admins,ou=groups,dc=test,dc=local", }, ), nil }, } adapter := makeAdapter(testConfig(), conn) user, err := adapter.LookupUser(context.Background(), "bernd") if err != nil { t.Fatal(err) } if user.Tenant != "tenant:friendly:binky" { t.Fatalf("tenant = %q", user.Tenant) } if len(user.Roles) != 2 || user.Roles[1] != "tenant-admin" { t.Fatalf("roles = %v", user.Roles) } } func TestLookupUser_IgnoresAmbiguousTenantGroups(t *testing.T) { conn := &mockConn{ searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) { return singleEntryResult( "uid=multi,ou=people,dc=test,dc=local", "multi", "Multi", "Tenant", "multi@example.test", []string{ "cn=tenant:friendly:binky:users,ou=groups,dc=test,dc=local", "cn=tenant:coulomb:users,ou=groups,dc=test,dc=local", }, ), nil }, } user, err := makeAdapter(testConfig(), conn).LookupUser(context.Background(), "multi") if err != nil { t.Fatal(err) } if user.Tenant != "" || len(user.Roles) != 0 { t.Fatalf("ambiguous envelope must be empty, got tenant=%q roles=%v", user.Tenant, user.Roles) } } func TestLookupUser_MapsEstablishedPlatformAdminGroup(t *testing.T) { conn := &mockConn{ searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) { return singleEntryResult( "uid=platform-root,ou=people,dc=test,dc=local", "platform-root", "Platform", "Root", "root@example.test", []string{ "cn=net-kingdom-admins,ou=groups,dc=test,dc=local", "cn=net-kingdom-users,ou=groups,dc=test,dc=local", }, ), nil }, } user, err := makeAdapter(testConfig(), conn).LookupUser(context.Background(), "platform-root") if err != nil { t.Fatal(err) } if user.Tenant != "tenant:platform" { t.Fatalf("tenant = %q", user.Tenant) } if len(user.Roles) != 2 || user.Roles[1] != "platform-operator" { t.Fatalf("roles = %v", user.Roles) } } func TestLookupUser_DisplayName_FallsBackToSN(t *testing.T) { dn := "uid=bob,ou=users,dc=netkingdom,dc=local" conn := &mockConn{ searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) { return singleEntryResult(dn, "bob", "", "Builder", "bob@example.com", nil), nil }, } adapter := makeAdapter(testConfig(), conn) user, err := adapter.LookupUser(context.Background(), "bob") if err != nil { t.Fatalf("unexpected error: %v", err) } if user.DisplayName != "Builder" { t.Errorf("DisplayName fallback: want %q, got %q", "Builder", user.DisplayName) } } func TestLookupUser_NotFound(t *testing.T) { conn := &mockConn{ searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) { return &ldap.SearchResult{}, nil // zero entries }, } adapter := makeAdapter(testConfig(), conn) _, err := adapter.LookupUser(context.Background(), "ghost") if err == nil { t.Fatal("expected error, got nil") } if !errors.Is(err, domain.ErrUserNotFound) { t.Errorf("expected domain.ErrUserNotFound, got %v", err) } } func TestLookupUser_ValidationWarningDoesNotBlockRuntimeLogin(t *testing.T) { // Return an entry with an empty DisplayName and empty sn. Runtime login // should still resolve the user; provisioning validators report the warning. dn := "uid=platform-root,ou=people,dc=netkingdom,dc=local" conn := &mockConn{ searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) { if req.BaseDN != "ou=people,dc=netkingdom,dc=local" { t.Fatalf("BaseDN: want ou=people,dc=netkingdom,dc=local, got %q", req.BaseDN) } attrs := []*ldap.EntryAttribute{ {Name: "uid", Values: []string{"platform-root"}}, {Name: "cn", Values: []string{""}}, {Name: "sn", Values: []string{""}}, {Name: "mail", Values: []string{"bernd.worsch@gmail.com"}}, } return &ldap.SearchResult{ Entries: []*ldap.Entry{{DN: dn, Attributes: attrs}}, }, nil }, } cfg := testConfig() cfg.UserOU = "ou=people" adapter := makeAdapter(cfg, conn) user, err := adapter.LookupUser(context.Background(), "platform-root") if err != nil { t.Fatalf("unexpected error: %v", err) } if user.ID != dn { t.Errorf("ID: want %q, got %q", dn, user.ID) } if user.Username != "platform-root" { t.Errorf("Username: want platform-root, got %q", user.Username) } if user.LDAPAttributes["_validation_warning"] == "" { t.Error("expected validation warning for missing displayName") } } // --------------------------------------------------------------------------- // LookupGroups // --------------------------------------------------------------------------- func TestLookupGroups_Success(t *testing.T) { userDN := "uid=alice,ou=users,dc=netkingdom,dc=local" conn := &mockConn{ searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) { return &ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: "cn=admins,ou=groups,dc=netkingdom,dc=local", Attributes: []*ldap.EntryAttribute{ {Name: "cn", Values: []string{"admins"}}, {Name: "description", Values: []string{"Admins group"}}, }, }, }, }, nil }, } adapter := makeAdapter(testConfig(), conn) groups, err := adapter.LookupGroups(context.Background(), userDN) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(groups) != 1 { t.Fatalf("want 1 group, got %d", len(groups)) } if groups[0].Name != "admins" { t.Errorf("Group name: want %q, got %q", "admins", groups[0].Name) } } func TestLookupGroups_Empty(t *testing.T) { conn := &mockConn{ searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) { return &ldap.SearchResult{}, nil }, } adapter := makeAdapter(testConfig(), conn) groups, err := adapter.LookupGroups(context.Background(), "uid=nobody,ou=users,dc=test,dc=local") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(groups) != 0 { t.Errorf("expected 0 groups, got %d", len(groups)) } } // --------------------------------------------------------------------------- // ValidatePassword // --------------------------------------------------------------------------- func TestValidatePassword_Success(t *testing.T) { userDN := "uid=alice,ou=users,dc=netkingdom,dc=local" callCount := 0 conn := &mockConn{ searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) { attrs := []*ldap.EntryAttribute{{Name: "dn", Values: []string{userDN}}} return &ldap.SearchResult{ Entries: []*ldap.Entry{{DN: userDN, Attributes: attrs}}, }, nil }, bindFn: func(username, password string) error { callCount++ // First call: service bind (BindDN); second call: user bind. return nil }, } // Provide two connections: one for the DN lookup and one for the user bind. connIdx := 0 conns := []*mockConn{conn, {bindFn: func(u, p string) error { return nil }}} adapter := lldap.NewForTest(testConfig(), func(_ string) (lldap.LDAPConn, error) { c := conns[connIdx] connIdx++ return c, nil }) ok, err := adapter.ValidatePassword(context.Background(), "alice", "correct") if err != nil { t.Fatalf("unexpected error: %v", err) } if !ok { t.Error("expected ValidatePassword to return true") } } func TestValidatePassword_WrongPassword(t *testing.T) { userDN := "uid=alice,ou=users,dc=netkingdom,dc=local" searchConn := &mockConn{ searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) { attrs := []*ldap.EntryAttribute{{Name: "dn", Values: []string{userDN}}} return &ldap.SearchResult{ Entries: []*ldap.Entry{{DN: userDN, Attributes: attrs}}, }, nil }, } userConn := &mockConn{ bindFn: func(username, password string) error { return ldap.NewError(ldap.LDAPResultInvalidCredentials, errors.New("invalid credentials")) }, } connIdx := 0 conns := []lldap.LDAPConn{searchConn, userConn} adapter := lldap.NewForTest(testConfig(), func(_ string) (lldap.LDAPConn, error) { c := conns[connIdx] connIdx++ return c, nil }) ok, err := adapter.ValidatePassword(context.Background(), "alice", "wrong") if err != nil { t.Fatalf("unexpected error: %v", err) } if ok { t.Error("expected ValidatePassword to return false for wrong password") } } func TestValidatePassword_BindFailure(t *testing.T) { // Service bind fails — infrastructure error. conn := &mockConn{ bindFn: func(username, password string) error { return errors.New("connection refused") }, } adapter := lldap.NewForTest(testConfig(), func(_ string) (lldap.LDAPConn, error) { return conn, nil }) ok, err := adapter.ValidatePassword(context.Background(), "alice", "pass") if err == nil { t.Fatal("expected infrastructure error, got nil") } if ok { t.Error("expected false on bind failure") } } func TestValidatePassword_UserNotFound(t *testing.T) { conn := &mockConn{ searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) { return &ldap.SearchResult{}, nil // no entries }, } adapter := makeAdapter(testConfig(), conn) ok, err := adapter.ValidatePassword(context.Background(), "ghost", "pass") if err != nil { t.Fatalf("unexpected error: %v", err) } if ok { t.Error("expected false for non-existent user") } }