All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 26s
429 lines
13 KiB
Go
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")
|
|
}
|
|
}
|