fix(api): align claim bounds across runtime snapshots
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -103,12 +104,12 @@ func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsCl
|
||||
goodsID := strings.Repeat("1", 32)
|
||||
worst := taskclaim.ClaimResponse{
|
||||
Task: taskclaim.ClaimedTask{
|
||||
ID: claimTaskID, Version: 3, Title: strings.Repeat("<", 120),
|
||||
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: 2, ExpiresAt: "9999-12-31T23:59:59.999999999Z"},
|
||||
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}}
|
||||
@@ -122,6 +123,7 @@ func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsCl
|
||||
|
||||
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"
|
||||
|
||||
@@ -647,6 +647,9 @@ func (store *Store) responseFor(record claimRecord, responseLease string) (Claim
|
||||
type candidate struct {
|
||||
AuthorizationID, TaskID, Title, GoodsID, SKUColor, SKUSize, TotalPriceCap string
|
||||
TaskVersion, Quantity int
|
||||
AuthorizationTaskVersion, AuthorizationQuantity int
|
||||
AuthorizationGoodsID, AuthorizationSKUColor, AuthorizationSKUSize string
|
||||
AuthorizationTotalPriceCap string
|
||||
AuthorizationExpiresText string
|
||||
AuthorizationExpiresAt time.Time
|
||||
}
|
||||
@@ -654,16 +657,12 @@ type candidate struct {
|
||||
func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (candidate, bool, error) {
|
||||
rows, err := transaction.QueryContext(ctx, `SELECT authorizations.id, tasks.id, tasks.version,
|
||||
tasks.title, tasks.goods_id, tasks.sku_color, tasks.sku_size, tasks.quantity,
|
||||
tasks.max_total_price, authorizations.expires_at
|
||||
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
|
||||
JOIN tasks ON tasks.id = authorizations.task_id
|
||||
WHERE authorizations.status = 'ACTIVE' AND tasks.status = 'PENDING'
|
||||
AND authorizations.task_version = tasks.version
|
||||
AND authorizations.goods_id = tasks.goods_id
|
||||
AND authorizations.sku_color = tasks.sku_color
|
||||
AND authorizations.sku_size = tasks.sku_size
|
||||
AND authorizations.quantity = tasks.quantity
|
||||
AND authorizations.total_price_cap = tasks.max_total_price
|
||||
ORDER BY authorizations.created_at, authorizations.rowid, authorizations.id`)
|
||||
if err != nil {
|
||||
return candidate{}, false, err
|
||||
@@ -673,6 +672,8 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can
|
||||
var item candidate
|
||||
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &item.Title,
|
||||
&item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.TotalPriceCap,
|
||||
&item.AuthorizationTaskVersion, &item.AuthorizationGoodsID, &item.AuthorizationSKUColor,
|
||||
&item.AuthorizationSKUSize, &item.AuthorizationQuantity, &item.AuthorizationTotalPriceCap,
|
||||
&item.AuthorizationExpiresText); err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
@@ -683,6 +684,9 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can
|
||||
if !validCandidate(item) {
|
||||
return candidate{}, false, errors.New("stored claim candidate is invalid")
|
||||
}
|
||||
if !candidateSnapshotMatches(item) {
|
||||
continue
|
||||
}
|
||||
if item.AuthorizationExpiresAt.After(now) {
|
||||
if err := rows.Close(); err != nil {
|
||||
return candidate{}, false, err
|
||||
@@ -699,7 +703,15 @@ 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 &&
|
||||
taskmodel.ValidTaskWireFields(item.Title, item.GoodsID, item.SKUColor, item.SKUSize, item.TotalPriceCap) &&
|
||||
item.Quantity > 0
|
||||
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 {
|
||||
|
||||
@@ -195,31 +195,41 @@ 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) {
|
||||
"task 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) {
|
||||
"task 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)
|
||||
"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)
|
||||
},
|
||||
"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)
|
||||
"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)
|
||||
},
|
||||
"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)
|
||||
"task overlong color": func(t *testing.T, database *sql.DB) {
|
||||
execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, strings.Repeat("色", 81), testTaskA)
|
||||
},
|
||||
"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)
|
||||
"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 {
|
||||
@@ -234,6 +244,12 @@ func TestClaimRejectsOutOfBoundsCandidatesWithoutBusinessMutation(t *testing.T)
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,17 @@ func ValidGoodsID(value string) bool {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -107,6 +107,35 @@ func TestValidateAcceptsWorstLegalUnicodeFieldBounds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
key, err := NewCreateKey()
|
||||
if err != nil {
|
||||
@@ -179,6 +208,7 @@ 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) },
|
||||
|
||||
Reference in New Issue
Block a user