easyai-ai-gateway/apps/api/cmd/migrate/main_test.go
chengcheng 4426eeccf7
All checks were successful
ci / verify (pull_request) Successful in 12m18s
chore(merge): 整合最新 main 与统一认证变更
合并远端生产 CI、依赖与 API 文档改动,保留统一认证和 SSF 能力。由于远端生产基线已占用 0062,将尚未发布的身份迁移整理为 0063 至 0068 的最终增量 Schema,移除仅用于开发期草稿升级的破坏性迁移步骤。

验证:go test ./...。其余生产门禁在合并提交后继续执行。
2026-07-17 19:06:37 +08:00

361 lines
14 KiB
Go

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 TestSecurityEventSchemaMigrationsDefineCurrentLifecycle(t *testing.T) {
streamPayload, err := os.ReadFile("../../migrations/0063_oidc_security_events.sql")
if err != nil {
t.Fatalf("read security event stream migration: %v", err)
}
streamSQL := string(streamPayload)
for _, statement := range []string{
"CREATE TABLE IF NOT EXISTS gateway_security_event_stream_state",
"CREATE TABLE IF NOT EXISTS gateway_security_event_verification_challenges",
"CREATE INDEX IF NOT EXISTS idx_gateway_security_event_challenges_expiry",
} {
if !strings.Contains(streamSQL, statement) {
t.Fatalf("security event stream migration is missing %q", statement)
}
}
connectionPayload, err := os.ReadFile("../../migrations/0064_security_event_connections.sql")
if err != nil {
t.Fatalf("read security event connection migration: %v", err)
}
connectionSQL := string(connectionPayload)
for _, statement := range []string{
"CREATE TABLE IF NOT EXISTS gateway_security_event_connections",
"management_client_id text",
"management_credential_ref text",
"CREATE TABLE IF NOT EXISTS gateway_security_event_connection_idempotency",
} {
if !strings.Contains(connectionSQL, statement) {
t.Fatalf("security event connection migration is missing %q", statement)
}
}
}
func TestIdentityConfigurationRevisionMigrationDefinesSecretReferencesAndSingleActiveRevision(t *testing.T) {
payload, err := os.ReadFile("../../migrations/0065_identity_configuration_revisions.sql")
if err != nil {
t.Fatal(err)
}
content := string(payload)
for _, required := range []string{
"CREATE TABLE IF NOT EXISTS gateway_identity_configuration_revisions",
"machine_credential_ref text",
"session_encryption_key_ref text",
"security_event_configuration_url text",
"WHERE state = 'active'",
"CHECK (state IN ('draft','validated','active','superseded','failed'))",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity configuration migration is missing %q", required)
}
}
for _, forbidden := range []string{"machine_secret", "client_secret", "exchange_token text", "onboarding_code text"} {
if strings.Contains(strings.ToLower(content), forbidden) {
t.Fatalf("identity configuration migration stores forbidden secret field %q", forbidden)
}
}
}
func TestIdentityOnboardingExchangeMigrationStoresOnlySecretReferences(t *testing.T) {
payload, err := os.ReadFile("../../migrations/0066_identity_onboarding_exchanges.sql")
if err != nil {
t.Fatal(err)
}
content := strings.ToLower(string(payload))
for _, required := range []string{
"create table if not exists gateway_identity_onboarding_exchanges",
"exchange_token_ref text not null",
"cleanup_status text not null default 'none'",
"security_event_credential_handoff_unsafe",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity onboarding migration is missing %q", required)
}
}
for _, forbidden := range []string{"exchange_token text", "onboarding_code", "client_secret", "machine_secret"} {
if strings.Contains(content, forbidden) {
t.Fatalf("identity onboarding migration stores forbidden secret field %q", forbidden)
}
}
}
func TestIdentityOnboardingMigrationDefinesCancellationLifecycle(t *testing.T) {
payload, err := os.ReadFile("../../migrations/0066_identity_onboarding_exchanges.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",
} {
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 TestIdentityOnboardingMigrationDefinesFinalErrorCategories(t *testing.T) {
payload, err := os.ReadFile("../../migrations/0066_identity_onboarding_exchanges.sql")
if err != nil {
t.Fatal(err)
}
content := strings.ToLower(string(payload))
for _, required := range []string{
"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 TestIdentityPairingErrorCategoriesExecuteAgainstCurrentSchema(t *testing.T) {
pool := newIdentityMigrationPostgresTestSchema(t)
ctx := context.Background()
for _, migration := range []string{
"../../migrations/0065_identity_configuration_revisions.sql",
"../../migrations/0066_identity_onboarding_exchanges.sql",
"../../migrations/0067_identity_secret_cleanup_queue.sql",
"../../migrations/0068_identity_pairing_start_reservation.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/0067_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 TestIdentityPairingCancellationLifecycleRejectsPartialStates(t *testing.T) {
pool := newIdentityMigrationPostgresTestSchema(t)
ctx := context.Background()
applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0065_identity_configuration_revisions.sql")
applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0066_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','pairing_step_failed')`,
pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID); err != nil {
t.Fatalf("seed legacy onboarding exchange: %v", err)
}
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
}