fix(identity): 完善统一认证配对恢复与安全退役
修复 credentials_saved 状态无法恢复、配对与激活并发冲突,以及 SSF 和身份 Secret 生命周期不完整的问题。新增持久化协调器、取消与清理状态机、事务级并发门禁、受控 SSF 凭据交接、禁用后的延迟 Secret 清理,并对生产环境统一认证及 Discovery 端点强制 HTTPS。 验证:go test ./...;go test -race ./internal/auth ./internal/identity ./internal/identityruntime ./internal/securityevents ./internal/httpapi ./internal/store -count=1;go vet ./...;真实 PostgreSQL 并发及清理成功/冲突回滚测试;pnpm openapi。
This commit is contained in:
+254
@@ -0,0 +1,254 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const identityPairingStartReservationUpgradeMigration = "../../migrations/0073_identity_pairing_start_reservation_upgrade.sql"
|
||||
|
||||
func TestIdentityPairingStartReservationUpgradeMigrationDefinesCurrentLifecycle(t *testing.T) {
|
||||
payload, err := os.ReadFile(identityPairingStartReservationUpgradeMigration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := strings.ToLower(string(payload))
|
||||
for _, required := range []string{
|
||||
"add column if not exists state text",
|
||||
"add column if not exists revision_id uuid",
|
||||
"add column if not exists updated_at timestamptz",
|
||||
"alter column state set not null",
|
||||
"gateway_identity_pairing_start_state_check",
|
||||
"foreign key (revision_id)",
|
||||
"idx_gateway_identity_pairing_start_revision_unique",
|
||||
"idx_gateway_identity_pairing_start_expiry",
|
||||
"canonical_pairing",
|
||||
"on conflict (singleton) do update",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("identity pairing start reservation upgrade migration is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"exchange_token text", "client_secret", "machine_secret", "secret_value"} {
|
||||
if strings.Contains(content, forbidden) {
|
||||
t.Fatalf("identity pairing start reservation upgrade migration stores forbidden value field %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityPairingStartReservationUpgradeMigratesLegacyShapeAndIsIdempotent(t *testing.T) {
|
||||
pool := newIdentityMigrationPostgresTestSchema(t)
|
||||
ctx := context.Background()
|
||||
applyIdentityPairingStartReservationPrerequisites(t, ctx, pool)
|
||||
|
||||
revisionID, pairingID, pairingExpiresAt := seedOutstandingIdentityPairingForReservationMigration(t, ctx, pool)
|
||||
legacyAttemptID := uuid.NewString()
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE TABLE gateway_identity_pairing_start_reservation (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
attempt_id uuid NOT NULL UNIQUE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
t.Fatalf("create legacy pairing start reservation: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,expires_at)
|
||||
VALUES(true,$1::uuid,now()+interval '2 minutes')`, legacyAttemptID); err != nil {
|
||||
t.Fatalf("seed legacy pairing start reservation: %v", err)
|
||||
}
|
||||
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration)
|
||||
assertCurrentIdentityPairingStartReservation(t, ctx, pool, pairingID, revisionID, pairingExpiresAt)
|
||||
assertIdentityPairingStartReservationSchema(t, ctx, pool)
|
||||
|
||||
// Migration runners apply each version once, but a repeat execution proves
|
||||
// that recovery from an interrupted/manual rollout does not duplicate state.
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration)
|
||||
assertCurrentIdentityPairingStartReservation(t, ctx, pool, pairingID, revisionID, pairingExpiresAt)
|
||||
assertIdentityPairingStartReservationSchema(t, ctx, pool)
|
||||
}
|
||||
|
||||
func TestIdentityPairingStartReservationUpgradePreservesLegacyStartingLease(t *testing.T) {
|
||||
pool := newIdentityMigrationPostgresTestSchema(t)
|
||||
ctx := context.Background()
|
||||
applyIdentityPairingStartReservationPrerequisites(t, ctx, pool)
|
||||
|
||||
attemptID := uuid.NewString()
|
||||
expiresAt := time.Now().UTC().Add(2 * time.Minute).Truncate(time.Microsecond)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE TABLE gateway_identity_pairing_start_reservation (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
attempt_id uuid NOT NULL UNIQUE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
t.Fatalf("create legacy pairing start reservation: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,expires_at)
|
||||
VALUES(true,$1::uuid,$2)`, attemptID, expiresAt); err != nil {
|
||||
t.Fatalf("seed legacy starting reservation: %v", err)
|
||||
}
|
||||
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration)
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration)
|
||||
|
||||
var gotAttemptID, state string
|
||||
var revisionID *string
|
||||
var gotExpiresAt, updatedAt time.Time
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT attempt_id::text,state,revision_id::text,expires_at,updated_at
|
||||
FROM gateway_identity_pairing_start_reservation`).
|
||||
Scan(&gotAttemptID, &state, &revisionID, &gotExpiresAt, &updatedAt); err != nil {
|
||||
t.Fatalf("read upgraded legacy starting reservation: %v", err)
|
||||
}
|
||||
if gotAttemptID != attemptID || state != "starting" || revisionID != nil ||
|
||||
!gotExpiresAt.Equal(expiresAt) || updatedAt.IsZero() {
|
||||
t.Fatalf("unexpected upgraded starting reservation attempt=%q state=%q revision=%v expires=%v updated=%v",
|
||||
gotAttemptID, state, revisionID, gotExpiresAt, updatedAt)
|
||||
}
|
||||
|
||||
_, err := pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET state='paired'`)
|
||||
requireIdentityPairingStartReservationMigrationCode(t, err, "23514", "paired reservation without revision")
|
||||
}
|
||||
|
||||
func TestIdentityPairingStartReservationUpgradeAcceptsFresh0071Schema(t *testing.T) {
|
||||
pool := newIdentityMigrationPostgresTestSchema(t)
|
||||
ctx := context.Background()
|
||||
applyIdentityPairingStartReservationPrerequisites(t, ctx, pool)
|
||||
|
||||
revisionID, pairingID, pairingExpiresAt := seedOutstandingIdentityPairingForReservationMigration(t, ctx, pool)
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0071_identity_pairing_start_reservation.sql")
|
||||
assertCurrentIdentityPairingStartReservation(t, ctx, pool, pairingID, revisionID, pairingExpiresAt)
|
||||
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration)
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration)
|
||||
assertCurrentIdentityPairingStartReservation(t, ctx, pool, pairingID, revisionID, pairingExpiresAt)
|
||||
assertIdentityPairingStartReservationSchema(t, ctx, pool)
|
||||
}
|
||||
|
||||
func applyIdentityPairingStartReservationPrerequisites(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
for _, migration := range []string{
|
||||
"../../migrations/0067_identity_configuration_revisions.sql",
|
||||
"../../migrations/0068_identity_onboarding_exchanges.sql",
|
||||
"../../migrations/0069_identity_pairing_cancellation.sql",
|
||||
} {
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, migration)
|
||||
}
|
||||
}
|
||||
|
||||
func seedOutstandingIdentityPairingForReservationMigration(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
) (string, string, time.Time) {
|
||||
t.Helper()
|
||||
revisionID := uuid.NewString()
|
||||
pairingID := uuid.NewString()
|
||||
expiresAt := time.Now().UTC().Add(30 * time.Minute).Truncate(time.Microsecond)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_identity_configuration_revisions (
|
||||
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
|
||||
) VALUES ($1::uuid,'draft','https://auth.test.example','default',
|
||||
'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil {
|
||||
t.Fatalf("seed identity revision for pairing reservation migration: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_identity_onboarding_exchanges (
|
||||
id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at
|
||||
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,$5)`,
|
||||
pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID, expiresAt); err != nil {
|
||||
t.Fatalf("seed identity exchange for pairing reservation migration: %v", err)
|
||||
}
|
||||
return revisionID, pairingID, expiresAt
|
||||
}
|
||||
|
||||
func assertCurrentIdentityPairingStartReservation(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
wantAttemptID string,
|
||||
wantRevisionID string,
|
||||
wantExpiresAt time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
var count int
|
||||
var attemptID, state, revisionID string
|
||||
var expiresAt, updatedAt time.Time
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT count(*) OVER (),attempt_id::text,state,revision_id::text,expires_at,updated_at
|
||||
FROM gateway_identity_pairing_start_reservation`).
|
||||
Scan(&count, &attemptID, &state, &revisionID, &expiresAt, &updatedAt); err != nil {
|
||||
t.Fatalf("read upgraded pairing start reservation: %v", err)
|
||||
}
|
||||
if count != 1 || attemptID != wantAttemptID || state != "paired" || revisionID != wantRevisionID ||
|
||||
!expiresAt.Equal(wantExpiresAt) || updatedAt.IsZero() {
|
||||
t.Fatalf("unexpected upgraded pairing reservation count=%d attempt=%q state=%q revision=%q expires=%v updated=%v",
|
||||
count, attemptID, state, revisionID, expiresAt, updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func assertIdentityPairingStartReservationSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
for _, column := range []struct {
|
||||
name string
|
||||
wantDefaultFragment string
|
||||
}{
|
||||
{name: "state", wantDefaultFragment: "starting"},
|
||||
{name: "updated_at", wantDefaultFragment: "now()"},
|
||||
} {
|
||||
var nullable, defaultExpression string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT is_nullable,COALESCE(column_default,'')
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema=current_schema()
|
||||
AND table_name='gateway_identity_pairing_start_reservation'
|
||||
AND column_name=$1`, column.name).Scan(&nullable, &defaultExpression); err != nil {
|
||||
t.Fatalf("read upgraded pairing reservation column %s: %v", column.name, err)
|
||||
}
|
||||
if nullable != "NO" || !strings.Contains(defaultExpression, column.wantDefaultFragment) {
|
||||
t.Fatalf("pairing reservation column %s nullable=%q default=%q", column.name, nullable, defaultExpression)
|
||||
}
|
||||
}
|
||||
|
||||
var expiryIndex, revisionIndex string
|
||||
if err := pool.QueryRow(ctx, `SELECT pg_get_indexdef('idx_gateway_identity_pairing_start_expiry'::regclass)`).
|
||||
Scan(&expiryIndex); err != nil {
|
||||
t.Fatalf("read pairing reservation expiry index: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT pg_get_indexdef('idx_gateway_identity_pairing_start_revision_unique'::regclass)`).
|
||||
Scan(&revisionIndex); err != nil {
|
||||
t.Fatalf("read pairing reservation revision index: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(expiryIndex), "(state, expires_at)") ||
|
||||
!strings.Contains(strings.ToLower(revisionIndex), "unique") ||
|
||||
!strings.Contains(strings.ToLower(revisionIndex), "(revision_id)") {
|
||||
t.Fatalf("unexpected pairing reservation indexes expiry=%q revision=%q", expiryIndex, revisionIndex)
|
||||
}
|
||||
|
||||
_, err := pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET state='starting'`)
|
||||
requireIdentityPairingStartReservationMigrationCode(t, err, "23514", "starting reservation with revision")
|
||||
_, err = pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET revision_id=$1::uuid`, uuid.NewString())
|
||||
requireIdentityPairingStartReservationMigrationCode(t, err, "23503", "reservation with unknown revision")
|
||||
}
|
||||
|
||||
func requireIdentityPairingStartReservationMigrationCode(t *testing.T, err error, wantCode, operation string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("%s unexpectedly satisfied migration constraints", operation)
|
||||
}
|
||||
var postgresError *pgconn.PgError
|
||||
if !errors.As(err, &postgresError) || postgresError.Code != wantCode {
|
||||
t.Fatalf("%s error=%v, want PostgreSQL code %s", operation, err, wantCode)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestSecurityEventIdempotencyRepairMigrationExists(t *testing.T) {
|
||||
@@ -85,3 +93,276 @@ func TestIdentityOnboardingExchangeMigrationStoresOnlySecretReferences(t *testin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityPairingCancellationMigrationSeparatesCleanupLifecycle(t *testing.T) {
|
||||
payload, err := os.ReadFile("../../migrations/0069_identity_pairing_cancellation.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := strings.ToLower(string(payload))
|
||||
for _, required := range []string{
|
||||
"'cancelled'",
|
||||
"cleanup_status text not null default 'none'",
|
||||
"cleanup_completed_at",
|
||||
"idx_gateway_identity_onboarding_cleanup",
|
||||
"last_error_category",
|
||||
"set last_error_category = 'pairing_step_failed'",
|
||||
"set local lock_timeout = '10s'",
|
||||
"set local statement_timeout = '60s'",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("identity pairing cancellation migration is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"exchange_token text", "client_secret", "machine_secret"} {
|
||||
if strings.Contains(content, forbidden) {
|
||||
t.Fatalf("identity pairing cancellation migration stores forbidden secret field %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityPairingErrorCategoryUpgradeMigrationExists(t *testing.T) {
|
||||
payload, err := os.ReadFile("../../migrations/0074_identity_pairing_error_categories.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := strings.ToLower(string(payload))
|
||||
for _, required := range []string{
|
||||
"drop constraint if exists gateway_identity_onboarding_error_category_check",
|
||||
"security_event_credential_handoff_unsafe",
|
||||
"security_event_connection_binding_missing",
|
||||
"security_event_connection_binding_unavailable",
|
||||
"security_event_connection_binding_invalid",
|
||||
"security_event_connection_binding_mismatch",
|
||||
"cleanup_security_event_credential_handoff_unsafe",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("identity pairing error category migration is missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityPairingErrorCategoryUpgradeMigrationExecutesAgainstCurrentSchema(t *testing.T) {
|
||||
pool := newIdentityMigrationPostgresTestSchema(t)
|
||||
ctx := context.Background()
|
||||
for _, migration := range []string{
|
||||
"../../migrations/0067_identity_configuration_revisions.sql",
|
||||
"../../migrations/0068_identity_onboarding_exchanges.sql",
|
||||
"../../migrations/0069_identity_pairing_cancellation.sql",
|
||||
"../../migrations/0070_identity_secret_cleanup_queue.sql",
|
||||
"../../migrations/0071_identity_pairing_start_reservation.sql",
|
||||
"../../migrations/0072_identity_secret_cleanup_claim_lifecycle.sql",
|
||||
"../../migrations/0073_identity_pairing_start_reservation_upgrade.sql",
|
||||
"../../migrations/0074_identity_pairing_error_categories.sql",
|
||||
} {
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, migration)
|
||||
}
|
||||
|
||||
revisionID, pairingID := uuid.NewString(), uuid.NewString()
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_identity_configuration_revisions (
|
||||
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
|
||||
) VALUES ($1::uuid,'draft','https://auth.test.example','default',
|
||||
'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil {
|
||||
t.Fatalf("seed identity revision: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_identity_onboarding_exchanges (
|
||||
id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at
|
||||
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,now() + interval '30 minutes')`,
|
||||
pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID); err != nil {
|
||||
t.Fatalf("seed onboarding exchange: %v", err)
|
||||
}
|
||||
|
||||
for _, category := range []string{
|
||||
"security_event_credential_handoff_unsafe",
|
||||
"security_event_connection_binding_missing",
|
||||
"security_event_connection_binding_unavailable",
|
||||
"security_event_connection_binding_invalid",
|
||||
"security_event_connection_binding_mismatch",
|
||||
"cleanup_security_event_credential_handoff_unsafe",
|
||||
} {
|
||||
if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
|
||||
SET last_error_category=$2 WHERE id=$1::uuid`, pairingID, category); err != nil {
|
||||
t.Fatalf("persist supported error category %q: %v", category, err)
|
||||
}
|
||||
}
|
||||
_, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
|
||||
SET last_error_category='credential_secret_leaked' WHERE id=$1::uuid`, pairingID)
|
||||
requireIdentityMigrationCheckViolation(t, err, "unknown upgraded identity pairing category")
|
||||
}
|
||||
|
||||
func TestIdentitySecretCleanupQueueMigrationStoresOnlyReferences(t *testing.T) {
|
||||
payload, err := os.ReadFile("../../migrations/0070_identity_secret_cleanup_queue.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := strings.ToLower(string(payload))
|
||||
for _, required := range []string{
|
||||
"create table if not exists gateway_identity_secret_cleanup_queue",
|
||||
"secret_ref text primary key",
|
||||
"not_before timestamptz not null",
|
||||
"status text not null default 'pending'",
|
||||
"claim_token uuid",
|
||||
"lease_expires_at timestamptz",
|
||||
"gateway_identity_secret_cleanup_claim_check",
|
||||
"idx_gateway_identity_secret_cleanup_due",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("identity Secret cleanup migration is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"secret_value", "client_secret", "machine_secret", "token text"} {
|
||||
if strings.Contains(content, forbidden) {
|
||||
t.Fatalf("identity Secret cleanup migration stores forbidden value field %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityPairingCancellationMigrationExecutesAgainstLegacyData(t *testing.T) {
|
||||
pool := newIdentityMigrationPostgresTestSchema(t)
|
||||
ctx := context.Background()
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0067_identity_configuration_revisions.sql")
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0068_identity_onboarding_exchanges.sql")
|
||||
|
||||
revisionID, pairingID := uuid.NewString(), uuid.NewString()
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_identity_configuration_revisions (
|
||||
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
|
||||
) VALUES ($1::uuid,'draft','https://auth.test.example','default',
|
||||
'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil {
|
||||
t.Fatalf("seed legacy identity revision: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_identity_onboarding_exchanges (
|
||||
id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at,last_error_category
|
||||
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,now() + interval '30 minutes','secret_token_abcdef')`,
|
||||
pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID); err != nil {
|
||||
t.Fatalf("seed legacy onboarding exchange: %v", err)
|
||||
}
|
||||
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0069_identity_pairing_cancellation.sql")
|
||||
|
||||
var status, cleanupStatus, errorCategory string
|
||||
var cancelledAt, cleanupCompletedAt *time.Time
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT status,cleanup_status,last_error_category,cancelled_at,cleanup_completed_at
|
||||
FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, pairingID).
|
||||
Scan(&status, &cleanupStatus, &errorCategory, &cancelledAt, &cleanupCompletedAt); err != nil {
|
||||
t.Fatalf("read migrated onboarding exchange: %v", err)
|
||||
}
|
||||
if status != "credentials_saved" || cleanupStatus != "none" || errorCategory != "pairing_step_failed" ||
|
||||
cancelledAt != nil || cleanupCompletedAt != nil {
|
||||
t.Fatalf("unexpected migrated exchange status=%q cleanup=%q error=%q cancelled=%v completed=%v",
|
||||
status, cleanupStatus, errorCategory, cancelledAt, cleanupCompletedAt)
|
||||
}
|
||||
|
||||
_, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges SET status='cancelled' WHERE id=$1::uuid`, pairingID)
|
||||
requireIdentityMigrationCheckViolation(t, err, "cancelled status without cleanup intent")
|
||||
|
||||
if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
|
||||
SET status='cancelled',cleanup_status='pending',cancelled_at=now()
|
||||
WHERE id=$1::uuid`, pairingID); err != nil {
|
||||
t.Fatalf("persist valid pending cleanup lifecycle: %v", err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
|
||||
SET cleanup_status='completed' WHERE id=$1::uuid`, pairingID)
|
||||
requireIdentityMigrationCheckViolation(t, err, "completed cleanup without completion timestamp")
|
||||
|
||||
if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
|
||||
SET cleanup_status='completed',cleanup_completed_at=now()
|
||||
WHERE id=$1::uuid`, pairingID); err != nil {
|
||||
t.Fatalf("persist valid completed cleanup lifecycle: %v", err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
|
||||
SET last_error_category='still-invalid' WHERE id=$1::uuid`, pairingID)
|
||||
requireIdentityMigrationCheckViolation(t, err, "invalid post-migration error category")
|
||||
|
||||
if err := pool.QueryRow(ctx, `SELECT status,cleanup_status FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, pairingID).
|
||||
Scan(&status, &cleanupStatus); err != nil {
|
||||
t.Fatalf("read completed cleanup lifecycle: %v", err)
|
||||
}
|
||||
if status != "cancelled" || cleanupStatus != "completed" {
|
||||
t.Fatalf("completed cleanup lifecycle status=%q cleanup=%q", status, cleanupStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func applyIdentityMigrationTestFile(t *testing.T, ctx context.Context, pool *pgxpool.Pool, path string) {
|
||||
t.Helper()
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", path, err)
|
||||
}
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin migration %s: %v", path, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(payload)); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
t.Fatalf("execute migration %s: %v", path, err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
t.Fatalf("commit migration %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireIdentityMigrationCheckViolation(t *testing.T, err error, operation string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("%s unexpectedly satisfied migration constraints", operation)
|
||||
}
|
||||
var postgresError *pgconn.PgError
|
||||
if !errors.As(err, &postgresError) || postgresError.Code != "23514" {
|
||||
t.Fatalf("%s error=%v, want PostgreSQL check violation", operation, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newIdentityMigrationPostgresTestSchema(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity migration PostgreSQL integration tests")
|
||||
}
|
||||
ctx := context.Background()
|
||||
admin, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect identity migration test database: %v", err)
|
||||
}
|
||||
var databaseName string
|
||||
if err := admin.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("read identity migration test database name: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(databaseName), "test") {
|
||||
admin.Close()
|
||||
t.Fatalf("refusing to use non-test database %q", databaseName)
|
||||
}
|
||||
|
||||
schemaName := "gateway_identity_migration_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
schemaIdentifier := pgx.Identifier{schemaName}.Sanitize()
|
||||
if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schemaIdentifier); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("create identity migration test schema: %v", err)
|
||||
}
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
|
||||
admin.Close()
|
||||
t.Fatalf("parse identity migration test database URL: %v", err)
|
||||
}
|
||||
config.ConnConfig.RuntimeParams["search_path"] = schemaName
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
|
||||
admin.Close()
|
||||
t.Fatalf("connect identity migration test schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
pool.Close()
|
||||
if _, err := admin.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`); err != nil {
|
||||
t.Errorf("drop identity migration test schema: %v", err)
|
||||
}
|
||||
admin.Close()
|
||||
})
|
||||
return pool
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user