feat(admin): add atomic task claim leases
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -23,6 +25,8 @@ const (
|
||||
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
|
||||
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
|
||||
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
|
||||
claimTokenSecretEnv = "CMBUYER_CLAIM_TOKEN_SECRET"
|
||||
claimLeaseTTLEnv = "CMBUYER_CLAIM_LEASE_TTL"
|
||||
minimumSecretLength = 32
|
||||
)
|
||||
|
||||
@@ -37,6 +41,8 @@ type Config struct {
|
||||
MaxTaskQuantity int
|
||||
MaxTotalPrice string
|
||||
EvidenceDirectory string
|
||||
ClaimTokenSecret []byte
|
||||
ClaimLeaseTTL time.Duration
|
||||
}
|
||||
|
||||
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
|
||||
@@ -112,6 +118,27 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) {
|
||||
return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv)
|
||||
}
|
||||
claimSecretText, err := required(lookup, claimTokenSecretEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
claimSecret, err := hex.DecodeString(claimSecretText)
|
||||
if err != nil || len(claimSecret) != 32 || hex.EncodeToString(claimSecret) != claimSecretText {
|
||||
return Config{}, fmt.Errorf("%s must be exactly 64 lowercase hexadecimal characters", claimTokenSecretEnv)
|
||||
}
|
||||
// Claim ownership, admin sessions and device authentication are separate security domains.
|
||||
// Reject both identical configuration text and identical effective key bytes.
|
||||
if claimSecretText == secret || bytes.Equal(claimSecret, []byte(secret)) {
|
||||
return Config{}, fmt.Errorf("%s must be isolated from %s", claimTokenSecretEnv, sessionSecretEnv)
|
||||
}
|
||||
claimTTLText, err := required(lookup, claimLeaseTTLEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
claimTTL, err := time.ParseDuration(claimTTLText)
|
||||
if err != nil || claimTTL <= 0 || claimTTL >= ttl {
|
||||
return Config{}, fmt.Errorf("%s must be positive and shorter than %s", claimLeaseTTLEnv, authorizationTTLEnv)
|
||||
}
|
||||
|
||||
return Config{
|
||||
AdminUsername: username,
|
||||
@@ -121,6 +148,8 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
DatabaseSource: databaseSource,
|
||||
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
|
||||
EvidenceDirectory: evidenceDirectory,
|
||||
ClaimTokenSecret: claimSecret,
|
||||
ClaimLeaseTTL: claimTTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package config_test
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/config"
|
||||
|
||||
@@ -25,13 +26,15 @@ func TestLoad(t *testing.T) {
|
||||
"CMBUYER_MAX_TASK_QUANTITY": "99",
|
||||
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
||||
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
||||
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
|
||||
"CMBUYER_CLAIM_LEASE_TTL": "1m",
|
||||
}
|
||||
|
||||
got, err := config.Load(lookup(values))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure {
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure || len(got.ClaimTokenSecret) != 32 || got.ClaimLeaseTTL != time.Minute {
|
||||
t.Fatalf("Load returned unexpected public configuration: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +54,8 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
"CMBUYER_MAX_TASK_QUANTITY": "99",
|
||||
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
||||
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
||||
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
|
||||
"CMBUYER_CLAIM_LEASE_TTL": "1m",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -68,6 +73,12 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
{"invalid maximum total price", func(values map[string]string) { values["CMBUYER_MAX_TOTAL_PRICE"] = "1" }, "CMBUYER_MAX_TOTAL_PRICE"},
|
||||
{"missing evidence directory", func(values map[string]string) { delete(values, "CMBUYER_EVIDENCE_DIR") }, "CMBUYER_EVIDENCE_DIR"},
|
||||
{"relative evidence directory", func(values map[string]string) { values["CMBUYER_EVIDENCE_DIR"] = "evidence" }, "CMBUYER_EVIDENCE_DIR"},
|
||||
{"invalid claim secret", func(values map[string]string) { values["CMBUYER_CLAIM_TOKEN_SECRET"] = strings.Repeat("A", 64) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"claim secret same raw session secret", func(values map[string]string) {
|
||||
values["CMBUYER_SESSION_SECRET"] = values["CMBUYER_CLAIM_TOKEN_SECRET"]
|
||||
}, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"claim secret same decoded session secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = strings.Repeat("\xab", 32) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"invalid claim lease ttl", func(values map[string]string) { values["CMBUYER_CLAIM_LEASE_TTL"] = "10m" }, "CMBUYER_CLAIM_LEASE_TTL"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertVersion(t, database, 5)
|
||||
assertTableExists(t, database, "tasks", true)
|
||||
assertTableExists(t, database, "spec_trials", false)
|
||||
assertTableExists(t, database, "order_authorizations", true)
|
||||
@@ -34,12 +34,20 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
||||
assertTableExists(t, database, "order_submissions", true)
|
||||
assertTableExists(t, database, "evidence_assets", true)
|
||||
assertTableExists(t, database, "device_credentials", true)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", true)
|
||||
assertTableExists(t, database, "single_pass_upgrade_guard", false)
|
||||
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("reapply migrations: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 5)
|
||||
|
||||
if err := migrations.Down(context, database, directory); err != nil {
|
||||
t.Fatalf("roll back task claim migration: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", false)
|
||||
assertTableExists(t, database, "device_credentials", true)
|
||||
|
||||
if err := migrations.Down(context, database, directory); err != nil {
|
||||
t.Fatalf("roll back device credential migration: %v", err)
|
||||
@@ -65,7 +73,7 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("reapply v2 after rollback: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertVersion(t, database, 5)
|
||||
}
|
||||
|
||||
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
|
||||
@@ -84,7 +92,7 @@ func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("upgrade v1 draft: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertVersion(t, database, 5)
|
||||
var got struct {
|
||||
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
|
||||
quantity, version int
|
||||
@@ -235,9 +243,7 @@ func TestV2SchemaConstraintsAndRelationships(t *testing.T) {
|
||||
|
||||
func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
migrateToV3(t, database)
|
||||
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
|
||||
insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1)
|
||||
@@ -271,9 +277,6 @@ func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("roll back empty device credential migration: %v", err)
|
||||
}
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("evidence-bearing schema downgraded successfully")
|
||||
}
|
||||
@@ -287,9 +290,7 @@ func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
|
||||
func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
migrateToV4(t, database)
|
||||
deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
hash := make([]byte, 32)
|
||||
for index := range hash {
|
||||
@@ -354,6 +355,132 @@ func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskClaimMigrationGuardsOwnershipConstraintsAndDowngradeFacts(t *testing.T) {
|
||||
t.Run("upgrade rejects unmappable execution facts atomically", func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
migrateToV4(t, database)
|
||||
insertV2Task(t, database, "legacy-task", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "legacy-auth", "legacy-task", 1, "legacy-start")
|
||||
insertV2Attempt(t, database, "legacy-attempt", "legacy-task", "legacy-auth", 1)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("v5 upgrade accepted an attempt without device/session ownership")
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", false)
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("legacy attempt after rejected upgrade = %d, err %v", count, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("schema binds authorization device session generation and token", func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
deviceA := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
deviceB := "23c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
sessionA := "33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
sessionB := "43c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
taskA := "53c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
authA := "63c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
attemptA := "73c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
taskB := "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
authB := "93c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
attemptB := "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
tokenA := make([]byte, 32)
|
||||
for index := range tokenA {
|
||||
tokenA[index] = byte(index + 1)
|
||||
}
|
||||
for index, device := range []string{deviceA, deviceB} {
|
||||
hash := make([]byte, 32)
|
||||
hash[0] = byte(index + 100)
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, ?, ?, 'ACTIVE', ?, NULL)`, device, "device "+strconv.Itoa(index), hash, migrationTime); err != nil {
|
||||
t.Fatalf("insert device: %v", err)
|
||||
}
|
||||
}
|
||||
insertV2Task(t, database, taskA, "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, authA, taskA, 1, "start-a")
|
||||
insertV2Attempt(t, database, attemptA, taskA, authA, 1)
|
||||
insertClaim := `INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 2, 'task', 1, 'goods', 'white', 'XL', 1, '1.00',
|
||||
'2026-08-04T01:00:00Z', ?, ?, '2026-08-04T00:05:00Z', ?, NULL)`
|
||||
if _, err := database.Exec(insertClaim, attemptA, taskA, authA, deviceA, sessionA, 1, make([]byte, 32), tokenA, migrationTime); err != nil {
|
||||
t.Fatalf("insert valid claim: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts
|
||||
(id,task_id,authorization_id,claim_generation,status,started_at)
|
||||
VALUES ('b3c9f507-7473-4fa6-8d71-8786c34c6301', ?, ?, 2, 'CLAIMED', ?)`, taskA, authA, migrationTime); err == nil {
|
||||
t.Fatal("second attempt for one authorization succeeded")
|
||||
}
|
||||
|
||||
insertV2Task(t, database, taskB, "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, authB, taskB, 1, "start-b")
|
||||
insertV2Attempt(t, database, attemptB, taskB, authB, 1)
|
||||
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceB, sessionB, 2, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
|
||||
t.Fatal("claim with generation different from its attempt succeeded")
|
||||
}
|
||||
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceA, sessionB, 1, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
|
||||
t.Fatal("second open claim for one device succeeded")
|
||||
}
|
||||
|
||||
claimRequest := `INSERT INTO task_claim_requests
|
||||
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
|
||||
VALUES (?, ?, ?, 'CLAIMED', ?, '2026-08-04T00:05:00Z', NULL, ?)`
|
||||
if _, err := database.Exec(claimRequest, "c3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionB, attemptA, migrationTime); err == nil {
|
||||
t.Fatal("claim request with another session succeeded")
|
||||
}
|
||||
if _, err := database.Exec(claimRequest, "d3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionA, attemptA, migrationTime); err != nil {
|
||||
t.Fatalf("insert bound claim request: %v", err)
|
||||
}
|
||||
renewal := `INSERT INTO purchase_attempt_lease_renewals
|
||||
(renew_request_id,task_id,attempt_id,device_id,session_id,claim_generation,
|
||||
claim_token_sha256,expected_lease_expires_at,lease_expires_at,created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '2026-08-04T00:05:00Z', '2026-08-04T00:06:00Z', ?)`
|
||||
if _, err := database.Exec(renewal, "e3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 2, tokenA, migrationTime); err == nil {
|
||||
t.Fatal("renewal with another generation succeeded")
|
||||
}
|
||||
wrongHash := append([]byte(nil), tokenA...)
|
||||
wrongHash[0] ^= 0xff
|
||||
if _, err := database.Exec(renewal, "f3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 1, wrongHash, migrationTime); err == nil {
|
||||
t.Fatal("renewal with another token hash succeeded")
|
||||
}
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("claim-bearing schema downgraded successfully")
|
||||
}
|
||||
assertVersion(t, database, 5)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", true)
|
||||
})
|
||||
|
||||
t.Run("empty request alone blocks downgrade", func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
device := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'device', ?, 'ACTIVE', ?, NULL)`, device, make([]byte, 32), migrationTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO task_claim_requests
|
||||
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
|
||||
VALUES ('23c9f507-7473-4fa6-8d71-8786c34c6301', ?,
|
||||
'33c9f507-7473-4fa6-8d71-8786c34c6301', 'EMPTY', NULL, NULL, NULL, ?)`, device, migrationTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("EMPTY request was silently dropped by downgrade")
|
||||
}
|
||||
assertVersion(t, database, 5)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -416,6 +543,24 @@ func migrateToV2(t *testing.T, database *sql.DB) {
|
||||
assertVersion(t, database, 2)
|
||||
}
|
||||
|
||||
func migrateToV3(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
migrateToV2(t, database)
|
||||
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
|
||||
t.Fatalf("apply v3: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 3)
|
||||
}
|
||||
|
||||
func migrateToV4(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
migrateToV3(t, database)
|
||||
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
|
||||
t.Fatalf("apply v4: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
}
|
||||
|
||||
func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil {
|
||||
|
||||
@@ -134,6 +134,7 @@ func TestRealDeviceCredentialIdentityIsolationAndMixedCredentials(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("issue credential: %v", err)
|
||||
}
|
||||
insertEvidenceClaim(t, database, issued.DeviceID)
|
||||
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
|
||||
if err != nil {
|
||||
t.Fatalf("new authenticator: %v", err)
|
||||
@@ -353,6 +354,8 @@ func newEvidenceRouter(t *testing.T, authenticator deviceauth.Authenticator) (ht
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
insertEvidenceAttempt(t, database)
|
||||
insertEvidenceClaimDevice(t, database, evidenceDeviceID)
|
||||
insertEvidenceClaim(t, database, evidenceDeviceID)
|
||||
store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
|
||||
if err != nil {
|
||||
t.Fatalf("new evidence store: %v", err)
|
||||
@@ -384,6 +387,30 @@ func insertEvidenceAttempt(t *testing.T, database *sql.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
func insertEvidenceClaimDevice(t *testing.T, database *sql.DB, deviceID string) {
|
||||
t.Helper()
|
||||
digest := sha256.Sum256([]byte("fake evidence device"))
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'fake evidence device', ?, 'ACTIVE', '2026-08-04T00:00:00Z', NULL)`, deviceID, digest[:]); err != nil {
|
||||
t.Fatalf("insert evidence device: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertEvidenceClaim(t *testing.T, database *sql.DB, deviceID string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, '23c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
|
||||
1, '123', 'black', 'M', 1, '1.00', '2026-08-04T00:00:00Z', ?, ?,
|
||||
'2026-08-04T02:00:00Z', '2026-08-04T00:00:00Z', NULL)`, evidenceAttemptID,
|
||||
evidenceTaskID, evidenceAuthID, deviceID, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32)); err != nil {
|
||||
t.Fatalf("insert evidence claim: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := newEvidenceUploadRequest(t, taskID, fields, file, fileContentType, filename, extra)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
"cmbuyer/admin/internal/transport/webui"
|
||||
@@ -36,11 +37,12 @@ type Options struct {
|
||||
TaskDetails taskdetail.Store
|
||||
Evidence evidence.Store
|
||||
DeviceAuthenticator deviceauth.Authenticator
|
||||
TaskClaims taskclaim.Service
|
||||
}
|
||||
|
||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||
func NewRouter(options Options) (*gin.Engine, error) {
|
||||
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil {
|
||||
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil || options.TaskClaims == nil {
|
||||
return nil, errors.New("server authentication options are incomplete")
|
||||
}
|
||||
|
||||
@@ -57,6 +59,8 @@ func NewRouter(options Options) (*gin.Engine, error) {
|
||||
router.POST("/tasks", createTask(options))
|
||||
router.POST("/tasks/start-purchases", startPurchases(options))
|
||||
router.POST("/api/v1/tasks/:id/evidence", uploadEvidence(options))
|
||||
router.POST("/api/v1/tasks/claim-next", claimNext(options))
|
||||
router.POST("/api/v1/tasks/:id/lease/renew", renewLease(options))
|
||||
router.GET("/evidence/:asset_id", readEvidence(options))
|
||||
router.GET("/static/tasks.js", func(context *gin.Context) {
|
||||
context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript())
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/server"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
|
||||
@@ -493,6 +494,10 @@ func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Man
|
||||
}
|
||||
|
||||
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator) (*gin.Engine, *auth.Manager) {
|
||||
return newRouterWithClaimService(t, store, details, evidenceStore, deviceAuthenticator, emptyTaskClaimService{})
|
||||
}
|
||||
|
||||
func newRouterWithClaimService(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator, claims taskclaim.Service) (*gin.Engine, *auth.Manager) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
@@ -508,6 +513,7 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta
|
||||
TaskDetails: details,
|
||||
Evidence: evidenceStore,
|
||||
DeviceAuthenticator: deviceAuthenticator,
|
||||
TaskClaims: claims,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
@@ -517,6 +523,16 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta
|
||||
|
||||
type emptyDetailStore struct{}
|
||||
|
||||
type emptyTaskClaimService struct{}
|
||||
|
||||
func (emptyTaskClaimService) ClaimNext(context.Context, string, taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
|
||||
return taskclaim.ClaimResponse{}, false, nil
|
||||
}
|
||||
|
||||
func (emptyTaskClaimService) Renew(context.Context, string, taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
|
||||
return taskclaim.RenewResponse{}, taskclaim.ErrNotCurrent
|
||||
}
|
||||
|
||||
func (emptyDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
|
||||
return taskdetail.Detail{}, taskdetail.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const maxClaimJSONBytes = 4096
|
||||
|
||||
func claimNext(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
principal, ok := authenticateDevice(context, options)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var command taskclaim.ClaimCommand
|
||||
if !decodeClaimJSON(context, &command) {
|
||||
return
|
||||
}
|
||||
response, found, err := options.TaskClaims.ClaimNext(context.Request.Context(), principal.ID, command)
|
||||
if err != nil {
|
||||
writeTaskClaimError(context, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
context.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
context.JSON(http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
func renewLease(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
principal, ok := authenticateDevice(context, options)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var command taskclaim.RenewCommand
|
||||
if !decodeClaimJSON(context, &command) {
|
||||
return
|
||||
}
|
||||
command.TaskID = context.Param("id")
|
||||
response, err := options.TaskClaims.Renew(context.Request.Context(), principal.ID, command)
|
||||
if err != nil {
|
||||
writeTaskClaimError(context, err)
|
||||
return
|
||||
}
|
||||
context.JSON(http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication precedes path interpretation, Content-Type parsing and every body read. This
|
||||
// keeps rejected devices from using parsing differences as an oracle or making the server buffer data.
|
||||
func authenticateDevice(context *gin.Context, options Options) (deviceauth.Principal, bool) {
|
||||
principal, err := options.DeviceAuthenticator.Authenticate(context.Request)
|
||||
if errors.Is(err, deviceauth.ErrUnauthenticated) {
|
||||
context.Header("WWW-Authenticate", "Bearer")
|
||||
context.Status(http.StatusUnauthorized)
|
||||
return deviceauth.Principal{}, false
|
||||
}
|
||||
if err != nil || !deviceauth.ValidDeviceID(principal.ID) {
|
||||
context.Status(http.StatusServiceUnavailable)
|
||||
return deviceauth.Principal{}, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func decodeClaimJSON(context *gin.Context, target any) bool {
|
||||
if !isJSONContentType(context.GetHeader("Content-Type")) {
|
||||
writeFixedError(context, http.StatusUnsupportedMediaType, "unsupported_media_type")
|
||||
return false
|
||||
}
|
||||
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxClaimJSONBytes)
|
||||
raw, err := io.ReadAll(context.Request.Body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeFixedError(context, http.StatusRequestEntityTooLarge, "request_too_large")
|
||||
} else {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
}
|
||||
return false
|
||||
}
|
||||
if len(raw) == 0 || !utf8.Valid(raw) {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
if !hasUniqueTopLevelJSONFields(raw) {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasUniqueTopLevelJSONFields(raw []byte) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
first, err := decoder.Token()
|
||||
if err != nil || first != json.Delim('{') {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for decoder.More() {
|
||||
key, err := decoder.Token()
|
||||
name, ok := key.(string)
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
if _, duplicate := seen[name]; duplicate {
|
||||
return false
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
var value json.RawMessage
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
last, err := decoder.Token()
|
||||
return err == nil && last == json.Delim('}')
|
||||
}
|
||||
|
||||
func writeTaskClaimError(context *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, taskclaim.ErrInvalid):
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
case errors.Is(err, taskclaim.ErrIdempotencyConflict):
|
||||
writeFixedError(context, http.StatusConflict, "idempotency_conflict")
|
||||
case errors.Is(err, taskclaim.ErrRequiresManual):
|
||||
writeFixedError(context, http.StatusConflict, "claim_requires_manual")
|
||||
case errors.Is(err, taskclaim.ErrNotCurrent):
|
||||
writeFixedError(context, http.StatusConflict, "claim_not_current")
|
||||
case errors.Is(err, taskclaim.ErrDeviceInactive):
|
||||
context.Header("WWW-Authenticate", "Bearer")
|
||||
context.Status(http.StatusUnauthorized)
|
||||
default:
|
||||
// Storage and transaction failures are intentionally bodyless: SQL, paths and candidate
|
||||
// details are server-only and must not become a device-facing diagnostic oracle.
|
||||
context.Status(http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFixedError(context *gin.Context, status int, code string) {
|
||||
context.JSON(status, gin.H{"error": code})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
)
|
||||
|
||||
const (
|
||||
claimDeviceID = "10000000-0000-4000-8000-000000000001"
|
||||
claimSessionID = "20000000-0000-4000-8000-000000000001"
|
||||
claimRequestID = "30000000-0000-4000-8000-000000000001"
|
||||
claimTaskID = "40000000-0000-4000-8000-000000000001"
|
||||
claimAttemptID = "50000000-0000-4000-8000-000000000001"
|
||||
claimRenewID = "60000000-0000-4000-8000-000000000001"
|
||||
)
|
||||
|
||||
func TestTaskClaimEndpointsAuthenticateBeforeBody(t *testing.T) {
|
||||
for _, authentication := range []struct {
|
||||
name string
|
||||
err error
|
||||
status int
|
||||
}{
|
||||
{"unauthenticated", deviceauth.ErrUnauthenticated, http.StatusUnauthorized},
|
||||
{"authentication storage unavailable", deviceauth.ErrUnavailable, http.StatusServiceUnavailable},
|
||||
} {
|
||||
t.Run(authentication.name, func(t *testing.T) {
|
||||
authenticator := &fakeDeviceAuthenticator{err: authentication.err}
|
||||
service := &fakeTaskClaimService{}
|
||||
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
||||
for _, path := range []string{"/api/v1/tasks/claim-next", "/api/v1/tasks/" + claimTaskID + "/lease/renew"} {
|
||||
body := &poisonBody{}
|
||||
request := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
request.Body = body
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != authentication.status || response.Body.Len() != 0 || body.reads != 0 || service.calls != 0 {
|
||||
t.Fatalf("%s = status %d, body %q, reads %d, calls %d", path, response.Code, response.Body.String(), body.reads, service.calls)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimNextStrictJSONSuccessEmptyAndErrors(t *testing.T) {
|
||||
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
|
||||
service := &fakeTaskClaimService{claimResponse: taskclaim.ClaimResponse{
|
||||
Task: taskclaim.ClaimedTask{ID: claimTaskID, Version: 3, Title: "测试", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", GoodsID: "1", SKUColor: "黑色", SKUSize: "M", Quantity: 1, MaxTotalPrice: "1.00"},
|
||||
Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: 2, ExpiresAt: "2026-08-04T01:10:00Z"},
|
||||
Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:03:00Z"},
|
||||
}, claimFound: true}
|
||||
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
||||
valid := `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`
|
||||
|
||||
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", valid, "application/json; charset=utf-8")
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), strings.Repeat("a", 64)) || service.claimCommand.ClaimRequestID != claimRequestID {
|
||||
t.Fatalf("claim success = %d %q command %#v", response.Code, response.Body.String(), service.claimCommand)
|
||||
}
|
||||
service.claimFound = false
|
||||
response = serveClaimJSON(router, "/api/v1/tasks/claim-next", valid, "application/json")
|
||||
if response.Code != http.StatusNoContent || response.Body.Len() != 0 {
|
||||
t.Fatalf("claim empty = %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name, body, contentType, code string
|
||||
status int
|
||||
}{
|
||||
{"unsupported type", valid, "text/plain", "unsupported_media_type", http.StatusUnsupportedMediaType},
|
||||
{"unknown field", strings.TrimSuffix(valid, "}") + `,"device_id":"` + claimDeviceID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
|
||||
{"duplicate session", `{"session_id":"` + claimSessionID + `","session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
|
||||
{"duplicate request", `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `","claim_request_id":"` + claimRequestID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
|
||||
{"extra json", valid + `{}`, "application/json", "invalid_request", http.StatusBadRequest},
|
||||
{"too large", strings.Repeat(" ", 4097), "application/json", "request_too_large", http.StatusRequestEntityTooLarge},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", test.body, test.contentType)
|
||||
if response.Code != test.status || response.Body.String() != `{"error":"`+test.code+`"}` {
|
||||
t.Fatalf("response = %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
invalidUTF8 := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/claim-next", bytes.NewReader([]byte{'{', 0xff, '}'}))
|
||||
invalidUTF8.Header.Set("Content-Type", "application/json")
|
||||
invalidResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(invalidResponse, invalidUTF8)
|
||||
if invalidResponse.Code != http.StatusBadRequest || invalidResponse.Body.String() != `{"error":"invalid_request"}` {
|
||||
t.Fatalf("invalid UTF-8 = %d %q", invalidResponse.Code, invalidResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewStrictBindingResponseAndFixedErrors(t *testing.T) {
|
||||
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
|
||||
service := &fakeTaskClaimService{renewResponse: taskclaim.RenewResponse{TaskID: claimTaskID, AttemptID: claimAttemptID, ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:04:00Z"}}
|
||||
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
||||
body := `{"renew_request_id":"` + claimRenewID + `","session_id":"` + claimSessionID + `","attempt_id":"` + claimAttemptID + `","claim_generation":1,"claim_token":"` + strings.Repeat("a", 64) + `","expected_lease_expires_at":"2026-08-04T01:03:00Z"}`
|
||||
response := serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json")
|
||||
if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "claim_token") || service.renewCommand.TaskID != claimTaskID {
|
||||
t.Fatalf("renew response = %d %q command %#v", response.Code, response.Body.String(), service.renewCommand)
|
||||
}
|
||||
|
||||
duplicateToken := strings.Replace(body, `"expected_lease_expires_at"`, `"claim_token":"`+strings.Repeat("a", 64)+`","expected_lease_expires_at"`, 1)
|
||||
response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", duplicateToken, "application/json")
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("duplicate token status = %d", response.Code)
|
||||
}
|
||||
|
||||
errorsToCodes := []struct {
|
||||
err error
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{taskclaim.ErrIdempotencyConflict, http.StatusConflict, `{"error":"idempotency_conflict"}`},
|
||||
{taskclaim.ErrRequiresManual, http.StatusConflict, `{"error":"claim_requires_manual"}`},
|
||||
{taskclaim.ErrNotCurrent, http.StatusConflict, `{"error":"claim_not_current"}`},
|
||||
{taskclaim.ErrDeviceInactive, http.StatusUnauthorized, ""},
|
||||
{errors.New("database path and SQL must stay private"), http.StatusServiceUnavailable, ""},
|
||||
}
|
||||
for _, test := range errorsToCodes {
|
||||
service.renewErr = test.err
|
||||
response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json")
|
||||
if response.Code != test.status || response.Body.String() != test.body {
|
||||
t.Fatalf("error %v = %d %q", test.err, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTaskClaimService struct {
|
||||
claimResponse taskclaim.ClaimResponse
|
||||
claimFound bool
|
||||
claimErr error
|
||||
renewResponse taskclaim.RenewResponse
|
||||
renewErr error
|
||||
claimCommand taskclaim.ClaimCommand
|
||||
renewCommand taskclaim.RenewCommand
|
||||
calls int
|
||||
}
|
||||
|
||||
func (service *fakeTaskClaimService) ClaimNext(_ context.Context, _ string, command taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
|
||||
service.calls++
|
||||
service.claimCommand = command
|
||||
return service.claimResponse, service.claimFound, service.claimErr
|
||||
}
|
||||
|
||||
func (service *fakeTaskClaimService) Renew(_ context.Context, _ string, command taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
|
||||
service.calls++
|
||||
service.renewCommand = command
|
||||
return service.renewResponse, service.renewErr
|
||||
}
|
||||
|
||||
func serveClaimJSON(router http.Handler, path, body, contentType string) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodPost, path, io.NopCloser(strings.NewReader(body)))
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
@@ -185,11 +185,16 @@ func (store *Store) Commit(ctx context.Context, principal deviceauth.Principal,
|
||||
return existing, true, nil
|
||||
}
|
||||
|
||||
var attemptCount int
|
||||
if err := transaction.QueryRowContext(ctx, "SELECT COUNT(*) FROM purchase_attempts WHERE task_id = ? AND id = ?", metadata.TaskID, metadata.AttemptID).Scan(&attemptCount); err != nil {
|
||||
var ownedClaimCount int
|
||||
if err := transaction.QueryRowContext(ctx, `SELECT COUNT(*) FROM purchase_attempt_claims
|
||||
WHERE task_id = ? AND attempt_id = ? AND claimed_by_device_id = ? AND closed_at IS NULL`,
|
||||
metadata.TaskID, metadata.AttemptID, principal.ID).Scan(&ownedClaimCount); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
if attemptCount != 1 {
|
||||
// Evidence is auditable only when the authenticated device owns the current attempt. The
|
||||
// idempotent asset lookup above deliberately remains first so closing a claim later cannot
|
||||
// destroy stable replay of an already committed screenshot.
|
||||
if ownedClaimCount != 1 {
|
||||
return core.Asset{}, false, core.ErrInvalid
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,41 @@ func TestStageCommitReplayAndOpen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitRequiresCurrentClaimOwnerButClosedClaimKeepsHistoricalReplay(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 4, 3)
|
||||
metadata := testMetadata(sha256Hex(pngBytes))
|
||||
stage := func() core.StagedFile {
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
return staged
|
||||
}
|
||||
otherDevice := deviceauth.Principal{ID: "73c9f507-7473-4fa6-8d71-8786c34c6301"}
|
||||
if _, _, err := store.Commit(context.Background(), otherDevice, metadata, stage()); !errors.Is(err, core.ErrInvalid) {
|
||||
t.Fatalf("device B upload to device A attempt error = %v", err)
|
||||
}
|
||||
principal := deviceauth.Principal{ID: testDeviceID}
|
||||
asset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
|
||||
if err != nil || replayed {
|
||||
t.Fatalf("owner first Commit = replayed %v, err %v", replayed, err)
|
||||
}
|
||||
if _, err := database.Exec("UPDATE purchase_attempt_claims SET closed_at='2026-08-04T03:00:00Z' WHERE attempt_id=?", testAttemptID); err != nil {
|
||||
t.Fatalf("close claim: %v", err)
|
||||
}
|
||||
replayedAsset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
|
||||
if err != nil || !replayed || replayedAsset.ID != asset.ID {
|
||||
t.Fatalf("closed claim historical replay = %#v replayed %v err %v", replayedAsset, replayed, err)
|
||||
}
|
||||
newMetadata := metadata
|
||||
newMetadata.UploadKey = "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
if _, _, err := store.Commit(context.Background(), principal, newMetadata, stage()); !errors.Is(err, core.ErrInvalid) {
|
||||
t.Fatalf("closed claim new upload error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentReplayCreatesOneAsset(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
@@ -494,6 +529,12 @@ func newTestStore(t *testing.T) (*sql.DB, *Store) {
|
||||
func insertAttemptFixture(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
timestamp := "2026-08-04T00:00:00Z"
|
||||
tokenHash := sha256.Sum256([]byte("evidence-device-token"))
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'evidence device', ?, 'ACTIVE', ?, NULL)`, testDeviceID, tokenHash[:], timestamp); err != nil {
|
||||
t.Fatalf("insert device: %v", err)
|
||||
}
|
||||
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', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, testTaskID, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
@@ -503,6 +544,15 @@ func insertAttemptFixture(t *testing.T, database *sql.DB) {
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, testAttemptID, testTaskID, testAuthID, timestamp); err != nil {
|
||||
t.Fatalf("insert attempt: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, '63c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
|
||||
1, '123', 'black', 'M', 1, '1.00', ?, ?, ?, '2026-08-04T02:00:00Z', ?, NULL)`,
|
||||
testAttemptID, testTaskID, testAuthID, testDeviceID, timestamp, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32), timestamp); err != nil {
|
||||
t.Fatalf("insert claim: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testMetadata(hash string) core.UploadMetadata {
|
||||
|
||||
@@ -0,0 +1,830 @@
|
||||
package taskclaim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
)
|
||||
|
||||
const writeTimeout = 2 * time.Second
|
||||
|
||||
type Store struct {
|
||||
database *sql.DB
|
||||
secret []byte
|
||||
leaseTTL time.Duration
|
||||
now func() time.Time
|
||||
random io.Reader
|
||||
randomMu sync.Mutex
|
||||
writeGate chan struct{}
|
||||
// The unexported linearization hooks let package tests coordinate real SQLite
|
||||
// transactions at the first write. Production construction always leaves them nil.
|
||||
beforeLinearization func()
|
||||
afterLinearization func()
|
||||
}
|
||||
|
||||
func NewStore(database *sql.DB, secret []byte, leaseTTL time.Duration) (*Store, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("task claim database is required")
|
||||
}
|
||||
if len(secret) != sha256.Size {
|
||||
return nil, errors.New("task claim secret must be 32 bytes")
|
||||
}
|
||||
if leaseTTL <= 0 {
|
||||
return nil, errors.New("task claim lease TTL must be positive")
|
||||
}
|
||||
if _, err := database.Exec("SELECT attempt_id, claim_nonce, claim_token_sha256 FROM purchase_attempt_claims LIMIT 1"); err != nil {
|
||||
return nil, errors.New("task claim migration is not available")
|
||||
}
|
||||
store := &Store{
|
||||
database: database, secret: append([]byte(nil), secret...), leaseTTL: leaseTTL,
|
||||
now: time.Now, random: rand.Reader, writeGate: make(chan struct{}, 1),
|
||||
}
|
||||
if err := store.validateSecretIsolation(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.validateStoredClaims(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// validateSecretIsolation ensures the HMAC key cannot also authenticate a device. The session
|
||||
// secret comparison is performed while parsing configuration, before either secret is discarded.
|
||||
func (store *Store) validateSecretIsolation() error {
|
||||
digest := sha256.Sum256(store.secret)
|
||||
var count int
|
||||
if err := store.database.QueryRow(`SELECT COUNT(*) FROM device_credentials WHERE token_sha256 = ?`, digest[:]).Scan(&count); err != nil {
|
||||
return errors.New("validate task claim secret isolation")
|
||||
}
|
||||
if count != 0 {
|
||||
return errors.New("task claim secret must be isolated from device credentials")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateStoredClaims covers open and closed claims. Replacing the secret must fail startup;
|
||||
// silently signing a new token would destroy idempotent recovery and the ownership audit chain.
|
||||
func (store *Store) validateStoredClaims(ctx context.Context) error {
|
||||
rows, err := store.database.QueryContext(ctx, `SELECT claims.claimed_by_device_id, claims.task_id, claims.authorization_id,
|
||||
claims.attempt_id, claims.claim_generation, claims.claim_nonce, typeof(claims.claim_nonce), length(claims.claim_nonce),
|
||||
claims.claim_token_sha256, typeof(claims.claim_token_sha256), length(claims.claim_token_sha256),
|
||||
claims.authorization_task_version, claims.goods_id, claims.sku_color, claims.sku_size,
|
||||
claims.quantity, claims.total_price_cap, claims.authorization_expires_at, claims.closed_at,
|
||||
attempts.claim_generation, attempts.status, authorizations.status, tasks.status
|
||||
FROM purchase_attempt_claims AS claims
|
||||
LEFT JOIN purchase_attempts AS attempts ON attempts.id = claims.attempt_id
|
||||
LEFT JOIN order_authorizations AS authorizations ON authorizations.id = claims.authorization_id
|
||||
LEFT JOIN tasks ON tasks.id = claims.task_id
|
||||
ORDER BY claims.attempt_id`)
|
||||
if err != nil {
|
||||
return errors.New("validate stored task claims")
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var deviceID, taskID, authorizationID, attemptID string
|
||||
var generation, authorizationTaskVersion, quantity int
|
||||
var nonce, storedHash []byte
|
||||
var nonceType, hashType, goodsID, color, size, price, expires string
|
||||
var nonceLength, hashLength int
|
||||
var closed, attemptStatus, authorizationStatus, taskStatus sql.NullString
|
||||
var attemptGeneration sql.NullInt64
|
||||
if err := rows.Scan(&deviceID, &taskID, &authorizationID, &attemptID, &generation,
|
||||
&nonce, &nonceType, &nonceLength, &storedHash, &hashType, &hashLength,
|
||||
&authorizationTaskVersion, &goodsID, &color, &size, &quantity, &price, &expires, &closed,
|
||||
&attemptGeneration, &attemptStatus, &authorizationStatus, &taskStatus); err != nil {
|
||||
return errors.New("validate stored task claims")
|
||||
}
|
||||
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(taskID) || !validUUID(authorizationID) || !validUUID(attemptID) ||
|
||||
generation <= 0 || nonceType != "blob" || nonceLength != sha256.Size || len(nonce) != sha256.Size ||
|
||||
hashType != "blob" || hashLength != sha256.Size || len(storedHash) != sha256.Size ||
|
||||
authorizationTaskVersion <= 0 || !digitsOnly(goodsID) || color == "" || size == "" || quantity <= 0 ||
|
||||
!canonicalMoney(price) || !validCanonicalTime(expires) || (closed.Valid && !validCanonicalTime(closed.String)) ||
|
||||
!attemptGeneration.Valid || attemptGeneration.Int64 != int64(generation) ||
|
||||
!validAttemptStatus(attemptStatus) || !validAuthorizationStatus(authorizationStatus) || !validTaskStatus(taskStatus) {
|
||||
return errors.New("stored task claim metadata is invalid")
|
||||
}
|
||||
token := deriveToken(store.secret, deviceID, taskID, authorizationID, attemptID, generation, nonce)
|
||||
if !matchingHash(tokenHash(token), storedHash) {
|
||||
return errors.New("task claim secret does not match stored claims")
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return errors.New("validate stored task claims")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) ClaimNext(ctx context.Context, deviceID string, command ClaimCommand) (ClaimResponse, bool, error) {
|
||||
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(command.SessionID) || !validUUID(command.ClaimRequestID) {
|
||||
return ClaimResponse{}, false, ErrInvalid
|
||||
}
|
||||
writeCtx, cancel := context.WithTimeout(ctx, writeTimeout)
|
||||
defer cancel()
|
||||
select {
|
||||
case store.writeGate <- struct{}{}:
|
||||
defer func() { <-store.writeGate }()
|
||||
case <-writeCtx.Done():
|
||||
return ClaimResponse{}, false, writeCtx.Err()
|
||||
}
|
||||
|
||||
transaction, err := store.database.BeginTx(writeCtx, nil)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
|
||||
// This must be the transaction's first database statement. The no-op conditional UPDATE takes
|
||||
// SQLite's write position and linearizes a concurrent credential revocation before any replay,
|
||||
// EMPTY response, conflict response, candidate read, or other business write is possible.
|
||||
if store.beforeLinearization != nil {
|
||||
store.beforeLinearization()
|
||||
}
|
||||
active, err := transaction.ExecContext(writeCtx, `UPDATE device_credentials SET status = status
|
||||
WHERE device_id = ? AND status = 'ACTIVE' AND revoked_at IS NULL`, deviceID)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if ok, err := exactlyOne(active); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
} else if !ok {
|
||||
return ClaimResponse{}, false, ErrDeviceInactive
|
||||
}
|
||||
if store.afterLinearization != nil {
|
||||
store.afterLinearization()
|
||||
}
|
||||
|
||||
now, err := store.serverNow()
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
request, found, err := findClaimRequest(writeCtx, transaction, command.ClaimRequestID)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if found {
|
||||
if request.DeviceID != deviceID || request.SessionID != command.SessionID {
|
||||
return ClaimResponse{}, false, ErrIdempotencyConflict
|
||||
}
|
||||
switch request.Outcome {
|
||||
case "EMPTY":
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return ClaimResponse{}, false, nil
|
||||
case "BLOCKED":
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return ClaimResponse{}, false, ErrRequiresManual
|
||||
case "CLAIMED":
|
||||
record, found, err := store.loadClaimByAttempt(writeCtx, transaction, request.AttemptID)
|
||||
if err != nil || !found {
|
||||
if err == nil {
|
||||
err = errors.New("stored claim request has no claim")
|
||||
}
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
response, err := store.responseFor(record, request.ResponseLeaseExpiresAt)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return response, true, nil
|
||||
default:
|
||||
return ClaimResponse{}, false, errors.New("stored claim request outcome is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
existing, found, err := store.loadOpenClaimByDevice(writeCtx, transaction, deviceID)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if found {
|
||||
current := existing.SessionID == command.SessionID && existing.ClosedAt == "" &&
|
||||
existing.LeaseExpiresAt.After(now) && existing.AuthorizationExpiresAt.After(now) &&
|
||||
existing.CurrentAuthorizationExpiresAt.After(now) && existing.AuthorizationStatus == "CLAIMED" &&
|
||||
existing.authorizationConsistent() && existing.recoverableBusinessState()
|
||||
if !current {
|
||||
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "BLOCKED", "", "", "manual_recovery_required", now); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return ClaimResponse{}, false, ErrRequiresManual
|
||||
}
|
||||
response, err := store.responseFor(existing, existing.LeaseExpiresText)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "CLAIMED", existing.AttemptID, existing.LeaseExpiresText, "", now); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return response, true, nil
|
||||
}
|
||||
|
||||
candidate, found, err := findCandidate(writeCtx, transaction, now)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if !found {
|
||||
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "EMPTY", "", "", "", now); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return ClaimResponse{}, false, nil
|
||||
}
|
||||
|
||||
generation, err := nextGeneration(writeCtx, transaction, candidate.TaskID)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
attemptID, err := store.newUUID()
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
nonce, err := store.randomBytes(sha256.Size)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
token := deriveToken(store.secret, deviceID, candidate.TaskID, candidate.AuthorizationID, attemptID, generation, nonce)
|
||||
storedTokenHash := tokenHash(token)
|
||||
leaseExpires := now.Add(store.leaseTTL)
|
||||
if candidate.AuthorizationExpiresAt.Before(leaseExpires) {
|
||||
leaseExpires = candidate.AuthorizationExpiresAt
|
||||
}
|
||||
leaseText := formatTime(leaseExpires)
|
||||
nowText := formatTime(now)
|
||||
|
||||
authorizationUpdate, err := transaction.ExecContext(writeCtx, `UPDATE order_authorizations SET status = 'CLAIMED'
|
||||
WHERE id = ? AND task_id = ? AND status = 'ACTIVE' AND task_version = ?
|
||||
AND goods_id = ? AND sku_color = ? AND sku_size = ? AND quantity = ?
|
||||
AND total_price_cap = ? AND expires_at = ?`,
|
||||
candidate.AuthorizationID, candidate.TaskID, candidate.TaskVersion, candidate.GoodsID,
|
||||
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap,
|
||||
candidate.AuthorizationExpiresText)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if ok, err := exactlyOne(authorizationUpdate); err != nil || !ok {
|
||||
if err == nil {
|
||||
err = errors.New("authorization changed during claim")
|
||||
}
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
taskUpdate, err := transaction.ExecContext(writeCtx, `UPDATE tasks SET status = 'CLAIMED', version = version + 1, updated_at = ?
|
||||
WHERE id = ? AND status = 'PENDING' AND version = ? AND title = ? AND goods_id = ?
|
||||
AND sku_color = ? AND sku_size = ? AND quantity = ? AND max_total_price = ?`,
|
||||
nowText, candidate.TaskID, candidate.TaskVersion, candidate.Title, candidate.GoodsID,
|
||||
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if ok, err := exactlyOne(taskUpdate); err != nil || !ok {
|
||||
if err == nil {
|
||||
err = errors.New("task changed during claim")
|
||||
}
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempts
|
||||
(id, task_id, authorization_id, claim_generation, status, started_at)
|
||||
VALUES (?, ?, ?, ?, 'CLAIMED', ?)`, attemptID, candidate.TaskID, candidate.AuthorizationID, generation, nowText); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempt_claims
|
||||
(attempt_id, task_id, authorization_id, claimed_by_device_id, session_id, claim_generation,
|
||||
task_version, task_title, authorization_task_version, goods_id, sku_color, sku_size, quantity,
|
||||
total_price_cap, authorization_expires_at, claim_nonce, claim_token_sha256,
|
||||
lease_expires_at, claimed_at, closed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
|
||||
attemptID, candidate.TaskID, candidate.AuthorizationID, deviceID, command.SessionID, generation,
|
||||
candidate.TaskVersion+1, candidate.Title, candidate.TaskVersion, candidate.GoodsID,
|
||||
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap,
|
||||
candidate.AuthorizationExpiresText, nonce, storedTokenHash, leaseText, nowText); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "CLAIMED", attemptID, leaseText, "", now); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
response := ClaimResponse{
|
||||
Task: ClaimedTask{ID: candidate.TaskID, Version: candidate.TaskVersion + 1, Title: candidate.Title,
|
||||
ProductURL: productURL(candidate.GoodsID), GoodsID: candidate.GoodsID, SKUColor: candidate.SKUColor,
|
||||
SKUSize: candidate.SKUSize, Quantity: candidate.Quantity, MaxTotalPrice: candidate.TotalPriceCap},
|
||||
Authorization: ClaimedAuthorization{ID: candidate.AuthorizationID, TaskVersion: candidate.TaskVersion, ExpiresAt: candidate.AuthorizationExpiresText},
|
||||
Attempt: ClaimedAttempt{ID: attemptID, ClaimToken: hex.EncodeToString(token), ClaimGeneration: generation, LeaseExpiresAt: leaseText},
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return response, true, nil
|
||||
}
|
||||
|
||||
func (store *Store) Renew(ctx context.Context, deviceID string, command RenewCommand) (RenewResponse, error) {
|
||||
providedToken, tokenOK := decodeToken(command.ClaimToken)
|
||||
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(command.TaskID) || !validUUID(command.RenewRequestID) ||
|
||||
!validUUID(command.SessionID) || !validUUID(command.AttemptID) || command.ClaimGeneration <= 0 ||
|
||||
!tokenOK || !validCanonicalTime(command.ExpectedLeaseExpiresAt) {
|
||||
return RenewResponse{}, ErrInvalid
|
||||
}
|
||||
providedHash := tokenHash(providedToken)
|
||||
writeCtx, cancel := context.WithTimeout(ctx, writeTimeout)
|
||||
defer cancel()
|
||||
select {
|
||||
case store.writeGate <- struct{}{}:
|
||||
defer func() { <-store.writeGate }()
|
||||
case <-writeCtx.Done():
|
||||
return RenewResponse{}, writeCtx.Err()
|
||||
}
|
||||
transaction, err := store.database.BeginTx(writeCtx, nil)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
|
||||
// As in ClaimNext, this is deliberately the first database statement in the transaction.
|
||||
if store.beforeLinearization != nil {
|
||||
store.beforeLinearization()
|
||||
}
|
||||
active, err := transaction.ExecContext(writeCtx, `UPDATE device_credentials SET status = status
|
||||
WHERE device_id = ? AND status = 'ACTIVE' AND revoked_at IS NULL`, deviceID)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if ok, err := exactlyOne(active); err != nil {
|
||||
return RenewResponse{}, err
|
||||
} else if !ok {
|
||||
return RenewResponse{}, ErrDeviceInactive
|
||||
}
|
||||
if store.afterLinearization != nil {
|
||||
store.afterLinearization()
|
||||
}
|
||||
|
||||
renewal, found, err := findRenewal(writeCtx, transaction, command.RenewRequestID)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if found {
|
||||
if renewal.TaskID != command.TaskID || renewal.AttemptID != command.AttemptID || renewal.DeviceID != deviceID ||
|
||||
renewal.SessionID != command.SessionID || renewal.Generation != command.ClaimGeneration ||
|
||||
renewal.ExpectedLeaseExpiresAt != command.ExpectedLeaseExpiresAt || !matchingHash(renewal.TokenHash, providedHash) {
|
||||
return RenewResponse{}, ErrIdempotencyConflict
|
||||
}
|
||||
response := RenewResponse{TaskID: renewal.TaskID, AttemptID: renewal.AttemptID, ClaimGeneration: renewal.Generation, LeaseExpiresAt: renewal.LeaseExpiresAt}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
now, err := store.serverNow()
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
record, found, err := store.loadClaimByAttempt(writeCtx, transaction, command.AttemptID)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if !found || record.TaskID != command.TaskID || record.DeviceID != deviceID || record.SessionID != command.SessionID ||
|
||||
record.Generation != command.ClaimGeneration || !matchingHash(record.TokenHash, providedHash) {
|
||||
return RenewResponse{}, ErrNotCurrent
|
||||
}
|
||||
stateCurrent := record.ClosedAt == "" && record.LeaseExpiresAt.After(now) && record.AuthorizationExpiresAt.After(now) &&
|
||||
record.CurrentAuthorizationExpiresAt.After(now) && record.AuthorizationStatus == "CLAIMED" &&
|
||||
record.authorizationConsistent() && record.recoverableBusinessState()
|
||||
if !stateCurrent || record.LeaseExpiresText != command.ExpectedLeaseExpiresAt {
|
||||
return RenewResponse{}, ErrNotCurrent
|
||||
}
|
||||
leaseExpires := now.Add(store.leaseTTL)
|
||||
if record.AuthorizationExpiresAt.Before(leaseExpires) {
|
||||
leaseExpires = record.AuthorizationExpiresAt
|
||||
}
|
||||
leaseText := formatTime(leaseExpires)
|
||||
updated, err := transaction.ExecContext(writeCtx, `UPDATE purchase_attempt_claims SET lease_expires_at = ?
|
||||
WHERE attempt_id = ? AND lease_expires_at = ? AND closed_at IS NULL`, leaseText, command.AttemptID, command.ExpectedLeaseExpiresAt)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if ok, err := exactlyOne(updated); err != nil || !ok {
|
||||
if err == nil {
|
||||
err = ErrNotCurrent
|
||||
}
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempt_lease_renewals
|
||||
(renew_request_id, task_id, attempt_id, device_id, session_id, claim_generation,
|
||||
claim_token_sha256, expected_lease_expires_at, lease_expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
command.RenewRequestID, command.TaskID, command.AttemptID, deviceID, command.SessionID,
|
||||
command.ClaimGeneration, record.TokenHash, command.ExpectedLeaseExpiresAt, leaseText, formatTime(now)); err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
response := RenewResponse{TaskID: command.TaskID, AttemptID: command.AttemptID, ClaimGeneration: command.ClaimGeneration, LeaseExpiresAt: leaseText}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type claimRequestRecord struct {
|
||||
DeviceID, SessionID, Outcome, AttemptID, ResponseLeaseExpiresAt string
|
||||
}
|
||||
|
||||
func findClaimRequest(ctx context.Context, transaction *sql.Tx, requestID string) (claimRequestRecord, bool, error) {
|
||||
var record claimRequestRecord
|
||||
var attemptID, responseLease sql.NullString
|
||||
err := transaction.QueryRowContext(ctx, `SELECT device_id, session_id, outcome, attempt_id, response_lease_expires_at
|
||||
FROM task_claim_requests WHERE claim_request_id = ?`, requestID).
|
||||
Scan(&record.DeviceID, &record.SessionID, &record.Outcome, &attemptID, &responseLease)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return claimRequestRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return claimRequestRecord{}, false, err
|
||||
}
|
||||
record.AttemptID, record.ResponseLeaseExpiresAt = attemptID.String, responseLease.String
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func insertClaimRequest(ctx context.Context, transaction *sql.Tx, requestID, deviceID, sessionID, outcome, attemptID, responseLease, errorCode string, now time.Time) error {
|
||||
var attempt, lease, code any
|
||||
if attemptID != "" {
|
||||
attempt = attemptID
|
||||
}
|
||||
if responseLease != "" {
|
||||
lease = responseLease
|
||||
}
|
||||
if errorCode != "" {
|
||||
code = errorCode
|
||||
}
|
||||
_, err := transaction.ExecContext(ctx, `INSERT INTO task_claim_requests
|
||||
(claim_request_id, device_id, session_id, outcome, attempt_id, response_lease_expires_at, error_code, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, requestID, deviceID, sessionID, outcome, attempt, lease, code, formatTime(now))
|
||||
return err
|
||||
}
|
||||
|
||||
type renewalRecord struct {
|
||||
TaskID, AttemptID, DeviceID, SessionID string
|
||||
Generation int
|
||||
TokenHash []byte
|
||||
ExpectedLeaseExpiresAt, LeaseExpiresAt string
|
||||
}
|
||||
|
||||
func findRenewal(ctx context.Context, transaction *sql.Tx, requestID string) (renewalRecord, bool, error) {
|
||||
var record renewalRecord
|
||||
err := transaction.QueryRowContext(ctx, `SELECT task_id, attempt_id, device_id, session_id,
|
||||
claim_generation, claim_token_sha256, expected_lease_expires_at, lease_expires_at
|
||||
FROM purchase_attempt_lease_renewals WHERE renew_request_id = ?`, requestID).
|
||||
Scan(&record.TaskID, &record.AttemptID, &record.DeviceID, &record.SessionID, &record.Generation,
|
||||
&record.TokenHash, &record.ExpectedLeaseExpiresAt, &record.LeaseExpiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return renewalRecord{}, false, nil
|
||||
}
|
||||
return record, err == nil, err
|
||||
}
|
||||
|
||||
type claimRecord struct {
|
||||
AttemptID, TaskID, AuthorizationID, DeviceID, SessionID string
|
||||
Generation, TaskVersion, CurrentTaskVersion int
|
||||
TaskTitle string
|
||||
Nonce, TokenHash []byte
|
||||
LeaseExpiresText, ClaimedAt, ClosedAt string
|
||||
LeaseExpiresAt time.Time
|
||||
AuthorizationTaskVersion int
|
||||
GoodsID, SKUColor, SKUSize, TotalPriceCap string
|
||||
Quantity int
|
||||
AuthorizationExpiresText, AuthorizationStatus string
|
||||
AuthorizationExpiresAt time.Time
|
||||
CurrentAuthorizationTaskVersion int
|
||||
CurrentGoodsID, CurrentSKUColor, CurrentSKUSize string
|
||||
CurrentQuantity int
|
||||
CurrentTotalPriceCap, CurrentAuthorizationExpiresText string
|
||||
CurrentAuthorizationExpiresAt time.Time
|
||||
AttemptStatus, TaskStatus string
|
||||
CurrentTaskTitle, CurrentTaskGoodsID string
|
||||
CurrentTaskSKUColor, CurrentTaskSKUSize string
|
||||
CurrentTaskQuantity int
|
||||
CurrentTaskMaxTotalPrice string
|
||||
CurrentAttemptGeneration int
|
||||
}
|
||||
|
||||
const claimSelect = `SELECT claims.attempt_id, claims.task_id, claims.authorization_id,
|
||||
claims.claimed_by_device_id, claims.session_id, claims.claim_generation, claims.task_version,
|
||||
claims.task_title, claims.authorization_task_version, claims.goods_id, claims.sku_color,
|
||||
claims.sku_size, claims.quantity, claims.total_price_cap, claims.authorization_expires_at,
|
||||
claims.claim_nonce, claims.claim_token_sha256, claims.lease_expires_at,
|
||||
claims.claimed_at, claims.closed_at, authorizations.task_version, authorizations.goods_id,
|
||||
authorizations.sku_color, authorizations.sku_size, authorizations.quantity,
|
||||
authorizations.total_price_cap, authorizations.expires_at, authorizations.status,
|
||||
attempts.claim_generation, attempts.status, tasks.status, tasks.version, tasks.title, tasks.goods_id,
|
||||
tasks.sku_color, tasks.sku_size, tasks.quantity, tasks.max_total_price
|
||||
FROM purchase_attempt_claims AS claims
|
||||
JOIN order_authorizations AS authorizations
|
||||
ON authorizations.task_id = claims.task_id AND authorizations.id = claims.authorization_id
|
||||
JOIN purchase_attempts AS attempts ON attempts.id = claims.attempt_id
|
||||
JOIN tasks ON tasks.id = claims.task_id `
|
||||
|
||||
func (store *Store) loadOpenClaimByDevice(ctx context.Context, transaction *sql.Tx, deviceID string) (claimRecord, bool, error) {
|
||||
return store.scanClaim(transaction.QueryRowContext(ctx, claimSelect+`WHERE claims.claimed_by_device_id = ? AND claims.closed_at IS NULL`, deviceID))
|
||||
}
|
||||
|
||||
func (store *Store) loadClaimByAttempt(ctx context.Context, transaction *sql.Tx, attemptID string) (claimRecord, bool, error) {
|
||||
return store.scanClaim(transaction.QueryRowContext(ctx, claimSelect+`WHERE claims.attempt_id = ?`, attemptID))
|
||||
}
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
func (store *Store) scanClaim(row rowScanner) (claimRecord, bool, error) {
|
||||
var record claimRecord
|
||||
var closed sql.NullString
|
||||
err := row.Scan(&record.AttemptID, &record.TaskID, &record.AuthorizationID, &record.DeviceID,
|
||||
&record.SessionID, &record.Generation, &record.TaskVersion, &record.TaskTitle,
|
||||
&record.AuthorizationTaskVersion, &record.GoodsID, &record.SKUColor, &record.SKUSize,
|
||||
&record.Quantity, &record.TotalPriceCap, &record.AuthorizationExpiresText,
|
||||
&record.Nonce, &record.TokenHash, &record.LeaseExpiresText, &record.ClaimedAt, &closed,
|
||||
&record.CurrentAuthorizationTaskVersion, &record.CurrentGoodsID, &record.CurrentSKUColor,
|
||||
&record.CurrentSKUSize, &record.CurrentQuantity, &record.CurrentTotalPriceCap,
|
||||
&record.CurrentAuthorizationExpiresText,
|
||||
&record.AuthorizationStatus, &record.CurrentAttemptGeneration, &record.AttemptStatus,
|
||||
&record.TaskStatus, &record.CurrentTaskVersion,
|
||||
&record.CurrentTaskTitle, &record.CurrentTaskGoodsID, &record.CurrentTaskSKUColor,
|
||||
&record.CurrentTaskSKUSize, &record.CurrentTaskQuantity, &record.CurrentTaskMaxTotalPrice)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return claimRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return claimRecord{}, false, err
|
||||
}
|
||||
record.ClosedAt = closed.String
|
||||
if !validUUID(record.AttemptID) || !validUUID(record.TaskID) || !validUUID(record.AuthorizationID) ||
|
||||
!deviceauth.ValidDeviceID(record.DeviceID) || !validUUID(record.SessionID) || record.Generation <= 0 ||
|
||||
record.CurrentAttemptGeneration != record.Generation ||
|
||||
record.TaskVersion <= 0 || record.AuthorizationTaskVersion <= 0 || strings.TrimSpace(record.TaskTitle) == "" ||
|
||||
!digitsOnly(record.GoodsID) || record.SKUColor == "" || record.SKUSize == "" || record.Quantity <= 0 ||
|
||||
!canonicalMoney(record.TotalPriceCap) || len(record.Nonce) != sha256.Size || len(record.TokenHash) != sha256.Size {
|
||||
return claimRecord{}, false, errors.New("stored task claim metadata is invalid")
|
||||
}
|
||||
record.LeaseExpiresAt, err = parseCanonicalTime(record.LeaseExpiresText)
|
||||
if err != nil {
|
||||
return claimRecord{}, false, errors.New("stored task claim lease is invalid")
|
||||
}
|
||||
record.AuthorizationExpiresAt, err = parseCanonicalTime(record.AuthorizationExpiresText)
|
||||
if err != nil {
|
||||
return claimRecord{}, false, errors.New("stored authorization expiry is invalid")
|
||||
}
|
||||
record.CurrentAuthorizationExpiresAt, err = parseCanonicalTime(record.CurrentAuthorizationExpiresText)
|
||||
if err != nil {
|
||||
return claimRecord{}, false, errors.New("current authorization expiry is invalid")
|
||||
}
|
||||
derived := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce)
|
||||
if !matchingHash(tokenHash(derived), record.TokenHash) {
|
||||
return claimRecord{}, false, errors.New("task claim secret does not match stored claim")
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func (record claimRecord) authorizationConsistent() bool {
|
||||
return record.AuthorizationTaskVersion == record.CurrentAuthorizationTaskVersion &&
|
||||
record.GoodsID == record.CurrentGoodsID && record.SKUColor == record.CurrentSKUColor &&
|
||||
record.SKUSize == record.CurrentSKUSize && record.Quantity == record.CurrentQuantity &&
|
||||
record.TotalPriceCap == record.CurrentTotalPriceCap &&
|
||||
record.AuthorizationExpiresText == record.CurrentAuthorizationExpiresText &&
|
||||
record.TaskTitle == record.CurrentTaskTitle && record.GoodsID == record.CurrentTaskGoodsID &&
|
||||
record.SKUColor == record.CurrentTaskSKUColor && record.SKUSize == record.CurrentTaskSKUSize &&
|
||||
record.Quantity == record.CurrentTaskQuantity && record.TotalPriceCap == record.CurrentTaskMaxTotalPrice
|
||||
}
|
||||
|
||||
func (record claimRecord) recoverableBusinessState() bool {
|
||||
if record.TaskStatus == "CLAIMED" && record.AttemptStatus == "CLAIMED" {
|
||||
return record.CurrentTaskVersion == record.TaskVersion
|
||||
}
|
||||
// A later server task may advance this same attempt to ORDERING. A valid lease and identical
|
||||
// ownership recover that attempt; claim-next still cannot select another task.
|
||||
return record.TaskStatus == "ORDERING" && record.AttemptStatus == "ORDERING" &&
|
||||
record.TaskVersion < math.MaxInt && record.CurrentTaskVersion == record.TaskVersion+1
|
||||
}
|
||||
|
||||
func (store *Store) responseFor(record claimRecord, responseLease string) (ClaimResponse, error) {
|
||||
if !validCanonicalTime(responseLease) {
|
||||
return ClaimResponse{}, errors.New("stored claim response lease is invalid")
|
||||
}
|
||||
token := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce)
|
||||
return ClaimResponse{
|
||||
Task: ClaimedTask{ID: record.TaskID, Version: record.TaskVersion, Title: record.TaskTitle,
|
||||
ProductURL: productURL(record.GoodsID), GoodsID: record.GoodsID, SKUColor: record.SKUColor,
|
||||
SKUSize: record.SKUSize, Quantity: record.Quantity, MaxTotalPrice: record.TotalPriceCap},
|
||||
Authorization: ClaimedAuthorization{ID: record.AuthorizationID, TaskVersion: record.AuthorizationTaskVersion, ExpiresAt: record.AuthorizationExpiresText},
|
||||
Attempt: ClaimedAttempt{ID: record.AttemptID, ClaimToken: hex.EncodeToString(token), ClaimGeneration: record.Generation, LeaseExpiresAt: responseLease},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
AuthorizationID, TaskID, Title, GoodsID, SKUColor, SKUSize, TotalPriceCap string
|
||||
TaskVersion, Quantity int
|
||||
AuthorizationExpiresText string
|
||||
AuthorizationExpiresAt time.Time
|
||||
}
|
||||
|
||||
func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (candidate, bool, error) {
|
||||
rows, err := transaction.QueryContext(ctx, `SELECT authorizations.id, tasks.id, tasks.version,
|
||||
tasks.title, tasks.goods_id, tasks.sku_color, tasks.sku_size, tasks.quantity,
|
||||
tasks.max_total_price, authorizations.expires_at
|
||||
FROM order_authorizations AS authorizations
|
||||
JOIN tasks ON tasks.id = authorizations.task_id
|
||||
WHERE authorizations.status = 'ACTIVE' AND tasks.status = 'PENDING'
|
||||
AND authorizations.task_version = tasks.version
|
||||
AND authorizations.goods_id = tasks.goods_id
|
||||
AND authorizations.sku_color = tasks.sku_color
|
||||
AND authorizations.sku_size = tasks.sku_size
|
||||
AND authorizations.quantity = tasks.quantity
|
||||
AND authorizations.total_price_cap = tasks.max_total_price
|
||||
ORDER BY authorizations.created_at, authorizations.rowid, authorizations.id`)
|
||||
if err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item candidate
|
||||
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &item.Title,
|
||||
&item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.TotalPriceCap,
|
||||
&item.AuthorizationExpiresText); err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
item.AuthorizationExpiresAt, err = parseCanonicalTime(item.AuthorizationExpiresText)
|
||||
if err != nil {
|
||||
return candidate{}, false, errors.New("stored authorization expiry is invalid")
|
||||
}
|
||||
if !validCandidate(item) {
|
||||
return candidate{}, false, errors.New("stored claim candidate is invalid")
|
||||
}
|
||||
if item.AuthorizationExpiresAt.After(now) {
|
||||
if err := rows.Close(); err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
return item, true, nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
return candidate{}, false, nil
|
||||
}
|
||||
|
||||
func validCandidate(item candidate) bool {
|
||||
return validUUID(item.AuthorizationID) && validUUID(item.TaskID) && item.TaskVersion > 0 && item.TaskVersion < math.MaxInt &&
|
||||
strings.TrimSpace(item.Title) != "" && digitsOnly(item.GoodsID) && item.SKUColor != "" && item.SKUSize != "" &&
|
||||
item.Quantity > 0 && canonicalMoney(item.TotalPriceCap)
|
||||
}
|
||||
|
||||
func validAttemptStatus(value sql.NullString) bool {
|
||||
return value.Valid && oneOf(value.String, "CLAIMED", "ORDERING", "FAILED", "FENCED", "ABANDONED")
|
||||
}
|
||||
|
||||
func validAuthorizationStatus(value sql.NullString) bool {
|
||||
return value.Valid && oneOf(value.String, "ACTIVE", "CLAIMED", "FENCED", "CONSUMED", "EXPIRED", "ABANDONED")
|
||||
}
|
||||
|
||||
func validTaskStatus(value sql.NullString) bool {
|
||||
return value.Valid && oneOf(value.String, "DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL",
|
||||
"WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED")
|
||||
}
|
||||
|
||||
func oneOf(value string, allowed ...string) bool {
|
||||
for _, item := range allowed {
|
||||
if value == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nextGeneration(ctx context.Context, transaction *sql.Tx, taskID string) (int, error) {
|
||||
var maximum int64
|
||||
if err := transaction.QueryRowContext(ctx, `SELECT COALESCE(MAX(claim_generation), 0) FROM purchase_attempts WHERE task_id = ?`, taskID).Scan(&maximum); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if maximum < 0 || maximum >= int64(math.MaxInt) {
|
||||
return 0, errors.New("task claim generation is exhausted")
|
||||
}
|
||||
return int(maximum) + 1, nil
|
||||
}
|
||||
|
||||
func (store *Store) serverNow() (time.Time, error) {
|
||||
now := store.now().UTC()
|
||||
if now.IsZero() {
|
||||
return time.Time{}, errors.New("task claim clock is invalid")
|
||||
}
|
||||
return now, nil
|
||||
}
|
||||
|
||||
func (store *Store) randomBytes(size int) ([]byte, error) {
|
||||
value := make([]byte, size)
|
||||
store.randomMu.Lock()
|
||||
_, err := io.ReadFull(store.random, value)
|
||||
store.randomMu.Unlock()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate task claim randomness: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (store *Store) newUUID() (string, error) {
|
||||
value, err := store.randomBytes(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
value[6] = (value[6] & 0x0f) | 0x40
|
||||
value[8] = (value[8] & 0x3f) | 0x80
|
||||
encoded := hex.EncodeToString(value)
|
||||
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], nil
|
||||
}
|
||||
|
||||
func exactlyOne(result sql.Result) (bool, error) {
|
||||
rows, err := result.RowsAffected()
|
||||
return rows == 1, err
|
||||
}
|
||||
|
||||
func formatTime(value time.Time) string { return value.UTC().Format(time.RFC3339Nano) }
|
||||
|
||||
func parseCanonicalTime(value string) (time.Time, error) {
|
||||
if !strings.HasSuffix(value, "Z") || strings.TrimSpace(value) != value {
|
||||
return time.Time{}, ErrInvalid
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil || parsed.Location() != time.UTC || formatTime(parsed) != value {
|
||||
return time.Time{}, ErrInvalid
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validCanonicalTime(value string) bool {
|
||||
_, err := parseCanonicalTime(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
||||
if character != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
||||
}
|
||||
|
||||
func digitsOnly(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < '0' || character > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func canonicalMoney(value string) bool {
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') {
|
||||
return false
|
||||
}
|
||||
for _, part := range parts {
|
||||
if !digitsOnly(part) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
cents := new(big.Int)
|
||||
_, ok := cents.SetString(parts[0]+parts[1], 10)
|
||||
return ok && cents.Sign() > 0
|
||||
}
|
||||
|
||||
func productURL(goodsID string) string {
|
||||
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package taskclaim
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"hash"
|
||||
)
|
||||
|
||||
const tokenDomain = "cmbuyer/task-claim-token/v1\x00"
|
||||
|
||||
func deriveToken(secret []byte, deviceID, taskID, authorizationID, attemptID string, generation int, nonce []byte) []byte {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(tokenDomain))
|
||||
writeTokenField(mac, deviceID)
|
||||
writeTokenField(mac, taskID)
|
||||
writeTokenField(mac, authorizationID)
|
||||
writeTokenField(mac, attemptID)
|
||||
var number [8]byte
|
||||
binary.BigEndian.PutUint64(number[:], uint64(generation))
|
||||
_, _ = mac.Write(number[:])
|
||||
writeTokenBytes(mac, nonce)
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func writeTokenField(writer hash.Hash, value string) { writeTokenBytes(writer, []byte(value)) }
|
||||
|
||||
func writeTokenBytes(writer hash.Hash, value []byte) {
|
||||
var size [4]byte
|
||||
binary.BigEndian.PutUint32(size[:], uint32(len(value)))
|
||||
_, _ = writer.Write(size[:])
|
||||
_, _ = writer.Write(value)
|
||||
}
|
||||
|
||||
func tokenHash(token []byte) []byte {
|
||||
sum := sha256.Sum256(token)
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func matchingHash(left, right []byte) bool {
|
||||
return len(left) == sha256.Size && len(right) == sha256.Size && subtle.ConstantTimeCompare(left, right) == 1
|
||||
}
|
||||
|
||||
func decodeToken(value string) ([]byte, bool) {
|
||||
if len(value) != sha256.Size*2 {
|
||||
return nil, false
|
||||
}
|
||||
decoded, err := hex.DecodeString(value)
|
||||
return decoded, err == nil && hex.EncodeToString(decoded) == value
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Package taskclaim owns the atomic task-claim and lease-renewal boundary.
|
||||
// A claim token proves only ownership of one attempt; it is never permission to submit an order.
|
||||
package taskclaim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("invalid task claim request")
|
||||
ErrIdempotencyConflict = errors.New("task claim idempotency conflict")
|
||||
ErrRequiresManual = errors.New("task claim requires manual recovery")
|
||||
ErrNotCurrent = errors.New("task claim is not current")
|
||||
ErrDeviceInactive = errors.New("task claim device is inactive")
|
||||
)
|
||||
|
||||
type ClaimCommand struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ClaimRequestID string `json:"claim_request_id"`
|
||||
}
|
||||
|
||||
type RenewCommand struct {
|
||||
TaskID string `json:"-"`
|
||||
RenewRequestID string `json:"renew_request_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
AttemptID string `json:"attempt_id"`
|
||||
ClaimGeneration int `json:"claim_generation"`
|
||||
ClaimToken string `json:"claim_token"`
|
||||
ExpectedLeaseExpiresAt string `json:"expected_lease_expires_at"`
|
||||
}
|
||||
|
||||
type ClaimedTask struct {
|
||||
ID string `json:"id"`
|
||||
Version int `json:"version"`
|
||||
Title string `json:"title"`
|
||||
ProductURL string `json:"product_url"`
|
||||
GoodsID string `json:"goods_id"`
|
||||
SKUColor string `json:"sku_color"`
|
||||
SKUSize string `json:"sku_size"`
|
||||
Quantity int `json:"quantity"`
|
||||
MaxTotalPrice string `json:"max_total_price"`
|
||||
}
|
||||
|
||||
type ClaimedAuthorization struct {
|
||||
ID string `json:"id"`
|
||||
TaskVersion int `json:"task_version"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
type ClaimedAttempt struct {
|
||||
ID string `json:"id"`
|
||||
ClaimToken string `json:"claim_token"`
|
||||
ClaimGeneration int `json:"claim_generation"`
|
||||
LeaseExpiresAt string `json:"lease_expires_at"`
|
||||
}
|
||||
|
||||
type ClaimResponse struct {
|
||||
Task ClaimedTask `json:"task"`
|
||||
Authorization ClaimedAuthorization `json:"authorization"`
|
||||
Attempt ClaimedAttempt `json:"attempt"`
|
||||
}
|
||||
|
||||
type RenewResponse struct {
|
||||
TaskID string `json:"task_id"`
|
||||
AttemptID string `json:"attempt_id"`
|
||||
ClaimGeneration int `json:"claim_generation"`
|
||||
LeaseExpiresAt string `json:"lease_expires_at"`
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
ClaimNext(context.Context, string, ClaimCommand) (ClaimResponse, bool, error)
|
||||
Renew(context.Context, string, RenewCommand) (RenewResponse, error)
|
||||
}
|
||||
@@ -14,14 +14,20 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailDevice = "e3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
)
|
||||
|
||||
func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
|
||||
database := openDetailDatabase(t)
|
||||
timestamp := "2026-08-04T00:00:00Z"
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'detail test device', zeroblob(32), 'ACTIVE', ?, NULL)`, detailDevice, timestamp); err != nil {
|
||||
t.Fatalf("insert device: %v", err)
|
||||
}
|
||||
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', 'shirt', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 3, ?, ?)`, detailTask, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
@@ -31,8 +37,17 @@ func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, detailTry, detailTask, detailAuth, timestamp); err != nil {
|
||||
t.Fatalf("insert attempt: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, 'f3c9f507-7473-4fa6-8d71-8786c34c6301', 1, 3, 'shirt',
|
||||
2, '123', 'black', 'M', 2, '30.00', ?, zeroblob(32), zeroblob(32),
|
||||
'2026-08-04T00:05:00Z', ?, NULL)`, detailTry, detailTask, detailAuth, detailDevice, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert claim: %v", err)
|
||||
}
|
||||
hash := strings.Repeat("a", 64)
|
||||
if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, 'device', ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", timestamp, timestamp); err != nil {
|
||||
if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, ?, ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", detailDevice, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert evidence: %v", err)
|
||||
}
|
||||
store, err := NewSQLiteStore(database)
|
||||
|
||||
Reference in New Issue
Block a user