fix(api): bound claim wire fields end to end
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,6 +8,8 @@ from pathlib import Path
|
||||
|
||||
from .errors import ValidationError
|
||||
from .validation import (
|
||||
MAX_SKU_TEXT_CODE_POINTS,
|
||||
MAX_TITLE_CODE_POINTS,
|
||||
canonical_product_url,
|
||||
require_exact_fields,
|
||||
require_goods_id,
|
||||
@@ -83,14 +85,18 @@ class PurchaseTask:
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.id, "invalid_task_id")
|
||||
require_positive_int(self.version, "invalid_task_version")
|
||||
require_string(self.title, "invalid_task_title", maximum=32 * 1024)
|
||||
if not self.title.strip():
|
||||
require_string(self.title, "invalid_task_title", maximum=MAX_TITLE_CODE_POINTS)
|
||||
if not self.title.strip() or self.title.strip() != self.title:
|
||||
raise ValidationError("invalid_task_title")
|
||||
require_goods_id(self.goods_id)
|
||||
if self.product_url != canonical_product_url(self.goods_id):
|
||||
raise ValidationError("invalid_product_url")
|
||||
require_string(self.sku_color, "invalid_sku_color", maximum=32 * 1024)
|
||||
require_string(self.sku_size, "invalid_sku_size", maximum=32 * 1024)
|
||||
require_string(self.sku_color, "invalid_sku_color", maximum=MAX_SKU_TEXT_CODE_POINTS)
|
||||
require_string(self.sku_size, "invalid_sku_size", maximum=MAX_SKU_TEXT_CODE_POINTS)
|
||||
if self.sku_color.strip() != self.sku_color:
|
||||
raise ValidationError("invalid_sku_color")
|
||||
if self.sku_size.strip() != self.sku_size:
|
||||
raise ValidationError("invalid_sku_size")
|
||||
require_positive_int(self.quantity, "invalid_quantity")
|
||||
require_money(self.max_total_price, "invalid_max_total_price")
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ RFC3339_Z_RE = re.compile(
|
||||
)
|
||||
MONEY_RE = re.compile(r"(?:0|[1-9][0-9]*)\.[0-9]{2}")
|
||||
GOODS_ID_RE = re.compile(r"[0-9]+")
|
||||
MAX_TITLE_CODE_POINTS = 120
|
||||
MAX_SKU_TEXT_CODE_POINTS = 80
|
||||
MAX_GOODS_ID_ASCII_CHARACTERS = 32
|
||||
MAX_MONEY_ASCII_CHARACTERS = 32
|
||||
|
||||
|
||||
def require_string(value: object, reason: str, *, maximum: int = 4096) -> str:
|
||||
@@ -92,14 +96,14 @@ def require_positive_int(value: object, reason: str = "invalid_integer") -> int:
|
||||
|
||||
|
||||
def require_money(value: object, reason: str = "invalid_money") -> str:
|
||||
text = require_string(value, reason, maximum=32 * 1024)
|
||||
text = require_string(value, reason, maximum=MAX_MONEY_ASCII_CHARACTERS)
|
||||
if MONEY_RE.fullmatch(text) is None or text == "0.00":
|
||||
raise ValidationError(reason)
|
||||
return text
|
||||
|
||||
|
||||
def require_goods_id(value: object) -> str:
|
||||
text = require_string(value, "invalid_goods_id", maximum=32 * 1024)
|
||||
text = require_string(value, "invalid_goods_id", maximum=MAX_GOODS_ID_ASCII_CHARACTERS)
|
||||
if GOODS_ID_RE.fullmatch(text) is None:
|
||||
raise ValidationError("invalid_goods_id")
|
||||
return text
|
||||
|
||||
@@ -100,15 +100,9 @@ class CoreModelsTests(unittest.TestCase):
|
||||
changed["task"]["max_total_price"] = invalid
|
||||
ClaimedTask.from_wire(changed)
|
||||
|
||||
wide = claim_wire()
|
||||
wide_goods = "1" * 33
|
||||
wide["task"].update(
|
||||
goods_id=wide_goods,
|
||||
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + wide_goods,
|
||||
max_total_price="1" * 31 + ".00",
|
||||
quantity=2_147_483_648,
|
||||
)
|
||||
self.assertEqual(ClaimedTask.from_wire(wide).task.quantity, 2_147_483_648)
|
||||
wide_quantity = claim_wire()
|
||||
wide_quantity["task"]["quantity"] = 2_147_483_648
|
||||
self.assertEqual(ClaimedTask.from_wire(wide_quantity).task.quantity, 2_147_483_648)
|
||||
for invalid_goods in ("123", "1٢3"):
|
||||
changed = claim_wire()
|
||||
changed["task"]["goods_id"] = invalid_goods
|
||||
@@ -120,6 +114,47 @@ class CoreModelsTests(unittest.TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(too_large)
|
||||
|
||||
def test_claim_fields_share_explicit_server_bounds(self) -> None:
|
||||
legal = claim_wire()
|
||||
legal_goods = "1" * 32
|
||||
legal["task"].update(
|
||||
title="😀" * 120,
|
||||
goods_id=legal_goods,
|
||||
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + legal_goods,
|
||||
sku_color="色" * 80,
|
||||
sku_size="码" * 80,
|
||||
max_total_price="1" * 29 + ".00",
|
||||
)
|
||||
claimed = ClaimedTask.from_wire(legal)
|
||||
self.assertEqual(len(claimed.task.title), 120)
|
||||
# Python's default ensure_ascii=True expands astral characters to surrogate
|
||||
# escape pairs, so this is a conservative parser-budget proof as well.
|
||||
self.assertLess(len(json.dumps(legal, separators=(",", ":")).encode()), 32 * 1024)
|
||||
|
||||
mutations = (
|
||||
("title", "😀" * 121),
|
||||
("title", " title"),
|
||||
("sku_color", "色" * 81),
|
||||
("sku_color", "black "),
|
||||
("sku_size", "码" * 81),
|
||||
("sku_size", " M"),
|
||||
("max_total_price", "1" * 30 + ".00"),
|
||||
)
|
||||
for field, invalid in mutations:
|
||||
changed = claim_wire()
|
||||
changed["task"][field] = invalid
|
||||
with self.subTest(field=field, length=len(invalid)), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(changed)
|
||||
|
||||
overlong_goods = "1" * 33
|
||||
changed = claim_wire()
|
||||
changed["task"].update(
|
||||
goods_id=overlong_goods,
|
||||
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + overlong_goods,
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(changed)
|
||||
|
||||
def test_wire_strings_reject_lone_surrogates_but_accept_valid_pair(self) -> None:
|
||||
for escaped in (r'"\ud800"', r'"\udc00"'):
|
||||
value = claim_wire()
|
||||
|
||||
@@ -75,6 +75,32 @@ class TaskSourceTests(unittest.TestCase):
|
||||
HttpTaskSource(redirect).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertEqual(len(redirect.calls), 1)
|
||||
|
||||
def test_claim_rejects_service_field_bound_drift_as_ambiguous(self) -> None:
|
||||
mutations = (
|
||||
("title", "😀" * 121),
|
||||
("sku_color", "色" * 81),
|
||||
("sku_size", "码" * 81),
|
||||
("max_total_price", "1" * 30 + ".00"),
|
||||
)
|
||||
for field, invalid in mutations:
|
||||
value = claim_wire()
|
||||
value["task"][field] = invalid
|
||||
transport = FakeTransport(response(200, value))
|
||||
with self.subTest(field=field), self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(transport).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertEqual(len(transport.calls), 1)
|
||||
|
||||
goods_id = "1" * 33
|
||||
value = claim_wire()
|
||||
value["task"].update(
|
||||
goods_id=goods_id,
|
||||
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + goods_id,
|
||||
)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(FakeTransport(response(200, value))).claim_next(
|
||||
self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)
|
||||
)
|
||||
|
||||
def test_fixed_conflict_and_renew_cas(self) -> None:
|
||||
conflict = FakeTransport(response(409, {"error": "claim_requires_manual"}))
|
||||
with self.assertRaises(ManualRemoteError):
|
||||
|
||||
+20
-7
@@ -96,6 +96,10 @@
|
||||
```
|
||||
|
||||
- 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。
|
||||
- `title` 以 `TrimSpace` 后持久化值计,最多 120 个 Unicode code point;`sku_color`、`sku_size`
|
||||
同样按持久化值计,各最多 80 个 Unicode code point。非法 UTF-8 必须先拒绝,不能把替换字符当作
|
||||
合法 code point;超限不得截断。
|
||||
- `goods_id` 只允许 1--32 位 ASCII 数字;规范金额只允许 1--32 个 ASCII 字符。
|
||||
- `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。
|
||||
- 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。
|
||||
|
||||
@@ -219,7 +223,12 @@
|
||||
- 同一 `claim_request_id` 同设备、同 session 稳定重放原结果;同键异载荷返回
|
||||
`409 {"error":"idempotency_conflict"}`。没有候选返回空 `204`,且 EMPTY 也持久化稳定重放。
|
||||
- claim 持久化完整成功响应快照;领取后的 task/authorization 源行变化不得让旧 request 的标题、规格、
|
||||
数量、金额、版本或到期时间漂移。源快照不一致时,新恢复/续租失败闭合。
|
||||
数量、金额、版本或到期时间漂移。每次首次构造和旧 request 重放都重新校验持久化响应快照的字段
|
||||
上限;源快照不一致时,新恢复/续租失败闭合。
|
||||
- 响应内 `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个;`goods_id`
|
||||
为 1--32 位 ASCII 数字,`max_total_price` 为最多 32 个 ASCII 字符的规范金额。创建、授权快照、
|
||||
candidate、持久化 claim snapshot 和 HTTP 输出共用同一合法域;既有畸形行只失败闭合,不迁移、
|
||||
截断或改写。最坏合法字段组合编码后必须明确小于既有 32 KiB claim 响应上限。
|
||||
- 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端
|
||||
首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约
|
||||
过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。
|
||||
@@ -365,9 +374,11 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
|
||||
|
||||
### 文本和金额校验
|
||||
|
||||
- 规格字段:Unicode 规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。
|
||||
- `goods_id`:仅 ASCII 十进制数字,canonical URL 中唯一。
|
||||
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后再比较和持久化。
|
||||
- 标题/规格字段:输入按既有 `TrimSpace` 形成实际持久化值;title 最多 120 个 Unicode code point,
|
||||
颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point;不按 UTF-8 字节或视觉 grapheme
|
||||
计数,不截断超限值。规格比较仍为规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。
|
||||
- `goods_id`:仅 1--32 位 ASCII 十进制数字,canonical URL 中唯一。
|
||||
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后必须是最多 32 个 ASCII 字符,再比较和持久化。
|
||||
- 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。
|
||||
|
||||
## 三、采购工具本地模块合约
|
||||
@@ -389,9 +400,11 @@ events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `Dura
|
||||
同一 device id 的 Bearer 后显式重放;网络、超时、503、截断、非法/未知 2xx 同样只保留原槽。协议/409
|
||||
终止槽但不换 key。成功响应落库失败时,重启仍用原 key 向服务端恢复事实。
|
||||
|
||||
金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零);wire
|
||||
整数为正 int64,拒绝 bool。claim 成功响应总上限 32 KiB;因此客户端不额外发明 goods/title/SKU/金额
|
||||
的单字段业务上限。RFC3339Nano 按 0--9 位小数的纳秒时间轴比较,不能用 Python 微秒精度截断。
|
||||
金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零,最多
|
||||
32 个 ASCII 字符);`goods_id` 只接受 1--32 位 ASCII 数字,title 最多 120 个 Unicode code point,
|
||||
颜色与尺码各最多 80 个。客户端不得截断或修复漂移响应。wire 整数为正 int64,拒绝 bool。claim 成功
|
||||
响应总上限仍为 32 KiB,最坏合法字段组合由双端契约测试证明严格小于该值。RFC3339Nano 按 0--9 位
|
||||
小数的纳秒时间轴比较,不能用 Python 微秒精度截断。
|
||||
|
||||
### 本地恢复合约
|
||||
|
||||
|
||||
+5
-1
@@ -22,7 +22,7 @@ write_paths:
|
||||
- client/tests/remote/**
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=55 synced=2026-08-04T17:18:14Z sha256=a96dc25e291e2ce904f3f78554e2354d450342d164601740c005d240c4d33c30 -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=55 synced=2026-08-04T17:25:11Z sha256=7fd613a5a64643deb5601136241868717a00cc9e7739c6f6a5bb2295e2a9210d -->
|
||||
## 问题 / 背景
|
||||
|
||||
claim 成功响应受 32 KiB 总 body 上限保护,但创建、授权快照、candidate 和重放链路尚未统一约束各自由文本字段,客户端也未冻结同一单字段合法域。异常或历史超长值可能让合法业务事实变成不可传输响应。
|
||||
@@ -65,6 +65,10 @@ claim 成功响应受 32 KiB 总 body 上限保护,但创建、授权快照、
|
||||
### 2026-08-04T17:18:04Z · ila
|
||||
|
||||
2026-08-05 实现范围收紧:HTTP handler 是 service 输出的最后边界,已获总控批准将精确文件 admin/internal/server/task_claims.go 纳入 write_paths;handler 序列化前重验 claim response 与 32 KiB 上限,不扩大到其他 server 文件。
|
||||
|
||||
### 2026-08-04T17:25:07Z · ila
|
||||
|
||||
2026-08-05 实现里程碑:服务端共享 validator 已覆盖创建持久化、start/authorization、candidate、claim snapshot/replay 和 handler 输出;客户端 wire 同界。最坏合法服务端 JSON 使用会被 escapeHTML 为 6 字节的 < 字符证明严格小于 32 KiB,客户端用 ensure_ascii=True 做保守预算证明。focused:Go tasks/taskclaim/server 全绿;client core 8/8、remote 15/15。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
Reference in New Issue
Block a user