Files
cmbuyer/admin/internal/taskclaim/store_test.go
T

985 lines
43 KiB
Go

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 TestClaimRejectsOutOfBoundsCandidatesWithoutBusinessMutation(t *testing.T) {
mutations := map[string]func(*testing.T, *sql.DB){
"invalid utf8 title": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), testTaskA)
},
"overlong title": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", 121), testTaskA)
},
"overlong goods id": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("1", 33)
execClaimSQL(t, database, `UPDATE tasks SET goods_id=? WHERE id=?`, value, testTaskA)
execClaimSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE id=?`, value, testAuthA)
},
"overlong color": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("色", 81)
execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, value, testTaskA)
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE id=?`, value, testAuthA)
},
"overlong size": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("码", 81)
execClaimSQL(t, database, `UPDATE tasks SET sku_size=? WHERE id=?`, value, testTaskA)
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE id=?`, value, testAuthA)
},
"overlong money": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("1", 30) + ".00"
execClaimSQL(t, database, `UPDATE tasks SET max_total_price=? WHERE id=?`, value, testTaskA)
execClaimSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE id=?`, value, testAuthA)
},
}
for name, mutate := range mutations {
t.Run(name, 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)
mutate(t, database)
store := mustStore(t, database, bytes.Repeat([]byte{0x32}, 32), 30*time.Second)
store.now = func() time.Time { return testNow }
if _, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}); err == nil || found {
t.Fatalf("ClaimNext = found %v, err %v; want closed failure", found, err)
}
assertClaimState(t, database, 0, "PENDING", "ACTIVE")
})
}
}
func TestClaimReplayRevalidatesImmutableResponseSnapshot(t *testing.T) {
mutations := map[string]func(*testing.T, *sql.DB){
"title": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET task_title=? WHERE task_id=?`, strings.Repeat("😀", 121), testTaskA)
},
"goods id": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET goods_id=? WHERE task_id=?`, strings.Repeat("1", 33), testTaskA)
},
"color": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET sku_color=? WHERE task_id=?`, strings.Repeat("色", 81), testTaskA)
},
"size": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET sku_size=? WHERE task_id=?`, strings.Repeat("码", 81), testTaskA)
},
"money": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET total_price_cap=? WHERE task_id=?`, strings.Repeat("1", 30)+".00", testTaskA)
},
}
for name, mutate := range mutations {
t.Run(name, 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{0x34}, 32), 30*time.Second)
store.now = func() time.Time { return testNow }
command := ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}
if _, found, err := store.ClaimNext(context.Background(), testDeviceA, command); err != nil || !found {
t.Fatalf("initial ClaimNext = found %v, err %v", found, err)
}
mutate(t, database)
if _, found, err := store.ClaimNext(context.Background(), testDeviceA, command); err == nil || found {
t.Fatalf("replay = found %v, err %v; want invalid snapshot", found, 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 execClaimSQL(t *testing.T, database *sql.DB, statement string, arguments ...any) {
t.Helper()
if _, err := database.Exec(statement, arguments...); err != nil {
t.Fatalf("execute claim test SQL: %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, &notNull, &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")
}
}