package migrations_test import ( "context" "database/sql" "os" "path/filepath" "runtime" "strconv" "strings" "testing" "cmbuyer/admin/internal/migrations" "cmbuyer/admin/internal/storage/sqlite" "github.com/pressly/goose/v3" ) const migrationTime = "2026-08-04T00:00:00Z" func TestUpDownAndIdempotence(t *testing.T) { database := openTestDatabase(t) directory := migrationDirectory(t) context := context.Background() if err := migrations.Up(context, database, directory); err != nil { t.Fatalf("apply migrations: %v", err) } assertVersion(t, database, 5) assertTableExists(t, database, "tasks", true) assertTableExists(t, database, "spec_trials", false) assertTableExists(t, database, "order_authorizations", true) assertTableExists(t, database, "purchase_attempts", true) assertTableExists(t, database, "order_submissions", true) assertTableExists(t, database, "evidence_assets", true) assertTableExists(t, database, "device_credentials", true) assertTableExists(t, database, "purchase_attempt_claims", true) assertTableExists(t, database, "single_pass_upgrade_guard", false) if err := migrations.Up(context, database, directory); err != nil { t.Fatalf("reapply migrations: %v", err) } assertVersion(t, database, 5) if err := migrations.Down(context, database, directory); err != nil { t.Fatalf("roll back task claim migration: %v", err) } assertVersion(t, database, 4) assertTableExists(t, database, "purchase_attempt_claims", false) assertTableExists(t, database, "device_credentials", true) if err := migrations.Down(context, database, directory); err != nil { t.Fatalf("roll back device credential migration: %v", err) } assertVersion(t, database, 3) assertTableExists(t, database, "device_credentials", false) assertTableExists(t, database, "evidence_assets", true) if err := migrations.Down(context, database, directory); err != nil { t.Fatalf("roll back evidence migration: %v", err) } assertVersion(t, database, 2) assertTableExists(t, database, "evidence_assets", false) if err := migrations.Down(context, database, directory); err != nil { t.Fatalf("roll back v2 migration: %v", err) } assertVersion(t, database, 1) assertTableExists(t, database, "spec_trials", true) assertTableExists(t, database, "purchase_attempts", false) assertTableExists(t, database, "single_pass_downgrade_guard", false) if err := migrations.Up(context, database, directory); err != nil { t.Fatalf("reapply v2 after rollback: %v", err) } assertVersion(t, database, 5) } func TestUpgradePreservesManualDraftLosslessly(t *testing.T) { database := openTestDatabase(t) migrateToV1(t, database) if _, err := database.Exec(` INSERT INTO tasks ( id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price, reference_asset_id, status, version, created_at, updated_at ) VALUES ('draft-one', 'MANUAL', 'source-ref', 'title', 'goods', 'white', 'XL', 2, '80.50', 'asset-id', 'DRAFT', 7, '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z') `); err != nil { t.Fatalf("insert v1 draft: %v", err) } if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { t.Fatalf("upgrade v1 draft: %v", err) } assertVersion(t, database, 5) var got struct { id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string quantity, version int } if err := database.QueryRow(`SELECT id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price, reference_asset_id, status, version, created_at, updated_at FROM tasks WHERE id = 'draft-one'`).Scan( &got.id, &got.source, &got.sourceRef, &got.title, &got.goodsID, &got.color, &got.size, &got.quantity, &got.maxPrice, &got.assetID, &got.status, &got.version, &got.created, &got.updated, ); err != nil { t.Fatalf("read upgraded draft: %v", err) } if got != (struct { id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string quantity, version int }{"draft-one", "MANUAL", "source-ref", "title", "goods", "white", "XL", "80.50", "asset-id", "DRAFT", "2026-08-03T00:00:00Z", "2026-08-03T01:00:00Z", 2, 7}) { t.Fatalf("upgraded draft changed: %#v", got) } } func TestUpgradeRejectsLegacyExecutionDataAtomically(t *testing.T) { tests := []struct { name string setup func(*testing.T, *sql.DB) }{ {"non-draft task", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "pending", "MANUAL", "PENDING", "1.00") }}, {"non-manual task", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "excel", "EXCEL", "DRAFT", "1.00") }}, {"invalid v2 money", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "zero", "MANUAL", "DRAFT", "0.00") }}, {"third decimal place", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "third-decimal", "MANUAL", "DRAFT", "1.234") }}, {"spec trial", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00") insertV1SpecTrial(t, database, "trial", "task") }}, {"authorization", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00") insertV1SpecTrial(t, database, "trial", "task") insertV1Authorization(t, database, "auth", "task", "trial") }}, {"submission", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00") insertV1SpecTrial(t, database, "trial", "task") insertV1Authorization(t, database, "auth", "task", "trial") if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price, quantity_read, confirm_page_amount, created_at) VALUES ('submission', 'task', 'auth', 'command', 'dry-run', 'FENCED', '1.00', 1, '1.00', ? )`, migrationTime); err != nil { t.Fatalf("insert v1 submission: %v", err) } }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { database := openTestDatabase(t) migrateToV1(t, database) test.setup(t, database) before := v1RowCount(t, database) if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil { t.Fatal("unsafe legacy data upgraded successfully") } assertVersion(t, database, 1) assertTableExists(t, database, "spec_trials", true) assertTableExists(t, database, "purchase_attempts", false) assertTableExists(t, database, "single_pass_upgrade_guard", false) if after := v1RowCount(t, database); after != before { t.Fatalf("v1 data changed after rejection: before=%d after=%d", before, after) } }) } } func TestV2SchemaConstraintsAndRelationships(t *testing.T) { database := openTestDatabase(t) if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { t.Fatalf("apply migrations: %v", err) } for _, column := range []struct{ table, name string }{ {"tasks", "max_total_price"}, {"order_authorizations", "total_price_cap"}, {"purchase_attempts", "gate1_unit_price"}, {"purchase_attempts", "gate2_unit_price"}, {"purchase_attempts", "confirm_amount"}, {"order_submissions", "gate1_unit_price"}, {"order_submissions", "gate2_unit_price"}, {"order_submissions", "confirm_amount"}, } { assertColumnType(t, database, column.table, column.name, "TEXT") } for _, legacy := range []string{"spec_trials", "authorized_unit_price", "spec_trial_id", "command_id", "dry_run_id"} { var count int if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE sql LIKE '%' || ? || '%'`, legacy).Scan(&count); err != nil { t.Fatalf("search schema for %s: %v", legacy, err) } if count != 0 { t.Fatalf("legacy identifier %q remains in v2 schema", legacy) } } insertV2Task(t, database, "task-one", "MANUAL", "DRAFT") insertV2Task(t, database, "task-two", "MANUAL", "DRAFT") for index, value := range []string{"", "0", "0.00", "-1.00", "1e2", "1.", "1.234", " 1.00", "one"} { if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, 'MANUAL', 'title', 'goods', 'white', 'XL', 1, ?, 'DRAFT', ?, ?)`, "bad-price-"+strconv.Itoa(index), value, migrationTime, migrationTime); err == nil { t.Fatalf("invalid total price %q succeeded", value) } } if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES ('bad-status', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', ?, ?)`, migrationTime, migrationTime); err == nil { t.Fatal("unknown task status succeeded") } insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one") insertV2Authorization(t, database, "auth-two", "task-two", 1, "start-two") if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-price', 'task-one', 2, 'bad-price', 'goods', 'white', 'XL', 1, '1.234', 'ACTIVE', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil { t.Fatal("third decimal authorization cap succeeded") } if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-status', 'task-one', 2, 'bad-status', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil { t.Fatal("unknown authorization status succeeded") } insertV2Authorization(t, database, "auth-one-b", "task-one", 2, "start-one-b") if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('duplicate-version', 'task-one', 1, 'different-start', 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil { t.Fatal("duplicate task version authorization succeeded") } if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('cross-attempt', 'task-one', 'auth-two', 1, 'CLAIMED', ?)`, migrationTime); err == nil { t.Fatal("attempt using another task authorization succeeded") } insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1) if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, gate1_unit_price, started_at) VALUES ('bad-attempt-price', 'task-one', 'auth-one', 2, 'ORDERING', '1.234', ?)`, migrationTime); err == nil { t.Fatal("third decimal gate price succeeded") } if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('bad-attempt-status', 'task-one', 'auth-one', 2, 'UNKNOWN', ?)`, migrationTime); err == nil { t.Fatal("unknown attempt status succeeded") } if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, failure_code, started_at) VALUES ('bad-code', 'task-one', 'auth-one', 2, 'FAILED', 'FREE_TEXT', ?)`, migrationTime); err == nil { t.Fatal("unknown failure code succeeded") } if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-submission', 'task-one', 'auth-two', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil { t.Fatal("submission using another task authorization succeeded") } if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-authorization-submission', 'task-one', 'auth-one-b', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil { t.Fatal("submission combining another same-task authorization and attempt succeeded") } if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-status', 'task-one', 'auth-one', 'attempt-one', 'UNKNOWN', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil { t.Fatal("unknown submission status succeeded") } if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-price', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.234', '1.00', 1, '1.00', ?)`, migrationTime); err == nil { t.Fatal("third decimal submission price succeeded") } insertV2Submission(t, database, "submission-one", "task-one", "auth-one", "attempt-one") if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('duplicate-auth', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil { t.Fatal("second submission for fenced authorization succeeded") } } func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) { database := openTestDatabase(t) migrateToV3(t, database) insertV2Task(t, database, "task-one", "MANUAL", "DRAFT") insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one") insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1) insertV2Task(t, database, "task-two", "MANUAL", "DRAFT") insertV2Authorization(t, database, "auth-two", "task-two", 1, "start-two") insertV2Attempt(t, database, "attempt-two", "task-two", "auth-two", 1) hash := strings.Repeat("a", 64) insert := `INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` validArgs := []any{"asset-one", "upload-one", "task-one", "attempt-one", "SKU_PANEL_GATE_1", "INTERNAL_RAW", hash, 100, "image/png", 100, 100, "aa/" + hash + ".png", "device-one", migrationTime, migrationTime} if _, err := database.Exec(insert, validArgs...); err != nil { t.Fatalf("insert valid evidence: %v", err) } for name, mutate := range map[string]func([]any){ "attempt from another task": func(values []any) { values[0], values[1], values[3] = "bad-task", "upload-bad-task", "attempt-two" }, "unapproved kind": func(values []any) { values[0], values[1], values[4] = "bad-kind", "upload-bad-kind", "ORDER_CONFIRM" }, "wrong privacy": func(values []any) { values[0], values[1], values[5] = "bad-privacy", "upload-bad-privacy", "PUBLIC" }, "uppercase hash": func(values []any) { values[0], values[1], values[6], values[11] = "bad-hash", "upload-bad-hash", strings.Repeat("A", 64), "AA/"+strings.Repeat("A", 64)+".png" }, "too many pixels": func(values []any) { values[0], values[1], values[9], values[10] = "bad-pixels", "upload-bad-pixels", 8192, 8192 }, "client path": func(values []any) { values[0], values[1], values[11] = "bad-path", "upload-bad-path", `..\secret.png` }, } { t.Run(name, func(t *testing.T) { values := append([]any(nil), validArgs...) mutate(values) if _, err := database.Exec(insert, values...); err == nil { t.Fatal("invalid evidence row succeeded") } }) } if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil { t.Fatal("evidence-bearing schema downgraded successfully") } assertVersion(t, database, 3) assertTableExists(t, database, "evidence_assets", true) var count int if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 { t.Fatalf("evidence after rejected downgrade = %d, err=%v", count, err) } } func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) { database := openTestDatabase(t) migrateToV4(t, database) deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301" hash := make([]byte, 32) for index := range hash { hash[index] = byte(index + 1) } insert := `INSERT INTO device_credentials (device_id, display_name, token_sha256, status, created_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?)` valid := []any{deviceID, "采购工具一号", hash, "ACTIVE", migrationTime, nil} if _, err := database.Exec(insert, valid...); err != nil { t.Fatalf("insert valid credential: %v", err) } for name, mutate := range map[string]func([]any){ "uppercase uuid": func(values []any) { values[0], values[2] = strings.ToUpper(deviceID), append([]byte(nil), hash...) }, "wrong uuid version": func(values []any) { values[0], values[2] = "23c9f507-7473-3fa6-8d71-8786c34c6301", append([]byte(nil), hash...) }, "blank display name": func(values []any) { values[0], values[1], values[2] = "33c9f507-7473-4fa6-8d71-8786c34c6301", "", append([]byte(nil), hash...) }, "padded display name": func(values []any) { values[0], values[1], values[2] = "43c9f507-7473-4fa6-8d71-8786c34c6301", " padded", append([]byte(nil), hash...) }, "text hash": func(values []any) { values[0], values[2] = "53c9f507-7473-4fa6-8d71-8786c34c6301", strings.Repeat("a", 32) }, "short blob hash": func(values []any) { values[0], values[2] = "63c9f507-7473-4fa6-8d71-8786c34c6301", make([]byte, 31) }, "unknown status": func(values []any) { values[0], values[2], values[3] = "73c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "UNKNOWN" }, "active with revoke time": func(values []any) { values[0], values[2], values[5] = "83c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), migrationTime }, "revoked without time": func(values []any) { values[0], values[2], values[3] = "93c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "REVOKED" }, "revoke before creation": func(values []any) { values[0], values[2], values[3], values[4], values[5] = "b3c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "REVOKED", "2026-08-04T01:00:00Z", "2026-08-04T00:00:00Z" }, "non UTC created time": func(values []any) { values[0], values[2], values[4] = "a3c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "2026-08-04T08:00:00+08:00" }, } { t.Run(name, func(t *testing.T) { values := append([]any(nil), valid...) mutate(values) if bytesValue, ok := values[2].([]byte); ok && len(bytesValue) == 32 { bytesValue[0]++ } if _, err := database.Exec(insert, values...); err == nil { t.Fatal("invalid device credential row succeeded") } }) } if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil { t.Fatal("credential-bearing schema downgraded successfully") } assertVersion(t, database, 4) assertTableExists(t, database, "device_credentials", true) var count int if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials`).Scan(&count); err != nil || count != 1 { t.Fatalf("credentials after rejected downgrade = %d, err=%v", count, err) } } func TestTaskClaimMigrationGuardsOwnershipConstraintsAndDowngradeFacts(t *testing.T) { t.Run("upgrade rejects unmappable execution facts atomically", func(t *testing.T) { database := openTestDatabase(t) migrateToV4(t, database) insertV2Task(t, database, "legacy-task", "MANUAL", "DRAFT") insertV2Authorization(t, database, "legacy-auth", "legacy-task", 1, "legacy-start") insertV2Attempt(t, database, "legacy-attempt", "legacy-task", "legacy-auth", 1) if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil { t.Fatal("v5 upgrade accepted an attempt without device/session ownership") } assertVersion(t, database, 4) assertTableExists(t, database, "purchase_attempt_claims", false) var count int if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&count); err != nil || count != 1 { t.Fatalf("legacy attempt after rejected upgrade = %d, err %v", count, err) } }) t.Run("schema binds authorization device session generation and token", func(t *testing.T) { database := openTestDatabase(t) if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { t.Fatalf("apply migrations: %v", err) } deviceA := "13c9f507-7473-4fa6-8d71-8786c34c6301" deviceB := "23c9f507-7473-4fa6-8d71-8786c34c6301" sessionA := "33c9f507-7473-4fa6-8d71-8786c34c6301" sessionB := "43c9f507-7473-4fa6-8d71-8786c34c6301" taskA := "53c9f507-7473-4fa6-8d71-8786c34c6301" authA := "63c9f507-7473-4fa6-8d71-8786c34c6301" attemptA := "73c9f507-7473-4fa6-8d71-8786c34c6301" taskB := "83c9f507-7473-4fa6-8d71-8786c34c6301" authB := "93c9f507-7473-4fa6-8d71-8786c34c6301" attemptB := "a3c9f507-7473-4fa6-8d71-8786c34c6301" tokenA := make([]byte, 32) for index := range tokenA { tokenA[index] = byte(index + 1) } for index, device := range []string{deviceA, deviceB} { hash := make([]byte, 32) hash[0] = byte(index + 100) if _, err := database.Exec(`INSERT INTO device_credentials (device_id,display_name,token_sha256,status,created_at,revoked_at) VALUES (?, ?, ?, 'ACTIVE', ?, NULL)`, device, "device "+strconv.Itoa(index), hash, migrationTime); err != nil { t.Fatalf("insert device: %v", err) } } insertV2Task(t, database, taskA, "MANUAL", "DRAFT") insertV2Authorization(t, database, authA, taskA, 1, "start-a") insertV2Attempt(t, database, attemptA, taskA, authA, 1) insertClaim := `INSERT INTO purchase_attempt_claims (attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation, task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity, total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at) VALUES (?, ?, ?, ?, ?, ?, 2, 'task', 1, 'goods', 'white', 'XL', 1, '1.00', '2026-08-04T01:00:00Z', ?, ?, '2026-08-04T00:05:00Z', ?, NULL)` if _, err := database.Exec(insertClaim, attemptA, taskA, authA, deviceA, sessionA, 1, make([]byte, 32), tokenA, migrationTime); err != nil { t.Fatalf("insert valid claim: %v", err) } if _, err := database.Exec(`INSERT INTO purchase_attempts (id,task_id,authorization_id,claim_generation,status,started_at) VALUES ('b3c9f507-7473-4fa6-8d71-8786c34c6301', ?, ?, 2, 'CLAIMED', ?)`, taskA, authA, migrationTime); err == nil { t.Fatal("second attempt for one authorization succeeded") } insertV2Task(t, database, taskB, "MANUAL", "DRAFT") insertV2Authorization(t, database, authB, taskB, 1, "start-b") insertV2Attempt(t, database, attemptB, taskB, authB, 1) if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceB, sessionB, 2, make([]byte, 32), make([]byte, 32), migrationTime); err == nil { t.Fatal("claim with generation different from its attempt succeeded") } if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceA, sessionB, 1, make([]byte, 32), make([]byte, 32), migrationTime); err == nil { t.Fatal("second open claim for one device succeeded") } claimRequest := `INSERT INTO task_claim_requests (claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at) VALUES (?, ?, ?, 'CLAIMED', ?, '2026-08-04T00:05:00Z', NULL, ?)` if _, err := database.Exec(claimRequest, "c3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionB, attemptA, migrationTime); err == nil { t.Fatal("claim request with another session succeeded") } if _, err := database.Exec(claimRequest, "d3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionA, attemptA, migrationTime); err != nil { t.Fatalf("insert bound claim request: %v", err) } renewal := `INSERT INTO purchase_attempt_lease_renewals (renew_request_id,task_id,attempt_id,device_id,session_id,claim_generation, claim_token_sha256,expected_lease_expires_at,lease_expires_at,created_at) VALUES (?, ?, ?, ?, ?, ?, ?, '2026-08-04T00:05:00Z', '2026-08-04T00:06:00Z', ?)` if _, err := database.Exec(renewal, "e3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 2, tokenA, migrationTime); err == nil { t.Fatal("renewal with another generation succeeded") } wrongHash := append([]byte(nil), tokenA...) wrongHash[0] ^= 0xff if _, err := database.Exec(renewal, "f3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 1, wrongHash, migrationTime); err == nil { t.Fatal("renewal with another token hash succeeded") } if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil { t.Fatal("claim-bearing schema downgraded successfully") } assertVersion(t, database, 5) assertTableExists(t, database, "purchase_attempt_claims", true) }) t.Run("empty request alone blocks downgrade", func(t *testing.T) { database := openTestDatabase(t) if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { t.Fatal(err) } device := "13c9f507-7473-4fa6-8d71-8786c34c6301" if _, err := database.Exec(`INSERT INTO device_credentials (device_id,display_name,token_sha256,status,created_at,revoked_at) VALUES (?, 'device', ?, 'ACTIVE', ?, NULL)`, device, make([]byte, 32), migrationTime); err != nil { t.Fatal(err) } if _, err := database.Exec(`INSERT INTO task_claim_requests (claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at) VALUES ('23c9f507-7473-4fa6-8d71-8786c34c6301', ?, '33c9f507-7473-4fa6-8d71-8786c34c6301', 'EMPTY', NULL, NULL, NULL, ?)`, device, migrationTime); err != nil { t.Fatal(err) } if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil { t.Fatal("EMPTY request was silently dropped by downgrade") } assertVersion(t, database, 5) }) } func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) { tests := []struct { name string setup func(*testing.T, *sql.DB) }{ {"authorization", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "task", "MANUAL", "DRAFT") insertV2Authorization(t, database, "auth", "task", 1, "start") }}, {"attempt", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "task", "MANUAL", "DRAFT") insertV2Authorization(t, database, "auth", "task", 1, "start") insertV2Attempt(t, database, "attempt", "task", "auth", 1) }}, {"submission", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "task", "MANUAL", "DRAFT") insertV2Authorization(t, database, "auth", "task", 1, "start") insertV2Attempt(t, database, "attempt", "task", "auth", 1) insertV2Submission(t, database, "submission", "task", "auth", "attempt") }}, {"non-draft task", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "pending", "MANUAL", "PENDING") }}, {"non-manual task", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "excel", "EXCEL", "DRAFT") }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { database := openTestDatabase(t) migrateToV2(t, database) test.setup(t, database) before := v2RowCount(t, database) if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil { t.Fatal("unsafe v2 data downgraded successfully") } assertVersion(t, database, 2) assertTableExists(t, database, "purchase_attempts", true) assertTableExists(t, database, "spec_trials", false) assertTableExists(t, database, "single_pass_downgrade_guard", false) if after := v2RowCount(t, database); after != before { t.Fatalf("v2 data changed after rejected downgrade: before=%d after=%d", before, after) } }) } } func migrateToV1(t *testing.T, database *sql.DB) { t.Helper() if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil { t.Fatalf("apply v1: %v", err) } assertVersion(t, database, 1) } func migrateToV2(t *testing.T, database *sql.DB) { t.Helper() if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil { t.Fatalf("apply v1: %v", err) } if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil { t.Fatalf("apply v2: %v", err) } assertVersion(t, database, 2) } func migrateToV3(t *testing.T, database *sql.DB) { t.Helper() migrateToV2(t, database) if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil { t.Fatalf("apply v3: %v", err) } assertVersion(t, database, 3) } func migrateToV4(t *testing.T, database *sql.DB) { t.Helper() migrateToV3(t, database) if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil { t.Fatalf("apply v4: %v", err) } assertVersion(t, database, 4) } func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) { t.Helper() if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil { t.Fatalf("insert v1 task: %v", err) } } func insertV1SpecTrial(t *testing.T, database *sql.DB, id, taskID string) { t.Helper() if _, err := database.Exec(`INSERT INTO spec_trials (id, task_id, attempt, product_title, selected_color, selected_size, unit_price, total_price, evidence_sha256, created_at) VALUES (?, ?, 1, 'title', 'white', 'XL', '1.00', '1.00', 'hash', ?)`, id, taskID, migrationTime); err != nil { t.Fatalf("insert v1 spec trial: %v", err) } } func insertV1Authorization(t *testing.T, database *sql.DB, id, taskID, trialID string) { t.Helper() if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity, authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, 1, 'goods', 'white', 'XL', 1, '1.00', '1.00', 'PENDING_DELIVERY', 'admin', ?, ?)`, id, taskID, trialID, migrationTime, migrationTime); err != nil { t.Fatalf("insert v1 authorization: %v", err) } } func insertV2Task(t *testing.T, database *sql.DB, id, source, status string) { t.Helper() if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, '1.00', ?, ?, ?)`, id, source, status, migrationTime, migrationTime); err != nil { t.Fatalf("insert v2 task: %v", err) } } func insertV2Authorization(t *testing.T, database *sql.DB, id, taskID string, version int, startKey string) { t.Helper() if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, id, taskID, version, startKey, migrationTime, migrationTime); err != nil { t.Fatalf("insert v2 authorization: %v", err) } } func insertV2Attempt(t *testing.T, database *sql.DB, id, taskID, authorizationID string, generation int) { t.Helper() if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, ?, 'CLAIMED', ?)`, id, taskID, authorizationID, generation, migrationTime); err != nil { t.Fatalf("insert v2 attempt: %v", err) } } func insertV2Submission(t *testing.T, database *sql.DB, id, taskID, authorizationID, attemptID string) { t.Helper() if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES (?, ?, ?, ?, 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, id, taskID, authorizationID, attemptID, migrationTime); err != nil { t.Fatalf("insert v2 submission: %v", err) } } func v1RowCount(t *testing.T, database *sql.DB) int { t.Helper() var count int if err := database.QueryRow(`SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM spec_trials) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM order_submissions)`).Scan(&count); err != nil { t.Fatalf("count v1 rows: %v", err) } return count } func v2RowCount(t *testing.T, database *sql.DB) int { t.Helper() var count int if err := database.QueryRow(`SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM purchase_attempts) + (SELECT COUNT(*) FROM order_submissions)`).Scan(&count); err != nil { t.Fatalf("count v2 rows: %v", err) } return count } func openTestDatabase(t *testing.T) *sql.DB { t.Helper() database, err := sqlite.Open(filepath.Join(t.TempDir(), "migrations.db")) if err != nil { t.Fatalf("open test database: %v", err) } t.Cleanup(func() { _ = database.Close() }) return database } func migrationDirectory(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) if !ok { t.Fatal("locate migration test source") } return filepath.Join(filepath.Dir(file), "..", "..", "migrations") } func assertVersion(t *testing.T, database *sql.DB, want int64) { t.Helper() got, err := goose.GetDBVersion(database) if err != nil { t.Fatalf("read migration version: %v", err) } if got != want { t.Fatalf("migration version = %d, want %d", got, want) } } func assertTableExists(t *testing.T, database *sql.DB, table string, want bool) { t.Helper() var count int if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count); err != nil { t.Fatalf("look up table %s: %v", table, err) } if got := count == 1; got != want { t.Fatalf("table %s exists = %t, want %t", table, got, want) } } func assertColumnType(t *testing.T, database *sql.DB, table, column, want string) { t.Helper() var got string if err := database.QueryRow(`SELECT type FROM pragma_table_info(?) WHERE name = ?`, table, column).Scan(&got); err != nil { t.Fatalf("read %s.%s type: %v", table, column, err) } if got != want { t.Fatalf("%s.%s type = %s, want %s", table, column, got, want) } } func TestMigrationsDoNotDisableForeignKeys(t *testing.T) { for _, name := range []string{"00002_single_pass_model.sql", "00003_evidence_assets.sql", "00004_device_credentials.sql"} { contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), name)) if err != nil { t.Fatalf("read %s: %v", name, err) } if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") { t.Fatalf("%s disables foreign keys", name) } } }