feat: implement T14, T10 — enforcement middleware, LLDAP adapter
- T14: Unsupported feature registry with 7 pre-registered profile boundaries - T10: LLDAP adapter implementing UserRepository; validator-gated reads 24 tests pass, go vet clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
22f7a7dc50
commit
b0adbc5daa
8 changed files with 1262 additions and 3 deletions
341
src/internal/adapters/lldap/adapter_test.go
Normal file
341
src/internal/adapters/lldap/adapter_test.go
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
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_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_ValidationFailure(t *testing.T) {
|
||||
// Return an entry with an empty DisplayName and empty sn — will fail validator.
|
||||
dn := "uid=broken,ou=users,dc=netkingdom,dc=local"
|
||||
conn := &mockConn{
|
||||
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
attrs := []*ldap.EntryAttribute{
|
||||
{Name: "uid", Values: []string{"broken"}},
|
||||
{Name: "cn", Values: []string{""}},
|
||||
{Name: "sn", Values: []string{""}},
|
||||
{Name: "mail", Values: []string{"broken@example.com"}},
|
||||
}
|
||||
return &ldap.SearchResult{
|
||||
Entries: []*ldap.Entry{{DN: dn, Attributes: attrs}},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := makeAdapter(testConfig(), conn)
|
||||
_, err := adapter.LookupUser(context.Background(), "broken")
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue