Files
pad/internal/server/bug3079_default_drop_test.go
T
xarmian a8f6fa7cae fix(items): an injected schema default takes the same type check a supplied value takes (BUG-3079) (#1362)
* fix(items): an injected schema default takes the same type check a supplied value takes (BUG-3079)

`ValidateFieldsDetailed` assigned a schema default and `continue`d past
`validateFieldType`, so the SAME BYTES were refused through one door and stored
through the other, decided only by who put them there. On a `status` retyped to
`multi_select` whose scalar default survived the retype:

    POST .../items {"fields":"{\"status\":\"open\"}"} -> 400 must be an array of strings
    POST .../items {"fields":"{}"}                    -> 201 stored {"status":"open"}

The second is the corruption. A scalar sitting in a list field is what BUG-3016,
BUG-3057, BUG-3067, BUG-3068 and BUG-3074 have each been defending against one
surface at a time, and it needs no retype of its own — only a schema whose
`default` and `type` disagree, which nothing prevents at collection-update time
either. It is reachable by any caller that creates without naming the field:
`pad item create`, the MCP create action, a plain API POST.

DROP RATHER THAN REFUSE, ruled on the trail. Nobody in the request typed the
value, and refusing would make a whole collection uncreatable — through surfaces
that never mention the field — over a schema defect its author must fix
elsewhere. It is the disposition TASK-2878 already chose for the one case that
had its own pass, now applied to every type.

A REQUIRED field is the exception, and it is the rule finishing its sentence
rather than a carve-out: dropping leaves the field absent, and an absent
required field is what IssueRequired reports. The message names the default as
the cause, because "field is required" pointing at a request that never
mentioned the field sends its reader to the wrong place.

THE VALIDATOR WAS NOT THE ONLY SITE. Dropping one step earlier silently deleted
the input two later passes were reporting FROM, so the drop kept happening and
the report vanished — DR-6 divergence in the direction that is harder to notice,
since the field is simply absent afterwards. The full suite caught it as
TestCopyEndpoint_NonStringDestinationDefaultDropsWithOrWithoutANullOverride, a
TASK-2878 test asserting the preflight reports a non-reference default as
`invalid_shape`; after the change it reported neither carried nor dropped. The
copy preflight and the cross-workspace copy door now fold the validator's drops
into the same bucket under the same `invalid_shape` reason — the same statement
in the same words, rather than a second spelling for one fact. The preflight's
`relationDropReason` map is renamed `dropReasonByKey`, because relations are now
one of two sources rather than the only one.

Prose the change falsified, swept (CONVE-23): four in-tree comments asserted
that `ValidateFields` skips its own type check, two of them load-bearing
arguments for why the relation pass exists. Each now says what is still true —
the shape half moved to the validator, and the relation pass keeps the half only
it can do, whether a shape-valid string names a live, visible item. Also the
`warnings.dropped_fields` contract note on `models.ItemWriteWarnings`, which
said "today that is one case".

Tool surface 0.34 -> 0.35. A write door stores something different from what it
stored before, which is the v0.30 / v0.29 / v0.16 behaviour-bump ground, and
`dropped_fields` widens from the single relation case v0.29 introduced to a
field of any type. The drift guard caught instructions.md and README.md;
CLAUDE.md swept by hand.

Evidence: 10 tests. Every unit test is written as a PAIR — supplied and injected
— because neither half proves it alone: "the default is dropped" is compatible
with a validator that refuses everything, and "a supplied value is refused" was
already true. Controls that a good default still applies, at the unit and at the
door, for the same reason. Full Go suite green (30 packages).

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* refactor(items): ValidateFieldsWithDrops returns ([]string, error) (BUG-3079)

ST1008 — error is the last return value. The signature went out as
`(error, []string)`, which reads backwards at every call site and is a lint
failure, not a style preference.

Mechanical: the definition, the wrapper above it, four production call sites
(create, update, bulk field update, cross-workspace copy) and one test call
site. No behaviour change.

Caught by `make lint`, which I had not run before opening the PR — `go test`
and `go vet` were both green and neither says anything about return order.
The test call site would have failed the suite too, but only AFTER the swap;
before it, the wrong order compiled everywhere.

make lint: 0 issues. Full Go suite green (30 packages).

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-14 09:03:24 -04:00

145 lines
5.2 KiB
Go

package server
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// BUG-3079 at the DOOR. `internal/items` pins the rule; this pins that a real
// create runs it and that the drop reaches the caller, which a unit test of the
// validator cannot say (CONVE-19).
func bug3079Collection(t *testing.T, srv *Server, ws, name, schema string) models.Collection {
t.Helper()
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/collections",
map[string]interface{}{"name": name, "schema": schema})
if rr.Code != http.StatusCreated {
t.Fatalf("create collection %q: %d %s", name, rr.Code, rr.Body.String())
}
var c models.Collection
parseJSON(t, rr, &c)
return c
}
type bug3079Created struct {
Fields json.RawMessage `json:"fields"`
Warnings struct {
DroppedFields []string `json:"dropped_fields"`
} `json:"warnings"`
}
func TestBUG3079_CreateDoorDropsAnInvalidDefault(t *testing.T) {
srv := testServer(t)
ws := createWSWithCollections(t, srv)
// `status` retyped to multi_select; the scalar default survived.
coll := bug3079Collection(t, srv, ws, "Stale",
`{"fields":[{"key":"status","type":"multi_select","options":["open","done"],"default":"open"}]}`)
// PREMISE, asserted rather than assumed: the same bytes SUPPLIED are still
// refused. Without this the test below passes on a build that accepts
// everything, and the bug was precisely the two doors disagreeing.
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/collections/"+coll.Slug+"/items",
map[string]interface{}{"title": "Supplied", "fields": `{"status":"open"}`})
if rr.Code != http.StatusBadRequest {
t.Fatalf("a supplied scalar must still be refused: %d %s", rr.Code, rr.Body.String())
}
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/collections/"+coll.Slug+"/items",
map[string]interface{}{"title": "Omitted", "fields": `{}`})
if rr.Code != http.StatusCreated {
t.Fatalf("the create must still land: %d %s", rr.Code, rr.Body.String())
}
var got bug3079Created
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v (%s)", err, rr.Body.String())
}
// The assertion that fails on an unfixed build: it stored {"status":"open"}.
var stored map[string]any
if err := json.Unmarshal(got.Fields, &stored); err != nil {
// `fields` serialises as a STRING on this shape; fall back to that.
var asString string
if json.Unmarshal(got.Fields, &asString) != nil {
t.Fatalf("fields is neither object nor string: %s", got.Fields)
}
if err := json.Unmarshal([]byte(asString), &stored); err != nil {
t.Fatalf("fields string is not JSON: %q", asString)
}
}
if v, ok := stored["status"]; ok {
t.Errorf("the invalid default was stored: status=%#v", v)
}
// Silent is the other half of the defect: a schema author cannot fix what
// nothing reports.
found := false
for _, k := range got.Warnings.DroppedFields {
if k == "status" {
found = true
}
}
if !found {
t.Errorf("the drop was not reported: warnings.dropped_fields = %v", got.Warnings.DroppedFields)
}
}
func TestBUG3079_CreateDoorStillAppliesAGoodDefault(t *testing.T) {
// Control at the door, for the reason the unit control exists: every
// assertion above is satisfied by a build that drops every default.
srv := testServer(t)
ws := createWSWithCollections(t, srv)
coll := bug3079Collection(t, srv, ws, "Healthy",
`{"fields":[{"key":"status","type":"select","options":["open","done"],"default":"open"}]}`)
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/collections/"+coll.Slug+"/items",
map[string]interface{}{"title": "Fine", "fields": `{}`})
if rr.Code != http.StatusCreated {
t.Fatalf("create: %d %s", rr.Code, rr.Body.String())
}
var got bug3079Created
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got.Warnings.DroppedFields) != 0 {
t.Fatalf("a healthy default reports nothing: %v", got.Warnings.DroppedFields)
}
var asString string
if json.Unmarshal(got.Fields, &asString) == nil {
if asString != `{"status":"open"}` {
t.Errorf("default not applied: %s", asString)
}
return
}
var stored map[string]any
if err := json.Unmarshal(got.Fields, &stored); err != nil {
t.Fatalf("fields: %s", got.Fields)
}
if stored["status"] != "open" {
t.Errorf("default not applied: %#v", stored)
}
}
func TestBUG3079_RequiredFieldWithABadDefaultRefusesAndSaysWhy(t *testing.T) {
// The ruled edge, at the door: dropping leaves a required field absent, so
// the create is refused — and the message names the DEFAULT, because a bare
// "field is required" points the reader at a request that never mentioned it.
srv := testServer(t)
ws := createWSWithCollections(t, srv)
coll := bug3079Collection(t, srv, ws, "RequiredStale",
`{"fields":[{"key":"status","type":"multi_select","options":["open"],"default":"open","required":true}]}`)
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/collections/"+coll.Slug+"/items",
map[string]interface{}{"title": "Blocked", "fields": `{}`})
if rr.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d %s", rr.Code, rr.Body.String())
}
if body := rr.Body.String(); !strings.Contains(body, "schema default") {
t.Errorf("the refusal does not name the default as the cause: %s", body)
}
}