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