feat(admin): add atomic task claim leases

This commit is contained in:
QiuSW
2026-08-04 22:33:16 +08:00
parent 526af1eb31
commit 5f060f60ee
20 changed files with 2923 additions and 49 deletions
+29
View File
@@ -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
}
+12 -1
View File
@@ -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 {
+157 -12
View File
@@ -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 {
+27
View File
@@ -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)
+5 -1
View File
@@ -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())
+16
View File
@@ -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
}
+164
View File
@@ -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})
}
+166
View File
@@ -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
}
+8 -3
View File
@@ -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 {
+830
View File
@@ -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
}
+895
View File
@@ -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, &notNull, &defaultValue, &primaryKey); err != nil {
t.Fatal(err)
}
if name == "claim_token" {
t.Fatal("schema contains a plaintext claim_token column")
}
}
decoded, _ := hex.DecodeString(token)
var nonce, storedHash []byte
if err := database.QueryRow("SELECT claim_nonce, claim_token_sha256 FROM purchase_attempt_claims").Scan(&nonce, &storedHash); err != nil {
t.Fatal(err)
}
if bytes.Equal(nonce, decoded) || bytes.Equal(storedHash, decoded) || len(nonce) != 32 || len(storedHash) != 32 {
t.Fatal("database contains plaintext token or malformed token metadata")
}
}
+52
View File
@@ -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
}
+74
View File
@@ -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)
}
+19 -4
View File
@@ -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)