Files
summercms/modules/cabana/widget_payload_test.go
Jakub Zych 6af88f9df6 feat(cabana): widget action payload and data channel (quick-261006-eyj)
- pact.AdminActionInput.Payload (json.RawMessage) carries the widget's own
  JSON value untouched; pact.AdminActionResult.Data is passed through as data
- cabana decodes payload with a 64 KiB cap (422 on body), refuses it on the
  toolbar and record routes, and embeds Data once encoded with a 256 KiB cap
  (opaque 500 when larger or unencodable); fill stays filtered
- root .swaggo overrides json.RawMessage so swag keeps record_id and values;
  admin.json and schema.d.ts regenerated (payload?: unknown, data?: unknown)
- TestWidgetPayloadAndData covers pass-through, cap, refusal and data 500
- cabana and pact READMEs, partials-and-widgets and admin-spa docs updated
2026-10-06 11:13:16 +02:00

127 lines
4.9 KiB
Go

package cabana_test
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
"testing"
)
// TestWidgetPayloadAndData drives the widget payload and data channel through
// the assembled router on PostgreSQL (quick-261006-eyj; T-Q261006-01, -02,
// -04): the payload reaches the action byte for byte, data comes back
// unfiltered while fill is still filtered, the 64 KiB payload cap, the
// toolbar and record refusal, and the 256 KiB or unencodable data as an
// opaque 500.
func TestWidgetPayloadAndData(t *testing.T) {
env, gdb := newActEnv(t)
mine := actInsert(t, gdb, "mine", "acme")
const widget = "/acme/demo/gadgets/widgets/lookup"
const toolbar = "/acme/demo/gadgets/toolbar/recount"
t.Run("payload reaches the action and data comes back untouched", func(t *testing.T) {
const payload = `{"order":[3,1,2],"note":"x"}`
rec := env.expect(t, http.StatusOK, http.MethodPost, widget,
fmt.Sprintf(`{"record_id":%d,"values":{"name":"typed","tenant":"other"},"payload":%s}`, mine, payload), "bearer")
calls := env.spy.take()
if len(calls) != 1 {
t.Fatalf("calls = %+v", calls)
}
if string(calls[0].Payload) != payload {
t.Fatalf("payload = %s, want %s", calls[0].Payload, payload)
}
if !reflect.DeepEqual(calls[0].Values, map[string]any{"name": "typed"}) {
t.Fatalf("values = %#v", calls[0].Values)
}
result := actResult(t, rec)
if !reflect.DeepEqual(result.Fill, map[string]any{"name": "reordered"}) {
t.Fatalf("fill = %#v", result.Fill)
}
want := map[string]any{
"echo": map[string]any{"order": []any{float64(3), float64(1), float64(2)}, "note": "x"},
"items": []any{map[string]any{"id": float64(1), "title": "a"}},
}
if !reflect.DeepEqual(result.Data, want) {
t.Fatalf("data = %#v, want %#v", result.Data, want)
}
})
t.Run("payload may be any JSON value and is nil when absent", func(t *testing.T) {
for _, literal := range []string{`[1,2]`, `"s"`, `0`, `false`, `null`} {
env.expect(t, http.StatusOK, http.MethodPost, widget, `{"payload":`+literal+`}`, "bearer")
calls := env.spy.take()
if len(calls) != 1 || string(calls[0].Payload) != literal {
t.Fatalf("payload %s: calls = %+v", literal, calls)
}
}
env.expect(t, http.StatusOK, http.MethodPost, widget, `{}`, "bearer")
calls := env.spy.take()
if len(calls) != 1 || len(calls[0].Payload) != 0 || calls[0].Payload != nil {
t.Fatalf("absent payload: calls = %+v", calls)
}
})
t.Run("no data key when the action returns none", func(t *testing.T) {
for _, path := range []string{widget, toolbar} {
rec := env.expect(t, http.StatusOK, http.MethodPost, path, `{}`, "bearer")
var body struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if _, ok := body.Data["data"]; ok {
t.Fatalf("%s carried a data key: %s", path, rec.Body.String())
}
}
env.spy.take()
})
t.Run("payload cap", func(t *testing.T) {
fits := `{"payload":"` + strings.Repeat("a", 65534) + `"}`
env.expect(t, http.StatusOK, http.MethodPost, widget, fits, "bearer")
if calls := env.spy.take(); len(calls) != 1 || len(calls[0].Payload) != 65536 {
t.Fatalf("64 KiB payload: calls = %d", len(calls))
}
over := `{"payload":"` + strings.Repeat("a", 65535) + `"}`
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, widget, over, "bearer")
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
if !strings.Contains(rec.Body.String(), `"body"`) {
t.Fatalf("422 without a body detail: %s", rec.Body.String())
}
if calls := env.spy.take(); len(calls) != 0 {
t.Fatalf("action ran for an oversized payload: %d calls", len(calls))
}
})
t.Run("toolbar and record routes refuse a payload", func(t *testing.T) {
for _, body := range []string{`{"payload":{}}`, `{"payload":null}`, `{"payload":1}`} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, toolbar, body, "bearer")
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
}
if calls := env.spy.take(); len(calls) != 0 {
t.Fatalf("toolbar ran with a payload: %+v", calls)
}
roster, rdb := newRosterEnv(t)
person := rosterInsert(t, rdb, rosterPerson{Tenant: "acme", Name: "Pat", Email: "pat@example.test"})
rec := roster.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPath(person, "/actions/activate"), `{"payload":1}`, "bearer")
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
if rosterLoad(t, rdb, person).Active {
t.Fatal("a record action ran with a payload")
}
})
t.Run("data over 256 KiB or unencodable is an opaque 500", func(t *testing.T) {
for _, literal := range []string{`"big"`, `"nan"`} {
rec := env.expect(t, http.StatusInternalServerError, http.MethodPost, widget, `{"payload":`+literal+`}`, "bearer")
actErrorCode(t, rec.Body.Bytes(), "error")
if strings.Contains(rec.Body.String(), "xxxx") {
t.Fatalf("500 leaked the data: %d bytes", rec.Body.Len())
}
}
env.spy.take()
})
}