package store import ( "context" "os" "path/filepath" "runtime" "sort" "strings" "sync" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" ) func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(t *testing.T) { databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) if databaseURL == "" { t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run OIDC JIT PostgreSQL integration tests") } ctx := context.Background() applyOIDCJITTestMigrations(t, ctx, databaseURL) db, err := Connect(ctx, databaseURL) if err != nil { t.Fatalf("connect store: %v", err) } defer db.Close() suffix := time.Now().UTC().Format("20060102150405.000000000") subject := "platform-jit-" + suffix input := ResolveOrProvisionOIDCUserInput{ Issuer: "https://auth.test.example/realms/easyai", Subject: subject, Username: "jit-user-" + suffix, Roles: []string{"basic"}, TenantID: "auth-center-test-tenant", GatewayTenantKey: "default", ProvisioningEnabled: true, } t.Cleanup(func() { _, _ = db.pool.Exec(context.Background(), ` DELETE FROM gateway_audit_logs WHERE target_gateway_user_id IN ( SELECT id FROM gateway_users WHERE source = 'oidc' AND external_user_id = $1 )`, subject) _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = $1`, subject) }) const callers = 12 results := make([]ResolveOrProvisionOIDCUserResult, callers) errs := make([]error, callers) var wg sync.WaitGroup for index := 0; index < callers; index++ { wg.Add(1) go func(index int) { defer wg.Done() results[index], errs[index] = db.ResolveOrProvisionOIDCUser(ctx, input) }(index) } wg.Wait() firstID := "" createdCount := 0 auditID := "" for index, err := range errs { if err != nil { t.Fatalf("concurrent resolve %d: %v", index, err) } result := results[index] if result.User == nil || result.User.GatewayUserID == "" { t.Fatalf("concurrent resolve %d returned no local user: %+v", index, result) } if firstID == "" { firstID = result.User.GatewayUserID } if result.User.GatewayUserID != firstID { t.Fatalf("concurrent resolve returned different users: %q and %q", firstID, result.User.GatewayUserID) } if result.Created { createdCount++ auditID = result.AuditID } } if createdCount != 1 { t.Fatalf("created count = %d, want 1", createdCount) } if auditID == "" { t.Fatal("first provision must return an audit ID") } var users, wallets, audits int if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_users WHERE source = 'oidc' AND external_user_id = $1`, subject).Scan(&users); err != nil { t.Fatalf("count OIDC users: %v", err) } if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_wallet_accounts WHERE gateway_user_id = $1::uuid AND currency = 'resource'`, firstID).Scan(&wallets); err != nil { t.Fatalf("count wallets: %v", err) } if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_audit_logs WHERE action = 'identity.oidc_user.provisioned' AND target_gateway_user_id = $1::uuid`, firstID).Scan(&audits); err != nil { t.Fatalf("count audits: %v", err) } if users != 1 || wallets != 1 || audits != 1 { t.Fatalf("users=%d wallets=%d audits=%d, want one of each", users, wallets, audits) } var auditProjection string if err := db.pool.QueryRow(ctx, ` SELECT COALESCE(actor_user_id, '') || metadata::text || after_state::text FROM gateway_audit_logs WHERE id = $1::uuid`, auditID).Scan(&auditProjection); err != nil { t.Fatalf("read OIDC provisioning audit: %v", err) } if strings.Contains(auditProjection, subject) || strings.Contains(auditProjection, input.Issuer) { t.Fatal("OIDC provisioning audit exposed raw external identity claims") } if _, err := db.pool.Exec(ctx, ` UPDATE gateway_users SET display_name = 'Manual Display Name', email = 'manual-profile@example.test', metadata = metadata || '{"manualProfile":true}'::jsonb WHERE id = $1::uuid`, firstID); err != nil { t.Fatalf("seed manually managed profile fields: %v", err) } input.Username = "jit-user-renamed-" + suffix input.Roles = []string{"basic", "admin"} input.ProvisioningEnabled = false repeated, err := db.ResolveOrProvisionOIDCUser(ctx, input) if err != nil { t.Fatalf("repeat resolve: %v", err) } if repeated.Created || repeated.AuditID != "" || repeated.User.GatewayUserID != firstID { t.Fatalf("unexpected repeat result: %+v", repeated) } if repeated.User.Username != input.Username || !containsOIDCTestRole(repeated.User.Roles, "admin") { t.Fatalf("repeat resolve did not sync token projection: %+v", repeated.User) } var displayName, email string var manualProfile bool if err := db.pool.QueryRow(ctx, ` SELECT COALESCE(display_name, ''), COALESCE(email, ''), COALESCE((metadata->>'manualProfile')::boolean, false) FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&displayName, &email, &manualProfile); err != nil { t.Fatalf("read manually managed profile fields: %v", err) } if displayName != "Manual Display Name" || email != "manual-profile@example.test" || !manualProfile { t.Fatalf("repeat resolve overwrote manually managed profile fields") } if _, err := db.pool.Exec(ctx, `UPDATE gateway_users SET status = 'disabled' WHERE id = $1::uuid`, firstID); err != nil { t.Fatalf("disable OIDC user: %v", err) } if _, err := db.ResolveOrProvisionOIDCUser(ctx, input); !errorsIs(err, ErrOIDCUserDisabled) { t.Fatalf("disabled resolve error = %v, want ErrOIDCUserDisabled", err) } var status string if err := db.pool.QueryRow(ctx, `SELECT status FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&status); err != nil { t.Fatalf("read disabled status: %v", err) } if status != "disabled" { t.Fatalf("disabled OIDC user was reactivated: %q", status) } } func TestResolveOrProvisionOIDCUserRejectsMissingMappingWithoutWrites(t *testing.T) { databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) if databaseURL == "" { t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run OIDC JIT PostgreSQL integration tests") } ctx := context.Background() applyOIDCJITTestMigrations(t, ctx, databaseURL) db, err := Connect(ctx, databaseURL) if err != nil { t.Fatalf("connect store: %v", err) } defer db.Close() subject := "platform-jit-missing-" + time.Now().UTC().Format("20060102150405.000000000") input := ResolveOrProvisionOIDCUserInput{ Issuer: "https://auth.test.example/realms/easyai", Subject: subject, Username: "missing-user", Roles: []string{"basic"}, TenantID: "auth-center-test-tenant", GatewayTenantKey: "missing-tenant-key", } if _, err := db.ResolveOrProvisionOIDCUser(ctx, input); !errorsIs(err, ErrOIDCUserNotProvisioned) { t.Fatalf("disabled JIT error = %v, want ErrOIDCUserNotProvisioned", err) } input.ProvisioningEnabled = true if _, err := db.ResolveOrProvisionOIDCUser(ctx, input); !errorsIs(err, ErrOIDCTenantUnavailable) { t.Fatalf("missing tenant error = %v, want ErrOIDCTenantUnavailable", err) } var users int if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_users WHERE source = 'oidc' AND external_user_id = $1`, subject).Scan(&users); err != nil { t.Fatalf("count rejected users: %v", err) } if users != 0 { t.Fatalf("rejected OIDC request created %d users", users) } suffix := time.Now().UTC().Format("20060102150405.000000000") groupKey := "jit-disabled-group-" + suffix tenantKey := "jit-disabled-tenant-" + suffix var groupID string if err := db.pool.QueryRow(ctx, ` INSERT INTO gateway_user_groups (group_key, name, status) VALUES ($1, 'OIDC JIT disabled group test', 'active') RETURNING id::text`, groupKey).Scan(&groupID); err != nil { t.Fatalf("create disabled-mapping test group: %v", err) } var tenantID string if err := db.pool.QueryRow(ctx, ` INSERT INTO gateway_tenants (tenant_key, name, default_user_group_id, status) VALUES ($1, 'OIDC JIT disabled tenant test', $2::uuid, 'disabled') RETURNING id::text`, tenantKey, groupID).Scan(&tenantID); err != nil { t.Fatalf("create disabled-mapping test tenant: %v", err) } t.Cleanup(func() { _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_tenants WHERE id = $1::uuid`, tenantID) _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_user_groups WHERE id = $1::uuid`, groupID) }) disabledMappingInput := input disabledMappingInput.Subject += "-disabled-mapping" disabledMappingInput.GatewayTenantKey = tenantKey if _, err := db.ResolveOrProvisionOIDCUser(ctx, disabledMappingInput); !errorsIs(err, ErrOIDCTenantUnavailable) { t.Fatalf("disabled tenant error = %v, want ErrOIDCTenantUnavailable", err) } if _, err := db.pool.Exec(ctx, `UPDATE gateway_tenants SET status = 'active' WHERE id = $1::uuid`, tenantID); err != nil { t.Fatalf("enable test tenant: %v", err) } if _, err := db.pool.Exec(ctx, `UPDATE gateway_user_groups SET status = 'disabled' WHERE id = $1::uuid`, groupID); err != nil { t.Fatalf("disable test group: %v", err) } if _, err := db.ResolveOrProvisionOIDCUser(ctx, disabledMappingInput); !errorsIs(err, ErrOIDCTenantUnavailable) { t.Fatalf("disabled user group error = %v, want ErrOIDCTenantUnavailable", err) } } func TestOIDCUserKeyIsStableAndDoesNotExposeClaims(t *testing.T) { issuer := "https://auth.test.example/realms/easyai" subject := "platform-sensitive-subject" first := deriveOIDCUserKey(issuer, subject) second := deriveOIDCUserKey(issuer+"/", subject) if first == "" || first != second { t.Fatalf("OIDC user key is not stable: %q != %q", first, second) } if strings.Contains(first, subject) || strings.Contains(first, issuer) { t.Fatalf("OIDC user key exposes raw claims: %q", first) } if first == deriveOIDCUserKey(issuer, subject+"-other") { t.Fatal("different subjects produced the same OIDC user key") } } func errorsIs(err error, target error) bool { for err != nil { if err == target { return true } type unwrapper interface{ Unwrap() error } wrapped, ok := err.(unwrapper) if !ok { return false } err = wrapped.Unwrap() } return false } func containsOIDCTestRole(roles []string, expected string) bool { for _, role := range roles { if role == expected { return true } } return false } func applyOIDCJITTestMigrations(t *testing.T, ctx context.Context, databaseURL string) { t.Helper() _, filename, _, _ := runtime.Caller(0) migrationFiles, err := filepath.Glob(filepath.Join(filepath.Dir(filename), "..", "..", "migrations", "*.sql")) if err != nil { t.Fatalf("read migration files: %v", err) } sort.Strings(migrationFiles) pool, err := pgxpool.New(ctx, databaseURL) if err != nil { t.Fatalf("connect migration db: %v", err) } defer pool.Close() if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil { t.Fatalf("ensure schema migrations: %v", err) } for _, migrationPath := range migrationFiles { version := strings.TrimSuffix(filepath.Base(migrationPath), filepath.Ext(migrationPath)) var exists bool if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&exists); err != nil { t.Fatalf("check migration %s: %v", version, err) } if exists { continue } migration, err := os.ReadFile(migrationPath) if err != nil { t.Fatalf("read migration %s: %v", version, err) } tx, err := pool.Begin(ctx) if err != nil { t.Fatalf("begin migration %s: %v", version, err) } if _, err := tx.Exec(ctx, string(migration)); err != nil { _ = tx.Rollback(ctx) t.Fatalf("apply migration %s: %v", version, err) } if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES($1)`, version); err != nil { _ = tx.Rollback(ctx) t.Fatalf("record migration %s: %v", version, err) } if err := tx.Commit(ctx); err != nil { t.Fatalf("commit migration %s: %v", version, err) } } }