Files
cmroubao/backend-api/internal/platform/migration/claims_migration_test.go
T

575 lines
15 KiB
Go

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 != 7 {
t.Fatalf("initial Up() applied = %d, want 7", applied)
}
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-v7) over historical data error = %v", err)
} else if applied != 3 {
t.Fatalf("Up(v5-v7) applied = %d, want 3", applied)
}
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-v7) error = %v", err)
} else if applied != 4 {
t.Fatalf("final Up(v4-v7) applied = %d, want 4", 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(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,
&notNull,
&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
}