fix(10.1): WR-04 judge action fill values by their JSON encoding

isJSONScalar now encodes each value and keeps it only when the encoding
is a string, number, boolean or null, so a named scalar whose MarshalJSON
writes an array or object, NaN and the infinities are dropped. writeJSON
encodes into a buffer before the status line, so an encode failure is a
logged 500 with the generic envelope instead of a 200 with a truncated
body.
This commit is contained in:
Jakub Zych
2026-09-29 09:56:44 +02:00
parent 7333f450ad
commit 0bdb6ebcac
5 changed files with 98 additions and 22 deletions

View File

@@ -14,14 +14,14 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates and for linking and unlinking. Framework code never guesses table, pivot or foreign-key names: the controller supplies them.
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys with scalar values. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. `cabana.Allows` implements the permission check: superusers pass, and grants ending in `.*` match by prefix.
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend`, login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`.
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
- OpenAPI documentation: `cabana.AdminList`, `cabana.AdminCreate` and the other `Admin*` functions have empty bodies and exist only to carry the swag annotations of each admin route.
- Operator commands for creating administrators and resetting their passwords (see CLI commands).

View File

@@ -7,7 +7,6 @@ import (
"io"
"log/slog"
"net/http"
"reflect"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
@@ -219,25 +218,18 @@ func onlyFillScalars(fill []string, values map[string]any) map[string]any {
}
// isJSONScalar reports whether v encodes as a JSON string, number, boolean or
// null.
// null. It decides by encoding the value, not by its reflect.Kind: a named
// scalar whose MarshalJSON writes an array or object is not a scalar, and a
// value that cannot be encoded (NaN, an infinity) is dropped rather than
// failing the whole response.
func isJSONScalar(v any) bool {
if v == nil || nestedValue(v) {
return v == nil
}
rv := reflect.ValueOf(v)
for rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return true
}
rv = rv.Elem()
}
switch rv.Kind() {
case reflect.Bool, reflect.String,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return true
default:
raw, err := json.Marshal(v)
if err != nil || len(raw) == 0 {
return false
}
switch raw[0] {
case '{', '[':
return false
}
return true
}

View File

@@ -1,7 +1,9 @@
package cabana
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
@@ -187,8 +189,20 @@ func WriteErrorDetails(w http.ResponseWriter, status int, code, message string,
})
}
// writeJSON encodes body before the status line goes out, so a value that
// cannot be encoded (NaN, an infinity, a failing MarshalJSON) is a logged 500
// with the generic body instead of a 200 with a truncated one.
func writeJSON(w http.ResponseWriter, status int, body any) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(body); err != nil {
slog.Error("cabana: response could not be encoded", "status", status, "error", err)
buf.Reset()
_ = json.NewEncoder(&buf).Encode(map[string]any{
"error": map[string]any{"code": "error", "message": msgServerError, "details": map[string]any{}},
})
status = http.StatusInternalServerError
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
_, _ = w.Write(buf.Bytes())
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"math"
"net/http"
"net/http/httptest"
"strings"
@@ -552,3 +553,55 @@ func TestCRUDFillTypeIsValidation(t *testing.T) {
t.Fatalf("year after rejected update = %v, want 1977", after.Year)
}
}
// TestWriteJSONEncodeFailure covers WR-04: a body that cannot be encoded is
// a 500 with the generic error envelope, not a 200 with a truncated body.
func TestWriteJSONEncodeFailure(t *testing.T) {
rec := httptest.NewRecorder()
WriteData(rec, http.StatusOK, map[string]any{"price": math.Inf(1)}, nil)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status=%d body=%q, want 500", rec.Code, rec.Body.String())
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body %q: %v", rec.Body.String(), err)
}
if body.Error.Code != "error" || body.Error.Message != msgServerError || body.Error.Details == nil {
t.Fatalf("envelope=%s", rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("content type %q", ct)
}
ok := httptest.NewRecorder()
WriteData(ok, http.StatusCreated, map[string]any{"n": 1}, nil)
if ok.Code != http.StatusCreated || ok.Body.String() != "{\"data\":{\"n\":1},\"meta\":{}}\n" {
t.Fatalf("status=%d body=%q", ok.Code, ok.Body.String())
}
}
// TestIsJSONScalar covers WR-04: scalar-ness is decided by the encoding.
func TestIsJSONScalar(t *testing.T) {
name := "x"
var nilPtr *string
for _, v := range []any{nil, "s", true, 1, uint8(2), 1.5, json.Number("3"), &name, nilPtr} {
if !isJSONScalar(v) {
t.Fatalf("%#v refused", v)
}
}
for _, v := range []any{math.NaN(), math.Inf(-1), []string{"a"}, map[string]any{}, struct{}{}, scalarAsArray("a"), func() {}} {
if isJSONScalar(v) {
t.Fatalf("%#v accepted", v)
}
}
}
type scalarAsArray string
func (s scalarAsArray) MarshalJSON() ([]byte, error) { return json.Marshal([]string{string(s)}) }

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io/fs"
"math"
"net/http"
"net/http/httptest"
"os"
@@ -52,6 +53,11 @@ type actGroup struct {
func (actGroup) TableName() string { return "cabana_ext_groups" }
// actTags is a string kind that encodes as a JSON array.
type actTags string
func (t actTags) MarshalJSON() ([]byte, error) { return json.Marshal(strings.Split(string(t), ",")) }
// actSpy records the inputs the registered actions receive.
type actSpy struct {
mu sync.Mutex
@@ -136,6 +142,9 @@ func (c actController) AdminActions() []pact.AdminAction {
return pact.AdminActionResult{}, errors.New("upstream said hunter2")
case "nested":
return pact.AdminActionResult{Fill: map[string]any{"name": []string{"a"}, "active": false}}, nil
case "encoded":
// Scalar kinds that do not encode as JSON scalars (WR-04).
return pact.AdminActionResult{Fill: map[string]any{"name": actTags("a,b"), "active": math.NaN()}}, nil
}
return pact.AdminActionResult{
Message: "acme.demo::lang.gadgets.looked_up",
@@ -361,6 +370,14 @@ func TestPhase101Actions(t *testing.T) {
}
})
t.Run("fill is judged by its JSON encoding, not its kind", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPost, widget, `{"values":{"name":"encoded"}}`, "bearer")
if result := actResult(t, rec); !reflect.DeepEqual(result.Fill, map[string]any{}) {
t.Fatalf("fill = %#v", result.Fill)
}
env.spy.take()
})
t.Run("widget on the create form gets no record", func(t *testing.T) {
env.expect(t, http.StatusOK, http.MethodPost, widget, `{}`, "bearer")
calls := env.spy.take()