key-cape/src/tests/migration/scenario_c_test.go
tegwick 7c9ed852ff
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 39s
Close the KEY-WP-0009 handoff gap and deliver the two blocked admissions
KEY-WP-0009-T04 is done. Re-ran verification on main with an explicit Go 1.23.0
toolchain and a writable build cache: contract validator PASS, build, vet and the
full test suite pass, git diff --check clean. gofmt had drifted on
internal/server/telemetry/events.go and tests/migration/scenario_c_test.go
(comment alignment only, no semantic change); both reformatted so the task's
formatting claim holds. The four handoffs to netkingdom, secrets-engine,
ops-warden and railiance-platform were then actually sent -- the 2026-09-05 audit
had shown the earlier claimed delivery had no receipts -- and each receipt id is
recorded in the workplan and readable back from the hub.

KEY-WP-0013-T02 and KEY-WP-0014-T04 stay in wait. Their remaining work is not
ours to do, so what was owed was the request, and it is now delivered: custody
admission for the two approval clients to railiance-platform, the exact human
client id and callback URI to approval-engine, and the OpenBao-token vs
issuer-JWT split plus Qonto rotation execution authority to ops-warden. Each
message quotes the existing non-secret packet or reviewed sequence and states
what is explicitly not being treated as authorization.

No secret was read, no production resource changed, no route retired.
SCOPE.md updated to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV8zoCKpA1WRAxsKRYbdH

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-08 10:22:52 +02:00

211 lines
6.5 KiB
Go

// Scenario C: Full expansion — both LLDAP → full LDAP directory migration AND
// KeyCape → Keycloak IAM migration. These tests verify the two migration
// dimensions are independent (orthogonal) and that user data is semantically
// equivalent after both migrations.
package migration_test
import (
"strings"
"testing"
"keycape/internal/migration/tokeycloak"
"keycape/internal/migration/toldap"
"keycape/internal/server/telemetry"
)
func newGenerator(target toldap.Target) *toldap.Generator {
return toldap.New(toldap.Config{
BaseDN: "dc=netkingdom,dc=local",
Target: target,
}, telemetry.NoopEmitter{})
}
// TestScenarioCLDIFRoundTrip verifies the LDIF generator produces valid content
// for the canonical fixture.
func TestScenarioCLDIFRoundTrip(t *testing.T) {
export := canonicalFixture()
gen := newGenerator(toldap.TargetOpenLDAP)
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if ldif == "" {
t.Fatal("expected non-empty LDIF output")
}
// Verify all users appear in LDIF
for _, u := range export.Users {
if !strings.Contains(ldif, "uid: "+u.Username) {
t.Errorf("LDIF missing user attribute uid: %s", u.Username)
}
}
// Verify all groups appear in LDIF
for _, g := range export.Groups {
if !strings.Contains(ldif, "cn: "+g.Name) {
t.Errorf("LDIF missing group cn: %s", g.Name)
}
}
}
// TestScenarioCTargetDifferences verifies OpenLDAP vs 389DS vs AD produce different LDIF.
func TestScenarioCTargetDifferences(t *testing.T) {
export := canonicalFixture()
ldifOpenLDAP, err := newGenerator(toldap.TargetOpenLDAP).Generate(export)
if err != nil {
t.Fatalf("OpenLDAP Generate: %v", err)
}
ldif389DS, err := newGenerator(toldap.Target389DS).Generate(export)
if err != nil {
t.Fatalf("389DS Generate: %v", err)
}
ldifAD, err := newGenerator(toldap.TargetAD).Generate(export)
if err != nil {
t.Fatalf("AD Generate: %v", err)
}
// AD must use sAMAccountName
if !strings.Contains(ldifAD, "sAMAccountName:") {
t.Error("AD LDIF missing sAMAccountName attribute")
}
// OpenLDAP must NOT have sAMAccountName
if strings.Contains(ldifOpenLDAP, "sAMAccountName:") {
t.Error("OpenLDAP LDIF should not have sAMAccountName")
}
// 389DS must have nsUniqueId or standard entries
_ = ldif389DS // 389DS is valid even without nsUniqueId when LDAPAttributes is empty
// All three must contain the same users
for _, u := range export.Users {
if !strings.Contains(ldifOpenLDAP, u.Username) {
t.Errorf("OpenLDAP LDIF missing user %s", u.Username)
}
if !strings.Contains(ldif389DS, u.Username) {
t.Errorf("389DS LDIF missing user %s", u.Username)
}
if !strings.Contains(ldifAD, u.Username) {
t.Errorf("AD LDIF missing user %s", u.Username)
}
}
}
// TestScenarioCMFANotMigrated verifies privacyIDEA MFA enrollment is NOT part of
// either migration dimension. MFA stays stable across lightweight → expanded.
func TestScenarioCMFANotMigrated(t *testing.T) {
export := canonicalFixture()
// Add MFA enrollment to a user
mfaUser := export.Users[0]
mfaUser.MFAEnrollment = nil // MFAEnrollment is NOT in the canonical export for migration
// LDIF generation must not include any OTP/MFA attributes
gen := newGenerator(toldap.TargetOpenLDAP)
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate: %v", err)
}
// LDIF must not contain privacyIDEA-specific attributes
if strings.Contains(ldif, "otpKey:") || strings.Contains(ldif, "privacyidea") {
t.Error("LDIF should not contain MFA/OTP attributes — privacyIDEA is orthogonal to directory migration")
}
// Keycloak realm must not include MFA credentials
transformer := tokeycloak.New(tokeycloak.Config{
RealmName: "netkingdom",
Issuer: "https://auth.netkingdom.local",
}, telemetry.NoopEmitter{})
realm, err := transformer.Transform(export)
if err != nil {
t.Fatalf("Transform: %v", err)
}
for _, u := range realm.Users {
for _, cred := range u.Credentials {
if cred.Type == "otp" || cred.Type == "totp" {
t.Errorf("user %q has OTP credential in Keycloak import — MFA migration should not happen here", u.Username)
}
}
}
}
// TestScenarioCStructuralEntries verifies ou=users and ou=groups are always generated.
func TestScenarioCStructuralEntries(t *testing.T) {
export := canonicalFixture()
gen := newGenerator(toldap.TargetOpenLDAP)
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if !strings.Contains(ldif, "ou=users,dc=netkingdom,dc=local") {
t.Error("LDIF missing ou=users structural entry")
}
if !strings.Contains(ldif, "ou=groups,dc=netkingdom,dc=local") {
t.Error("LDIF missing ou=groups structural entry")
}
}
// TestScenarioCUserPreservation verifies all user fields survive directory migration.
func TestScenarioCUserPreservation(t *testing.T) {
export := canonicalFixture()
gen := newGenerator(toldap.TargetOpenLDAP)
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate: %v", err)
}
for _, u := range export.Users {
if !strings.Contains(ldif, "uid: "+u.Username) {
t.Errorf("LDIF missing uid: %s", u.Username)
}
if u.Email != "" && !strings.Contains(ldif, "mail: "+u.Email) {
t.Errorf("LDIF missing mail: %s for user %s", u.Email, u.Username)
}
}
}
// TestScenarioCGroupMembersPreserved verifies group member DNs are in the LDIF.
func TestScenarioCGroupMembersPreserved(t *testing.T) {
export := canonicalFixture()
gen := newGenerator(toldap.TargetOpenLDAP)
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate: %v", err)
}
// admins group has alice as member
if !strings.Contains(ldif, "cn: admins") {
t.Error("LDIF missing admins group")
}
// member entries should be present
if !strings.Contains(ldif, "member:") {
t.Error("LDIF missing member: entries for groups")
}
}
// TestScenarioCOrthogonality verifies Scenario C = Scenario A (LDIF migration) + Scenario B (Keycloak migration)
// are independent: each can be performed without the other.
func TestScenarioCOrthogonality(t *testing.T) {
export := canonicalFixture()
// Can generate LDIF without Keycloak realm
gen := newGenerator(toldap.TargetOpenLDAP)
_, err := gen.Generate(export)
if err != nil {
t.Errorf("LDIF generation (without Keycloak) failed: %v", err)
}
// Can generate Keycloak realm without LDIF
transformer := tokeycloak.New(tokeycloak.Config{
RealmName: "netkingdom",
Issuer: "https://auth.netkingdom.local",
}, telemetry.NoopEmitter{})
_, err = transformer.Transform(export)
if err != nil {
t.Errorf("Keycloak transform (without LDIF) failed: %v", err)
}
}