fix(api): bound claim wire fields end to end

This commit is contained in:
QiuSW
2026-08-05 01:25:23 +08:00
parent 57a5b2e91c
commit 7ea1c5349f
17 changed files with 514 additions and 98 deletions
+14 -2
View File
@@ -14,7 +14,10 @@ import (
"github.com/gin-gonic/gin"
)
const maxClaimJSONBytes = 4096
const (
maxClaimJSONBytes = 4096
maxClaimResponseJSONBytes = 32 * 1024
)
func claimNext(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
@@ -35,7 +38,16 @@ func claimNext(options Options) gin.HandlerFunc {
context.Status(http.StatusNoContent)
return
}
context.JSON(http.StatusOK, response)
if !taskclaim.ValidClaimResponse(response) {
context.Status(http.StatusServiceUnavailable)
return
}
encoded, err := json.Marshal(response)
if err != nil || len(encoded) > maxClaimResponseJSONBytes {
context.Status(http.StatusServiceUnavailable)
return
}
context.Data(http.StatusOK, "application/json; charset=utf-8", encoded)
}
}
+47
View File
@@ -3,6 +3,7 @@ package server_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
@@ -98,6 +99,52 @@ func TestClaimNextStrictJSONSuccessEmptyAndErrors(t *testing.T) {
}
}
func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsClosed(t *testing.T) {
goodsID := strings.Repeat("1", 32)
worst := taskclaim.ClaimResponse{
Task: taskclaim.ClaimedTask{
ID: claimTaskID, Version: 3, Title: strings.Repeat("<", 120),
ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID,
GoodsID: goodsID, SKUColor: strings.Repeat("<", 80), SKUSize: strings.Repeat("<", 80),
Quantity: 9_223_372_036_854_775_807, MaxTotalPrice: strings.Repeat("9", 29) + ".00",
},
Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: 2, ExpiresAt: "9999-12-31T23:59:59.999999999Z"},
Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 9_223_372_036_854_775_807, LeaseExpiresAt: "9999-12-31T23:59:59.999999999Z"},
}
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
service := &fakeTaskClaimService{claimResponse: worst, claimFound: true}
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
request := `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", request, "application/json")
if response.Code != http.StatusOK || !json.Valid(response.Body.Bytes()) || response.Body.Len() >= 32*1024 {
t.Fatalf("worst legal response = status %d, bytes %d, valid JSON %v", response.Code, response.Body.Len(), json.Valid(response.Body.Bytes()))
}
mutations := map[string]func(*taskclaim.ClaimResponse){
"invalid utf8 title": func(response *taskclaim.ClaimResponse) { response.Task.Title = string([]byte{0xff}) },
"overlong title": func(response *taskclaim.ClaimResponse) { response.Task.Title += "<" },
"overlong goods id": func(response *taskclaim.ClaimResponse) {
response.Task.GoodsID += "1"
response.Task.ProductURL += "1"
},
"overlong color": func(response *taskclaim.ClaimResponse) { response.Task.SKUColor += "<" },
"overlong size": func(response *taskclaim.ClaimResponse) { response.Task.SKUSize += "<" },
"overlong money": func(response *taskclaim.ClaimResponse) { response.Task.MaxTotalPrice = strings.Repeat("9", 30) + ".00" },
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
invalid := worst
mutate(&invalid)
service := &fakeTaskClaimService{claimResponse: invalid, claimFound: true}
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", request, "application/json")
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 {
t.Fatalf("invalid service response = %d %q", response.Code, response.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"}}
+22 -41
View File
@@ -10,12 +10,12 @@ import (
"fmt"
"io"
"math"
"math/big"
"strings"
"sync"
"time"
"cmbuyer/admin/internal/deviceauth"
taskmodel "cmbuyer/admin/internal/tasks"
)
const writeTimeout = 2 * time.Second
@@ -80,7 +80,7 @@ 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.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.closed_at,
attempts.claim_generation, attempts.status, authorizations.status, tasks.status
FROM purchase_attempt_claims AS claims
@@ -96,21 +96,21 @@ func (store *Store) validateStoredClaims(ctx context.Context) error {
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 nonceType, hashType, title, 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,
&title, &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)) ||
authorizationTaskVersion <= 0 || !taskmodel.ValidTaskWireFields(title, goodsID, color, size, price) || quantity <= 0 ||
!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")
@@ -577,9 +577,9 @@ func (store *Store) scanClaim(row rowScanner) (claimRecord, bool, error) {
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 {
record.TaskVersion <= 0 || record.AuthorizationTaskVersion <= 0 ||
!taskmodel.ValidTaskWireFields(record.TaskTitle, record.GoodsID, record.SKUColor, record.SKUSize, record.TotalPriceCap) ||
record.Quantity <= 0 || 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)
@@ -602,7 +602,11 @@ func (store *Store) scanClaim(row rowScanner) (claimRecord, bool, error) {
}
func (record claimRecord) authorizationConsistent() bool {
return record.AuthorizationTaskVersion == record.CurrentAuthorizationTaskVersion &&
return taskmodel.ValidAuthorizationFields(record.CurrentGoodsID, record.CurrentSKUColor,
record.CurrentSKUSize, record.CurrentTotalPriceCap) &&
taskmodel.ValidTaskWireFields(record.CurrentTaskTitle, record.CurrentTaskGoodsID,
record.CurrentTaskSKUColor, record.CurrentTaskSKUSize, record.CurrentTaskMaxTotalPrice) &&
record.AuthorizationTaskVersion == record.CurrentAuthorizationTaskVersion &&
record.GoodsID == record.CurrentGoodsID && record.SKUColor == record.CurrentSKUColor &&
record.SKUSize == record.CurrentSKUSize && record.Quantity == record.CurrentQuantity &&
record.TotalPriceCap == record.CurrentTotalPriceCap &&
@@ -623,8 +627,12 @@ func (record claimRecord) recoverableBusinessState() bool {
}
func (store *Store) responseFor(record claimRecord, responseLease string) (ClaimResponse, error) {
if !validCanonicalTime(responseLease) {
return ClaimResponse{}, errors.New("stored claim response lease is invalid")
// Exact idempotent replay is allowed to ignore later source-row drift, but the
// immutable response snapshot itself must still satisfy the current wire bounds.
if !validCanonicalTime(responseLease) ||
!taskmodel.ValidTaskWireFields(record.TaskTitle, record.GoodsID, record.SKUColor, record.SKUSize, record.TotalPriceCap) ||
record.Quantity <= 0 {
return ClaimResponse{}, errors.New("stored claim response snapshot is invalid")
}
token := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce)
return ClaimResponse{
@@ -690,8 +698,8 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can
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)
taskmodel.ValidTaskWireFields(item.Title, item.GoodsID, item.SKUColor, item.SKUSize, item.TotalPriceCap) &&
item.Quantity > 0
}
func validAttemptStatus(value sql.NullString) bool {
@@ -798,33 +806,6 @@ func validUUID(value string) bool {
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
}
+89
View File
@@ -193,6 +193,88 @@ func TestClaimRollsBackEveryBusinessMutationOnLateFailure(t *testing.T) {
}
}
func TestClaimRejectsOutOfBoundsCandidatesWithoutBusinessMutation(t *testing.T) {
mutations := map[string]func(*testing.T, *sql.DB){
"invalid utf8 title": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), testTaskA)
},
"overlong title": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", 121), testTaskA)
},
"overlong goods id": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("1", 33)
execClaimSQL(t, database, `UPDATE tasks SET goods_id=? WHERE id=?`, value, testTaskA)
execClaimSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE id=?`, value, testAuthA)
},
"overlong color": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("色", 81)
execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, value, testTaskA)
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE id=?`, value, testAuthA)
},
"overlong size": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("码", 81)
execClaimSQL(t, database, `UPDATE tasks SET sku_size=? WHERE id=?`, value, testTaskA)
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE id=?`, value, testAuthA)
},
"overlong money": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("1", 30) + ".00"
execClaimSQL(t, database, `UPDATE tasks SET max_total_price=? WHERE id=?`, value, testTaskA)
execClaimSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE id=?`, value, testAuthA)
},
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
database := openClaimTestDatabase(t)
insertDevice(t, database, testDeviceA, []byte("device-a"))
insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true)
mutate(t, database)
store := mustStore(t, database, bytes.Repeat([]byte{0x32}, 32), 30*time.Second)
store.now = func() time.Time { return testNow }
if _, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}); err == nil || found {
t.Fatalf("ClaimNext = found %v, err %v; want closed failure", found, err)
}
assertClaimState(t, database, 0, "PENDING", "ACTIVE")
})
}
}
func TestClaimReplayRevalidatesImmutableResponseSnapshot(t *testing.T) {
mutations := map[string]func(*testing.T, *sql.DB){
"title": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET task_title=? WHERE task_id=?`, strings.Repeat("😀", 121), testTaskA)
},
"goods id": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET goods_id=? WHERE task_id=?`, strings.Repeat("1", 33), testTaskA)
},
"color": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET sku_color=? WHERE task_id=?`, strings.Repeat("色", 81), testTaskA)
},
"size": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET sku_size=? WHERE task_id=?`, strings.Repeat("码", 81), testTaskA)
},
"money": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET total_price_cap=? WHERE task_id=?`, strings.Repeat("1", 30)+".00", testTaskA)
},
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
database := openClaimTestDatabase(t)
insertDevice(t, database, testDeviceA, []byte("device-a"))
insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true)
store := mustStore(t, database, bytes.Repeat([]byte{0x34}, 32), 30*time.Second)
store.now = func() time.Time { return testNow }
command := ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}
if _, found, err := store.ClaimNext(context.Background(), testDeviceA, command); err != nil || !found {
t.Fatalf("initial ClaimNext = found %v, err %v", found, err)
}
mutate(t, database)
if _, found, err := store.ClaimNext(context.Background(), testDeviceA, command); err == nil || found {
t.Fatalf("replay = found %v, err %v; want invalid snapshot", found, err)
}
})
}
}
func TestClaimEligibilityStableOrderAndConcurrentUniqueness(t *testing.T) {
database := openClaimTestDatabase(t)
insertDevice(t, database, testDeviceA, []byte("device-a"))
@@ -849,6 +931,13 @@ func insertCandidate(t *testing.T, database *sql.DB, taskID, authorizationID str
}
}
func execClaimSQL(t *testing.T, database *sql.DB, statement string, arguments ...any) {
t.Helper()
if _, err := database.Exec(statement, arguments...); err != nil {
t.Fatalf("execute claim test SQL: %v", err)
}
}
func assertClaimState(t *testing.T, database *sql.DB, wantClaims int, wantTaskStatus, wantAuthorizationStatus string) {
t.Helper()
var count int
+26
View File
@@ -5,6 +5,9 @@ package taskclaim
import (
"context"
"errors"
"math"
taskmodel "cmbuyer/admin/internal/tasks"
)
var (
@@ -61,6 +64,29 @@ type ClaimResponse struct {
Attempt ClaimedAttempt `json:"attempt"`
}
// ValidClaimResponse closes the service-to-HTTP boundary as well as the SQLite
// boundary. A fake or future Service implementation cannot bypass the same field
// limits enforced while creating and claiming the task.
func ValidClaimResponse(response ClaimResponse) bool {
authorizationExpires, authorizationErr := parseCanonicalTime(response.Authorization.ExpiresAt)
leaseExpires, leaseErr := parseCanonicalTime(response.Attempt.LeaseExpiresAt)
return validUUID(response.Task.ID) && response.Task.Version > 0 &&
response.Authorization.TaskVersion > 0 && response.Authorization.TaskVersion < math.MaxInt &&
response.Task.Version == response.Authorization.TaskVersion+1 &&
taskmodel.ValidTaskWireFields(response.Task.Title, response.Task.GoodsID,
response.Task.SKUColor, response.Task.SKUSize, response.Task.MaxTotalPrice) &&
response.Task.ProductURL == productURL(response.Task.GoodsID) && response.Task.Quantity > 0 &&
validUUID(response.Authorization.ID) && authorizationErr == nil &&
validUUID(response.Attempt.ID) && response.Attempt.ClaimGeneration > 0 &&
len(response.Attempt.ClaimToken) == 64 && tokenTextValid(response.Attempt.ClaimToken) &&
leaseErr == nil && !leaseExpires.After(authorizationExpires)
}
func tokenTextValid(value string) bool {
_, ok := decodeToken(value)
return ok
}
type RenewResponse struct {
TaskID string `json:"task_id"`
AttemptID string `json:"attempt_id"`
+6 -1
View File
@@ -50,7 +50,7 @@ type TaskRow struct {
}
func normalizeCents(value string) (string, *big.Int, bool) {
if value == "" || strings.TrimSpace(value) != value {
if value == "" || len(value) > MaxMoneyASCIICharacters || strings.TrimSpace(value) != value {
return "", nil, false
}
parts := strings.Split(value, ".")
@@ -71,6 +71,11 @@ func normalizeCents(value string) (string, *big.Int, bool) {
return value, cents, true
}
func ValidCanonicalMoney(value string) bool {
canonical, _, ok := normalizeCents(value)
return ok && canonical == value
}
func startItems(command StartCommand) ([]StartItem, error) {
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
return nil, ErrInvalidStart
@@ -8,6 +8,7 @@ import (
"math"
"reflect"
"sort"
"strings"
"sync"
"testing"
"time"
@@ -164,12 +165,27 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
"nondigit goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='937x' WHERE id=?`, id)
},
"overlong goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id=? WHERE id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id)
},
"invalid utf8 title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), id)
},
"overlong title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id)
},
"empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id)
},
"overlong color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_color=? WHERE id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id)
},
"empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id)
},
"overlong size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size=? WHERE id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id)
},
"quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxQuantity = 1
},
@@ -179,6 +195,9 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
"noncanonical leading zero": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='012.80' WHERE id=?`, id)
},
"overlong canonical price": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price=? WHERE id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id)
},
"price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxTotalPrice = "12.79"
},
@@ -199,6 +218,43 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
}
}
func TestStartPurchasesReplayRejectsMalformedAuthorizationOrTaskSnapshot(t *testing.T) {
mutations := map[string]func(*testing.T, *sql.DB, string){
"authorization goods id": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE task_id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id)
},
"authorization color": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE task_id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id)
},
"authorization size": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE task_id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id)
},
"authorization money": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE task_id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id)
},
"task title": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id)
},
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}
if _, err := store.StartPurchases(context.Background(), command, "admin"); err != nil {
t.Fatal(err)
}
mutate(t, database, id)
if _, err := store.StartPurchases(context.Background(), command, "admin"); !errors.Is(err, ErrStartConflict) {
t.Fatalf("replay error = %v, want ErrStartConflict", err)
}
assertAuthorizationCount(t, database, 1)
})
}
}
func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) {
for _, test := range []struct {
name string
+25 -15
View File
@@ -142,7 +142,9 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
}
return StartResult{}, err
}
if status != "DRAFT" || version != item.ExpectedTaskVersion || !goodsIDValid(goods) || color == "" || size == "" || quantity < 1 || quantity > store.policy.MaxQuantity {
if status != "DRAFT" || version != item.ExpectedTaskVersion ||
!ValidTaskWireFields(title, goods, color, size, price) ||
quantity < 1 || quantity > store.policy.MaxQuantity {
return StartResult{}, ErrStartConflict
}
canonical, cents, ok := normalizeCents(price)
@@ -179,20 +181,15 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
return result, nil
}
func goodsIDValid(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []StartItem) (StartResult, bool, error) {
rows, err := tx.QueryContext(ctx, "SELECT id,task_id,task_version,expires_at FROM order_authorizations WHERE start_key=? ORDER BY task_id", startKey)
rows, err := tx.QueryContext(ctx, `SELECT authorizations.id,authorizations.task_id,
authorizations.task_version,authorizations.expires_at,authorizations.goods_id,
authorizations.sku_color,authorizations.sku_size,authorizations.quantity,
authorizations.total_price_cap,tasks.title,tasks.goods_id,tasks.sku_color,
tasks.sku_size,tasks.quantity,tasks.max_total_price
FROM order_authorizations AS authorizations
JOIN tasks ON tasks.id = authorizations.task_id
WHERE authorizations.start_key=? ORDER BY authorizations.task_id`, startKey)
if err != nil {
return StartResult{}, false, err
}
@@ -201,9 +198,22 @@ func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []Start
for rows.Next() {
var item AuthorizedTask
var expires string
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires); err != nil {
var authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice string
var taskTitle, taskGoodsID, taskColor, taskSize, taskPrice string
var authorizationQuantity, taskQuantity int
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires,
&authorizationGoodsID, &authorizationColor, &authorizationSize, &authorizationQuantity,
&authorizationPrice, &taskTitle, &taskGoodsID, &taskColor, &taskSize, &taskQuantity,
&taskPrice); err != nil {
return StartResult{}, false, err
}
if !ValidAuthorizationFields(authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice) ||
authorizationQuantity <= 0 ||
!ValidTaskWireFields(taskTitle, taskGoodsID, taskColor, taskSize, taskPrice) || taskQuantity <= 0 ||
authorizationGoodsID != taskGoodsID || authorizationColor != taskColor ||
authorizationSize != taskSize || authorizationQuantity != taskQuantity || authorizationPrice != taskPrice {
return StartResult{}, false, ErrStartConflict
}
item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
if err != nil {
return StartResult{}, false, err
+5
View File
@@ -37,6 +37,11 @@ func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
}
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
// Validate again at the persistence boundary. HTTP form validation is not the only
// caller, and a malformed row here would later make an authorized claim unencodable.
if !validUUID(draft.ID) || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) || draft.Quantity <= 0 {
return Draft{}, ErrInvalidDraft
}
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
defer cancel()
// SQLite permits one writer at a time. Serializing this store's short create
+55 -7
View File
@@ -9,14 +9,21 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
)
const (
maxTitleLength = 120
maxSKUText = 80
MaxTitleCodePoints = 120
MaxSKUTextCodePoints = 80
MaxGoodsIDCharacters = 32
MaxMoneyASCIICharacters = 32
maxSKUText = MaxSKUTextCodePoints
)
var ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
var (
ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
ErrInvalidDraft = errors.New("invalid draft")
)
type Draft struct {
ID string
@@ -41,13 +48,13 @@ func Validate(form Form) (Draft, Errors) {
if !validUUID(draft.ID) {
errors["create_key"] = "创建请求已过期,请重新打开表单。"
}
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
if !validBoundedText(draft.Title, MaxTitleCodePoints) {
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
}
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
if !validBoundedText(draft.SKUColor, MaxSKUTextCodePoints) {
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
}
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
if !validBoundedText(draft.SKUSize, MaxSKUTextCodePoints) {
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
}
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
@@ -93,6 +100,9 @@ func CanonicalGoodsID(value string) (string, bool) {
return "", false
}
}
if !ValidGoodsID(goodsIDs[0]) {
return "", false
}
return goodsIDs[0], true
}
@@ -155,5 +165,43 @@ func normalizeMoney(value string) (string, bool) {
if whole == "0" && strings.Trim(fraction, "0") == "" {
return "", false
}
return whole + "." + (fraction + "00")[:2], true
canonical := whole + "." + (fraction + "00")[:2]
if len(canonical) > MaxMoneyASCIICharacters {
return "", false
}
return canonical, true
}
// ValidTaskWireFields is shared by creation, authorization and claim. Keeping one
// bounded domain prevents a database row from being valid in one stage but impossible
// to encode inside the fixed claim response budget in another stage.
func ValidTaskWireFields(title, goodsID, skuColor, skuSize, maxTotalPrice string) bool {
return validBoundedText(title, MaxTitleCodePoints) &&
ValidAuthorizationFields(goodsID, skuColor, skuSize, maxTotalPrice)
}
func ValidAuthorizationFields(goodsID, skuColor, skuSize, totalPriceCap string) bool {
return ValidGoodsID(goodsID) &&
validBoundedText(skuColor, MaxSKUTextCodePoints) &&
validBoundedText(skuSize, MaxSKUTextCodePoints) &&
ValidCanonicalMoney(totalPriceCap)
}
func ValidGoodsID(value string) bool {
if value == "" || len(value) > MaxGoodsIDCharacters {
return false
}
for index := 0; index < len(value); index++ {
if value[index] < '0' || value[index] > '9' {
return false
}
}
return true
}
func validBoundedText(value string, maximum int) bool {
// RuneCountInString replaces malformed byte sequences with RuneError. Validate first
// so corrupt SQLite text cannot consume the code-point budget as if it were legitimate.
return utf8.ValidString(value) && value != "" && strings.TrimSpace(value) == value &&
utf8.RuneCountInString(value) <= maximum
}
+58 -9
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"testing"
"time"
@@ -38,13 +39,16 @@ func TestValidateNormalizesManualDraft(t *testing.T) {
func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"}
for name, update := range map[string]func(*Form){
"empty title": func(form *Form) { form.Title = " " },
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
"zero quantity": func(form *Form) { form.Quantity = "0" },
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
"empty title": func(form *Form) { form.Title = " " },
"invalid utf8 title": func(form *Form) { form.Title = string([]byte{0xff}) },
"long title": func(form *Form) { form.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
"invalid utf8 size": func(form *Form) { form.SKUSize = string([]byte{0xff}) },
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
"zero quantity": func(form *Form) { form.Quantity = "0" },
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
} {
t.Run(name, func(t *testing.T) {
form := base
@@ -67,6 +71,7 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
"https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2",
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
"https://mobile.yangkeduo.com/other.html?goods_id=1",
"https://mobile.yangkeduo.com/goods.html?goods_id=" + strings.Repeat("1", MaxGoodsIDCharacters+1),
} {
if _, ok := CanonicalGoodsID(value); ok {
t.Fatalf("CanonicalGoodsID accepted %q", value)
@@ -75,19 +80,33 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
}
func TestNormalizeMoneyBoundaries(t *testing.T) {
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00"} {
maximum := strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00"
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00", maximum: maximum} {
got, ok := normalizeMoney(value)
if !ok || got != want {
t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want)
}
}
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1"} {
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1", strings.Repeat("9", MaxMoneyASCIICharacters-2) + ".00"} {
if got, ok := normalizeMoney(value); ok {
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
}
}
}
func TestValidateAcceptsWorstLegalUnicodeFieldBounds(t *testing.T) {
goodsID := strings.Repeat("1", MaxGoodsIDCharacters)
draft, validation := Validate(Form{
CreateKey: testKey, Title: strings.Repeat("😀", MaxTitleCodePoints),
ProductURL: CanonicalURL(goodsID), SKUColor: strings.Repeat("色", MaxSKUTextCodePoints),
SKUSize: strings.Repeat("码", MaxSKUTextCodePoints), Quantity: "1",
MaxTotalPrice: strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00",
})
if !validation.Valid() || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) {
t.Fatalf("worst legal draft = %#v, validation = %#v", draft, validation)
}
}
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
key, err := NewCreateKey()
if err != nil {
@@ -157,6 +176,36 @@ func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
}
}
func TestSQLiteStoreRejectsInvalidDraftAtPersistenceBoundary(t *testing.T) {
mutations := map[string]func(*Draft){
"untrimmed title": func(draft *Draft) { draft.Title = " title" },
"invalid utf8 title": func(draft *Draft) { draft.Title = string([]byte{0xff}) },
"long title": func(draft *Draft) { draft.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
"long color": func(draft *Draft) { draft.SKUColor = strings.Repeat("色", MaxSKUTextCodePoints+1) },
"long size": func(draft *Draft) { draft.SKUSize = strings.Repeat("码", MaxSKUTextCodePoints+1) },
"long goods id": func(draft *Draft) { draft.GoodsID = strings.Repeat("1", MaxGoodsIDCharacters+1) },
"long money": func(draft *Draft) { draft.MaxTotalPrice = strings.Repeat("1", MaxMoneyASCIICharacters-2) + ".00" },
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
draft := testDraft(testKey, "title")
mutate(&draft)
if _, err := store.CreateDraft(context.Background(), draft); !errors.Is(err, ErrInvalidDraft) {
t.Fatalf("CreateDraft error = %v, want ErrInvalidDraft", err)
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM tasks").Scan(&count); err != nil || count != 0 {
t.Fatalf("tasks after invalid create = %d, err %v", count, err)
}
})
}
}
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)