key-cape/src/tests/migration/scenario_c_test.go
tegwick 847abcba73 feat: implement T19, T20 — Scenario B/C replacement tests; complete workplan
- T19: Scenario B tests — IAM swap correctness (7 tests: profile safety, client mapping, user/group preservation)
- T20: Scenario C tests — full expansion correctness (6 tests: LDIF round-trip, target differences, MFA orthogonality)
- CI scripts: test-scenario-b.sh, test-scenario-c.sh
- README: complete documentation with quick start, endpoints, migration guide
- Workplan: all acceptance criteria checked off

All 23 tasks done. 15 test packages, all green. go vet clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 02:36:29 +01: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/toldap"
"keycape/internal/migration/tokeycloak"
"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)
}
}