package taskclaim import ( "bytes" "context" "crypto/sha256" "database/sql" "encoding/hex" "errors" "path/filepath" "reflect" "runtime" "strings" "sync" "testing" "time" "cmbuyer/admin/internal/migrations" "cmbuyer/admin/internal/storage/sqlite" ) const ( testDeviceA = "10000000-0000-4000-8000-000000000001" testDeviceB = "10000000-0000-4000-8000-000000000002" testSessionA = "20000000-0000-4000-8000-000000000001" testSessionB = "20000000-0000-4000-8000-000000000002" testTaskA = "30000000-0000-4000-8000-000000000001" testTaskB = "30000000-0000-4000-8000-000000000002" testAuthA = "40000000-0000-4000-8000-000000000001" testAuthB = "40000000-0000-4000-8000-000000000002" testClaimRequestA = "50000000-0000-4000-8000-000000000001" testClaimRequestB = "50000000-0000-4000-8000-000000000002" testClaimRequestC = "50000000-0000-4000-8000-000000000003" testRenewRequestA = "60000000-0000-4000-8000-000000000001" testRenewRequestB = "60000000-0000-4000-8000-000000000002" ) var testNow = time.Date(2026, 8, 4, 1, 2, 3, 123000000, time.UTC) func TestClaimReplayEmptyManualAndSecretRecovery(t *testing.T) { database := openClaimTestDatabase(t) insertDevice(t, database, testDeviceA, []byte("device-a")) insertDevice(t, database, testDeviceB, []byte("device-b")) insertCandidate(t, database, testTaskA, testAuthA, testNow.Add(-time.Minute), testNow.Add(10*time.Minute), true) secret := bytes.Repeat([]byte{0x11}, 32) store := mustStore(t, database, secret, 30*time.Second) store.now = func() time.Time { return testNow } command := ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA} claimed, found, err := store.ClaimNext(context.Background(), testDeviceA, command) if err != nil || !found { t.Fatalf("ClaimNext = found %v, err %v", found, err) } if claimed.Task.ID != testTaskA || claimed.Task.Version != 3 || claimed.Authorization.ID != testAuthA || claimed.Authorization.TaskVersion != 2 || claimed.Attempt.ClaimGeneration != 1 || len(claimed.Attempt.ClaimToken) != 64 || strings.ToLower(claimed.Attempt.ClaimToken) != claimed.Attempt.ClaimToken { t.Fatalf("unexpected claim response: %#v", claimed) } assertClaimState(t, database, 1, "CLAIMED", "CLAIMED") assertNoPlaintextTokenColumnOrValue(t, database, claimed.Attempt.ClaimToken) replayed, found, err := store.ClaimNext(context.Background(), testDeviceA, command) if err != nil || !found || !reflect.DeepEqual(replayed, claimed) { t.Fatalf("same request replay = %#v, found %v, err %v", replayed, found, err) } restarted := mustStore(t, database, secret, 30*time.Second) restarted.now = func() time.Time { return testNow.Add(5 * time.Second) } replayed, found, err = restarted.ClaimNext(context.Background(), testDeviceA, command) if err != nil || !found || !reflect.DeepEqual(replayed, claimed) { t.Fatalf("restart replay = %#v, found %v, err %v", replayed, found, err) } if _, err := NewStore(database, bytes.Repeat([]byte{0x22}, 32), 30*time.Second); err == nil { t.Fatal("NewStore accepted a secret that cannot rebuild existing claims") } sameSession, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{ SessionID: testSessionA, ClaimRequestID: "50000000-0000-4000-8000-000000000005", }) if err != nil || !found || sameSession.Attempt.ID != claimed.Attempt.ID || sameSession.Attempt.ClaimToken != claimed.Attempt.ClaimToken { t.Fatalf("same-session recovery = %#v, found %v, err %v", sameSession, found, err) } if _, err := database.Exec(`UPDATE order_authorizations SET goods_id='937122477376', sku_color='白色', sku_size='L', quantity=3, total_price_cap='40.00', expires_at=? WHERE id=?`, formatTime(testNow.Add(20*time.Minute)), testAuthA); err != nil { t.Fatalf("mutate authorization source: %v", err) } if _, err := database.Exec(`UPDATE tasks SET title='漂移标题', goods_id='937122477376', sku_color='白色', sku_size='L', quantity=3, max_total_price='40.00' WHERE id=?`, testTaskA); err != nil { t.Fatalf("mutate task source: %v", err) } afterDrift := mustStore(t, database, secret, 30*time.Second) afterDrift.now = func() time.Time { return testNow.Add(6 * time.Second) } stable, found, err := afterDrift.ClaimNext(context.Background(), testDeviceA, command) if err != nil || !found || !reflect.DeepEqual(stable, claimed) { t.Fatalf("source-drift replay = %#v, found %v, err %v; want original %#v", stable, found, err, claimed) } if _, _, err := afterDrift.ClaimNext(context.Background(), testDeviceA, ClaimCommand{ SessionID: testSessionA, ClaimRequestID: "50000000-0000-4000-8000-000000000006", }); !errors.Is(err, ErrRequiresManual) { t.Fatalf("new recovery after source drift error = %v", err) } manualCommand := ClaimCommand{SessionID: testSessionB, ClaimRequestID: testClaimRequestB} if _, _, err := store.ClaimNext(context.Background(), testDeviceA, manualCommand); !errors.Is(err, ErrRequiresManual) { t.Fatalf("different session error = %v, want ErrRequiresManual", err) } if _, _, err := store.ClaimNext(context.Background(), testDeviceA, manualCommand); !errors.Is(err, ErrRequiresManual) { t.Fatalf("manual replay error = %v, want ErrRequiresManual", err) } assertClaimState(t, database, 1, "CLAIMED", "CLAIMED") emptyCommand := ClaimCommand{SessionID: testSessionB, ClaimRequestID: testClaimRequestC} if _, found, err := store.ClaimNext(context.Background(), testDeviceB, emptyCommand); err != nil || found { t.Fatalf("empty claim = found %v, err %v", found, err) } insertCandidate(t, database, testTaskB, testAuthB, testNow, testNow.Add(10*time.Minute), true) if _, found, err := store.ClaimNext(context.Background(), testDeviceB, emptyCommand); err != nil || found { t.Fatalf("persisted EMPTY replay = found %v, err %v", found, err) } claimedB, found, err := store.ClaimNext(context.Background(), testDeviceB, ClaimCommand{ SessionID: testSessionB, ClaimRequestID: "50000000-0000-4000-8000-000000000004", }) if err != nil || !found || claimedB.Task.ID != testTaskB { t.Fatalf("new request after EMPTY = %#v, found %v, err %v", claimedB, found, err) } var distinctNonces int if err := database.QueryRow("SELECT COUNT(DISTINCT claim_nonce) FROM purchase_attempt_claims").Scan(&distinctNonces); err != nil || distinctNonces != 2 { t.Fatalf("distinct claim nonces = %d, err %v", distinctNonces, err) } } func TestSameSessionOrderingRecoveryNeverClaimsAnotherTask(t *testing.T) { database := openClaimTestDatabase(t) insertDevice(t, database, testDeviceA, []byte("device-a")) insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true) insertCandidate(t, database, testTaskB, testAuthB, testNow.Add(time.Second), testNow.Add(10*time.Minute), true) store := mustStore(t, database, bytes.Repeat([]byte{0x21}, 32), time.Minute) store.now = func() time.Time { return testNow } claimed, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}) if err != nil || !found || claimed.Task.ID != testTaskA { t.Fatalf("initial claim = %#v, found %v, err %v", claimed, found, err) } if _, err := database.Exec("UPDATE tasks SET status='ORDERING', version=version+1 WHERE id=?", testTaskA); err != nil { t.Fatal(err) } if _, err := database.Exec("UPDATE purchase_attempts SET status='ORDERING' WHERE id=?", claimed.Attempt.ID); err != nil { t.Fatal(err) } recovered, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestB}) if err != nil || !found || recovered.Attempt.ID != claimed.Attempt.ID || recovered.Task.ID != testTaskA { t.Fatalf("ORDERING recovery = %#v, found %v, err %v", recovered, found, err) } var attempts int var taskBStatus string if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&attempts); err != nil { t.Fatal(err) } if err := database.QueryRow("SELECT status FROM tasks WHERE id=?", testTaskB).Scan(&taskBStatus); err != nil { t.Fatal(err) } if attempts != 1 || taskBStatus != "PENDING" { t.Fatalf("ORDERING recovery attempts/taskB = %d/%s", attempts, taskBStatus) } if _, err := database.Exec("UPDATE tasks SET version=version+1 WHERE id=?", testTaskA); err != nil { t.Fatal(err) } if _, _, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestC}); !errors.Is(err, ErrRequiresManual) { t.Fatalf("ORDERING recovery with drifted version error = %v", err) } if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&attempts); err != nil || attempts != 1 { t.Fatalf("attempts after drifted ORDERING recovery = %d, err %v", attempts, err) } } func TestClaimRollsBackEveryBusinessMutationOnLateFailure(t *testing.T) { database := openClaimTestDatabase(t) insertDevice(t, database, testDeviceA, []byte("device-a")) insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true) if _, err := database.Exec(`CREATE TRIGGER fail_claim_insert BEFORE INSERT ON purchase_attempt_claims BEGIN SELECT RAISE(ABORT, 'injected claim failure'); END`); err != nil { t.Fatal(err) } store := mustStore(t, database, bytes.Repeat([]byte{0x31}, 32), 30*time.Second) store.now = func() time.Time { return testNow } if _, _, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}); err == nil { t.Fatal("ClaimNext succeeded despite injected late failure") } assertClaimState(t, database, 0, "PENDING", "ACTIVE") for _, table := range []string{"purchase_attempts", "task_claim_requests"} { var count int if err := database.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil || count != 0 { t.Fatalf("%s rows after rollback = %d, err %v", table, count, err) } } } func TestClaimEligibilityStableOrderAndConcurrentUniqueness(t *testing.T) { database := openClaimTestDatabase(t) insertDevice(t, database, testDeviceA, []byte("device-a")) insertDevice(t, database, testDeviceB, []byte("device-b")) // The oldest row has a mismatched snapshot and is ineligible; the next oldest valid row wins. insertCandidate(t, database, testTaskA, testAuthA, testNow.Add(-2*time.Minute), testNow.Add(10*time.Minute), false) insertCandidate(t, database, testTaskB, testAuthB, testNow.Add(-time.Minute), testNow.Add(10*time.Minute), true) store := mustStore(t, database, bytes.Repeat([]byte{0x33}, 32), time.Minute) store.now = func() time.Time { return testNow } type result struct { response ClaimResponse found bool err error } commands := []struct{ device, session, request string }{ {testDeviceA, testSessionA, testClaimRequestA}, {testDeviceB, testSessionB, testClaimRequestB}, } results := make(chan result, 2) var wait sync.WaitGroup for _, command := range commands { command := command wait.Add(1) go func() { defer wait.Done() response, found, err := store.ClaimNext(context.Background(), command.device, ClaimCommand{SessionID: command.session, ClaimRequestID: command.request}) results <- result{response, found, err} }() } wait.Wait() close(results) foundCount := 0 for result := range results { if result.err != nil { t.Fatalf("concurrent ClaimNext error: %v", result.err) } if result.found { foundCount++ if result.response.Task.ID != testTaskB { t.Fatalf("claimed task = %s, want stable eligible task B", result.response.Task.ID) } } } if foundCount != 1 { t.Fatalf("successful claims = %d, want 1", foundCount) } var claimCount int if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_claims").Scan(&claimCount); err != nil || claimCount != 1 { t.Fatalf("claim count = %d, err %v", claimCount, err) } var taskStatus, authorizationStatus string if err := database.QueryRow(`SELECT tasks.status, order_authorizations.status FROM tasks JOIN order_authorizations ON order_authorizations.task_id=tasks.id WHERE tasks.id=?`, testTaskB). Scan(&taskStatus, &authorizationStatus); err != nil || taskStatus != "CLAIMED" || authorizationStatus != "CLAIMED" { t.Fatalf("claimed B states = %s/%s, err %v", taskStatus, authorizationStatus, err) } } func TestClaimConcurrencyAcrossDistinctDatabasesAndStores(t *testing.T) { path := filepath.ToSlash(filepath.Join(t.TempDir(), "shared-claim.db")) source := "file:" + path + "?_busy_timeout=5000&_journal_mode=WAL" databaseA, err := sqlite.Open(source) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = databaseA.Close() }) if err := migrations.Up(context.Background(), databaseA, claimMigrationDirectory(t)); err != nil { t.Fatal(err) } databaseB, err := sqlite.Open(source) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = databaseB.Close() }) databaseA.SetMaxOpenConns(1) databaseB.SetMaxOpenConns(1) insertDevice(t, databaseA, testDeviceA, []byte("device-a")) insertDevice(t, databaseA, testDeviceB, []byte("device-b")) insertCandidate(t, databaseA, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true) secret := bytes.Repeat([]byte{0x39}, 32) storeA := mustStore(t, databaseA, secret, time.Minute) storeB := mustStore(t, databaseB, secret, time.Minute) storeA.now = func() time.Time { return testNow } storeB.now = func() time.Time { return testNow } firstLinearized := make(chan struct{}) releaseFirst := make(chan struct{}) secondAtFirstWrite := make(chan struct{}) var releaseOnce sync.Once release := func() { releaseOnce.Do(func() { close(releaseFirst) }) } t.Cleanup(release) storeA.afterLinearization = func() { close(firstLinearized) <-releaseFirst } storeB.beforeLinearization = func() { // Reaching this hook means B has begun its own transaction and its very next // database operation is the first-write UPDATE currently held by A. close(secondAtFirstWrite) } type result struct { found bool err error } firstResult := make(chan result, 1) secondResult := make(chan result, 1) go func() { _, found, err := storeA.ClaimNext(context.Background(), testDeviceA, ClaimCommand{ SessionID: testSessionA, ClaimRequestID: testClaimRequestA, }) firstResult <- result{found: found, err: err} }() select { case <-firstLinearized: case result := <-firstResult: t.Fatalf("first ClaimNext returned before holding SQLite write position: found %v, err %v", result.found, result.err) case <-time.After(time.Second): t.Fatal("first ClaimNext did not reach SQLite write position") } go func() { _, found, err := storeB.ClaimNext(context.Background(), testDeviceB, ClaimCommand{ SessionID: testSessionB, ClaimRequestID: testClaimRequestB, }) secondResult <- result{found: found, err: err} }() select { case <-secondAtFirstWrite: // A still owns the SQLite write position here. B cannot have observed or // changed claim state, so releasing A below creates deterministic contention. case result := <-secondResult: release() <-firstResult t.Fatalf("second ClaimNext returned before reaching the contended first write: found %v, err %v", result.found, result.err) case <-time.After(time.Second): release() <-firstResult t.Fatal("second ClaimNext did not reach the contended SQLite first write") } select { case result := <-secondResult: release() <-firstResult t.Fatalf("second ClaimNext completed while first transaction held SQLite write position: found %v, err %v", result.found, result.err) default: } release() first := <-firstResult second := <-secondResult if first.err != nil || !first.found { t.Fatalf("first cross-database ClaimNext = found %v, err %v", first.found, first.err) } if second.err != nil || second.found { t.Fatalf("second cross-database ClaimNext = found %v, err %v", second.found, second.err) } var attempts, claims, requestsCount, claimedRequests, emptyRequests int queries := []struct { query string value *int }{ {"SELECT COUNT(*) FROM purchase_attempts", &attempts}, {"SELECT COUNT(*) FROM purchase_attempt_claims", &claims}, {"SELECT COUNT(*) FROM task_claim_requests", &requestsCount}, {"SELECT COUNT(*) FROM task_claim_requests WHERE outcome='CLAIMED'", &claimedRequests}, {"SELECT COUNT(*) FROM task_claim_requests WHERE outcome='EMPTY'", &emptyRequests}, } for _, query := range queries { if err := databaseA.QueryRow(query.query).Scan(query.value); err != nil { t.Fatal(err) } } if attempts != 1 || claims != 1 || requestsCount != 2 || claimedRequests != 1 || emptyRequests != 1 { t.Fatalf("cross-database attempts/claims/requests/claimed/empty = %d/%d/%d/%d/%d", attempts, claims, requestsCount, claimedRequests, emptyRequests) } } func TestRenewCASReplayCapAndNoResurrection(t *testing.T) { database := openClaimTestDatabase(t) insertDevice(t, database, testDeviceA, []byte("device-a")) insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(40*time.Second), true) store := mustStore(t, database, bytes.Repeat([]byte{0x44}, 32), 30*time.Second) current := testNow store.now = func() time.Time { return current } claim, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}) if err != nil || !found { t.Fatalf("ClaimNext = found %v, err %v", found, err) } current = testNow.Add(20 * time.Second) command := RenewCommand{TaskID: testTaskA, RenewRequestID: testRenewRequestA, SessionID: testSessionA, AttemptID: claim.Attempt.ID, ClaimGeneration: claim.Attempt.ClaimGeneration, ClaimToken: claim.Attempt.ClaimToken, ExpectedLeaseExpiresAt: claim.Attempt.LeaseExpiresAt} renewed, err := store.Renew(context.Background(), testDeviceA, command) if err != nil { t.Fatalf("Renew: %v", err) } wantCap := formatTime(testNow.Add(40 * time.Second)) if renewed.LeaseExpiresAt != wantCap { t.Fatalf("renewed lease = %s, want authorization cap %s", renewed.LeaseExpiresAt, wantCap) } current = testNow.Add(25 * time.Second) replay, err := store.Renew(context.Background(), testDeviceA, command) if err != nil || !reflect.DeepEqual(replay, renewed) { t.Fatalf("renew replay = %#v, err %v", replay, err) } changed := command changed.ExpectedLeaseExpiresAt = renewed.LeaseExpiresAt if _, err := store.Renew(context.Background(), testDeviceA, changed); !errors.Is(err, ErrIdempotencyConflict) { t.Fatalf("same key different payload error = %v", err) } stale := command stale.RenewRequestID = "60000000-0000-4000-8000-000000000004" if _, err := store.Renew(context.Background(), testDeviceA, stale); !errors.Is(err, ErrNotCurrent) { t.Fatalf("out-of-order expected lease error = %v", err) } wrongToken := command wrongToken.RenewRequestID = testRenewRequestB wrongToken.ClaimToken = strings.Repeat("0", 64) if _, err := store.Renew(context.Background(), testDeviceA, wrongToken); !errors.Is(err, ErrNotCurrent) { t.Fatalf("wrong token error = %v", err) } current = testNow.Add(40 * time.Second) // equality is expired; no grace and no resurrection. expired := command expired.RenewRequestID = "60000000-0000-4000-8000-000000000003" expired.ExpectedLeaseExpiresAt = renewed.LeaseExpiresAt if _, err := store.Renew(context.Background(), testDeviceA, expired); !errors.Is(err, ErrNotCurrent) { t.Fatalf("expired renewal error = %v", err) } var lease, taskStatus, attemptStatus, authorizationStatus string if err := database.QueryRow(`SELECT claims.lease_expires_at, tasks.status, attempts.status, authorizations.status FROM purchase_attempt_claims claims JOIN tasks ON tasks.id=claims.task_id JOIN purchase_attempts attempts ON attempts.id=claims.attempt_id JOIN order_authorizations authorizations ON authorizations.id=claims.authorization_id`). Scan(&lease, &taskStatus, &attemptStatus, &authorizationStatus); err != nil { t.Fatal(err) } if lease != wantCap || taskStatus != "CLAIMED" || attemptStatus != "CLAIMED" || authorizationStatus != "CLAIMED" { t.Fatalf("renew changed business state: lease=%s task=%s attempt=%s auth=%s", lease, taskStatus, attemptStatus, authorizationStatus) } if _, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=? WHERE device_id=?`, formatTime(current), testDeviceA); err != nil { t.Fatal(err) } revoked := expired revoked.RenewRequestID = "60000000-0000-4000-8000-000000000005" if _, err := store.Renew(context.Background(), testDeviceA, revoked); !errors.Is(err, ErrDeviceInactive) { t.Fatalf("renew after revocation error = %v", err) } } func TestRenewRequiresPairedBusinessStateAndExactTaskVersion(t *testing.T) { tests := []struct { name string mutate func(*testing.T, *sql.DB, ClaimResponse) wantError bool }{ {"claimed exact version", func(*testing.T, *sql.DB, ClaimResponse) {}, false}, {"ordering exact next version", func(t *testing.T, database *sql.DB, claim ClaimResponse) { if _, err := database.Exec("UPDATE tasks SET status='ORDERING',version=version+1 WHERE id=?", testTaskA); err != nil { t.Fatal(err) } if _, err := database.Exec("UPDATE purchase_attempts SET status='ORDERING' WHERE id=?", claim.Attempt.ID); err != nil { t.Fatal(err) } }, false}, {"claimed version drift", func(t *testing.T, database *sql.DB, _ ClaimResponse) { if _, err := database.Exec("UPDATE tasks SET version=version+1 WHERE id=?", testTaskA); err != nil { t.Fatal(err) } }, true}, {"ordering version drift", func(t *testing.T, database *sql.DB, claim ClaimResponse) { if _, err := database.Exec("UPDATE tasks SET status='ORDERING',version=version+2 WHERE id=?", testTaskA); err != nil { t.Fatal(err) } if _, err := database.Exec("UPDATE purchase_attempts SET status='ORDERING' WHERE id=?", claim.Attempt.ID); err != nil { t.Fatal(err) } }, true}, {"task ordering attempt claimed", func(t *testing.T, database *sql.DB, _ ClaimResponse) { if _, err := database.Exec("UPDATE tasks SET status='ORDERING',version=version+1 WHERE id=?", testTaskA); err != nil { t.Fatal(err) } }, true}, {"task claimed attempt ordering", func(t *testing.T, database *sql.DB, claim ClaimResponse) { if _, err := database.Exec("UPDATE purchase_attempts SET status='ORDERING' WHERE id=?", claim.Attempt.ID); err != nil { t.Fatal(err) } }, true}, } for index, test := range tests { t.Run(test.name, func(t *testing.T) { database, store, claim := claimedRenewFixture(t, byte(0x50+index)) test.mutate(t, database, claim) _, err := store.Renew(context.Background(), testDeviceA, renewCommandFor(claim, testRenewRequestA)) if test.wantError { if !errors.Is(err, ErrNotCurrent) { t.Fatalf("Renew error = %v, want ErrNotCurrent", err) } var lease string var renewals int if scanErr := database.QueryRow("SELECT lease_expires_at FROM purchase_attempt_claims WHERE attempt_id=?", claim.Attempt.ID).Scan(&lease); scanErr != nil { t.Fatal(scanErr) } if scanErr := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_lease_renewals").Scan(&renewals); scanErr != nil { t.Fatal(scanErr) } if lease != claim.Attempt.LeaseExpiresAt || renewals != 0 { t.Fatalf("rejected renew changed lease/rows = %s/%d", lease, renewals) } return } if err != nil { t.Fatalf("Renew valid state: %v", err) } }) } } func TestConcurrentRenewCASUsesSQLiteNotOneStoreGate(t *testing.T) { database := openClaimTestDatabase(t) insertDevice(t, database, testDeviceA, []byte("device-a")) insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true) secret := bytes.Repeat([]byte{0x48}, 32) storeA := mustStore(t, database, secret, time.Minute) storeA.now = func() time.Time { return testNow } claim, found, err := storeA.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}) if err != nil || !found { t.Fatalf("ClaimNext = found %v, err %v", found, err) } storeB := mustStore(t, database, secret, time.Minute) renewNow := testNow.Add(10 * time.Second) storeA.now = func() time.Time { return renewNow } storeB.now = func() time.Time { return renewNow } base := RenewCommand{TaskID: testTaskA, SessionID: testSessionA, AttemptID: claim.Attempt.ID, ClaimGeneration: claim.Attempt.ClaimGeneration, ClaimToken: claim.Attempt.ClaimToken, ExpectedLeaseExpiresAt: claim.Attempt.LeaseExpiresAt} commands := []RenewCommand{base, base} commands[0].RenewRequestID = testRenewRequestA commands[1].RenewRequestID = testRenewRequestB type result struct{ err error } results := make(chan result, 2) var wait sync.WaitGroup for index, claimStore := range []*Store{storeA, storeB} { index, claimStore := index, claimStore wait.Add(1) go func() { defer wait.Done() _, err := claimStore.Renew(context.Background(), testDeviceA, commands[index]) results <- result{err: err} }() } wait.Wait() close(results) successes, stale := 0, 0 for result := range results { switch { case result.err == nil: successes++ case errors.Is(result.err, ErrNotCurrent): stale++ default: t.Fatalf("concurrent Renew error = %v", result.err) } } var renewalCount int if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_lease_renewals").Scan(&renewalCount); err != nil { t.Fatal(err) } if successes != 1 || stale != 1 || renewalCount != 1 { t.Fatalf("concurrent renew success/stale/rows = %d/%d/%d", successes, stale, renewalCount) } } func TestRenewRevocationLinearizationBothOrders(t *testing.T) { t.Run("revocation first", func(t *testing.T) { database, store, claim := claimedRenewFixture(t, 0x49) if _, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=? WHERE device_id=?`, formatTime(testNow.Add(time.Second)), testDeviceA); err != nil { t.Fatal(err) } command := renewCommandFor(claim, testRenewRequestA) if _, err := store.Renew(context.Background(), testDeviceA, command); !errors.Is(err, ErrDeviceInactive) { t.Fatalf("Renew after revocation error = %v", err) } }) t.Run("renew write position first", func(t *testing.T) { database, store, claim := claimedRenewFixture(t, 0x4a) linearized := make(chan struct{}) release := make(chan struct{}) store.afterLinearization = func() { close(linearized); <-release } renewResult := make(chan error, 1) go func() { _, err := store.Renew(context.Background(), testDeviceA, renewCommandFor(claim, testRenewRequestA)) renewResult <- err }() <-linearized revocationStarted := make(chan struct{}) revocationResult := make(chan error, 1) go func() { close(revocationStarted) _, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=? WHERE device_id=? AND status='ACTIVE'`, formatTime(testNow.Add(2*time.Second)), testDeviceA) revocationResult <- err }() <-revocationStarted close(release) if err := <-renewResult; err != nil { t.Fatalf("renew holding first write position: %v", err) } if err := <-revocationResult; err != nil { t.Fatalf("revocation after renew: %v", err) } var renewals int if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_lease_renewals").Scan(&renewals); err != nil || renewals != 1 { t.Fatalf("renewal rows = %d, err %v", renewals, err) } }) } func TestStartupValidatesClosedClaimStorageTypesAndStatuses(t *testing.T) { for _, test := range []struct { name string mutate func(*testing.T, *sql.DB) }{ {"text nonce", func(t *testing.T, database *sql.DB) { if _, err := database.Exec(`UPDATE purchase_attempt_claims SET claim_nonce=CAST('12345678901234567890123456789012' AS TEXT)`); err != nil { t.Fatal(err) } }}, {"invalid attempt status", func(t *testing.T, database *sql.DB) { if _, err := database.Exec("UPDATE purchase_attempts SET status='CORRUPT'"); err != nil { t.Fatal(err) } }}, } { t.Run(test.name, func(t *testing.T) { database := openClaimTestDatabase(t) database.SetMaxOpenConns(1) insertDevice(t, database, testDeviceA, []byte("device-a")) insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true) secret := bytes.Repeat([]byte{0x4b}, 32) store := mustStore(t, database, secret, time.Minute) store.now = func() time.Time { return testNow } claim, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}) if err != nil || !found { t.Fatalf("ClaimNext = found %v, err %v", found, err) } if _, err := database.Exec("UPDATE purchase_attempt_claims SET closed_at=? WHERE attempt_id=?", formatTime(testNow.Add(2*time.Minute)), claim.Attempt.ID); err != nil { t.Fatal(err) } if _, err := NewStore(database, secret, time.Minute); err != nil { t.Fatalf("valid closed claim rejected: %v", err) } if _, err := database.Exec("PRAGMA ignore_check_constraints=ON"); err != nil { t.Fatal(err) } test.mutate(t, database) if _, err := NewStore(database, secret, time.Minute); err == nil { t.Fatal("NewStore accepted corrupted closed claim storage") } }) } } func TestStartupRejectsClaimAttemptGenerationCorruption(t *testing.T) { database := openClaimTestDatabase(t) database.SetMaxOpenConns(1) insertDevice(t, database, testDeviceA, []byte("device-a")) insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true) secret := bytes.Repeat([]byte{0x4c}, 32) store := mustStore(t, database, secret, time.Minute) store.now = func() time.Time { return testNow } claim, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}) if err != nil || !found { t.Fatalf("ClaimNext = found %v, err %v", found, err) } var nonce []byte if err := database.QueryRow("SELECT claim_nonce FROM purchase_attempt_claims WHERE attempt_id=?", claim.Attempt.ID).Scan(&nonce); err != nil { t.Fatal(err) } corruptGeneration := claim.Attempt.ClaimGeneration + 1 corruptToken := deriveToken(secret, testDeviceA, testTaskA, testAuthA, claim.Attempt.ID, corruptGeneration, nonce) if _, err := database.Exec("PRAGMA foreign_keys=OFF"); err != nil { t.Fatal(err) } if _, err := database.Exec(`UPDATE purchase_attempt_claims SET claim_generation=?,claim_token_sha256=? WHERE attempt_id=?`, corruptGeneration, tokenHash(corruptToken), claim.Attempt.ID); err != nil { t.Fatalf("inject generation corruption: %v", err) } if _, err := NewStore(database, secret, time.Minute); err == nil { t.Fatal("NewStore accepted claim generation different from its attempt") } } func TestRevocationLinearizesBeforeOrAfterClaim(t *testing.T) { t.Run("revocation first", func(t *testing.T) { database := openClaimTestDatabase(t) insertDevice(t, database, testDeviceA, []byte("device-a")) insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true) if _, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=? WHERE device_id=?`, formatTime(testNow), testDeviceA); err != nil { t.Fatal(err) } store := mustStore(t, database, bytes.Repeat([]byte{0x55}, 32), 30*time.Second) if _, _, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}); !errors.Is(err, ErrDeviceInactive) { t.Fatalf("ClaimNext error = %v, want inactive", err) } assertClaimState(t, database, 0, "PENDING", "ACTIVE") }) t.Run("claim write position first", func(t *testing.T) { database := openClaimTestDatabase(t) insertDevice(t, database, testDeviceA, []byte("device-a")) insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true) store := mustStore(t, database, bytes.Repeat([]byte{0x66}, 32), 30*time.Second) store.now = func() time.Time { return testNow } linearized := make(chan struct{}) release := make(chan struct{}) store.afterLinearization = func() { close(linearized); <-release } claimResult := make(chan error, 1) go func() { _, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}) if err == nil && !found { err = errors.New("claim unexpectedly empty") } claimResult <- err }() <-linearized revocationStarted := make(chan struct{}) revocationResult := make(chan error, 1) go func() { close(revocationStarted) _, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=? WHERE device_id=? AND status='ACTIVE'`, formatTime(testNow.Add(time.Second)), testDeviceA) revocationResult <- err }() <-revocationStarted close(release) if err := <-claimResult; err != nil { t.Fatalf("claim holding first write position: %v", err) } if err := <-revocationResult; err != nil { t.Fatalf("revocation after claim: %v", err) } assertClaimState(t, database, 1, "CLAIMED", "CLAIMED") }) } func TestTokenDomainSeparationAndDeviceSecretIsolation(t *testing.T) { secret := bytes.Repeat([]byte{0x77}, 32) nonce := bytes.Repeat([]byte{0x88}, 32) base := deriveToken(secret, testDeviceA, testTaskA, testAuthA, "70000000-0000-4000-8000-000000000001", 1, nonce) variants := [][]byte{ deriveToken(secret, testDeviceB, testTaskA, testAuthA, "70000000-0000-4000-8000-000000000001", 1, nonce), deriveToken(secret, testDeviceA, testTaskB, testAuthA, "70000000-0000-4000-8000-000000000001", 1, nonce), deriveToken(secret, testDeviceA, testTaskA, testAuthB, "70000000-0000-4000-8000-000000000001", 1, nonce), deriveToken(secret, testDeviceA, testTaskA, testAuthA, "70000000-0000-4000-8000-000000000002", 1, nonce), deriveToken(secret, testDeviceA, testTaskA, testAuthA, "70000000-0000-4000-8000-000000000001", 2, nonce), } for index, variant := range variants { if matchingHash(base, variant) { t.Fatalf("token variant %d was not domain separated", index) } } database := openClaimTestDatabase(t) insertDevice(t, database, testDeviceA, secret) if _, err := NewStore(database, secret, time.Minute); err == nil { t.Fatal("NewStore accepted a key equal to a device token") } } func openClaimTestDatabase(t *testing.T) *sql.DB { t.Helper() path := filepath.ToSlash(filepath.Join(t.TempDir(), "claim.db")) database, err := sqlite.Open("file:" + path + "?_busy_timeout=5000&_journal_mode=WAL") if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { _ = database.Close() }) if err := migrations.Up(context.Background(), database, claimMigrationDirectory(t)); err != nil { t.Fatalf("migrate database: %v", err) } return database } func claimMigrationDirectory(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) if !ok { t.Fatal("locate test file") } return filepath.Join(filepath.Dir(file), "..", "..", "migrations") } func mustStore(t *testing.T, database *sql.DB, secret []byte, ttl time.Duration) *Store { t.Helper() store, err := NewStore(database, secret, ttl) if err != nil { t.Fatalf("NewStore: %v", err) } return store } func claimedRenewFixture(t *testing.T, secretByte byte) (*sql.DB, *Store, ClaimResponse) { t.Helper() database := openClaimTestDatabase(t) insertDevice(t, database, testDeviceA, []byte("device-a")) insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true) store := mustStore(t, database, bytes.Repeat([]byte{secretByte}, 32), time.Minute) store.now = func() time.Time { return testNow } claim, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}) if err != nil || !found { t.Fatalf("ClaimNext = found %v, err %v", found, err) } store.now = func() time.Time { return testNow.Add(10 * time.Second) } return database, store, claim } func renewCommandFor(claim ClaimResponse, requestID string) RenewCommand { return RenewCommand{TaskID: testTaskA, RenewRequestID: requestID, SessionID: testSessionA, AttemptID: claim.Attempt.ID, ClaimGeneration: claim.Attempt.ClaimGeneration, ClaimToken: claim.Attempt.ClaimToken, ExpectedLeaseExpiresAt: claim.Attempt.LeaseExpiresAt} } func insertDevice(t *testing.T, database *sql.DB, deviceID string, token []byte) { t.Helper() digest := sha256.Sum256(token) if _, err := database.Exec(`INSERT INTO device_credentials (device_id,display_name,token_sha256,status,created_at,revoked_at) VALUES (?, ?, ?, 'ACTIVE', ?, NULL)`, deviceID, "test device", digest[:], formatTime(testNow.Add(-time.Hour))); err != nil { t.Fatalf("insert device: %v", err) } } func insertCandidate(t *testing.T, database *sql.DB, taskID, authorizationID string, createdAt, expiresAt time.Time, snapshotMatches bool) { t.Helper() if _, err := database.Exec(`INSERT INTO tasks (id,source,title,goods_id,sku_color,sku_size,quantity,max_total_price,status,version,created_at,updated_at) VALUES (?, 'MANUAL', '测试商品', '937122477375', '黑色', 'M', 2, '30.00', 'PENDING', 2, ?, ?)`, taskID, formatTime(createdAt), formatTime(createdAt)); err != nil { t.Fatalf("insert task: %v", err) } color := "黑色" if !snapshotMatches { color = "白色" } 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 (?, ?, 2, ?, '937122477375', ?, 'M', 2, '30.00', 'ACTIVE', 'admin', ?, ?)`, authorizationID, taskID, authorizationID, color, formatTime(createdAt), formatTime(expiresAt)); err != nil { t.Fatalf("insert authorization: %v", err) } } func assertClaimState(t *testing.T, database *sql.DB, wantClaims int, wantTaskStatus, wantAuthorizationStatus string) { t.Helper() var count int if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_claims").Scan(&count); err != nil || count != wantClaims { t.Fatalf("claim count = %d, err %v, want %d", count, err, wantClaims) } var taskStatus, authorizationStatus string if err := database.QueryRow(`SELECT tasks.status, order_authorizations.status FROM tasks JOIN order_authorizations ON order_authorizations.task_id=tasks.id WHERE tasks.id=?`, testTaskA).Scan(&taskStatus, &authorizationStatus); err != nil { t.Fatal(err) } if taskStatus != wantTaskStatus || authorizationStatus != wantAuthorizationStatus { t.Fatalf("states = %s/%s, want %s/%s", taskStatus, authorizationStatus, wantTaskStatus, wantAuthorizationStatus) } } func assertNoPlaintextTokenColumnOrValue(t *testing.T, database *sql.DB, token string) { t.Helper() rows, err := database.Query("PRAGMA table_info(purchase_attempt_claims)") if err != nil { t.Fatal(err) } defer rows.Close() for rows.Next() { var cid, notNull, primaryKey int var name, kind string var defaultValue any if err := rows.Scan(&cid, &name, &kind, ¬Null, &defaultValue, &primaryKey); err != nil { t.Fatal(err) } if name == "claim_token" { t.Fatal("schema contains a plaintext claim_token column") } } decoded, _ := hex.DecodeString(token) var nonce, storedHash []byte if err := database.QueryRow("SELECT claim_nonce, claim_token_sha256 FROM purchase_attempt_claims").Scan(&nonce, &storedHash); err != nil { t.Fatal(err) } if bytes.Equal(nonce, decoded) || bytes.Equal(storedHash, decoded) || len(nonce) != 32 || len(storedHash) != 32 { t.Fatal("database contains plaintext token or malformed token metadata") } }