test(store): exact boundary cuts and sanitise-before-cut ordering for decision errors (BUG-4018 r1)

Boundary test asserts the exact cut for 2-, 3- and 4-byte runes; new
FailDecisionJob subtests pin sanitise-then-cut. nulcolumns reason says
last_error may embed the provider body.

Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8
This commit is contained in:
xarmian
2026-10-21 20:33:53 +00:00
parent 4a04ba2187
commit 852e0f9c7a
2 changed files with 77 additions and 5 deletions
+76 -4
View File
@@ -475,14 +475,86 @@ func TestFailDecisionJob_RecordsAnyMessage(t *testing.T) {
}
}
// lastRuneStartAtOrBelow returns the largest rune start <= limit in a string
// built as pad ASCII bytes followed by n runes of width w. Computed from the
// construction, not from the function under test.
func lastRuneStartAtOrBelow(pad, w, n, limit int) int {
best := 0
for k := 0; k <= n; k++ {
if p := pad + k*w; p <= limit {
best = p
}
}
return best
}
func TestTruncateDecisionError_Boundary(t *testing.T) {
for _, r := range []string{"é", "€", "😀"} {
for pad := 1018; pad <= 1024; pad++ {
w := len(r)
for pad := 1010; pad <= 1024; pad++ {
in := strings.Repeat("a", pad) + r + r
got := truncateDecisionError(in)
if !utf8.ValidString(got) || len(got) > maxDecisionErrorBytes || !strings.HasPrefix(in, got) {
t.Fatalf("pad=%d rune=%q: len=%d valid=%v", pad, r, len(got), utf8.ValidString(got))
want := in
if len(in) > maxDecisionErrorBytes {
want = in[:lastRuneStartAtOrBelow(pad, w, 2, maxDecisionErrorBytes)]
}
if got := truncateDecisionError(in); got != want {
t.Fatalf("pad=%d rune=%q: len(got)=%d len(want)=%d", pad, r, len(got), len(want))
}
}
}
exact := strings.Repeat("a", maxDecisionErrorBytes)
if got := truncateDecisionError(exact); got != exact {
t.Fatalf("exact-limit ASCII changed: len=%d", len(got))
}
}
// Sanitising can grow a message (NUL or an invalid byte becomes a 3-byte
// U+FFFD), so it must run BEFORE the cut or the stored value can exceed the
// limit (BUG-4018).
func TestFailDecisionJob_SanitiseBeforeCut(t *testing.T) {
interleaved := strings.Repeat("a\xff", 400)
cases := []struct {
name, msg, want string
pre func(t *testing.T)
}{
{
name: "under limit raw, over after sanitise",
msg: interleaved,
want: strings.Repeat("a\uFFFD", 256), // 256*4 = 1024
pre: func(t *testing.T) {
if len(interleaved) >= maxDecisionErrorBytes || len(strings.ToValidUTF8(interleaved, "\uFFFD")) <= maxDecisionErrorBytes {
t.Fatal("precondition: raw < 1024 and sanitised > 1024")
}
},
},
{"nul straddling the boundary", strings.Repeat("a", 1023) + "\x00tail", strings.Repeat("a", 1023), nil},
{"nul just below the boundary", strings.Repeat("a", 1022) + "\x00tail", strings.Repeat("a", 1022), nil},
{"4-byte rune straddling", strings.Repeat("a", 1022) + "😀tail", strings.Repeat("a", 1022), nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if c.pre != nil {
c.pre(t)
}
s, ws, col := decisionFixture(t)
s.SetDecisionSetResolver(func(string) []string { return nil })
item := createTestItem(t, s, ws.ID, col.ID, "Fail", "")
j := failOneAttentionJob(t, s, ws.ID, item.ID, c.msg)
if !utf8.ValidString(j.LastError) || strings.ContainsRune(j.LastError, 0) || len(j.LastError) > maxDecisionErrorBytes {
t.Fatalf("last_error not clean: len=%d valid=%v", len(j.LastError), utf8.ValidString(j.LastError))
}
if j.LastError != c.want {
t.Fatalf("last_error len=%d, want len=%d", len(j.LastError), len(c.want))
}
if j.Attempts != 1 {
t.Fatalf("attempts = %d, want 1", j.Attempts)
}
if failing, err := s.DecisionJobsFailing(ws.ID, "attention"); err != nil || !failing {
t.Fatalf("DecisionJobsFailing = %v, %v; want true", failing, err)
}
if got, err := s.OwedDecisionJobs(ws.ID, []string{item.ID}, "attention"); err != nil || got[item.ID] {
t.Fatalf("a failed job still reads as owed: %v %v", got, err)
}
})
}
}
+1 -1
View File
@@ -529,7 +529,7 @@ var nulExcluded = map[string]string{
"decision_jobs.claimed_by": "runner id minted by the server's decision tick, never request-derived",
"items.lease_worker_id": "request-derived, but written only after models.ValidWorkerID accepts it (\"wk_\" + 32 lowercase hex, enforced in store.leaseOurs before any SQL), so it cannot hold a NUL (TASK-3613, migration 142)",
"items.lease_user_id": "the authenticated caller's server-generated user id, set only by ClaimItemLease from the session; never request-derived (BUG-3341, migration 104)",
"decision_jobs.last_error": "embeds the provider response body, so NOT server-composed; NUL-free because truncateDecisionError sanitises it through textguard.Repair before the write (BUG-4018)",
"decision_jobs.last_error": "may embed the provider response body (it stores cause.Error()), so NOT server-composed; NUL-free because truncateDecisionError sanitises it through textguard.Repair before the write (BUG-4018)",
"decision_jobs.not_before": "server timestamp, decisionTime of an evaluated_at plus a server-installed window (TASK-3916, migration 150)",
"item_decisions.answer": "json.Marshal of decision.Answer: a NUL anywhere in it is written as the six-byte escape, never a raw byte, and the column is TEXT on both dialects so no jsonb parser decodes it (migration 090)",
"item_relation_links.source_field_key": "only written by replaceRelationLinks, as a field definition's key value read from collections.schema in the same transaction; that column's classJSON trigger checks every decoded value; migration 088 states the derivation (BUG-3108 writer list)",