Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6145fc4468 | ||
|
|
f6cd65208d | ||
|
|
7ea1c5349f | ||
|
|
57a5b2e91c | ||
|
|
63c2c59b41 |
@@ -14,7 +14,10 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxClaimJSONBytes = 4096
|
const (
|
||||||
|
maxClaimJSONBytes = 4096
|
||||||
|
maxClaimResponseJSONBytes = 32 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
func claimNext(options Options) gin.HandlerFunc {
|
func claimNext(options Options) gin.HandlerFunc {
|
||||||
return func(context *gin.Context) {
|
return func(context *gin.Context) {
|
||||||
@@ -35,7 +38,16 @@ func claimNext(options Options) gin.HandlerFunc {
|
|||||||
context.Status(http.StatusNoContent)
|
context.Status(http.StatusNoContent)
|
||||||
return
|
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,8 +3,10 @@ package server_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -98,6 +100,53 @@ func TestClaimNextStrictJSONSuccessEmptyAndErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsClosed(t *testing.T) {
|
||||||
|
goodsID := strings.Repeat("1", 32)
|
||||||
|
worst := taskclaim.ClaimResponse{
|
||||||
|
Task: taskclaim.ClaimedTask{
|
||||||
|
ID: claimTaskID, Version: math.MaxInt, 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: math.MaxInt - 1, 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}) },
|
||||||
|
"c0 separator title": func(response *taskclaim.ClaimResponse) { response.Task.Title = "visible\u001dhidden" },
|
||||||
|
"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) {
|
func TestRenewStrictBindingResponseAndFixedErrors(t *testing.T) {
|
||||||
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
|
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
|
||||||
service := &fakeTaskClaimService{renewResponse: taskclaim.RenewResponse{TaskID: claimTaskID, AttemptID: claimAttemptID, ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:04:00Z"}}
|
service := &fakeTaskClaimService{renewResponse: taskclaim.RenewResponse{TaskID: claimTaskID, AttemptID: claimAttemptID, ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:04:00Z"}}
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"math/big"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cmbuyer/admin/internal/deviceauth"
|
"cmbuyer/admin/internal/deviceauth"
|
||||||
|
taskmodel "cmbuyer/admin/internal/tasks"
|
||||||
)
|
)
|
||||||
|
|
||||||
const writeTimeout = 2 * time.Second
|
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,
|
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.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.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,
|
claims.quantity, claims.total_price_cap, claims.authorization_expires_at, claims.closed_at,
|
||||||
attempts.claim_generation, attempts.status, authorizations.status, tasks.status
|
attempts.claim_generation, attempts.status, authorizations.status, tasks.status
|
||||||
FROM purchase_attempt_claims AS claims
|
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 deviceID, taskID, authorizationID, attemptID string
|
||||||
var generation, authorizationTaskVersion, quantity int
|
var generation, authorizationTaskVersion, quantity int
|
||||||
var nonce, storedHash []byte
|
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 nonceLength, hashLength int
|
||||||
var closed, attemptStatus, authorizationStatus, taskStatus sql.NullString
|
var closed, attemptStatus, authorizationStatus, taskStatus sql.NullString
|
||||||
var attemptGeneration sql.NullInt64
|
var attemptGeneration sql.NullInt64
|
||||||
if err := rows.Scan(&deviceID, &taskID, &authorizationID, &attemptID, &generation,
|
if err := rows.Scan(&deviceID, &taskID, &authorizationID, &attemptID, &generation,
|
||||||
&nonce, &nonceType, &nonceLength, &storedHash, &hashType, &hashLength,
|
&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 {
|
&attemptGeneration, &attemptStatus, &authorizationStatus, &taskStatus); err != nil {
|
||||||
return errors.New("validate stored task claims")
|
return errors.New("validate stored task claims")
|
||||||
}
|
}
|
||||||
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(taskID) || !validUUID(authorizationID) || !validUUID(attemptID) ||
|
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(taskID) || !validUUID(authorizationID) || !validUUID(attemptID) ||
|
||||||
generation <= 0 || nonceType != "blob" || nonceLength != sha256.Size || len(nonce) != sha256.Size ||
|
generation <= 0 || nonceType != "blob" || nonceLength != sha256.Size || len(nonce) != sha256.Size ||
|
||||||
hashType != "blob" || hashLength != sha256.Size || len(storedHash) != sha256.Size ||
|
hashType != "blob" || hashLength != sha256.Size || len(storedHash) != sha256.Size ||
|
||||||
authorizationTaskVersion <= 0 || !digitsOnly(goodsID) || color == "" || size == "" || quantity <= 0 ||
|
authorizationTaskVersion <= 0 || !taskmodel.ValidTaskWireFields(title, goodsID, color, size, price) || quantity <= 0 ||
|
||||||
!canonicalMoney(price) || !validCanonicalTime(expires) || (closed.Valid && !validCanonicalTime(closed.String)) ||
|
!validCanonicalTime(expires) || (closed.Valid && !validCanonicalTime(closed.String)) ||
|
||||||
!attemptGeneration.Valid || attemptGeneration.Int64 != int64(generation) ||
|
!attemptGeneration.Valid || attemptGeneration.Int64 != int64(generation) ||
|
||||||
!validAttemptStatus(attemptStatus) || !validAuthorizationStatus(authorizationStatus) || !validTaskStatus(taskStatus) {
|
!validAttemptStatus(attemptStatus) || !validAuthorizationStatus(authorizationStatus) || !validTaskStatus(taskStatus) {
|
||||||
return errors.New("stored task claim metadata is invalid")
|
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) ||
|
if !validUUID(record.AttemptID) || !validUUID(record.TaskID) || !validUUID(record.AuthorizationID) ||
|
||||||
!deviceauth.ValidDeviceID(record.DeviceID) || !validUUID(record.SessionID) || record.Generation <= 0 ||
|
!deviceauth.ValidDeviceID(record.DeviceID) || !validUUID(record.SessionID) || record.Generation <= 0 ||
|
||||||
record.CurrentAttemptGeneration != record.Generation ||
|
record.CurrentAttemptGeneration != record.Generation ||
|
||||||
record.TaskVersion <= 0 || record.AuthorizationTaskVersion <= 0 || strings.TrimSpace(record.TaskTitle) == "" ||
|
record.TaskVersion <= 0 || record.AuthorizationTaskVersion <= 0 ||
|
||||||
!digitsOnly(record.GoodsID) || record.SKUColor == "" || record.SKUSize == "" || record.Quantity <= 0 ||
|
!taskmodel.ValidTaskWireFields(record.TaskTitle, record.GoodsID, record.SKUColor, record.SKUSize, record.TotalPriceCap) ||
|
||||||
!canonicalMoney(record.TotalPriceCap) || len(record.Nonce) != sha256.Size || len(record.TokenHash) != sha256.Size {
|
record.Quantity <= 0 || len(record.Nonce) != sha256.Size || len(record.TokenHash) != sha256.Size {
|
||||||
return claimRecord{}, false, errors.New("stored task claim metadata is invalid")
|
return claimRecord{}, false, errors.New("stored task claim metadata is invalid")
|
||||||
}
|
}
|
||||||
record.LeaseExpiresAt, err = parseCanonicalTime(record.LeaseExpiresText)
|
record.LeaseExpiresAt, err = parseCanonicalTime(record.LeaseExpiresText)
|
||||||
@@ -602,7 +602,11 @@ func (store *Store) scanClaim(row rowScanner) (claimRecord, bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (record claimRecord) authorizationConsistent() bool {
|
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.GoodsID == record.CurrentGoodsID && record.SKUColor == record.CurrentSKUColor &&
|
||||||
record.SKUSize == record.CurrentSKUSize && record.Quantity == record.CurrentQuantity &&
|
record.SKUSize == record.CurrentSKUSize && record.Quantity == record.CurrentQuantity &&
|
||||||
record.TotalPriceCap == record.CurrentTotalPriceCap &&
|
record.TotalPriceCap == record.CurrentTotalPriceCap &&
|
||||||
@@ -623,8 +627,12 @@ func (record claimRecord) recoverableBusinessState() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) responseFor(record claimRecord, responseLease string) (ClaimResponse, error) {
|
func (store *Store) responseFor(record claimRecord, responseLease string) (ClaimResponse, error) {
|
||||||
if !validCanonicalTime(responseLease) {
|
// Exact idempotent replay is allowed to ignore later source-row drift, but the
|
||||||
return ClaimResponse{}, errors.New("stored claim response lease is invalid")
|
// 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)
|
token := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce)
|
||||||
return ClaimResponse{
|
return ClaimResponse{
|
||||||
@@ -639,6 +647,9 @@ func (store *Store) responseFor(record claimRecord, responseLease string) (Claim
|
|||||||
type candidate struct {
|
type candidate struct {
|
||||||
AuthorizationID, TaskID, Title, GoodsID, SKUColor, SKUSize, TotalPriceCap string
|
AuthorizationID, TaskID, Title, GoodsID, SKUColor, SKUSize, TotalPriceCap string
|
||||||
TaskVersion, Quantity int
|
TaskVersion, Quantity int
|
||||||
|
AuthorizationTaskVersion, AuthorizationQuantity int
|
||||||
|
AuthorizationGoodsID, AuthorizationSKUColor, AuthorizationSKUSize string
|
||||||
|
AuthorizationTotalPriceCap string
|
||||||
AuthorizationExpiresText string
|
AuthorizationExpiresText string
|
||||||
AuthorizationExpiresAt time.Time
|
AuthorizationExpiresAt time.Time
|
||||||
}
|
}
|
||||||
@@ -646,16 +657,12 @@ type candidate struct {
|
|||||||
func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (candidate, bool, error) {
|
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,
|
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.title, tasks.goods_id, tasks.sku_color, tasks.sku_size, tasks.quantity,
|
||||||
tasks.max_total_price, authorizations.expires_at
|
tasks.max_total_price, authorizations.task_version, authorizations.goods_id,
|
||||||
|
authorizations.sku_color, authorizations.sku_size, authorizations.quantity,
|
||||||
|
authorizations.total_price_cap, authorizations.expires_at
|
||||||
FROM order_authorizations AS authorizations
|
FROM order_authorizations AS authorizations
|
||||||
JOIN tasks ON tasks.id = authorizations.task_id
|
JOIN tasks ON tasks.id = authorizations.task_id
|
||||||
WHERE authorizations.status = 'ACTIVE' AND tasks.status = 'PENDING'
|
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`)
|
ORDER BY authorizations.created_at, authorizations.rowid, authorizations.id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return candidate{}, false, err
|
return candidate{}, false, err
|
||||||
@@ -665,6 +672,8 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can
|
|||||||
var item candidate
|
var item candidate
|
||||||
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &item.Title,
|
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &item.Title,
|
||||||
&item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.TotalPriceCap,
|
&item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.TotalPriceCap,
|
||||||
|
&item.AuthorizationTaskVersion, &item.AuthorizationGoodsID, &item.AuthorizationSKUColor,
|
||||||
|
&item.AuthorizationSKUSize, &item.AuthorizationQuantity, &item.AuthorizationTotalPriceCap,
|
||||||
&item.AuthorizationExpiresText); err != nil {
|
&item.AuthorizationExpiresText); err != nil {
|
||||||
return candidate{}, false, err
|
return candidate{}, false, err
|
||||||
}
|
}
|
||||||
@@ -675,6 +684,9 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can
|
|||||||
if !validCandidate(item) {
|
if !validCandidate(item) {
|
||||||
return candidate{}, false, errors.New("stored claim candidate is invalid")
|
return candidate{}, false, errors.New("stored claim candidate is invalid")
|
||||||
}
|
}
|
||||||
|
if !candidateSnapshotMatches(item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if item.AuthorizationExpiresAt.After(now) {
|
if item.AuthorizationExpiresAt.After(now) {
|
||||||
if err := rows.Close(); err != nil {
|
if err := rows.Close(); err != nil {
|
||||||
return candidate{}, false, err
|
return candidate{}, false, err
|
||||||
@@ -690,8 +702,16 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can
|
|||||||
|
|
||||||
func validCandidate(item candidate) bool {
|
func validCandidate(item candidate) bool {
|
||||||
return validUUID(item.AuthorizationID) && validUUID(item.TaskID) && item.TaskVersion > 0 && item.TaskVersion < math.MaxInt &&
|
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 != "" &&
|
taskmodel.ValidTaskWireFields(item.Title, item.GoodsID, item.SKUColor, item.SKUSize, item.TotalPriceCap) &&
|
||||||
item.Quantity > 0 && canonicalMoney(item.TotalPriceCap)
|
item.Quantity > 0 && item.AuthorizationTaskVersion > 0 && item.AuthorizationTaskVersion < math.MaxInt &&
|
||||||
|
taskmodel.ValidAuthorizationFields(item.AuthorizationGoodsID, item.AuthorizationSKUColor,
|
||||||
|
item.AuthorizationSKUSize, item.AuthorizationTotalPriceCap) && item.AuthorizationQuantity > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateSnapshotMatches(item candidate) bool {
|
||||||
|
return item.AuthorizationTaskVersion == item.TaskVersion && item.AuthorizationGoodsID == item.GoodsID &&
|
||||||
|
item.AuthorizationSKUColor == item.SKUColor && item.AuthorizationSKUSize == item.SKUSize &&
|
||||||
|
item.AuthorizationQuantity == item.Quantity && item.AuthorizationTotalPriceCap == item.TotalPriceCap
|
||||||
}
|
}
|
||||||
|
|
||||||
func validAttemptStatus(value sql.NullString) bool {
|
func validAttemptStatus(value sql.NullString) bool {
|
||||||
@@ -798,33 +818,6 @@ func validUUID(value string) bool {
|
|||||||
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
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 {
|
func productURL(goodsID string) string {
|
||||||
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
|
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,6 +193,104 @@ func TestClaimRollsBackEveryBusinessMutationOnLateFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClaimRejectsOutOfBoundsCandidatesWithoutBusinessMutation(t *testing.T) {
|
||||||
|
mutations := map[string]func(*testing.T, *sql.DB){
|
||||||
|
"task invalid utf8 title": func(t *testing.T, database *sql.DB) {
|
||||||
|
execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), testTaskA)
|
||||||
|
},
|
||||||
|
"task overlong title": func(t *testing.T, database *sql.DB) {
|
||||||
|
execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", 121), testTaskA)
|
||||||
|
},
|
||||||
|
"task overlong goods id": func(t *testing.T, database *sql.DB) {
|
||||||
|
execClaimSQL(t, database, `UPDATE tasks SET goods_id=? WHERE id=?`, strings.Repeat("1", 33), testTaskA)
|
||||||
|
},
|
||||||
|
"task invalid utf8 color": func(t *testing.T, database *sql.DB) {
|
||||||
|
execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, string([]byte{0xff}), testTaskA)
|
||||||
|
},
|
||||||
|
"task overlong color": func(t *testing.T, database *sql.DB) {
|
||||||
|
execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, strings.Repeat("色", 81), testTaskA)
|
||||||
|
},
|
||||||
|
"task overlong size": func(t *testing.T, database *sql.DB) {
|
||||||
|
execClaimSQL(t, database, `UPDATE tasks SET sku_size=? WHERE id=?`, strings.Repeat("码", 81), testTaskA)
|
||||||
|
},
|
||||||
|
"task overlong money": func(t *testing.T, database *sql.DB) {
|
||||||
|
execClaimSQL(t, database, `UPDATE tasks SET max_total_price=? WHERE id=?`, strings.Repeat("1", 30)+".00", testTaskA)
|
||||||
|
},
|
||||||
|
"authorization overlong goods id": func(t *testing.T, database *sql.DB) {
|
||||||
|
execClaimSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE id=?`, strings.Repeat("1", 33), testAuthA)
|
||||||
|
},
|
||||||
|
"authorization invalid utf8 color": func(t *testing.T, database *sql.DB) {
|
||||||
|
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE id=?`, string([]byte{0xff}), testAuthA)
|
||||||
|
},
|
||||||
|
"authorization overlong color": func(t *testing.T, database *sql.DB) {
|
||||||
|
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE id=?`, strings.Repeat("色", 81), testAuthA)
|
||||||
|
},
|
||||||
|
"authorization overlong size": func(t *testing.T, database *sql.DB) {
|
||||||
|
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE id=?`, strings.Repeat("码", 81), testAuthA)
|
||||||
|
},
|
||||||
|
"authorization overlong money": func(t *testing.T, database *sql.DB) {
|
||||||
|
execClaimSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE id=?`, strings.Repeat("1", 30)+".00", 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")
|
||||||
|
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 invalid candidate = %d, err %v", table, count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestClaimEligibilityStableOrderAndConcurrentUniqueness(t *testing.T) {
|
||||||
database := openClaimTestDatabase(t)
|
database := openClaimTestDatabase(t)
|
||||||
insertDevice(t, database, testDeviceA, []byte("device-a"))
|
insertDevice(t, database, testDeviceA, []byte("device-a"))
|
||||||
@@ -849,6 +947,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) {
|
func assertClaimState(t *testing.T, database *sql.DB, wantClaims int, wantTaskStatus, wantAuthorizationStatus string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var count int
|
var count int
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ package taskclaim
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
taskmodel "cmbuyer/admin/internal/tasks"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -61,6 +64,29 @@ type ClaimResponse struct {
|
|||||||
Attempt ClaimedAttempt `json:"attempt"`
|
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 {
|
type RenewResponse struct {
|
||||||
TaskID string `json:"task_id"`
|
TaskID string `json:"task_id"`
|
||||||
AttemptID string `json:"attempt_id"`
|
AttemptID string `json:"attempt_id"`
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ type TaskRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeCents(value string) (string, *big.Int, bool) {
|
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
|
return "", nil, false
|
||||||
}
|
}
|
||||||
parts := strings.Split(value, ".")
|
parts := strings.Split(value, ".")
|
||||||
@@ -71,6 +71,11 @@ func normalizeCents(value string) (string, *big.Int, bool) {
|
|||||||
return value, cents, true
|
return value, cents, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidCanonicalMoney(value string) bool {
|
||||||
|
canonical, _, ok := normalizeCents(value)
|
||||||
|
return ok && canonical == value
|
||||||
|
}
|
||||||
|
|
||||||
func startItems(command StartCommand) ([]StartItem, error) {
|
func startItems(command StartCommand) ([]StartItem, error) {
|
||||||
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
|
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
|
||||||
return nil, ErrInvalidStart
|
return nil, ErrInvalidStart
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -164,12 +165,27 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
|
|||||||
"nondigit goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
"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)
|
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) {
|
"empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||||
execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id)
|
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) {
|
"empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||||
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id)
|
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) {
|
"quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
|
||||||
store.policy.MaxQuantity = 1
|
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) {
|
"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)
|
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) {
|
"price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
|
||||||
store.policy.MaxTotalPrice = "12.79"
|
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) {
|
func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -142,7 +142,9 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
|
|||||||
}
|
}
|
||||||
return StartResult{}, err
|
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
|
return StartResult{}, ErrStartConflict
|
||||||
}
|
}
|
||||||
canonical, cents, ok := normalizeCents(price)
|
canonical, cents, ok := normalizeCents(price)
|
||||||
@@ -179,20 +181,15 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
|
|||||||
return result, nil
|
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) {
|
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 {
|
if err != nil {
|
||||||
return StartResult{}, false, err
|
return StartResult{}, false, err
|
||||||
}
|
}
|
||||||
@@ -201,9 +198,22 @@ func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []Start
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item AuthorizedTask
|
var item AuthorizedTask
|
||||||
var expires string
|
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
|
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)
|
item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return StartResult{}, false, err
|
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) {
|
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)
|
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
// SQLite permits one writer at a time. Serializing this store's short create
|
// SQLite permits one writer at a time. Serializing this store's short create
|
||||||
|
|||||||
@@ -9,14 +9,21 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxTitleLength = 120
|
MaxTitleCodePoints = 120
|
||||||
maxSKUText = 80
|
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 {
|
type Draft struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -41,13 +48,13 @@ func Validate(form Form) (Draft, Errors) {
|
|||||||
if !validUUID(draft.ID) {
|
if !validUUID(draft.ID) {
|
||||||
errors["create_key"] = "创建请求已过期,请重新打开表单。"
|
errors["create_key"] = "创建请求已过期,请重新打开表单。"
|
||||||
}
|
}
|
||||||
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
|
if !validBoundedText(draft.Title, MaxTitleCodePoints) {
|
||||||
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
|
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
|
||||||
}
|
}
|
||||||
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
|
if !validBoundedText(draft.SKUColor, MaxSKUTextCodePoints) {
|
||||||
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
|
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
|
||||||
}
|
}
|
||||||
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
|
if !validBoundedText(draft.SKUSize, MaxSKUTextCodePoints) {
|
||||||
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
|
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
|
||||||
}
|
}
|
||||||
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
|
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
|
||||||
@@ -93,6 +100,9 @@ func CanonicalGoodsID(value string) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !ValidGoodsID(goodsIDs[0]) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
return goodsIDs[0], true
|
return goodsIDs[0], true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,5 +165,54 @@ func normalizeMoney(value string) (string, bool) {
|
|||||||
if whole == "0" && strings.Trim(fraction, "0") == "" {
|
if whole == "0" && strings.Trim(fraction, "0") == "" {
|
||||||
return "", false
|
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.
|
||||||
|
if !utf8.ValidString(value) || value == "" || strings.TrimSpace(value) != value ||
|
||||||
|
utf8.RuneCountInString(value) > maximum {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
// Python str.strip treats these four C0 separators as whitespace while Go
|
||||||
|
// TrimSpace does not. Reject them everywhere so both wire models have one
|
||||||
|
// explicit persisted-text domain instead of runtime-dependent trimming.
|
||||||
|
if character >= '\u001c' && character <= '\u001f' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -38,13 +39,16 @@ func TestValidateNormalizesManualDraft(t *testing.T) {
|
|||||||
func TestValidateRejectsInvalidFieldsAndURLs(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"}
|
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){
|
for name, update := range map[string]func(*Form){
|
||||||
"empty title": func(form *Form) { form.Title = " " },
|
"empty title": func(form *Form) { form.Title = " " },
|
||||||
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
|
"invalid utf8 title": func(form *Form) { form.Title = string([]byte{0xff}) },
|
||||||
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
|
"long title": func(form *Form) { form.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
|
||||||
"zero quantity": func(form *Form) { form.Quantity = "0" },
|
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
|
||||||
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
|
"invalid utf8 size": func(form *Form) { form.SKUSize = string([]byte{0xff}) },
|
||||||
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
|
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
|
||||||
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
|
"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) {
|
t.Run(name, func(t *testing.T) {
|
||||||
form := base
|
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%26goods_id%3D2",
|
||||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
|
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
|
||||||
"https://mobile.yangkeduo.com/other.html?goods_id=1",
|
"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 {
|
if _, ok := CanonicalGoodsID(value); ok {
|
||||||
t.Fatalf("CanonicalGoodsID accepted %q", value)
|
t.Fatalf("CanonicalGoodsID accepted %q", value)
|
||||||
@@ -75,19 +80,62 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeMoneyBoundaries(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)
|
got, ok := normalizeMoney(value)
|
||||||
if !ok || got != want {
|
if !ok || got != want {
|
||||||
t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, 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 {
|
if got, ok := normalizeMoney(value); ok {
|
||||||
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
|
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 TestPersistedTextHasRuntimeIndependentC0AndNBSPDomain(t *testing.T) {
|
||||||
|
for name, invalid := range map[string]string{
|
||||||
|
"c0 prefix": "\u001cvalue",
|
||||||
|
"c0 suffix": "value\u001f",
|
||||||
|
"c0 interior": "value\u001dinside",
|
||||||
|
"nbsp prefix": "\u00a0value",
|
||||||
|
"nbsp suffix": "value\u00a0",
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if validBoundedText(invalid, MaxTitleCodePoints) {
|
||||||
|
t.Fatalf("validBoundedText(%q) accepted runtime-dependent text", invalid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !validBoundedText("left\u00a0right", MaxTitleCodePoints) {
|
||||||
|
t.Fatal("interior NBSP must remain a valid Unicode code point")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual form input is normalized with Go TrimSpace before persistence.
|
||||||
|
draft, validation := Validate(Form{
|
||||||
|
CreateKey: testKey, Title: "\u00a0title\u00a0",
|
||||||
|
ProductURL: CanonicalURL("1"), SKUColor: "\u00a0black\u00a0",
|
||||||
|
SKUSize: "\u00a0M\u00a0", Quantity: "1", MaxTotalPrice: "1",
|
||||||
|
})
|
||||||
|
if !validation.Valid() || draft.Title != "title" || draft.SKUColor != "black" || draft.SKUSize != "M" {
|
||||||
|
t.Fatalf("NBSP form normalization = %#v, errors = %#v", draft, validation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
|
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
|
||||||
key, err := NewCreateKey()
|
key, err := NewCreateKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,6 +205,37 @@ func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSQLiteStoreRejectsInvalidDraftAtPersistenceBoundary(t *testing.T) {
|
||||||
|
mutations := map[string]func(*Draft){
|
||||||
|
"untrimmed title": func(draft *Draft) { draft.Title = " title" },
|
||||||
|
"c0 interior title": func(draft *Draft) { draft.Title = "title\u001dhidden" },
|
||||||
|
"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) {
|
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
|
||||||
database := migratedDatabase(t)
|
database := migratedDatabase(t)
|
||||||
store, err := NewSQLiteStore(database)
|
store, err := NewSQLiteStore(database)
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .errors import ValidationError
|
from .errors import ValidationError
|
||||||
from .validation import (
|
from .validation import (
|
||||||
|
MAX_SKU_TEXT_CODE_POINTS,
|
||||||
|
MAX_TITLE_CODE_POINTS,
|
||||||
canonical_product_url,
|
canonical_product_url,
|
||||||
require_exact_fields,
|
require_exact_fields,
|
||||||
require_goods_id,
|
require_goods_id,
|
||||||
require_lower_hex_64,
|
require_lower_hex_64,
|
||||||
require_money,
|
require_money,
|
||||||
|
require_persisted_text,
|
||||||
require_positive_int,
|
require_positive_int,
|
||||||
require_rfc3339_z,
|
require_rfc3339_z,
|
||||||
rfc3339_z_nanoseconds,
|
rfc3339_z_nanoseconds,
|
||||||
@@ -83,14 +86,12 @@ class PurchaseTask:
|
|||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
require_uuid4(self.id, "invalid_task_id")
|
require_uuid4(self.id, "invalid_task_id")
|
||||||
require_positive_int(self.version, "invalid_task_version")
|
require_positive_int(self.version, "invalid_task_version")
|
||||||
require_string(self.title, "invalid_task_title", maximum=32 * 1024)
|
require_persisted_text(self.title, "invalid_task_title", maximum=MAX_TITLE_CODE_POINTS)
|
||||||
if not self.title.strip():
|
|
||||||
raise ValidationError("invalid_task_title")
|
|
||||||
require_goods_id(self.goods_id)
|
require_goods_id(self.goods_id)
|
||||||
if self.product_url != canonical_product_url(self.goods_id):
|
if self.product_url != canonical_product_url(self.goods_id):
|
||||||
raise ValidationError("invalid_product_url")
|
raise ValidationError("invalid_product_url")
|
||||||
require_string(self.sku_color, "invalid_sku_color", maximum=32 * 1024)
|
require_persisted_text(self.sku_color, "invalid_sku_color", maximum=MAX_SKU_TEXT_CODE_POINTS)
|
||||||
require_string(self.sku_size, "invalid_sku_size", maximum=32 * 1024)
|
require_persisted_text(self.sku_size, "invalid_sku_size", maximum=MAX_SKU_TEXT_CODE_POINTS)
|
||||||
require_positive_int(self.quantity, "invalid_quantity")
|
require_positive_int(self.quantity, "invalid_quantity")
|
||||||
require_money(self.max_total_price, "invalid_max_total_price")
|
require_money(self.max_total_price, "invalid_max_total_price")
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ RFC3339_Z_RE = re.compile(
|
|||||||
)
|
)
|
||||||
MONEY_RE = re.compile(r"(?:0|[1-9][0-9]*)\.[0-9]{2}")
|
MONEY_RE = re.compile(r"(?:0|[1-9][0-9]*)\.[0-9]{2}")
|
||||||
GOODS_ID_RE = re.compile(r"[0-9]+")
|
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
|
||||||
|
# Go strings.TrimSpace uses Unicode White_Space plus the six ASCII space
|
||||||
|
# characters below, but unlike Python str.strip it does not include U+001C--
|
||||||
|
# U+001F. Keep the wire contract independent of either runtime's defaults.
|
||||||
|
GO_UNICODE_WHITE_SPACE = "\t\n\v\f\r \u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000"
|
||||||
|
|
||||||
|
|
||||||
def require_string(value: object, reason: str, *, maximum: int = 4096) -> str:
|
def require_string(value: object, reason: str, *, maximum: int = 4096) -> str:
|
||||||
@@ -31,6 +39,20 @@ def require_string(value: object, reason: str, *, maximum: int = 4096) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def require_persisted_text(value: object, reason: str, *, maximum: int) -> str:
|
||||||
|
"""Validate text stored by Go after TrimSpace, without Python trim drift."""
|
||||||
|
|
||||||
|
text = require_string(value, reason, maximum=maximum)
|
||||||
|
if text.strip(GO_UNICODE_WHITE_SPACE) != text:
|
||||||
|
raise ValidationError(reason)
|
||||||
|
# Python str.strip treats these C0 separators as whitespace while Go does
|
||||||
|
# not. Reject them anywhere on both ends instead of assigning them two
|
||||||
|
# runtime-dependent meanings.
|
||||||
|
if any(0x1C <= ord(character) <= 0x1F for character in text):
|
||||||
|
raise ValidationError(reason)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def require_uuid4(value: object, reason: str = "invalid_uuid") -> str:
|
def require_uuid4(value: object, reason: str = "invalid_uuid") -> str:
|
||||||
text = require_string(value, reason, maximum=36)
|
text = require_string(value, reason, maximum=36)
|
||||||
if UUID4_RE.fullmatch(text) is None:
|
if UUID4_RE.fullmatch(text) is None:
|
||||||
@@ -92,14 +114,14 @@ def require_positive_int(value: object, reason: str = "invalid_integer") -> int:
|
|||||||
|
|
||||||
|
|
||||||
def require_money(value: object, reason: str = "invalid_money") -> str:
|
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":
|
if MONEY_RE.fullmatch(text) is None or text == "0.00":
|
||||||
raise ValidationError(reason)
|
raise ValidationError(reason)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def require_goods_id(value: object) -> str:
|
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:
|
if GOODS_ID_RE.fullmatch(text) is None:
|
||||||
raise ValidationError("invalid_goods_id")
|
raise ValidationError("invalid_goods_id")
|
||||||
return text
|
return text
|
||||||
|
|||||||
@@ -100,15 +100,9 @@ class CoreModelsTests(unittest.TestCase):
|
|||||||
changed["task"]["max_total_price"] = invalid
|
changed["task"]["max_total_price"] = invalid
|
||||||
ClaimedTask.from_wire(changed)
|
ClaimedTask.from_wire(changed)
|
||||||
|
|
||||||
wide = claim_wire()
|
wide_quantity = claim_wire()
|
||||||
wide_goods = "1" * 33
|
wide_quantity["task"]["quantity"] = 2_147_483_648
|
||||||
wide["task"].update(
|
self.assertEqual(ClaimedTask.from_wire(wide_quantity).task.quantity, 2_147_483_648)
|
||||||
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)
|
|
||||||
for invalid_goods in ("123", "1٢3"):
|
for invalid_goods in ("123", "1٢3"):
|
||||||
changed = claim_wire()
|
changed = claim_wire()
|
||||||
changed["task"]["goods_id"] = invalid_goods
|
changed["task"]["goods_id"] = invalid_goods
|
||||||
@@ -120,6 +114,49 @@ class CoreModelsTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
ClaimedTask.from_wire(too_large)
|
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(
|
||||||
|
version=9_223_372_036_854_775_807,
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
legal["authorization"]["task_version"] = 9_223_372_036_854_775_806
|
||||||
|
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:
|
def test_wire_strings_reject_lone_surrogates_but_accept_valid_pair(self) -> None:
|
||||||
for escaped in (r'"\ud800"', r'"\udc00"'):
|
for escaped in (r'"\ud800"', r'"\udc00"'):
|
||||||
value = claim_wire()
|
value = claim_wire()
|
||||||
@@ -131,8 +168,20 @@ class CoreModelsTests(unittest.TestCase):
|
|||||||
self.assertEqual(ClaimedTask.from_wire(value).task.title, "😀")
|
self.assertEqual(ClaimedTask.from_wire(value).task.title, "😀")
|
||||||
|
|
||||||
def test_title_rejects_ascii_and_unicode_whitespace_only(self) -> None:
|
def test_title_rejects_ascii_and_unicode_whitespace_only(self) -> None:
|
||||||
for title in ("", " \t\r\n", "\u3000", " \u3000\t"):
|
for title in ("", " \t\r\n", "\u3000", " \u3000\t", "\u00a0title", "title\u00a0"):
|
||||||
value = claim_wire()
|
value = claim_wire()
|
||||||
value["task"]["title"] = title
|
value["task"]["title"] = title
|
||||||
with self.subTest(title=repr(title)), self.assertRaises(ValidationError):
|
with self.subTest(title=repr(title)), self.assertRaises(ValidationError):
|
||||||
ClaimedTask.from_wire(value)
|
ClaimedTask.from_wire(value)
|
||||||
|
|
||||||
|
def test_persisted_text_has_runtime_independent_c0_and_nbsp_domain(self) -> None:
|
||||||
|
for field in ("title", "sku_color", "sku_size"):
|
||||||
|
for invalid in ("\u001cvalue", "value\u001f", "value\u001dinside", "\u00a0value", "value\u00a0"):
|
||||||
|
value = claim_wire()
|
||||||
|
value["task"][field] = invalid
|
||||||
|
with self.subTest(field=field, invalid=repr(invalid)), self.assertRaises(ValidationError):
|
||||||
|
ClaimedTask.from_wire(value)
|
||||||
|
|
||||||
|
value = claim_wire()
|
||||||
|
value["task"][field] = "left\u00a0right"
|
||||||
|
self.assertEqual(getattr(ClaimedTask.from_wire(value).task, field), "left\u00a0right")
|
||||||
|
|||||||
@@ -75,6 +75,32 @@ class TaskSourceTests(unittest.TestCase):
|
|||||||
HttpTaskSource(redirect).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
HttpTaskSource(redirect).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||||
self.assertEqual(len(redirect.calls), 1)
|
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:
|
def test_fixed_conflict_and_renew_cas(self) -> None:
|
||||||
conflict = FakeTransport(response(409, {"error": "claim_requires_manual"}))
|
conflict = FakeTransport(response(409, {"error": "claim_requires_manual"}))
|
||||||
with self.assertRaises(ManualRemoteError):
|
with self.assertRaises(ManualRemoteError):
|
||||||
|
|||||||
+26
-7
@@ -96,6 +96,11 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。
|
- 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。
|
||||||
|
- `title` 以 Go `strings.TrimSpace`(Unicode White_Space)后的持久化值计,最多 120 个 Unicode code
|
||||||
|
point;`sku_color`、`sku_size` 同样按持久化值计,各最多 80 个 Unicode code point。非法 UTF-8
|
||||||
|
必须先拒绝,不能把替换字符当作合法 code point;为消除 Go 与 Python 默认 trim 差异,U+001C--U+001F
|
||||||
|
四个 C0 分隔符无论位置一律拒绝;超限不得截断。
|
||||||
|
- `goods_id` 只允许 1--32 位 ASCII 数字;规范金额只允许 1--32 个 ASCII 字符。
|
||||||
- `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。
|
- `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。
|
||||||
- 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。
|
- 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。
|
||||||
|
|
||||||
@@ -219,7 +224,14 @@
|
|||||||
- 同一 `claim_request_id` 同设备、同 session 稳定重放原结果;同键异载荷返回
|
- 同一 `claim_request_id` 同设备、同 session 稳定重放原结果;同键异载荷返回
|
||||||
`409 {"error":"idempotency_conflict"}`。没有候选返回空 `204`,且 EMPTY 也持久化稳定重放。
|
`409 {"error":"idempotency_conflict"}`。没有候选返回空 `204`,且 EMPTY 也持久化稳定重放。
|
||||||
- claim 持久化完整成功响应快照;领取后的 task/authorization 源行变化不得让旧 request 的标题、规格、
|
- 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 输出共用同一合法域;既有畸形行只失败闭合,不迁移、
|
||||||
|
截断或改写。candidate 查询必须先读取 task 与 authorization 两侧字段并分别验证;任一侧畸形必须
|
||||||
|
回滚且不得持久化 `EMPTY`,只有两侧均合法但快照不一致时才跳过。最坏合法字段组合编码后必须明确
|
||||||
|
小于既有 32 KiB claim 响应上限。
|
||||||
- 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端
|
- 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端
|
||||||
首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约
|
首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约
|
||||||
过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。
|
过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。
|
||||||
@@ -365,9 +377,13 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
|
|||||||
|
|
||||||
### 文本和金额校验
|
### 文本和金额校验
|
||||||
|
|
||||||
- 规格字段:Unicode 规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。
|
- 标题/规格字段:输入按 Go `strings.TrimSpace` 的 Unicode White_Space 集形成实际持久化值;title 最多
|
||||||
- `goods_id`:仅 ASCII 十进制数字,canonical URL 中唯一。
|
120 个 Unicode code point,颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point;
|
||||||
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后再比较和持久化。
|
U+001C--U+001F 四个 C0 分隔符在任意位置均拒绝,客户端使用同一固定空白集而不依赖 Python
|
||||||
|
`str.strip()` 默认语义。不按 UTF-8 字节或视觉 grapheme 计数,不截断超限值。规格比较仍为规范化后
|
||||||
|
精确相等;不得包含、前缀、编辑距离或 AI 猜测。
|
||||||
|
- `goods_id`:仅 1--32 位 ASCII 十进制数字,canonical URL 中唯一。
|
||||||
|
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后必须是最多 32 个 ASCII 字符,再比较和持久化。
|
||||||
- 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。
|
- 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。
|
||||||
|
|
||||||
## 三、采购工具本地模块合约
|
## 三、采购工具本地模块合约
|
||||||
@@ -389,9 +405,12 @@ events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `Dura
|
|||||||
同一 device id 的 Bearer 后显式重放;网络、超时、503、截断、非法/未知 2xx 同样只保留原槽。协议/409
|
同一 device id 的 Bearer 后显式重放;网络、超时、503、截断、非法/未知 2xx 同样只保留原槽。协议/409
|
||||||
终止槽但不换 key。成功响应落库失败时,重启仍用原 key 向服务端恢复事实。
|
终止槽但不换 key。成功响应落库失败时,重启仍用原 key 向服务端恢复事实。
|
||||||
|
|
||||||
金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零);wire
|
金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零,最多
|
||||||
整数为正 int64,拒绝 bool。claim 成功响应总上限 32 KiB;因此客户端不额外发明 goods/title/SKU/金额
|
32 个 ASCII 字符);`goods_id` 只接受 1--32 位 ASCII 数字,title 最多 120 个 Unicode code point,
|
||||||
的单字段业务上限。RFC3339Nano 按 0--9 位小数的纳秒时间轴比较,不能用 Python 微秒精度截断。
|
颜色与尺码各最多 80 个。客户端用与 Go `strings.TrimSpace` 相同的固定 Unicode White_Space 集校验
|
||||||
|
持久化文本,并与服务端共同拒绝任意位置的 U+001C--U+001F;不得截断或修复漂移响应。wire 整数为正 int64,拒绝 bool。claim 成功
|
||||||
|
响应总上限仍为 32 KiB,最坏合法字段组合由双端契约测试证明严格小于该值。RFC3339Nano 按 0--9 位
|
||||||
|
小数的纳秒时间轴比较,不能用 Python 微秒精度截断。
|
||||||
|
|
||||||
### 本地恢复合约
|
### 本地恢复合约
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,10 @@
|
|||||||
凭据保护、SQLite append-only 恢复状态图、Global named mutex 与 durable gateway。真机能力尚待最终人工
|
凭据保护、SQLite append-only 恢复状态图、Global named mutex 与 durable gateway。真机能力尚待最终人工
|
||||||
验收,数量/确认页/提交仍未开放。
|
验收,数量/确认页/提交仍未开放。
|
||||||
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
|
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
|
||||||
采购工具 189 项离线单元测试(全部 mock,不连接真机)。
|
采购工具 192 项离线单元测试(全部 mock,不连接真机)。
|
||||||
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
|
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
|
||||||
一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。仓库不含业务实例数据。
|
一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。T-211 已统一 title、SKU、
|
||||||
|
goods_id 与金额的双端 wire 上限,并证明最坏合法 claim 响应小于 32 KiB。仓库不含业务实例数据。
|
||||||
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
||||||
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
|
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
|
||||||
并且不覆盖低版本环境;成功后打印真实启动命令。
|
并且不覆盖低版本环境;成功后打印真实启动命令。
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
| 路径 | 状态 | 说明 |
|
| 路径 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||||
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-210、T-301~T-308、T-400~T-405) | T-302 已完成;T-103 等最终真机验收;双端围栏、真实提交、调和、打包与只读验收均已落成任务 |
|
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-211、T-301~T-308、T-400~T-405) | T-303、T-211 已完成;T-103 等最终真机验收;双端围栏、真实提交、调和、打包与只读验收均已落成任务 |
|
||||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
|
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
|
||||||
@@ -81,6 +82,8 @@
|
|||||||
T-304/T-306/T-307/T-308,再与 T-305 汇合到
|
T-304/T-306/T-307/T-308,再与 T-305 汇合到
|
||||||
T-400 离线一次性提交安全编排。围栏后管理链为 T-403 → T-402;T-401 必须在新的真实单趟重新
|
T-400 离线一次性提交安全编排。围栏后管理链为 T-403 → T-402;T-401 必须在新的真实单趟重新
|
||||||
通过三闸门,首次点击后固定进入 `UNKNOWN`/人工调和,T-405 打包后由 T-404 只读验收。
|
通过三闸门,首次点击后固定进入 `UNKNOWN`/人工调和,T-405 打包后由 T-404 只读验收。
|
||||||
|
- T-211 已完成 claim wire 字段上限闭合:创建、授权、candidate、持久化快照、重放、HTTP 输出和
|
||||||
|
客户端解析使用同一合法域;单侧畸形历史数据失败闭合且不写 EMPTY,最坏合法响应严格小于 32 KiB。
|
||||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||||
可读字段时必须先回修原型与交互清单。
|
可读字段时必须先回修原型与交互清单。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
id: T-211
|
||||||
|
title: claim wire 输出字段上限闭合
|
||||||
|
phase: 2
|
||||||
|
deps: [T-203, T-302, T-303]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-08-05
|
||||||
|
vikunja_task_id: 55
|
||||||
|
context_ref: cfb238d
|
||||||
|
work_branch: task/t-211-claim-wire-bounds
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-211.md
|
||||||
|
- docs/api.md
|
||||||
|
- docs/current-state.md
|
||||||
|
- admin/internal/tasks/**
|
||||||
|
- admin/internal/taskclaim/**
|
||||||
|
- admin/internal/server/task_claims.go
|
||||||
|
- admin/internal/server/task_claims_test.go
|
||||||
|
- client/src/cmbuyer_client/core/**
|
||||||
|
- client/tests/core/**
|
||||||
|
- client/tests/remote/**
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=55 synced=2026-08-04T17:52:41Z sha256=4bdc0bccc833e75d759de4f2270b372484e92e261f979c3b28f501687209b60a -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
claim 成功响应受 32 KiB 总 body 上限保护,但创建、授权快照、candidate 和重放链路尚未统一约束各自由文本字段,客户端也未冻结同一单字段合法域。异常或历史超长值可能让合法业务事实变成不可传输响应。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-005。
|
||||||
|
- 依赖:T-203、T-302、T-303。
|
||||||
|
- API:POST /api/v1/tasks/claim-next 及客户端 claim wire model。
|
||||||
|
- 本任务无 UI、真机或人工验收。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 统一 title 不超过 120 Unicode code points,sku_color 与 sku_size 各不超过 80 Unicode code points。
|
||||||
|
2. goods_id 仅允许 1--32 位 ASCII 数字;canonical money 仅允许不超过 32 个 ASCII 字符,并保留既有正数、两位小数、无前导零与 int64 约束。
|
||||||
|
3. 在创建、开始采购授权快照、claim candidate、持久化响应与幂等重放边界逐层 fail closed;不迁移、不截断或改写既有数据。
|
||||||
|
4. 客户端 wire model 使用相同边界,漂移响应按协议错误拒绝。
|
||||||
|
5. 增加最坏合法字段的 claim JSON 契约测试,明确编码后小于既有 32 KiB 响应上限。
|
||||||
|
6. HTTP claim handler 在序列化前重验完整 service 响应并保持 32 KiB 上限;因此将精确文件 admin/internal/server/task_claims.go 纳入写路径,不扩大到其他 server 文件。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 边界值通过,超过一个 Unicode code point/ASCII 字符即拒绝;组合字符按 code point 而非字节计数。
|
||||||
|
- 创建、授权、candidate、首次 claim 与重放均覆盖畸形既有数据并失败闭合。
|
||||||
|
- 客户端 title/SKU/goods_id/money 与服务端同界。
|
||||||
|
- 最坏合法 claim JSON 明确小于 32768 字节,保持现有 HTTP body cap。
|
||||||
|
- handler 拒绝越界或畸形 service 输出,且不泄露响应内容。
|
||||||
|
- 两端完整测试、vet/build、compileall、根 init、上下文与 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T17:10:26Z · ila
|
||||||
|
|
||||||
|
2026-08-05 开始 T-211:基于 main@cfb238d,在独立 worktree 收紧 claim wire 字段上限;不迁移或截断既有数据,不涉及设备页面、提交订单或付款。
|
||||||
|
|
||||||
|
### 2026-08-04T17:11:07Z · ila
|
||||||
|
|
||||||
|
2026-08-05 主脑预审收紧:Go 必须先 utf8.ValidString,再按既有 TrimSpace 后实际持久化值计算 code point;start/authorization 遇既有畸形 task 原子失败,claim replay 重验持久化响应快照;不增加迁移。
|
||||||
|
|
||||||
|
### 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。
|
||||||
|
|
||||||
|
### 2026-08-04T17:38:44Z · ila
|
||||||
|
|
||||||
|
2026-08-05 审阅修复:统一 Go/Python 持久化文本域,客户端使用 Go Unicode White_Space 固定集,两端均拒绝任意位置 U+001C--U+001F,并补 NBSP/C0 回归。候选查询不再用 SQL 相等条件静默过滤:先读取 task 与 authorization 两侧快照并分别验证,单侧畸形立即回滚且不写 EMPTY/attempt;两侧均合法但不一致才按既有语义跳过。最坏合法 JSON 也覆盖 int64 版本编码上界。
|
||||||
|
|
||||||
|
### 2026-08-04T17:48:38Z · ila
|
||||||
|
|
||||||
|
2026-08-05 completion verification: main-agent review PASS; independent adversarial audit PASS; fixed SHA `f6cd65208db5f35155946a7855773ee56e7a9d65`; client 192 tests PASS; `go test -count=1 ./...` PASS; `go vet ./...` PASS; repository-root `init.ps1` PASS.
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个 Unicode
|
||||||
|
code point;边界以 Go `strings.TrimSpace` 的 Unicode White_Space 集处理后的实际持久化值为准。
|
||||||
|
客户端使用同一固定空白集,不依赖 Python `str.strip()` 默认语义;两端均拒绝任意位置的
|
||||||
|
U+001C--U+001F 四个 C0 分隔符。Go 必须先通过 `utf8.ValidString`,不能把
|
||||||
|
非法 UTF-8 解码出的 `RuneError` 当成合法 code point;不按 UTF-8 字节、不按视觉 grapheme,
|
||||||
|
也不得为通过校验截断或规范化改写非空内容。
|
||||||
|
- `goods_id` 只允许 1--32 位 ASCII 数字;canonical money 只允许 1--32 个 ASCII 字符,并继续
|
||||||
|
满足既有正数、恰好两位小数、无非法前导零的规范格式。数量、版本与 generation 的正 int64
|
||||||
|
边界保持不变,不能接受 bool、浮点数或字符串整数。
|
||||||
|
- 创建任务、开始采购的授权快照、claim candidate、首次响应快照与幂等重放必须使用同一字段合法域。
|
||||||
|
candidate 必须先读取并分别校验 task 与 authorization 两侧字段;任一侧畸形立即回滚且不写 `EMPTY`,
|
||||||
|
只有两侧均合法但快照不一致时才跳过。start/authorization 遇到既有畸形 task 必须原子失败,claim
|
||||||
|
replay 还必须重新校验持久化响应快照。任一层发现超限或畸形既有数据都失败闭合,不迁移、不截断、
|
||||||
|
不覆盖或修复历史行。
|
||||||
|
- claim 成功响应继续受既有 32 KiB HTTP body 上限约束;最坏合法字段组合的编码结果必须由契约测试
|
||||||
|
明确证明小于该上限。HTTP handler 在序列化前重验完整 claim response,并拒绝任何越界 service
|
||||||
|
输出;不得调高响应上限掩盖无界字段。
|
||||||
|
- 客户端 claim wire model 与服务端使用完全相同的上限;未知、缺失、超限或格式漂移响应必须拒绝,
|
||||||
|
不得静默接受后再截断入库。
|
||||||
|
- 本任务不修改数据库迁移或 schema,不连接设备,不改变授权、身份、租约、幂等、int64、金额或安全
|
||||||
|
边界,不实现事件、围栏、提交订单、付款或任何页面自动化能力。
|
||||||
Reference in New Issue
Block a user