test(10.1-04): cover the Phase 10.1 extension point Go code

- acme fixture plugin under modules/cabana/testdata/extension (gadgets
  controller, header and form partials, lookup widget, JS and CSS)
- TestPhase101FormExtensionSchema, TestPhase101PartialSchema and
  TestPhase101Toolbar: every widget, partial and toolbar boot rule
- TestPhase101PartialSanitizer: tag, attribute and URL allowlist, escaping,
  per-request trans, size/node/depth caps and the view-model guard
- TestPhase101Assets: exact-key asset hits, revalidation, SPA fall-through,
  boot path checks and ?v= schema URLs
- TestPhase101Actions (PostgreSQL): scoping, fill filter, strict body,
  action permission, error mapping, CSRF header, toolbar and partial routes
- TestPhase101BoardwalkExports: ContentType and SetSecurityHeaders
This commit is contained in:
Jakub Zych
2026-09-29 02:48:12 +02:00
parent c3c547c394
commit 7eed4acd87
15 changed files with 1832 additions and 0 deletions

View File

@@ -0,0 +1,515 @@
package cabana_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/surf"
"gorm.io/gorm"
)
// actDir is the acme fixture plugin tree shared with the internal Phase 10.1
// schema, sanitizer and asset tests.
const actDir = "testdata/extension"
type actGadget struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Active bool `gorm:"column:active"`
GroupID *uint `gorm:"column:group_id"`
// Tenant is the controller's form scope; it is not a form field.
Tenant string `gorm:"column:tenant"`
}
func (actGadget) TableName() string { return "cabana_ext_gadgets" }
func (actGadget) Fillable() []string { return []string{"name", "active"} }
func (actGadget) Rules() map[string]string { return map[string]string{"name": "required"} }
type actGroup struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
}
func (actGroup) TableName() string { return "cabana_ext_groups" }
// actSpy records the inputs the registered actions receive.
type actSpy struct {
mu sync.Mutex
calls []pact.AdminActionInput
}
func (s *actSpy) record(in pact.AdminActionInput) {
s.mu.Lock()
defer s.mu.Unlock()
s.calls = append(s.calls, in)
}
func (s *actSpy) take() []pact.AdminActionInput {
s.mu.Lock()
defer s.mu.Unlock()
out := s.calls
s.calls = nil
return out
}
type actPlugin struct{ spy *actSpy }
func (actPlugin) ID() string { return "acme.demo" }
func (actPlugin) Requires() []string { return nil }
func (actPlugin) Register(*backpack.App) error { return nil }
func (actPlugin) Boot(*backpack.App) error { return nil }
func (p actPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{actController{spy: p.spy}}
}
func (actPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.demo.access", Roles: []string{"developer"}}, {Code: "acme.demo.run", Roles: []string{"developer"}}}
}
func (actPlugin) AdminFS() fs.FS { return os.DirFS(actDir) }
// LangFS serves only the fixture's lang/ tree.
func (actPlugin) LangFS() fs.FS {
out := fstest.MapFS{}
for _, name := range []string{"lang/en/lang.yaml", "lang/pl/lang.yaml"} {
data, err := os.ReadFile(filepath.Join(actDir, name))
if err != nil {
panic(err)
}
out[name] = &fstest.MapFile{Data: data}
}
return out
}
type actController struct{ spy *actSpy }
func (actController) ID() string { return "acme.demo.gadgets" }
func (actController) ModelName() string { return "Gadget" }
func (actController) ConfigDir() string { return "controllers/gadgets" }
func (actController) RequiredPermissions() []string { return []string{"acme.demo.access"} }
func (actController) NewRecord() any { return &actGadget{} }
func (actController) AdminJS() []string { return []string{"assets/js/lookup.js"} }
func (actController) AdminCSS() []string { return []string{"assets/css/gadgets.css"} }
func (actController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &actGroup{} }, ForeignKey: "group_id"}}
}
// ListExtendQuery and FormExtendQuery scope every lookup to the acme tenant,
// so a record of another tenant is out of scope.
func (actController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
return db.Where("tenant = ?", "acme")
}
func (actController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
return db.Where("tenant = ?", "acme")
}
// AdminActions: lookup answers by the name value it receives (invalid, boom,
// nested or a normal fill); recount is the declared toolbar action; hidden is
// registered but not in toolbar.buttons.
func (c actController) AdminActions() []pact.AdminAction {
return []pact.AdminAction{{
Name: "lookup", Label: "acme.demo::lang.gadgets.lookup", Permissions: []string{"acme.demo.run"},
Run: func(_ context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
c.spy.record(in)
switch in.Values["name"] {
case "invalid":
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{"name": []string{"Name is taken."}}}
case "boom":
return pact.AdminActionResult{}, errors.New("upstream said hunter2")
case "nested":
return pact.AdminActionResult{Fill: map[string]any{"name": []string{"a"}, "active": false}}, nil
}
return pact.AdminActionResult{
Message: "acme.demo::lang.gadgets.looked_up",
Fill: map[string]any{"name": "looked-up", "active": true, "tenant": "other", "group": 1, "id": 99},
}, nil
},
}, {
Name: "recount", Label: "acme.demo::lang.gadgets.recount", Permissions: []string{"acme.demo.run"},
Run: func(_ context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
c.spy.record(in)
return pact.AdminActionResult{Message: "acme.demo::lang.gadgets.recounted", Fill: map[string]any{"name": "ignored"}}, nil
},
}, {
Name: "hidden", Label: "Hidden",
Run: func(_ context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
c.spy.record(in)
return pact.AdminActionResult{}, nil
},
}}
}
func (actController) PartialData(_ context.Context, name string, record any) (any, error) {
switch name {
case "stats":
return struct {
Items []struct {
Label string
Count int
}
}{Items: []struct {
Label string
Count int
}{{Label: "acme.demo::lang.gadgets.total", Count: 2}}}, nil
case "summary":
view := struct{ Name string }{}
if gadget, ok := record.(*actGadget); ok && gadget != nil {
if gadget.Name == "explode" {
return nil, errors.New("view model failed")
}
view.Name = gadget.Name
}
return view, nil
}
return nil, fmt.Errorf("unknown partial %s", name)
}
type actEnv struct {
h http.Handler
spy *actSpy
token string
cookie *http.Cookie
limited string
}
// actRequest is one admin API call. auth is "bearer" (developer token),
// "limited" (a token without acme.demo.run), "cookie" (cookie plus
// X-Requested-With) or "cookie-only" (cookie without the CSRF header).
func (e *actEnv) call(t *testing.T, method, rel, body, auth string) *httptest.ResponseRecorder {
t.Helper()
var reader *strings.Reader
if body != "" {
reader = strings.NewReader(body)
} else {
reader = strings.NewReader("")
}
req := httptest.NewRequest(method, adminAPI(rel), reader)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept-Language", "en")
switch auth {
case "bearer":
req.Header.Set("Authorization", "Bearer "+e.token)
case "limited":
req.Header.Set("Authorization", "Bearer "+e.limited)
case "cookie":
req.AddCookie(e.cookie)
req.Header.Set("X-Requested-With", "XMLHttpRequest")
case "cookie-only":
req.AddCookie(e.cookie)
default:
t.Fatalf("unknown auth mode %s", auth)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
func (e *actEnv) expect(t *testing.T, status int, method, rel, body, auth string) *httptest.ResponseRecorder {
t.Helper()
rec := e.call(t, method, rel, body, auth)
if rec.Code != status {
t.Fatalf("%s %s %s status=%d want %d body=%s", auth, method, rel, rec.Code, status, rec.Body.String())
}
return rec
}
func actResult(t *testing.T, rec *httptest.ResponseRecorder) cabana.AdminActionResult {
t.Helper()
var body cabana.Envelope[cabana.AdminActionResult]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("action body %s: %v", rec.Body.String(), err)
}
return body.Data
}
func newActEnv(t *testing.T) (*actEnv, *gorm.DB) {
t.Helper()
gdb := adminGorm(t)
models := []any{&actGadget{}, &actGroup{}}
if err := gdb.Migrator().DropTable(models...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(models...); err != nil {
t.Fatal(err)
}
stamp := fmt.Sprintf("a%d", time.Now().UnixNano())
login := "ext-" + stamp
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
var roleID uint
if err := gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at)
VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`, "Ext limited "+stamp, "ext-limited-"+stamp, `{"acme.demo.access":1}`).Scan(&roleID).Error; err != nil || roleID == 0 {
t.Fatalf("limited role: id=%d err=%v", roleID, err)
}
limitedLogin := "ext-limited-" + stamp
limited := insertAdmin(t, gdb, limitedLogin, limitedLogin+"@example.test", adminTestPassword, true, false)
if err := gdb.Exec(`UPDATE backend_users SET role_id = ? WHERE id = ?`, roleID, limited.ID).Error; err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-extension\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
t.Fatal(err)
}
spy := &actSpy{}
plugins := []party.Plugin{actPlugin{spy: spy}}
if err := phrasebook.Activate(app, plugins); err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
env := &actEnv{h: h, spy: spy}
rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": login, "password": adminTestPassword})
if rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
}
env.token = accessToken(t, rec.Body.Bytes())
// The admin cookie carries the same JWT the SPA's cookie login sets.
env.cookie = &http.Cookie{Name: cabana.AdminCookieName, Value: env.token}
rec = postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": limitedLogin, "password": adminTestPassword})
if rec.Code != http.StatusOK {
t.Fatalf("limited login status=%d body=%s", rec.Code, rec.Body.String())
}
env.limited = accessToken(t, rec.Body.Bytes())
return env, gdb
}
func actInsert(t *testing.T, gdb *gorm.DB, name, tenant string) uint {
t.Helper()
row := actGadget{Name: name, Tenant: tenant}
if err := gdb.Create(&row).Error; err != nil {
t.Fatal(err)
}
return row.ID
}
// TestPhase101Actions drives the widget, toolbar and partial routes through
// the assembled router on PostgreSQL (D-05, D-07, D-09, D-12; T-10.1-04 to
// T-10.1-07): record scoping, the fill filter in both directions, the strict
// body, the action permission, error mapping and the CSRF header.
func TestPhase101Actions(t *testing.T) {
env, gdb := newActEnv(t)
mine := actInsert(t, gdb, "mine", "acme")
foreign := actInsert(t, gdb, "foreign", "other")
explode := actInsert(t, gdb, "explode", "acme")
const widget = "/acme/demo/gadgets/widgets/lookup"
const toolbar = "/acme/demo/gadgets/toolbar/recount"
t.Run("widget with an in-scope record", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPost, widget,
fmt.Sprintf(`{"record_id":%d,"values":{"name":"typed","active":true,"tenant":"other","group":3,"id":7}}`, mine), "bearer")
result := actResult(t, rec)
if !reflect.DeepEqual(result.Fill, map[string]any{"name": "looked-up", "active": true}) || result.Message != "Name and Active were filled in." {
t.Fatalf("result = %+v", result)
}
calls := env.spy.take()
if len(calls) != 1 {
t.Fatalf("calls = %+v", calls)
}
in := calls[0]
record, ok := in.Record.(*actGadget)
if in.Field != "lookup" || in.RecordID == nil || *in.RecordID != uint64(mine) || !ok || record.ID != mine || record.Name != "mine" {
t.Fatalf("input = %+v record=%+v", in, in.Record)
}
if !reflect.DeepEqual(in.Values, map[string]any{"name": "typed", "active": true}) {
t.Fatalf("values = %#v", in.Values)
}
})
t.Run("non-scalar values and fill are dropped", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPost, widget, `{"values":{"name":"nested","active":{"x":1}}}`, "bearer")
if result := actResult(t, rec); !reflect.DeepEqual(result.Fill, map[string]any{"active": false}) {
t.Fatalf("fill = %#v", result.Fill)
}
calls := env.spy.take()
if len(calls) != 1 || !reflect.DeepEqual(calls[0].Values, map[string]any{"name": "nested"}) {
t.Fatalf("calls = %+v", calls)
}
})
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()
if len(calls) != 1 || calls[0].RecordID != nil || calls[0].Record != nil || len(calls[0].Values) != 0 || calls[0].Values == nil {
t.Fatalf("calls = %+v", calls)
}
})
t.Run("out-of-scope and missing records are 404", func(t *testing.T) {
for _, id := range []uint{foreign, 999999} {
rec := env.expect(t, http.StatusNotFound, http.MethodPost, widget, fmt.Sprintf(`{"record_id":%d}`, id), "bearer")
if strings.Contains(rec.Body.String(), "foreign") {
t.Fatalf("404 leaked the record: %s", rec.Body.String())
}
}
if calls := env.spy.take(); len(calls) != 0 {
t.Fatalf("action ran for an out-of-scope record: %+v", calls)
}
})
t.Run("strict body", func(t *testing.T) {
for _, body := range []string{
`{"record_id":1,"extra":true}`,
`{"values":{}} {}`,
`{"record_id":-1}`,
`{"record_id":"1"}`,
`{"values":[1]}`,
`{`,
`[]`,
``,
} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, widget, body, "bearer")
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
}
if calls := env.spy.take(); len(calls) != 0 {
t.Fatalf("action ran for a malformed body: %+v", calls)
}
})
t.Run("only widget fields are routes", func(t *testing.T) {
for _, field := range []string{"name", "summary", "group", "missing"} {
env.expect(t, http.StatusNotFound, http.MethodPost, "/acme/demo/gadgets/widgets/"+field, `{}`, "bearer")
}
env.expect(t, http.StatusNotFound, http.MethodPost, "/acme/demo/nope/widgets/lookup", `{}`, "bearer")
})
t.Run("action permission on top of the controller's", func(t *testing.T) {
env.expect(t, http.StatusForbidden, http.MethodPost, widget, `{}`, "limited")
env.expect(t, http.StatusForbidden, http.MethodPost, toolbar, `{}`, "limited")
// The limited admin may open the controller, and its list schema
// offers no toolbar action it cannot run.
rec := env.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets/schema/list", "", "limited")
var list cabana.Envelope[cabana.ListSchema]
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil || len(list.Data.ToolbarActions) != 0 {
t.Fatalf("limited toolbarActions = %+v err=%v", list.Data.ToolbarActions, err)
}
env.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets/partials/stats", "", "limited")
if calls := env.spy.take(); len(calls) != 0 {
t.Fatalf("action ran for a denied admin: %+v", calls)
}
})
t.Run("action errors", func(t *testing.T) {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, widget, `{"values":{"name":"invalid"}}`, "bearer")
if !strings.Contains(rec.Body.String(), "Name is taken.") {
t.Fatalf("validation details missing: %s", rec.Body.String())
}
rec = env.expect(t, http.StatusInternalServerError, http.MethodPost, widget, `{"values":{"name":"boom"}}`, "bearer")
actErrorCode(t, rec.Body.Bytes(), "error")
if strings.Contains(rec.Body.String(), "hunter2") {
t.Fatalf("500 body leaked the error text: %s", rec.Body.String())
}
env.spy.take()
})
t.Run("cookie POSTs need X-Requested-With", func(t *testing.T) {
for _, path := range []string{widget, toolbar} {
rec := env.expect(t, http.StatusForbidden, http.MethodPost, path, `{}`, "cookie-only")
actErrorCode(t, rec.Body.Bytes(), "forbidden")
env.expect(t, http.StatusOK, http.MethodPost, path, `{}`, "cookie")
}
if calls := env.spy.take(); len(calls) != 2 {
t.Fatalf("calls = %d, want only the two with the header", len(calls))
}
})
t.Run("toolbar", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPost, toolbar, `{}`, "bearer")
result := actResult(t, rec)
if result.Message != "Gadgets were recounted." || result.Fill == nil || len(result.Fill) != 0 {
t.Fatalf("toolbar result = %+v", result)
}
calls := env.spy.take()
if len(calls) != 1 || !reflect.DeepEqual(calls[0], pact.AdminActionInput{}) {
t.Fatalf("toolbar input = %+v", calls)
}
for _, name := range []string{"hidden", "create", "delete", "lookup", "missing"} {
env.expect(t, http.StatusNotFound, http.MethodPost, "/acme/demo/gadgets/toolbar/"+name, `{}`, "bearer")
}
for _, body := range []string{fmt.Sprintf(`{"record_id":%d}`, mine), `{"values":{}}`, `{"values":{"name":"x"}}`} {
env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, toolbar, body, "bearer")
}
if calls := env.spy.take(); len(calls) != 0 {
t.Fatalf("refused toolbar calls ran: %+v", calls)
}
})
t.Run("partials", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets/partials/stats", "", "bearer")
if !strings.Contains(rec.Body.String(), `"text":"All gadgets"`) || !strings.Contains(rec.Body.String(), `"text":"2"`) {
t.Fatalf("stats = %s", rec.Body.String())
}
rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("/acme/demo/gadgets/partials/summary?id=%d", mine), "", "bearer")
if !strings.Contains(rec.Body.String(), `"text":"mine"`) || !strings.Contains(rec.Body.String(), `"aria-label":"Summary"`) {
t.Fatalf("summary = %s", rec.Body.String())
}
rec = env.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets/partials/summary", "", "bearer")
if !strings.Contains(rec.Body.String(), `"tag":"p"`) || strings.Contains(rec.Body.String(), "mine") {
t.Fatalf("create-form summary = %s", rec.Body.String())
}
for _, rel := range []string{
"/acme/demo/gadgets/partials/missing",
"/acme/demo/gadgets/partials/stats?id=" + fmt.Sprint(mine),
"/acme/demo/gadgets/partials/summary?id=abc",
"/acme/demo/gadgets/partials/summary?id=0",
"/acme/demo/gadgets/partials/summary?id=-1",
"/acme/demo/gadgets/partials/summary?id=" + fmt.Sprint(foreign),
} {
rec := env.expect(t, http.StatusNotFound, http.MethodGet, rel, "", "bearer")
if strings.Contains(rec.Body.String(), "foreign") {
t.Fatalf("%s leaked the record: %s", rel, rec.Body.String())
}
}
rec = env.expect(t, http.StatusInternalServerError, http.MethodGet, fmt.Sprintf("/acme/demo/gadgets/partials/summary?id=%d", explode), "", "bearer")
actErrorCode(t, rec.Body.Bytes(), "error")
if strings.Contains(rec.Body.String(), "view model failed") {
t.Fatalf("500 leaked the error: %s", rec.Body.String())
}
})
}
func actErrorCode(t *testing.T, raw []byte, code string) {
t.Helper()
var body struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &body); err != nil || body.Error.Code != code {
t.Fatalf("error code=%q, want %s (%v); body %s", body.Error.Code, code, err, raw)
}
}