package migration import ( "context" "database/sql" "path/filepath" "strings" "testing" "cmroubao/backend-api/internal/platform/database" ) const ( claimsUserID = "00000000-0000-4000-8000-000000000401" claimsDeviceID = "00000000-0000-4000-8000-000000000402" claimsAssetID = "00000000-0000-4000-8000-000000000403" claimsTaskID = "00000000-0000-4000-8000-000000000404" claimsEventID = "00000000-0000-4000-8000-000000000405" claimsExecutionID = "00000000-0000-4000-8000-000000000406" claimsSecondAsset = "00000000-0000-4000-8000-000000000407" claimsSecondTask = "00000000-0000-4000-8000-000000000408" claimsNewEventID = "00000000-0000-4000-8000-000000000409" claimsSecondExec = "00000000-0000-4000-8000-000000000410" claimsTimestamp = "2026-07-26T12:00:00Z" claimsExpiry = "2026-07-26T12:10:00Z" claimsCreator = "local-admin" claimsRequestSHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" claimsDeviceSecret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ) func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { db, runner := openClaimsMigrationDatabase(t) ctx := context.Background() if applied, err := runner.Up(ctx); err != nil { t.Fatalf("initial Up() error = %v", err) } else if applied != 8 { t.Fatalf("initial Up() applied = %d, want 8", applied) } if err := runner.Down(ctx); err != nil { t.Fatalf("initial Down(v8) error = %v", err) } if err := runner.Down(ctx); err != nil { t.Fatalf("initial Down(v7) error = %v", err) } if err := runner.Down(ctx); err != nil { t.Fatalf("initial Down(v6) error = %v", err) } if err := runner.Down(ctx); err != nil { t.Fatalf("initial Down(v5) error = %v", err) } seedClaimsHistoricalFixture(t, db) if applied, err := runner.Up(ctx); err != nil { t.Fatalf("Up(v5-v8) over historical data error = %v", err) } else if applied != 4 { t.Fatalf("Up(v5-v8) applied = %d, want 4", applied) } assertClaimsHistory(t, db, true) if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v8) with compatible history error = %v", err) } assertClaimsHistory(t, db, true) if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v7) with compatible history error = %v", err) } assertClaimsHistory(t, db, true) if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v6) with compatible history error = %v", err) } assertClaimsHistory(t, db, true) if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v5) with compatible history error = %v", err) } assertClaimsHistory(t, db, true) if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v4) with compatible history error = %v", err) } assertClaimsHistory(t, db, false) if applied, err := runner.Up(ctx); err != nil { t.Fatalf("final Up(v4-v8) error = %v", err) } else if applied != 5 { t.Fatalf("final Up(v4-v8) applied = %d, want 5", applied) } assertClaimsHistory(t, db, true) } func TestClaimsMigrationEnforcesLifecycleConstraints(t *testing.T) { db, runner := openClaimsMigrationDatabase(t) if _, err := runner.Up(context.Background()); err != nil { t.Fatalf("Up() error = %v", err) } seedClaimsHistoricalFixture(t, db) insertClaimsTask( t, db, claimsSecondAsset, claimsSecondTask, "second-source", ) assertClaimsStatementRejected( t, db, `UPDATE devices SET accessibility_enabled = 2 WHERE id = ?`, claimsDeviceID, ) assertClaimsStatementRejected( t, db, `UPDATE devices SET pdd_installed = -1 WHERE id = ?`, claimsDeviceID, ) assertClaimsStatementRejected( t, db, `UPDATE purchase_tasks SET claim_token_hash = ? WHERE id = ?`, strings.Repeat("A", 64), claimsTaskID, ) if _, err := db.Exec( `UPDATE purchase_tasks SET status = 'CLAIMED', claimed_by_user_id = ?, claimed_by_device_id = ?, claim_generation = 1, claim_token_hash = ?, claim_issued_at = ?, claim_expires_at = ? WHERE id = ?`, claimsUserID, claimsDeviceID, claimsRequestSHA, claimsTimestamp, claimsExpiry, claimsTaskID, ); err != nil { t.Fatalf("set first active claim: %v", err) } assertClaimsStatementRejected( t, db, `UPDATE purchase_tasks SET status = 'CLAIMED', claimed_by_user_id = ?, claimed_by_device_id = ?, claim_generation = 1, claim_token_hash = ?, claim_issued_at = ?, claim_expires_at = ? WHERE id = ?`, claimsUserID, claimsDeviceID, claimsRequestSHA, claimsTimestamp, claimsExpiry, claimsSecondTask, ) assertClaimsStatementRejected( t, db, `INSERT INTO task_executions ( id, task_id, attempt_no, claim_generation, user_id, device_id, current_step, last_heartbeat_at, order_submitted, started_at ) VALUES (?, ?, 1, 1, ?, ?, 'STARTED', ?, 1, ?)`, claimsExecutionID, claimsTaskID, claimsUserID, claimsDeviceID, claimsTimestamp, claimsTimestamp, ) if _, err := db.Exec( `INSERT INTO task_executions ( id, task_id, attempt_no, claim_generation, user_id, device_id, current_step, last_heartbeat_at, order_submitted, started_at ) VALUES (?, ?, 1, 1, ?, ?, 'STARTED', ?, 0, ?)`, claimsExecutionID, claimsTaskID, claimsUserID, claimsDeviceID, claimsTimestamp, claimsTimestamp, ); err != nil { t.Fatalf("insert valid execution: %v", err) } assertClaimsStatementRejected( t, db, `INSERT INTO task_executions ( id, task_id, attempt_no, claim_generation, user_id, device_id, current_step, last_heartbeat_at, order_submitted, started_at ) VALUES (?, ?, 1, 1, ?, ?, ?, ?, 0, ?)`, claimsSecondExec, claimsSecondTask, claimsUserID, claimsDeviceID, strings.Repeat("A", 65), claimsTimestamp, claimsTimestamp, ) if _, err := db.Exec( `INSERT INTO lifecycle_requests ( user_id, device_id, operation, idempotency_key, request_sha256, result_kind, created_at ) VALUES (?, ?, 'CLAIM_NEXT', 'no-task', ?, 'NO_TASK', ?)`, claimsUserID, claimsDeviceID, claimsRequestSHA, claimsTimestamp, ); err != nil { t.Fatalf("insert valid NO_TASK replay: %v", err) } assertClaimsStatementRejected( t, db, `INSERT INTO lifecycle_requests ( user_id, device_id, operation, idempotency_key, request_sha256, result_kind, created_at ) VALUES (?, ?, 'START', 'invalid-no-task', ?, 'NO_TASK', ?)`, claimsUserID, claimsDeviceID, claimsRequestSHA, claimsTimestamp, ) if _, err := db.Exec( `INSERT INTO lifecycle_requests ( user_id, device_id, operation, idempotency_key, request_sha256, result_kind, task_id, claim_generation, execution_id, created_at ) VALUES (?, ?, 'START', 'valid-start', ?, 'EXECUTION', ?, 1, ?, ?)`, claimsUserID, claimsDeviceID, claimsRequestSHA, claimsTaskID, claimsExecutionID, claimsTimestamp, ); err != nil { t.Fatalf("insert valid START replay: %v", err) } if _, err := db.Exec( `INSERT INTO lifecycle_requests ( user_id, device_id, operation, idempotency_key, request_sha256, result_kind, task_id, claim_generation, created_at ) VALUES (?, ?, 'RELEASE', 'valid-release', ?, 'TASK', ?, 1, ?)`, claimsUserID, claimsDeviceID, claimsRequestSHA, claimsTaskID, claimsTimestamp, ); err != nil { t.Fatalf("insert valid RELEASE replay: %v", err) } if _, err := db.Exec( `INSERT INTO lifecycle_requests ( user_id, device_id, operation, idempotency_key, request_sha256, result_kind, task_id, claim_generation, execution_id, created_at ) VALUES (?, ?, 'CANCEL_ACK', 'valid-cancel-ack', ?, 'TASK', ?, 1, ?, ?)`, claimsUserID, claimsDeviceID, claimsRequestSHA, claimsTaskID, claimsExecutionID, claimsTimestamp, ); err != nil { t.Fatalf("insert valid CANCEL_ACK replay: %v", err) } assertClaimsStatementRejected( t, db, `INSERT INTO lifecycle_requests ( user_id, device_id, operation, idempotency_key, request_sha256, result_kind, task_id, claim_generation, created_at ) VALUES (?, ?, 'CANCEL_ACK', 'cancel-ack-without-execution', ?, 'TASK', ?, 1, ?)`, claimsUserID, claimsDeviceID, claimsRequestSHA, claimsTaskID, claimsTimestamp, ) assertClaimsStatementRejected( t, db, `INSERT INTO task_events ( id, task_id, event_type, message, occurred_at ) VALUES (?, ?, 'TASK_UNKNOWN', 'unknown', ?)`, claimsNewEventID, claimsTaskID, claimsTimestamp, ) } func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) { db, runner := openClaimsMigrationDatabase(t) ctx := context.Background() if _, err := runner.Up(ctx); err != nil { t.Fatalf("Up() error = %v", err) } seedClaimsHistoricalFixture(t, db) if _, err := db.Exec( `INSERT INTO task_events ( id, task_id, event_type, message, occurred_at, actor_user_id, actor_device_id ) VALUES (?, ?, 'TASK_CLAIMED', 'claimed', ?, ?, ?)`, claimsNewEventID, claimsTaskID, claimsTimestamp, claimsUserID, claimsDeviceID, ); err != nil { t.Fatalf("insert v4 audit event: %v", err) } if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v8) error = %v", err) } if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v7) error = %v", err) } if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v6) error = %v", err) } if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v5) error = %v", err) } if err := runner.Down(ctx); err == nil { t.Fatal("Down(v4) succeeded with non-representable audit event") } if !claimsColumnExists(t, db, "task_events", "actor_device_id") { t.Fatal("failed Down(v4) partially replaced task_events") } if !claimsTableExists(t, db, "task_executions") { t.Fatal("failed Down(v4) partially removed task_executions") } var count int if err := db.QueryRow( "SELECT COUNT(*) FROM task_events WHERE id = ?", claimsNewEventID, ).Scan(&count); err != nil { t.Fatalf("query retained audit event: %v", err) } if count != 1 { t.Fatalf("retained audit event count = %d, want 1", count) } } func openClaimsMigrationDatabase( t *testing.T, ) (*sql.DB, *Runner) { t.Helper() db, err := database.Open( context.Background(), filepath.Join(t.TempDir(), "claims-migration.db"), ) if err != nil { t.Fatalf("database.Open() error = %v", err) } t.Cleanup(func() { _ = db.Close() }) runner, err := New(db) if err != nil { t.Fatalf("New() error = %v", err) } return db, runner } func seedClaimsHistoricalFixture(t *testing.T, db *sql.DB) { t.Helper() statements := []struct { query string args []any }{ { `INSERT INTO users ( id, username, password_hash, role, is_active, created_at, updated_at ) VALUES (?, 'buyer', 'hash', 'BUYER', 1, ?, ?)`, []any{claimsUserID, claimsTimestamp, claimsTimestamp}, }, { `INSERT INTO devices ( id, name, token_hash, bound_user_id, is_enabled, created_at, updated_at ) VALUES (?, 'historical device', ?, ?, 1, ?, ?)`, []any{ claimsDeviceID, claimsDeviceSecret, claimsUserID, claimsTimestamp, claimsTimestamp, }, }, } for _, statement := range statements { if _, err := db.Exec(statement.query, statement.args...); err != nil { t.Fatalf("seed historical auth data: %v", err) } } insertClaimsTask(t, db, claimsAssetID, claimsTaskID, "historical-source") if _, err := db.Exec( `INSERT INTO task_events ( id, task_id, event_type, message, occurred_at, actor_user_id ) VALUES (?, ?, 'TASK_CREATED', 'created', ?, ?)`, claimsEventID, claimsTaskID, claimsTimestamp, claimsUserID, ); err != nil { t.Fatalf("seed historical task event: %v", err) } } func insertClaimsTask( t *testing.T, db *sql.DB, assetID string, taskID string, sourceRef string, ) { t.Helper() if _, err := db.Exec( `INSERT INTO assets ( id, creator_subject, purpose, media_type, size_bytes, sha256, storage_key, created_at ) VALUES (?, ?, 'TASK_REFERENCE', 'image/jpeg', 10, ?, ?, ?)`, assetID, claimsCreator, claimsRequestSHA, "assets/"+assetID+".jpg", claimsTimestamp, ); err != nil { t.Fatalf("insert asset %s: %v", assetID, err) } if _, err := db.Exec( `INSERT INTO purchase_tasks ( id, creator_subject, source_ref, title, description, sku, image_asset_id, quantity, currency, status, version, created_at, updated_at, created_by_user_id ) VALUES (?, ?, ?, 'title', 'description', 'sku', ?, 1, 'CNY', 'PENDING', 1, ?, ?, ?)`, taskID, claimsCreator, sourceRef, assetID, claimsTimestamp, claimsTimestamp, claimsUserID, ); err != nil { t.Fatalf("insert task %s: %v", taskID, err) } } func assertClaimsHistory(t *testing.T, db *sql.DB, v4 bool) { t.Helper() var taskCount int if err := db.QueryRow( "SELECT COUNT(*) FROM purchase_tasks WHERE id = ?", claimsTaskID, ).Scan(&taskCount); err != nil { t.Fatalf("query historical task: %v", err) } if taskCount != 1 { t.Fatalf("historical task count = %d, want 1", taskCount) } var actorUserID sql.NullString if err := db.QueryRow( "SELECT actor_user_id FROM task_events WHERE id = ?", claimsEventID, ).Scan(&actorUserID); err != nil { t.Fatalf("query historical event actor: %v", err) } if !actorUserID.Valid || actorUserID.String != claimsUserID { t.Fatalf("historical event actor = %v, want %s", actorUserID, claimsUserID) } if claimsColumnExists( t, db, "purchase_tasks", "claim_generation", ) != v4 { t.Fatalf("claim_generation presence does not match v4=%t", v4) } if claimsColumnExists( t, db, "task_events", "actor_device_id", ) != v4 { t.Fatalf("actor_device_id presence does not match v4=%t", v4) } if claimsTableExists(t, db, "task_executions") != v4 { t.Fatalf("task_executions presence does not match v4=%t", v4) } if v4 { var generation int var tokenHash sql.NullString if err := db.QueryRow( `SELECT claim_generation, claim_token_hash FROM purchase_tasks WHERE id = ?`, claimsTaskID, ).Scan(&generation, &tokenHash); err != nil { t.Fatalf("query historical claim defaults: %v", err) } if generation != 0 || tokenHash.Valid { t.Fatalf( "historical claim defaults generation=%d token=%v", generation, tokenHash, ) } } } func assertClaimsStatementRejected( t *testing.T, db *sql.DB, query string, args ...any, ) { t.Helper() if _, err := db.Exec(query, args...); err == nil { t.Fatalf("statement unexpectedly succeeded: %s", query) } } func claimsTableExists(t *testing.T, db *sql.DB, name string) bool { t.Helper() var count int if err := db.QueryRow( `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name, ).Scan(&count); err != nil { t.Fatalf("query table %s: %v", name, err) } return count == 1 } func claimsColumnExists( t *testing.T, db *sql.DB, table string, column string, ) bool { t.Helper() rows, err := db.Query("PRAGMA table_info(" + table + ")") if err != nil { t.Fatalf("PRAGMA table_info(%s): %v", table, err) } defer rows.Close() for rows.Next() { var cid int var name string var dataType string var notNull int var defaultValue any var primaryKey int if err := rows.Scan( &cid, &name, &dataType, ¬Null, &defaultValue, &primaryKey, ); err != nil { t.Fatalf("scan table_info(%s): %v", table, err) } if name == column { return true } } if err := rows.Err(); err != nil { t.Fatalf("iterate table_info(%s): %v", table, err) } return false }