key-cape/src/internal/adapters/lldap/adapter_test.go
tegwick 90a20783e5
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 26s
Map platform-root group to platform operator
2026-07-29 21:30:56 +02:00

429 lines
13 KiB
Go

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")
}
}