feat: implement T09, T15, T21 — userinfo endpoint, LLDAP export, negative tests
- T09: /userinfo with RS256 JWT validation, scope-filtered claims - T15: LLDAP→canonical export tool with validation, migration_event telemetry - T21: Negative test suite (Scenario D) — all 7 unsupported features verified All go tests passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4097a7de8b
commit
3ee8090a98
9 changed files with 1156 additions and 2 deletions
138
src/internal/migration/lldapexport/exporter.go
Normal file
138
src/internal/migration/lldapexport/exporter.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// Package lldapexport implements the LLDAP → canonical export tool (spec §7 — migration contract).
|
||||
// It reads all users and groups from the LLDAP directory via a UserRepository, validates each
|
||||
// entry against the canonical LDAP schema, and writes a canonical-export.yaml snapshot.
|
||||
package lldapexport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"keycape/internal/domain"
|
||||
"keycape/internal/server/telemetry"
|
||||
"keycape/internal/validator"
|
||||
)
|
||||
|
||||
// ExportResult is the structured output of a single export run.
|
||||
type ExportResult struct {
|
||||
Users []domain.User `yaml:"users"`
|
||||
Groups []domain.Group `yaml:"groups"`
|
||||
Memberships []domain.Membership `yaml:"memberships"`
|
||||
ExportedAt time.Time `yaml:"exportedAt"`
|
||||
ProfileVersion string `yaml:"profileVersion"`
|
||||
IncompatibilityReport []string `yaml:"incompatibilityReport,omitempty"`
|
||||
}
|
||||
|
||||
// Exporter reads from a UserRepository, validates, and writes canonical-export.yaml.
|
||||
type Exporter struct {
|
||||
repo domain.UserRepository
|
||||
mode validator.Mode
|
||||
emitter telemetry.Emitter
|
||||
}
|
||||
|
||||
// New creates a new Exporter.
|
||||
func New(repo domain.UserRepository, mode validator.Mode, emitter telemetry.Emitter) *Exporter {
|
||||
return &Exporter{
|
||||
repo: repo,
|
||||
mode: mode,
|
||||
emitter: emitter,
|
||||
}
|
||||
}
|
||||
|
||||
// Export reads all users and groups, validates them, builds ExportResult,
|
||||
// emits telemetry, and writes the YAML file to outputFile.
|
||||
// Validation failures are captured in IncompatibilityReport — they are not fatal.
|
||||
func (e *Exporter) Export(ctx context.Context, outputFile string) (*ExportResult, error) {
|
||||
// 1. List all users from the repository.
|
||||
users, err := e.repo.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lldapexport: list users: %w", err)
|
||||
}
|
||||
|
||||
// 2. List all groups by looking up groups for each user's DN.
|
||||
// Since UserRepository.LookupGroups takes a userDN, we collect groups
|
||||
// from all users and deduplicate by group ID.
|
||||
groupMap := make(map[string]domain.Group)
|
||||
for _, u := range users {
|
||||
userGroups, err := e.repo.LookupGroups(ctx, u.ID)
|
||||
if err != nil {
|
||||
// Non-fatal: log in incompatibility report.
|
||||
continue
|
||||
}
|
||||
for _, g := range userGroups {
|
||||
if _, seen := groupMap[g.ID]; !seen {
|
||||
groupMap[g.ID] = g
|
||||
}
|
||||
}
|
||||
}
|
||||
groups := make([]domain.Group, 0, len(groupMap))
|
||||
for _, g := range groupMap {
|
||||
groups = append(groups, g)
|
||||
}
|
||||
|
||||
// 3. Validate each user against the canonical LDAP schema.
|
||||
var incompatibilities []string
|
||||
validatedUsers := make([]domain.User, 0, len(users))
|
||||
for _, u := range users {
|
||||
snap := validator.Snapshot{Users: []domain.User{u}}
|
||||
report := validator.Validate(snap, e.mode)
|
||||
if !report.Passed {
|
||||
for _, r := range report.Structural {
|
||||
if !r.Passed {
|
||||
incompatibilities = append(incompatibilities,
|
||||
fmt.Sprintf("user %q structural/%s: %s", u.Username, r.Rule, r.Message))
|
||||
}
|
||||
}
|
||||
for _, r := range report.Semantic {
|
||||
if !r.Passed {
|
||||
incompatibilities = append(incompatibilities,
|
||||
fmt.Sprintf("user %q semantic/%s: %s", u.Username, r.Rule, r.Message))
|
||||
}
|
||||
}
|
||||
}
|
||||
validatedUsers = append(validatedUsers, u)
|
||||
}
|
||||
|
||||
// 4. Build memberships from group member lists.
|
||||
var memberships []domain.Membership
|
||||
for _, g := range groups {
|
||||
for _, memberID := range g.Members {
|
||||
memberships = append(memberships, domain.Membership{
|
||||
UserID: memberID,
|
||||
GroupID: g.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Build ExportResult.
|
||||
result := &ExportResult{
|
||||
Users: validatedUsers,
|
||||
Groups: groups,
|
||||
Memberships: memberships,
|
||||
ExportedAt: time.Now().UTC(),
|
||||
ProfileVersion: "0.1",
|
||||
IncompatibilityReport: incompatibilities,
|
||||
}
|
||||
|
||||
// 6. Emit migration_event telemetry.
|
||||
e.emitter.Emit(ctx, telemetry.Event{
|
||||
Timestamp: time.Now().UTC(),
|
||||
EventType: telemetry.EventMigration,
|
||||
Endpoint: "lldap-export",
|
||||
Result: "success",
|
||||
})
|
||||
|
||||
// 7. Write YAML to output file.
|
||||
data, err := yaml.Marshal(result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lldapexport: marshal YAML: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(outputFile, data, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("lldapexport: write file %q: %w", outputFile, err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
235
src/internal/migration/lldapexport/exporter_test.go
Normal file
235
src/internal/migration/lldapexport/exporter_test.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
package lldapexport_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"keycape/internal/domain"
|
||||
"keycape/internal/migration/lldapexport"
|
||||
"keycape/internal/server/telemetry"
|
||||
"keycape/internal/validator"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock UserRepository
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type mockRepo struct {
|
||||
users []domain.User
|
||||
groups []domain.Group
|
||||
}
|
||||
|
||||
func (m *mockRepo) LookupUser(_ context.Context, username string) (*domain.User, error) {
|
||||
for i, u := range m.users {
|
||||
if u.Username == username {
|
||||
return &m.users[i], nil
|
||||
}
|
||||
}
|
||||
return nil, domain.ErrUserNotFound
|
||||
}
|
||||
|
||||
func (m *mockRepo) LookupGroups(_ context.Context, _ string) ([]domain.Group, error) {
|
||||
return m.groups, nil
|
||||
}
|
||||
|
||||
func (m *mockRepo) ValidatePassword(_ context.Context, _, _ string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *mockRepo) ListUsers(_ context.Context) ([]domain.User, error) {
|
||||
return m.users, nil
|
||||
}
|
||||
|
||||
// Compile-time check.
|
||||
var _ domain.UserRepository = (*mockRepo)(nil)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capture emitter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type capEmitter struct {
|
||||
events []telemetry.Event
|
||||
}
|
||||
|
||||
func (c *capEmitter) Emit(_ context.Context, ev telemetry.Event) {
|
||||
c.events = append(c.events, ev)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func validUser() domain.User {
|
||||
return domain.User{
|
||||
ID: "uid=alice,ou=users,dc=example,dc=local",
|
||||
Username: "alice",
|
||||
DisplayName: "Alice Liddell",
|
||||
Email: "alice@example.com",
|
||||
Enabled: true,
|
||||
Groups: []string{"admins"},
|
||||
}
|
||||
}
|
||||
|
||||
func validGroup() domain.Group {
|
||||
return domain.Group{
|
||||
ID: "cn=admins,ou=groups,dc=example,dc=local",
|
||||
Name: "admins",
|
||||
Description: "Admin group",
|
||||
Members: []string{"uid=alice,ou=users,dc=example,dc=local"},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExporter_Export_UsersAndGroups(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
repo := &mockRepo{
|
||||
users: []domain.User{validUser()},
|
||||
groups: []domain.Group{validGroup()},
|
||||
}
|
||||
|
||||
outFile := filepath.Join(t.TempDir(), "export.yaml")
|
||||
exp := lldapexport.New(repo, validator.ModeProvisioning, em)
|
||||
result, err := exp.Export(context.Background(), outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Export returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Users) != 1 {
|
||||
t.Errorf("expected 1 user, got %d", len(result.Users))
|
||||
}
|
||||
if result.Users[0].Username != "alice" {
|
||||
t.Errorf("expected username alice, got %q", result.Users[0].Username)
|
||||
}
|
||||
if len(result.Groups) != 1 {
|
||||
t.Errorf("expected 1 group, got %d", len(result.Groups))
|
||||
}
|
||||
if result.Groups[0].Name != "admins" {
|
||||
t.Errorf("expected group name admins, got %q", result.Groups[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExporter_Export_WritesYAMLFile(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
repo := &mockRepo{
|
||||
users: []domain.User{validUser()},
|
||||
groups: []domain.Group{validGroup()},
|
||||
}
|
||||
|
||||
outFile := filepath.Join(t.TempDir(), "canonical-export.yaml")
|
||||
exp := lldapexport.New(repo, validator.ModeProvisioning, em)
|
||||
_, err := exp.Export(context.Background(), outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Export returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("output file not written: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("output file is empty")
|
||||
}
|
||||
// File should be valid YAML containing "alice".
|
||||
content := string(data)
|
||||
if len(content) < 10 {
|
||||
t.Errorf("output file suspiciously short: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExporter_Export_EmitsMigrationEvent(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
repo := &mockRepo{
|
||||
users: []domain.User{validUser()},
|
||||
groups: []domain.Group{},
|
||||
}
|
||||
|
||||
outFile := filepath.Join(t.TempDir(), "export.yaml")
|
||||
exp := lldapexport.New(repo, validator.ModeProvisioning, em)
|
||||
_, err := exp.Export(context.Background(), outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Export returned error: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, ev := range em.events {
|
||||
if ev.EventType == telemetry.EventMigration {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected migration_event telemetry, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExporter_Export_IncompatibilityReport_BadUser(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
// A user with empty DisplayName will fail canonical schema validation.
|
||||
badUser := domain.User{
|
||||
ID: "uid=broken,ou=users,dc=example,dc=local",
|
||||
Username: "broken",
|
||||
DisplayName: "", // missing required field
|
||||
Email: "broken@example.com",
|
||||
Enabled: true,
|
||||
}
|
||||
repo := &mockRepo{
|
||||
users: []domain.User{badUser},
|
||||
groups: []domain.Group{},
|
||||
}
|
||||
|
||||
outFile := filepath.Join(t.TempDir(), "export.yaml")
|
||||
exp := lldapexport.New(repo, validator.ModeProvisioning, em)
|
||||
result, err := exp.Export(context.Background(), outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Export should not return error for bad data (it reports incompatibilities): %v", err)
|
||||
}
|
||||
|
||||
if len(result.IncompatibilityReport) == 0 {
|
||||
t.Error("expected incompatibility report entries for user with missing displayName")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExporter_Export_BuildsMemberships(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
user := validUser()
|
||||
group := validGroup()
|
||||
repo := &mockRepo{
|
||||
users: []domain.User{user},
|
||||
groups: []domain.Group{group},
|
||||
}
|
||||
|
||||
outFile := filepath.Join(t.TempDir(), "export.yaml")
|
||||
exp := lldapexport.New(repo, validator.ModeProvisioning, em)
|
||||
result, err := exp.Export(context.Background(), outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Export returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Memberships) == 0 {
|
||||
t.Error("expected memberships to be built from group members")
|
||||
}
|
||||
if result.Memberships[0].GroupID != group.ID {
|
||||
t.Errorf("membership GroupID: want %q, got %q", group.ID, result.Memberships[0].GroupID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExporter_Export_ProfileVersion(t *testing.T) {
|
||||
em := &capEmitter{}
|
||||
repo := &mockRepo{users: []domain.User{validUser()}, groups: []domain.Group{}}
|
||||
|
||||
outFile := filepath.Join(t.TempDir(), "export.yaml")
|
||||
exp := lldapexport.New(repo, validator.ModeProvisioning, em)
|
||||
result, err := exp.Export(context.Background(), outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Export returned error: %v", err)
|
||||
}
|
||||
|
||||
if result.ProfileVersion != "0.1" {
|
||||
t.Errorf("expected ProfileVersion 0.1, got %q", result.ProfileVersion)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue