mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
fix(server): a typed settings member over non-object stored settings answers 400 (BUG-3976) (#2256)
PATCH /collections/{slug} with tracks_work or awaits_human merges into the
stored settings. When those are not a JSON object (import, a hand edit, an
old row), the merge failed with a plain error and answered 500. It now
wraps models.ErrSettingsNotObject, and both collection handlers map it to
400 validation_error with the same text. A full settings write still
repairs the row.
Over HTTP only the flags-only update can reach it: the request decoder
already refuses incoming settings that are not an object (IDEA-1488), so
the create mapping is defensive and commented as such. The tests pin the
decoder's 400 for those doors so they cannot regress to a 500.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
This commit is contained in:
@@ -3,6 +3,7 @@ package models
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -106,6 +107,12 @@ func boolSettingValid(settingsJSON, key string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ErrSettingsNotObject is wrapped by SetTracksWork and SetAwaitsHuman when the
|
||||
// settings they merge into do not parse as a JSON object, so a handler can
|
||||
// answer the caller 400 instead of 500 (BUG-3976). A full settings write
|
||||
// replaces such a row; a typed member alone does not rewrite the rest.
|
||||
var ErrSettingsNotObject = errors.New("the collection's settings are not a JSON object")
|
||||
|
||||
// setBoolSetting returns settingsJSON with key set to v and every other key
|
||||
// unchanged.
|
||||
func setBoolSetting(settingsJSON, key string, v bool) (string, error) {
|
||||
@@ -115,7 +122,7 @@ func setBoolSetting(settingsJSON, key string, v bool) (string, error) {
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil || m == nil {
|
||||
return "", errors.New("the collection's settings are not a JSON object; " + key + " cannot be set")
|
||||
return "", fmt.Errorf("%w; %s cannot be set", ErrSettingsNotObject, key)
|
||||
}
|
||||
if v {
|
||||
m[key] = json.RawMessage("true")
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
"github.com/PerpetualSoftware/pad/internal/store"
|
||||
)
|
||||
|
||||
// BUG-3976: a typed tracks_work / awaits_human member merges into the stored
|
||||
// settings, and stored settings that are not a JSON object (import, a hand
|
||||
// edit, an old row) made that write answer 500. It now answers a typed 400,
|
||||
// changes nothing, and a full settings write still repairs the row.
|
||||
|
||||
// nonObjectSettings are the probe table's shapes. Postgres stores settings as
|
||||
// JSONB, so text that is not JSON cannot arise there and runs on SQLite only.
|
||||
var nonObjectSettings = []struct {
|
||||
raw string
|
||||
sqliteOnly bool
|
||||
}{
|
||||
{`[]`, false},
|
||||
{`"x"`, false},
|
||||
{`7`, false},
|
||||
{`not json`, true},
|
||||
{`null`, false},
|
||||
}
|
||||
|
||||
var typedSettingMembers = []string{"tracks_work", "awaits_human"}
|
||||
|
||||
type bug3976Fixture struct {
|
||||
*accessFixture
|
||||
base string
|
||||
}
|
||||
|
||||
func newBUG3976Fixture(t *testing.T, d store.DriverType) *bug3976Fixture {
|
||||
f := newAccessFixture(t, d)
|
||||
return &bug3976Fixture{accessFixture: f, base: "/api/v1/workspaces/" + f.wsSlug + "/collections"}
|
||||
}
|
||||
|
||||
// corruptCollection creates a collection and overwrites its stored settings
|
||||
// with raw, as an import or a hand edit would.
|
||||
func (f *bug3976Fixture) corruptCollection(name, raw string) models.Collection {
|
||||
f.t.Helper()
|
||||
rr := f.do("POST", f.base, f.ownerTok, map[string]any{"name": name})
|
||||
f.must(rr, http.StatusCreated, "create "+name)
|
||||
var c models.Collection
|
||||
parseJSON(f.t, rr, &c)
|
||||
if _, err := f.srv.store.DB().Exec(f.srv.store.D().Rebind(`UPDATE collections SET settings = ? WHERE id = ?`), raw, c.ID); err != nil {
|
||||
f.t.Fatalf("store settings %s: %v", raw, err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (f *bug3976Fixture) storedSettings(id string) string {
|
||||
f.t.Helper()
|
||||
var s string
|
||||
if err := f.srv.store.DB().QueryRow(f.srv.store.D().Rebind(`SELECT settings FROM collections WHERE id = ?`), id).Scan(&s); err != nil {
|
||||
f.t.Fatalf("read settings: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func errorEnvelope(t *testing.T, body []byte) (code, message string) {
|
||||
t.Helper()
|
||||
var env struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("error envelope: %v (%s)", err, body)
|
||||
}
|
||||
return env.Error.Code, env.Error.Message
|
||||
}
|
||||
|
||||
// Door 1, the defect: a flags-only PATCH over corrupt stored settings.
|
||||
func TestBUG3976_TypedMemberOverNonObjectStoredSettings(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d store.DriverType) {
|
||||
f := newBUG3976Fixture(t, d)
|
||||
for i, shape := range nonObjectSettings {
|
||||
if shape.sqliteOnly && d != store.DriverSQLite {
|
||||
continue
|
||||
}
|
||||
for _, member := range typedSettingMembers {
|
||||
c := f.corruptCollection("Door1 "+member+" "+string(rune('A'+i)), shape.raw)
|
||||
before := f.storedSettings(c.ID)
|
||||
rr := f.do("PATCH", f.base+"/"+c.Slug, f.ownerTok, map[string]any{member: true})
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("stored %s, %s: got %d %s, want 400", shape.raw, member, rr.Code, rr.Body.String())
|
||||
}
|
||||
code, msg := errorEnvelope(t, rr.Body.Bytes())
|
||||
want := "the collection's settings are not a JSON object; " + member + " cannot be set"
|
||||
if code != "validation_error" || msg != want {
|
||||
t.Fatalf("stored %s, %s: error %q %q, want validation_error %q", shape.raw, member, code, msg, want)
|
||||
}
|
||||
if after := f.storedSettings(c.ID); after != before {
|
||||
t.Fatalf("stored %s, %s: a refused write changed settings %q -> %q", shape.raw, member, before, after)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Doors 2 and 3: incoming settings that are not an object never reach the
|
||||
// merge. The request decoder refuses them (IDEA-1488); these rows pin the
|
||||
// measured answer (BUG-3976 checkpoint 1) so neither door regresses to a 500.
|
||||
func TestBUG3976_IncomingNonObjectSettingsWithTypedMember(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d store.DriverType) {
|
||||
f := newBUG3976Fixture(t, d)
|
||||
for i, shape := range nonObjectSettings {
|
||||
for _, member := range typedSettingMembers {
|
||||
name := "Door3 " + member + " " + string(rune('A'+i))
|
||||
rr := f.do("POST", f.base, f.ownerTok, map[string]any{"name": name, "settings": shape.raw, member: true})
|
||||
if code, msg := errorEnvelope(t, rr.Body.Bytes()); rr.Code != http.StatusBadRequest || code != "bad_request" || msg != models.ErrInvalidSettingsType.Error() {
|
||||
t.Fatalf("create, settings %s, %s: got %d %s", shape.raw, member, rr.Code, rr.Body.String())
|
||||
}
|
||||
rr = f.do("POST", f.base, f.ownerTok, map[string]any{"name": "Door2 " + member + " " + string(rune('A'+i))})
|
||||
f.must(rr, http.StatusCreated, "create door 2 target")
|
||||
var c models.Collection
|
||||
parseJSON(t, rr, &c)
|
||||
rr = f.do("PATCH", f.base+"/"+c.Slug, f.ownerTok, map[string]any{"settings": shape.raw, member: true})
|
||||
if code, msg := errorEnvelope(t, rr.Body.Bytes()); rr.Code != http.StatusBadRequest || code != "bad_request" || msg != models.ErrInvalidSettingsType.Error() {
|
||||
t.Fatalf("full update, settings %s, %s: got %d %s", shape.raw, member, rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Control: a full settings write repairs a corrupt row, and the typed member
|
||||
// then works. The refusal leaves a way out.
|
||||
func TestBUG3976_FullSettingsWriteRepairsTheRow(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d store.DriverType) {
|
||||
f := newBUG3976Fixture(t, d)
|
||||
for _, member := range typedSettingMembers {
|
||||
c := f.corruptCollection("Repair "+member, `[]`)
|
||||
f.must(f.do("PATCH", f.base+"/"+c.Slug, f.ownerTok, map[string]any{"settings": map[string]any{}}), http.StatusOK, "full settings write")
|
||||
f.must(f.do("PATCH", f.base+"/"+c.Slug, f.ownerTok, map[string]any{member: false}), http.StatusOK, member+" after repair")
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(f.storedSettings(c.ID)), &got); err != nil {
|
||||
t.Fatalf("%s: repaired settings are not an object: %v", member, err)
|
||||
}
|
||||
if got[member] != false {
|
||||
t.Fatalf("%s: repaired settings %v, want %s=false", member, got, member)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The sentinel at the store, for both members and both store doors (create
|
||||
// and update), so the handlers' errors.Is has something to match.
|
||||
func TestBUG3976_StoreWrapsErrSettingsNotObject(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d store.DriverType) {
|
||||
f := newBUG3976Fixture(t, d)
|
||||
yes := true
|
||||
for _, member := range typedSettingMembers {
|
||||
c := f.corruptCollection("Store "+member, `[]`)
|
||||
upd := models.CollectionUpdate{}
|
||||
create := models.CollectionCreate{Name: "Store create " + member, Settings: `[]`}
|
||||
if member == "tracks_work" {
|
||||
upd.TracksWork, create.TracksWork = &yes, &yes
|
||||
} else {
|
||||
upd.AwaitsHuman, create.AwaitsHuman = &yes, &yes
|
||||
}
|
||||
if _, err := f.srv.store.UpdateCollection(c.ID, upd); !errors.Is(err, models.ErrSettingsNotObject) {
|
||||
t.Fatalf("UpdateCollection %s: err %v, want ErrSettingsNotObject", member, err)
|
||||
}
|
||||
if _, err := f.srv.store.CreateCollection(f.wsID, create); !errors.Is(err, models.ErrSettingsNotObject) {
|
||||
t.Fatalf("CreateCollection %s: err %v, want ErrSettingsNotObject", member, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -338,6 +338,14 @@ func (s *Server) handleCreateCollection(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusBadRequest, "bad_request", v.Reason)
|
||||
return
|
||||
}
|
||||
// BUG-3976, defensive: unreachable over HTTP today, because
|
||||
// CollectionCreate.UnmarshalJSON already refuses settings that are not
|
||||
// an object (IDEA-1488). Kept so the create door answers like update
|
||||
// if that ever changes.
|
||||
if errors.Is(err, models.ErrSettingsNotObject) {
|
||||
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -546,6 +554,14 @@ func (s *Server) handleUpdateCollection(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusBadRequest, "bad_request", v.Reason)
|
||||
return
|
||||
}
|
||||
// BUG-3976: a typed tracks_work / awaits_human member over STORED
|
||||
// settings that are not an object (import, a hand edit, an old row).
|
||||
// The caller's input is fine but the write cannot be done; a full
|
||||
// settings write repairs the row.
|
||||
if errors.Is(err, models.ErrSettingsNotObject) {
|
||||
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user