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

@@ -453,3 +453,40 @@ func TestPhase10BoardwalkServing(t *testing.T) {
} }
}) })
} }
// TestPhase101BoardwalkExports covers the two helpers cabana reuses for
// plugin assets (Phase 10.1 D-16): ContentType's explicit JavaScript and CSS
// types and its fallbacks, and SetSecurityHeaders' full admin header set.
func TestPhase101BoardwalkExports(t *testing.T) {
for name, want := range map[string]string{
"assets/js/lookup.js": "text/javascript; charset=utf-8",
"assets/js/lookup.MJS": "text/javascript; charset=utf-8",
"assets/css/gadgets.css": "text/css; charset=utf-8",
"fonts/inter.woff2": "font/woff2",
"file.no-such-extension": "application/octet-stream",
"no-extension": "application/octet-stream",
} {
if got := ContentType(name); got != want {
t.Fatalf("ContentType(%q) = %q, want %q", name, got, want)
}
}
h := http.Header{}
h.Set("Content-Security-Policy", "default-src *")
SetSecurityHeaders(h)
want := map[string]string{
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "same-origin",
"X-Frame-Options": "DENY",
"Content-Security-Policy": "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'",
"X-Robots-Tag": "noindex, nofollow",
}
if len(h) != len(want) {
t.Fatalf("headers = %v", h)
}
for key, value := range want {
if got := h.Values(key); len(got) != 1 || got[0] != value {
t.Fatalf("%s = %v, want %q", key, got, value)
}
}
}

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)
}
}

View File

@@ -0,0 +1,226 @@
package cabana
import (
"crypto/sha256"
"encoding/hex"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/boardwalk"
"git.golem15.com/golem15/summercms/modules/pact"
)
// extAssetRouter mounts the plugin asset route and the SPA shell the way
// service.mount does under the default prefix, with the real embedded SPA
// handler behind the fall-through.
func extAssetRouter(t *testing.T, reg *Registry) http.Handler {
t.Helper()
spa, err := boardwalk.Handler(DefaultAdminPrefix, http.HandlerFunc(writeNotFound))
if err != nil {
t.Fatal(err)
}
svc := &service{reg: reg, spa: spa}
mux := http.NewServeMux()
mux.HandleFunc("GET "+DefaultAdminPrefix+"/assets/{vendor}/{plugin}/{file...}", svc.pluginAsset)
mux.HandleFunc("GET "+DefaultAdminPrefix+"/{path...}", svc.serveSPA)
return mux
}
func serve(h http.Handler, method, target string, header map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
for key, value := range header {
req.Header.Set(key, value)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// TestPhase101Assets covers the plugin asset route (D-13, D-15, D-16;
// T-10.1-01, T-10.1-02, T-10.1-03): exact-key hits with explicit types and
// the admin security headers, revalidation, fall-through for everything else,
// the boot checks on declared paths, and the ?v= schema URLs.
func TestPhase101Assets(t *testing.T) {
reg, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir))
h := extAssetRouter(t, reg)
base := DefaultAdminPrefix + "/assets/acme/demo/"
for _, tc := range []struct{ file, url, contentType string }{
{extJS, base + "js/lookup.js", "text/javascript; charset=utf-8"},
{extCSS, base + "css/gadgets.css", "text/css; charset=utf-8"},
} {
t.Run("hit "+tc.file, func(t *testing.T) {
body, err := os.ReadFile(extDir + "/" + tc.file)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(body)
etag := `"` + hex.EncodeToString(sum[:]) + `"`
rec := serve(h, http.MethodGet, tc.url+"?v=ignored", nil)
hdr := rec.Header()
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
t.Fatalf("status=%d body=%.80q", rec.Code, rec.Body.String())
}
for key, want := range map[string]string{
"Content-Type": tc.contentType,
"X-Content-Type-Options": "nosniff",
"Cross-Origin-Resource-Policy": "same-origin",
"Cache-Control": "no-cache",
"ETag": etag,
"X-Frame-Options": "DENY",
"Referrer-Policy": "same-origin",
} {
if got := hdr.Get(key); got != want {
t.Fatalf("%s=%q want %q", key, got, want)
}
}
if !strings.Contains(hdr.Get("Content-Security-Policy"), "script-src 'self'") {
t.Fatalf("CSP=%q", hdr.Get("Content-Security-Policy"))
}
if strings.Contains(hdr.Get("Cache-Control"), "immutable") {
t.Fatal("plugin files must be revalidated, never immutable")
}
notModified := serve(h, http.MethodGet, tc.url, map[string]string{"If-None-Match": etag})
if notModified.Code != http.StatusNotModified || notModified.Body.Len() != 0 {
t.Fatalf("If-None-Match status=%d body=%d", notModified.Code, notModified.Body.Len())
}
stale := serve(h, http.MethodGet, tc.url, map[string]string{"If-None-Match": `"stale"`})
if stale.Code != http.StatusOK {
t.Fatalf("stale ETag status=%d", stale.Code)
}
head := serve(h, http.MethodHead, tc.url, nil)
if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Type") != tc.contentType {
t.Fatalf("HEAD status=%d body=%d type=%q", head.Code, head.Body.Len(), head.Header().Get("Content-Type"))
}
})
}
t.Run("everything else falls through to the SPA", func(t *testing.T) {
for _, target := range []string{
base + "controllers/gadgets/config_list.yaml",
base + "controllers/gadgets/_stats.htm",
base + "models/gadget/fields.yaml",
base + "js/other.js",
base + "assets/js/lookup.js",
base + "js/lookup.js/",
base + "JS/lookup.js",
DefaultAdminPrefix + "/assets/acme/other/js/lookup.js",
base + "..%2Fcontrollers%2Fgadgets%2Fconfig_list.yaml",
base + "js/..%2F..%2Fcontrollers/gadgets/_stats.htm",
base + "%2e%2e/controllers/gadgets/_stats.htm",
base + "js%2Flookup.js%00.yaml",
} {
rec := serve(h, http.MethodGet, target, nil)
body := rec.Body.String()
if rec.Code == http.StatusOK || strings.Contains(body, "modelClass") || strings.Contains(body, "summer-stat") ||
strings.Contains(body, "customElements") || strings.Contains(body, "fields:") {
t.Fatalf("%s status=%d body=%.120q", target, rec.Code, body)
}
}
})
t.Run("dist assets still come from the SPA handler", func(t *testing.T) {
dist, err := boardwalk.Dist()
if err != nil {
t.Fatal(err)
}
matches, err := fs.Glob(dist, "assets/index-*.js")
if err != nil || len(matches) == 0 {
t.Fatalf("no dist entry script: %v", err)
}
rec := serve(h, http.MethodGet, DefaultAdminPrefix+"/"+matches[0], nil)
if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" ||
rec.Header().Get("Content-Type") != "text/javascript; charset=utf-8" {
t.Fatalf("dist %s status=%d headers=%v", matches[0], rec.Code, rec.Header())
}
})
t.Run("schema URLs carry a content version", func(t *testing.T) {
svc := &service{reg: reg}
assets := svc.controllerAssets(cc)
version := regexp.MustCompile(`\?v=([0-9a-f]{12})$`)
for _, tc := range []struct{ url, file string }{{assets.Scripts[0], extJS}, {assets.Styles[0], extCSS}} {
match := version.FindStringSubmatch(tc.url)
if match == nil || !strings.HasPrefix(tc.url, base+strings.TrimPrefix(tc.file, "assets/")+"?v=") {
t.Fatalf("asset url %q", tc.url)
}
body, _ := os.ReadFile(extDir + "/" + tc.file)
sum := sha256.Sum256(body)
if match[1] != hex.EncodeToString(sum[:])[:12] {
t.Fatalf("version %s does not hash %s", match[1], tc.file)
}
}
if len(assets.Scripts) != 1 || len(assets.Styles) != 1 {
t.Fatalf("assets = %+v", assets)
}
if empty := svc.controllerAssets(nil); empty.Scripts == nil || empty.Styles == nil || len(empty.Scripts)+len(empty.Styles) != 0 {
t.Fatalf("nil controller assets = %#v", empty)
}
prefixed := (&service{reg: reg, prefix: "/acme-admin"}).controllerAssets(cc)
if !strings.HasPrefix(prefixed.Scripts[0], "/acme-admin/assets/acme/demo/js/lookup.js?v=") {
t.Fatalf("prefixed url %q", prefixed.Scripts[0])
}
})
t.Run("two controllers of one plugin share one entry", func(t *testing.T) {
spares := newExtController()
spares.id = "acme.demo.spares"
fsys := os.DirFS(extDir)
shared, err := compileRegistry([]controllerRef{
{plugin: extPlugin{fsys: fsys}, ctl: newExtController()},
{plugin: extPlugin{fsys: fsys}, ctl: spares},
})
if err != nil {
t.Fatal(err)
}
first, _ := shared.Get(extID)
second, _ := shared.Get("acme.demo.spares")
if len(shared.assets) != 2 || shared.assets["acme/demo/js/lookup.js"] != first.scripts[0] ||
first.scripts[0].key != second.scripts[0].key {
t.Fatalf("assets=%v first=%v second=%v", shared.assets, first.scripts, second.scripts)
}
})
assetCase := func(name string, js, css []string, want string) bootCase {
return bootCase{name: name, ctl: extController{extAssets{extBase: extBase{actions: extActions()}, js: js, css: css}},
want: []string{extPluginID, extID, want}}
}
runBootCases(t, []bootCase{
assetCase("path outside assets/", []string{"js/lookup.js"}, nil, "asset path must be a clean path under assets/"),
assetCase("leading slash", []string{"/assets/js/lookup.js"}, nil, "asset path must be a clean path under assets/"),
assetCase("dot-dot segment", []string{"assets/../assets/js/lookup.js"}, nil, "asset path must be a clean path under assets/"),
assetCase("escaping dot-dot", []string{"assets/../controllers/gadgets/_stats.js"}, nil, "asset path must be a clean path under assets/"),
assetCase("bare assets directory", []string{"assets/"}, nil, "asset path must be a clean path under assets/"),
assetCase("text file", []string{"assets/js/notes.txt"}, nil, "asset must end in .js or .mjs"),
assetCase("stylesheet declared as JS", []string{extCSS}, nil, "asset must end in .js or .mjs"),
assetCase("script declared as CSS", []string{extJS}, []string{extJS}, "asset must end in .css"),
assetCase("missing file", []string{"assets/js/missing.js"}, nil, "asset is not in the plugin's embedded files"),
assetCase("duplicate path", []string{extJS, extJS}, nil, "asset declared twice"),
})
t.Run("three-segment plugin ID", func(t *testing.T) {
ctl := newExtController()
ctl.id = "acme.demo.extra.gadgets"
err := compileClientAssets("acme.demo.extra", &CompiledController{Controller: ctl}, os.DirFS(extDir))
if err == nil || !strings.Contains(err.Error(), "plugin ID must be vendor.plugin") {
t.Fatalf("err = %v", err)
}
for _, id := range []string{"acme", "acme.de mo", "ac/me.demo"} {
if err := compileClientAssets(id, &CompiledController{Controller: ctl}, fstest.MapFS{}); err == nil {
t.Fatalf("plugin ID %q served assets", id)
}
}
if err := compileClientAssets(extPluginID, &CompiledController{Controller: extBase{}}, fstest.MapFS{}); err != nil {
t.Fatalf("controller without assets: %v", err)
}
})
}
var _ pact.AdminClientAssets = extAssets{}

View File

@@ -0,0 +1,284 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"html/template"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/towel"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// sanitizeHTML runs raw markup through the same fragment parse and allowlist
// walk a rendered partial goes through, without html/template in front (which
// would already strip comments).
func sanitizeHTML(t *testing.T, src string) []PartialNode {
t.Helper()
container := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div}
parsed, err := html.ParseFragment(strings.NewReader(src), container)
if err != nil {
t.Fatalf("parse %q: %v", src, err)
}
nodes, err := sanitizePartialNodes(parsed, 0, &partialBudget{nodes: partialMaxNodes})
if err != nil {
t.Fatalf("sanitize %q: %v", src, err)
}
return nodes
}
func nodesJSON(t *testing.T, nodes []PartialNode) string {
t.Helper()
raw, err := json.Marshal(nodes)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
// renderPartial parses src as a partial template and renders it once.
func renderPartial(t *testing.T, src string, data any) ([]PartialNode, error) {
t.Helper()
compiled, err := parsePartial("probe", []byte(src))
if err != nil {
t.Fatalf("parse template: %v", err)
}
return compiled.render(context.Background(), nil, data)
}
// TestPhase101PartialSanitizer covers the server half of T-10.1-08, T-10.1-09
// and T-10.1-12: the tag, attribute and URL allowlist, escaping of view-model
// data, per-request translation, the size, node and depth caps, and the
// view-model guard.
func TestPhase101PartialSanitizer(t *testing.T) {
t.Run("dropped tags go with their subtree", func(t *testing.T) {
for tag := range partialDroppedTags {
var src string
switch tag {
case "input", "link", "meta", "base", "embed":
src = `<p>keep</p><` + tag + ` value="gone" href="/gone" content="gone"><p>tail</p>`
case "svg", "math":
src = `<p>keep</p><` + tag + `><text>gone</text><mi>gone</mi></` + tag + `><p>tail</p>`
case "select":
src = `<p>keep</p><select><option>gone</option></select><p>tail</p>`
default:
src = `<p>keep</p><` + tag + `>gone <b>gone</b></` + tag + `><p>tail</p>`
}
got := nodesJSON(t, sanitizeHTML(t, src))
if strings.Contains(got, "gone") || strings.Contains(got, `"`+tag+`"`) {
t.Fatalf("%s survived: %s", tag, got)
}
if !strings.Contains(got, `"text":"keep"`) || !strings.Contains(got, `"text":"tail"`) {
t.Fatalf("%s took its siblings with it: %s", tag, got)
}
}
if len(partialDroppedTags) != 19 {
t.Fatalf("dropped tag list has %d entries, the test expects 19", len(partialDroppedTags))
}
})
t.Run("unknown elements are unwrapped, children kept", func(t *testing.T) {
nodes := sanitizeHTML(t, `<article><acme-card><span class="x">kept</span></acme-card> text</article>`)
want := []PartialNode{{Tag: "span", Attrs: map[string]string{"class": "x"}, Children: []PartialNode{{Text: "kept"}}}, {Text: " text"}}
if !reflect.DeepEqual(nodes, want) {
t.Fatalf("nodes = %s", nodesJSON(t, nodes))
}
})
t.Run("attribute allowlist", func(t *testing.T) {
nodes := sanitizeHTML(t, `<div id="x" style="color:red" onclick="steal()" onmouseover="steal()" class="c" title="t" lang="pl" dir="rtl" role="note" aria-label="a" data-count="3" formaction="/x" tabindex="1">v</div>`)
want := map[string]string{"class": "c", "title": "t", "lang": "pl", "dir": "rtl", "role": "note", "aria-label": "a", "data-count": "3"}
if len(nodes) != 1 || !reflect.DeepEqual(nodes[0].Attrs, want) {
t.Fatalf("attrs = %s", nodesJSON(t, nodes))
}
cells := nodesJSON(t, sanitizeHTML(t, `<table><tbody><tr><td colspan="2" rowspan="1" scope="row" width="9">c</td></tr></tbody></table><time datetime="2026-09-29">d</time><meter value="1" min="0" max="2" low="0" high="2" optimum="1" onload="x()">m</meter><progress value="1" max="3">p</progress><data value="7">n</data>`))
for _, want := range []string{`"colspan":"2"`, `"rowspan":"1"`, `"scope":"row"`, `"datetime":"2026-09-29"`, `"optimum":"1"`, `"low":"0"`, `"high":"2"`, `"max":"3"`, `"value":"7"`} {
if !strings.Contains(cells, want) {
t.Fatalf("%s missing from %s", want, cells)
}
}
if strings.Contains(cells, "width") || strings.Contains(cells, "onload") {
t.Fatalf("per-tag attributes leaked: %s", cells)
}
// A per-tag attribute is not global: colspan on a div is dropped.
if got := sanitizeHTML(t, `<div colspan="2" href="/x" src="/y">v</div>`); got[0].Attrs != nil {
t.Fatalf("div attrs = %v", got[0].Attrs)
}
})
t.Run("link and image URLs", func(t *testing.T) {
for _, tc := range []struct {
src, attr string
keep bool
}{
{`<a href="/x">l</a>`, "href", true},
{`<a href="#top">l</a>`, "href", true},
{`<a href="/">l</a>`, "href", true},
{`<a href="//evil.test/x">l</a>`, "href", false},
{`<a href="/\evil.test">l</a>`, "href", false},
{`<a href="javascript:alert(1)">l</a>`, "href", false},
{`<a href="JaVaScRiPt:alert(1)">l</a>`, "href", false},
{`<a href="https://evil.test">l</a>`, "href", false},
{`<a href="x">l</a>`, "href", false},
{"<a href=\"/\t/evil.test\">l</a>", "href", false},
{`<a href="/&#10;/evil.test">l</a>`, "href", false},
{`<a href="">l</a>`, "href", false},
{`<img src="/a.png" alt="a" width="10" height="10">`, "src", true},
{`<img src="data:image/png;base64,AAAA">`, "src", false},
{`<img src="#frag">`, "src", false},
{`<img src="//evil.test/a.png">`, "src", false},
} {
nodes := sanitizeHTML(t, tc.src)
if len(nodes) != 1 {
t.Fatalf("%s: nodes = %s", tc.src, nodesJSON(t, nodes))
}
_, kept := nodes[0].Attrs[tc.attr]
if kept != tc.keep {
t.Fatalf("%s: %s kept=%v, want %v (%v)", tc.src, tc.attr, kept, tc.keep, nodes[0].Attrs)
}
}
if got := sanitizeHTML(t, `<img src="/a.png" alt="a" width="10" height="10" onerror="x()">`); !reflect.DeepEqual(got[0].Attrs, map[string]string{"src": "/a.png", "alt": "a", "width": "10", "height": "10"}) {
t.Fatalf("img attrs = %v", got[0].Attrs)
}
})
t.Run("comments and doctypes are dropped", func(t *testing.T) {
got := nodesJSON(t, sanitizeHTML(t, `<p>a<!-- secret -->b</p><!DOCTYPE html><!-- also -->`))
if strings.Contains(got, "secret") || strings.Contains(got, "also") || strings.Contains(got, "html") {
t.Fatalf("comment leaked: %s", got)
}
nodes, err := renderPartial(t, "<p>a<!-- template comment -->b</p>", nil)
if err != nil || strings.Contains(nodesJSON(t, nodes), "template comment") {
t.Fatalf("rendered comment: %v %s", err, nodesJSON(t, nodes))
}
})
t.Run("view-model markup stays text", func(t *testing.T) {
hostile := `<script>alert(1)</script><b onclick="x()">bold</b>`
nodes, err := renderPartial(t, `<p class="n">{{ .Data.Name }}</p><span title="{{ .Data.Name }}">t</span>`, struct{ Name string }{hostile})
if err != nil {
t.Fatal(err)
}
want := []PartialNode{
{Tag: "p", Attrs: map[string]string{"class": "n"}, Children: []PartialNode{{Text: hostile}}},
{Tag: "span", Attrs: map[string]string{"title": hostile}, Children: []PartialNode{{Text: "t"}}},
}
if !reflect.DeepEqual(nodes, want) {
t.Fatalf("nodes = %s", nodesJSON(t, nodes))
}
})
t.Run("trans resolves per request on one compiled partial", func(t *testing.T) {
_, tr := extTranslator(t)
_, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir))
stats := cc.partials["stats"]
vm, _ := extPartialView("stats", nil)
for _, tc := range []struct{ locale, label string }{{"en", "All gadgets"}, {"pl", "Wszystkie gadżety"}, {"en", "All gadgets"}} {
nodes, err := stats.render(towel.WithLocale(context.Background(), tc.locale), tr, vm)
if err != nil {
t.Fatalf("%s render: %v", tc.locale, err)
}
got := nodesJSON(t, nodes)
if !strings.Contains(got, `"text":"`+tc.label+`"`) || !strings.Contains(got, `"text":"3"`) || !strings.Contains(got, `"class":"summer-stats"`) {
t.Fatalf("%s nodes = %s", tc.locale, got)
}
}
// The pristine template was never executed, so it can still be cloned.
if _, err := stats.pristine.Clone(); err != nil {
t.Fatalf("pristine template was executed: %v", err)
}
})
t.Run("caps", func(t *testing.T) {
big := strings.Repeat("x", partialMaxBytes+1)
if _, err := renderPartial(t, `<p>{{ .Data }}</p>`, big); !errors.Is(err, errPartialTooLarge) {
t.Fatalf("size cap err = %v", err)
}
if _, err := renderPartial(t, `<p>{{ .Data }}</p>`, strings.Repeat("x", partialMaxBytes-len("<p></p>"))); err != nil {
t.Fatalf("output at the size cap: %v", err)
}
// Every <b>x</b> is two nodes: the element and its text.
atCap := strings.Repeat("<b>x</b>", partialMaxNodes/2)
if _, err := renderPartial(t, atCap, nil); err != nil {
t.Fatalf("%d nodes: %v", partialMaxNodes, err)
}
if _, err := renderPartial(t, atCap+"y", nil); err == nil || !strings.Contains(err.Error(), "2000 nodes") {
t.Fatalf("node cap err = %v", err)
}
nested := func(depth int) string {
return strings.Repeat("<div>", depth) + "x" + strings.Repeat("</div>", depth)
}
if _, err := renderPartial(t, nested(partialMaxDepth), nil); err != nil {
t.Fatalf("depth %d: %v", partialMaxDepth, err)
}
if _, err := renderPartial(t, nested(partialMaxDepth+1), nil); err == nil || !strings.Contains(err.Error(), "depth 32") {
t.Fatalf("depth cap err = %v", err)
}
})
t.Run("view-model guard", func(t *testing.T) {
_, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir))
for name, vm := range map[string]any{
"model": extGadget{},
"pointer": &extGadget{},
"slice": []extGadget{{}},
"slice of ptr": []*extGadget{{}},
"map of model": map[string]*extGadget{},
"template.HTML": struct{ Body template.HTML }{},
"nested HTMLAttr": struct {
Items []struct{ A template.HTMLAttr }
}{},
"template.URL": map[string]template.URL{},
} {
if refusedViewModel(cc, vm) == "" {
t.Fatalf("%s view model was accepted", name)
}
}
for name, vm := range map[string]any{
"nil": nil,
"curated": struct{ Name string }{},
"items": struct{ Items []extStatItem }{},
"string": "text",
} {
if reason := refusedViewModel(cc, vm); reason != "" {
t.Fatalf("%s view model refused: %s", name, reason)
}
}
})
t.Run("the partial route refuses a model view model", func(t *testing.T) {
app, _ := extTranslator(t)
reg, _ := mustCompileExt(t, leakyController{newExtController()}, os.DirFS(extDir))
svc := &service{app: app, reg: reg}
// _summary.htm reads .Data.Name, which the model has too: without
// the guard the model would render.
req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/gadgets/partials/summary"), nil)
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "gadgets")
req.SetPathValue("name", "summary")
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}))
rec := httptest.NewRecorder()
svc.partial(rec, req)
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "top-secret") {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "error")
})
}
// leakyController hands its GORM model to the template, which the partial
// handler must refuse.
type leakyController struct{ extController }
func (leakyController) PartialData(context.Context, string, any) (any, error) {
return &extGadget{Name: "top-secret"}, nil
}

View File

@@ -0,0 +1,667 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"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/towel"
)
// The Phase 10.1 schema, sanitizer and asset tests load the acme fixture
// plugin under testdata/extension: a gadgets controller with a header
// partial, a registered toolbar action, a lookup widget and a summary form
// partial, plus one JS and one CSS file.
const (
extDir = "testdata/extension"
extPluginID = "acme.demo"
extID = "acme.demo.gadgets"
extFields = "models/gadget/fields.yaml"
extList = "controllers/gadgets/config_list.yaml"
extForm = "controllers/gadgets/config_form.yaml"
extStats = "controllers/gadgets/_stats.htm"
extSummary = "controllers/gadgets/_summary.htm"
extJS = "assets/js/lookup.js"
extCSS = "assets/css/gadgets.css"
)
// extFS copies the fixture tree into a MapFS, so a case can rewrite or drop
// one file without touching the others.
func extFS(t *testing.T) fstest.MapFS {
t.Helper()
root := os.DirFS(extDir)
out := fstest.MapFS{}
err := fs.WalkDir(root, ".", func(name string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
data, err := fs.ReadFile(root, name)
if err != nil {
return err
}
out[name] = &fstest.MapFile{Data: data}
return nil
})
if err != nil {
t.Fatalf("fixture tree: %v", err)
}
return out
}
// edit replaces old with new in one fixture file and fails when old is not
// there, so a case can never silently test the unmodified fixture.
func edit(t *testing.T, fsys fstest.MapFS, file, old, new string) {
t.Helper()
src := string(fsys[file].Data)
if !strings.Contains(src, old) {
t.Fatalf("%s does not contain %q", file, old)
}
fsys[file] = &fstest.MapFile{Data: []byte(strings.Replace(src, old, new, 1))}
}
type extGadget struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Active bool `gorm:"column:active"`
GroupID *uint `gorm:"column:group_id"`
CollectionID *uint `gorm:"column:collection_id"`
}
type extGroup struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
}
func extRun(message string) func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) {
return func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) {
return pact.AdminActionResult{Message: message}, nil
}
}
// extActions are the fixture's registered actions: the lookup widget action
// and the recount toolbar action, both gated by acme.demo.run.
func extActions() []pact.AdminAction {
return []pact.AdminAction{
{Name: "lookup", Label: "acme.demo::lang.gadgets.lookup", Permissions: []string{"acme.demo.run"}, Run: extRun("acme.demo::lang.gadgets.looked_up")},
{Name: "recount", Label: "acme.demo::lang.gadgets.recount", Permissions: []string{"acme.demo.run"}, Run: extRun("acme.demo::lang.gadgets.recounted")},
}
}
// extBase is a gadgets controller with registered actions but neither client
// assets nor partial data.
type extBase struct {
id string
actions []pact.AdminAction
// formless drops the group relation contract, for a controller that
// ships no config_form.yaml.
formless bool
}
func (c extBase) ID() string {
if c.id == "" {
return extID
}
return c.id
}
func (extBase) ModelName() string { return "Gadget" }
func (extBase) ConfigDir() string { return "controllers/gadgets" }
func (extBase) RequiredPermissions() []string { return []string{"acme.demo.access"} }
func (extBase) NewRecord() any { return &extGadget{} }
func (c extBase) AdminActions() []pact.AdminAction { return c.actions }
func (c extBase) AdminFieldRelations() []FieldRelationContract {
if c.formless {
return nil
}
return []FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &extGroup{} }, ForeignKey: "group_id"}}
}
// extAssets adds pact.AdminClientAssets.
type extAssets struct {
extBase
js, css []string
}
func (c extAssets) AdminJS() []string { return c.js }
func (c extAssets) AdminCSS() []string { return c.css }
// extController is the complete fixture controller: actions, assets and
// partial view models.
type extController struct{ extAssets }
func (extController) PartialData(_ context.Context, name string, record any) (any, error) {
return extPartialView(name, record)
}
// extNoAssets has partial data but declares no client assets.
type extNoAssets struct{ extBase }
func (extNoAssets) PartialData(_ context.Context, name string, record any) (any, error) {
return extPartialView(name, record)
}
type extStatItem struct {
Label string
Count int
}
func extPartialView(name string, record any) (any, error) {
switch name {
case "stats":
return struct{ Items []extStatItem }{Items: []extStatItem{{Label: "acme.demo::lang.gadgets.total", Count: 3}}}, nil
case "summary":
view := struct{ Name string }{}
if gadget, ok := record.(*extGadget); ok && gadget != nil {
view.Name = gadget.Name
}
return view, nil
}
return nil, errors.New("unknown partial " + name)
}
func newExtController() extController {
return extController{extAssets{extBase: extBase{actions: extActions()}, js: []string{extJS}, css: []string{extCSS}}}
}
// extPlugin serves the fixture tree and the fixture permissions.
type extPlugin struct {
id string
fsys fs.FS
perms []pact.Permission
}
func (p extPlugin) ID() string {
if p.id == "" {
return extPluginID
}
return p.id
}
func (extPlugin) Requires() []string { return nil }
func (extPlugin) Register(*backpack.App) error { return nil }
func (extPlugin) Boot(*backpack.App) error { return nil }
func (p extPlugin) AdminFS() fs.FS { return p.fsys }
func (p extPlugin) Permissions() []pact.Permission {
if p.perms != nil {
return p.perms
}
return []pact.Permission{{Code: "acme.demo.access", Roles: []string{"developer"}}, {Code: "acme.demo.run", Roles: []string{"developer"}}}
}
var _ party.Plugin = extPlugin{}
func compileExt(pluginID string, ctl pact.AdminController, fsys fs.FS) (*Registry, error) {
return compileRegistry([]controllerRef{{plugin: extPlugin{id: pluginID, fsys: fsys}, ctl: ctl}})
}
func mustCompileExt(t *testing.T, ctl pact.AdminController, fsys fs.FS) (*Registry, *CompiledController) {
t.Helper()
reg, err := compileExt(extPluginID, ctl, fsys)
if err != nil {
t.Fatalf("compile fixture: %v", err)
}
cc, ok := reg.Get(ctl.ID())
if !ok {
t.Fatalf("controller %s not compiled", ctl.ID())
}
return reg, cc
}
// extTranslator activates phrasebook with the framework strings and the
// fixture's en and pl catalog.
func extTranslator(t *testing.T) (*backpack.App, *phrasebook.Translator) {
t.Helper()
app := backpack.New(nil)
lang := fstest.MapFS{}
for name, file := range extFS(t) {
if strings.HasPrefix(name, "lang/") {
lang[name] = file
}
}
if err := phrasebook.Activate(app, []langPlugin{{id: extPluginID, lang: lang}}); err != nil {
t.Fatal(err)
}
tr, ok := app.Lookup[*phrasebook.Translator]()
if !ok || tr == nil {
t.Fatal("translator missing")
}
return app, tr
}
// bootCase is one fixture variant that must fail boot with every want
// substring in the error.
type bootCase struct {
name string
pluginID string
ctl pact.AdminController
mutate func(t *testing.T, fsys fstest.MapFS)
want []string
}
func runBootCases(t *testing.T, cases []bootCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fsys := extFS(t)
if tc.mutate != nil {
tc.mutate(t, fsys)
}
pluginID := tc.pluginID
if pluginID == "" {
pluginID = extPluginID
}
ctl := tc.ctl
if ctl == nil {
ctl = newExtController()
}
_, err := compileExt(pluginID, ctl, fsys)
if err == nil {
t.Fatal("expected a boot error")
}
for _, want := range tc.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error %q is missing %q", err, want)
}
}
})
}
}
// TestPhase101FormExtensionSchema covers `type: widget` (D-06, D-07, D-13):
// the valid fixture compiles its widget with the action label and fill keys,
// and every rule that guards a widget stops boot naming the plugin, the
// controller and, for YAML rules, the file.
func TestPhase101FormExtensionSchema(t *testing.T) {
t.Run("valid widget compiles", func(t *testing.T) {
reg, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir))
field, ok := widgetField(cc, "lookup")
if !ok {
t.Fatal("lookup widget not compiled")
}
if field.Widget != "acme-demo-lookup" || field.Action != "lookup" || field.ActionLabel != "acme.demo::lang.gadgets.lookup" ||
!reflect.DeepEqual(field.Fill, []string{"name", "active"}) {
t.Fatalf("widget field = %+v", field)
}
if _, ok := widgetField(cc, "name"); ok {
t.Fatal("a text field was reported as a widget")
}
if len(cc.Actions) != 2 || cc.Actions["lookup"].Run == nil || cc.Actions["recount"].Run == nil {
t.Fatalf("actions = %v", cc.Actions)
}
if len(cc.scripts) != 1 || len(cc.styles) != 1 || len(reg.assets) != 2 {
t.Fatalf("scripts=%d styles=%d assets=%d", len(cc.scripts), len(cc.styles), len(reg.assets))
}
if !isRegisteredWidget(cc.Form, "lookup") {
t.Fatalf("form fields = %+v", cc.Form.Fields)
}
})
withActions := func(actions ...pact.AdminAction) pact.AdminController {
ctl := newExtController()
ctl.actions = actions
return ctl
}
lookup := extActions()[0]
named := func(name string) pact.AdminAction {
action := lookup
action.Name = name
return action
}
fieldsFile := []string{extPluginID, extID, extFields}
cases := []bootCase{
{name: "widget key on a text field", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, " type: text\n", " type: text\n widget: acme-demo-name\n")
}, want: append(fieldsFile, "widget is only valid on type: widget")},
{name: "fill key on a switch", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, " type: switch\n", " type: switch\n fill: [name]\n")
}, want: append(fieldsFile, "fill is only valid on type: widget")},
{name: "widget without a tag", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, " widget: acme-demo-lookup\n", "")
}, want: append(fieldsFile, "widget (the custom-element tag) is required")},
{name: "widget without an action", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, " action: lookup\n", "")
}, want: append(fieldsFile, "is not an identifier")},
{name: "tag without a hyphen", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "widget: acme-demo-lookup", "widget: acmedemolookup")
}, want: append(fieldsFile, "not a valid custom-element name")},
{name: "tag with uppercase", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "widget: acme-demo-lookup", "widget: acme-demo-Lookup")
}, want: append(fieldsFile, "not a valid custom-element name")},
{name: "another plugin's prefix", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "widget: acme-demo-lookup", "widget: acme-other-lookup")
}, want: append(fieldsFile, `must start with the plugin prefix "acme-demo-"`)},
{name: "reserved element name", pluginID: "font.face", ctl: extController{extAssets{extBase: extBase{id: "font.face.gadgets", actions: extActions()}, js: []string{extJS}}},
mutate: func(t *testing.T, fsys fstest.MapFS) {
for name, file := range fsys {
fsys[name] = &fstest.MapFile{Data: []byte(strings.ReplaceAll(string(file.Data), "~/plugins/acme/demo/", "~/plugins/font/face/"))}
}
edit(t, fsys, extFields, "widget: acme-demo-lookup", "widget: font-face-src")
}, want: []string{"font.face", "font.face.gadgets", extFields, `"font-face-src" is a reserved element name`}},
{name: "unregistered action", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "action: lookup", "action: missing")
}, want: append(fieldsFile, "action missing is not registered")},
{name: "registered action named create", ctl: withActions(extActions()[0], extActions()[1], named("create")),
want: []string{extPluginID, extID, "action create uses a reserved built-in name"}},
{name: "registered action named delete", ctl: withActions(extActions()[0], extActions()[1], named("delete")),
want: []string{extPluginID, extID, "action delete uses a reserved built-in name"}},
{name: "action without Run", ctl: withActions(pact.AdminAction{Name: "lookup", Label: "Look up"}, extActions()[1]),
want: []string{extPluginID, extID, "action lookup has no Run function"}},
{name: "duplicate action", ctl: withActions(extActions()[0], extActions()[1], extActions()[0]),
want: []string{extPluginID, extID, "duplicate action lookup"}},
{name: "action name not an identifier", ctl: withActions(extActions()[0], extActions()[1], named("re-count")),
want: []string{extPluginID, extID, `action name "re-count" is not an identifier`}},
{name: "fill key that is not a field", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "fill: [name, active]", "fill: [name, missing]")
}, want: append(fieldsFile, "fill missing is not a field of this form")},
{name: "protected fill key", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "fields:\n", "fields:\n collection_id:\n label: Collection\n type: text\n")
edit(t, fsys, extFields, "fill: [name, active]", "fill: [name, collection_id]")
}, want: append(fieldsFile, "fill collection_id is not a writable scalar field")},
{name: "relation fill key", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "fill: [name, active]", "fill: [name, group]")
}, want: append(fieldsFile, "fill group is not a writable scalar field")},
{name: "repeated fill key", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "fill: [name, active]", "fill: [name, name]")
}, want: append(fieldsFile, "fill: duplicate field name")},
{name: "widget without controller JS", ctl: extNoAssets{extBase{actions: extActions()}},
want: append(fieldsFile, "a widget needs the controller to declare its JS")},
{name: "unknown key on a widget", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, " action: lookup\n", " action: lookup\n onLoad: boot\n")
}, want: append(fieldsFile, "onLoad")},
}
runBootCases(t, cases)
t.Run("widget and partial are refused on a settings form", func(t *testing.T) {
for typ, field := range map[string]string{
"widget": " type: widget\n widget: acme-demo-lookup\n action: lookup\n",
"partial": " type: partial\n path: summary\n",
} {
fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n enabled:\n type: switch\n extra:\n" + field)}}
item := pact.SettingsItem{Code: "demo", Label: "Demo", Form: "models/settings/fields.yaml", Model: "DemoSettings", NewModel: func() any { return &extSettings{} }}
_, err := compileSetting(extPluginID, item, fsys)
if err == nil || !strings.Contains(err.Error(), "setting demo field extra: type "+typ+" is not supported on a settings form") {
t.Fatalf("%s: err = %v", typ, err)
}
}
})
t.Run("unknown action permission fails compileContributions", func(t *testing.T) {
ctl := newExtController()
ctl.actions = extActions()
ctl.actions[1].Permissions = []string{"acme.demo.nope"}
reg, err := compileExt(extPluginID, ctl, os.DirFS(extDir))
if err != nil {
t.Fatal(err)
}
err = compileContributions(reg, []party.Plugin{extPlugin{fsys: os.DirFS(extDir)}})
if err == nil || !strings.Contains(err.Error(), "action "+extID+".recount references unknown permission acme.demo.nope") {
t.Fatalf("err = %v", err)
}
reg, _ = compileExt(extPluginID, newExtController(), os.DirFS(extDir))
if err := compileContributions(reg, []party.Plugin{extPlugin{fsys: os.DirFS(extDir)}}); err != nil {
t.Fatalf("valid permissions: %v", err)
}
})
t.Run("action labels: phrase keys must resolve, literals pass", func(t *testing.T) {
_, tr := extTranslator(t)
check := func(label string) error {
ctl := newExtController()
ctl.actions = extActions()
ctl.actions[1].Label = label
reg, err := compileExt(extPluginID, ctl, os.DirFS(extDir))
if err != nil {
t.Fatal(err)
}
return validateMessageKeys(reg, tr)
}
if err := check("acme.demo::lang.gadgets.recount"); err != nil {
t.Fatalf("resolving key: %v", err)
}
if err := check("Recount everything"); err != nil {
t.Fatalf("literal label: %v", err)
}
err := check("acme.demo::lang.gadgets.missing")
if err == nil || !strings.Contains(err.Error(), "action recount label names missing phrase key acme.demo::lang.gadgets.missing") {
t.Fatalf("missing key: %v", err)
}
})
}
func isRegisteredWidget(form *FormSchema, name string) bool {
if form == nil {
return false
}
for _, field := range form.Fields {
if field.Name == name {
return field.Type == "widget"
}
}
return false
}
type extSettings struct {
ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"`
}
func (extSettings) Fillable() []string { return []string{"enabled"} }
func (extSettings) Rules() map[string]string { return map[string]string{} }
// TestPhase101PartialSchema covers config_list.yaml headerPartial and
// `type: partial` (D-09, D-11): both compile from the fixture, and every
// template or path problem stops boot with the partial-name hint.
func TestPhase101PartialSchema(t *testing.T) {
t.Run("header and form partials compile", func(t *testing.T) {
_, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir))
if cc.List.HeaderPartial != "stats" {
t.Fatalf("headerPartial = %q", cc.List.HeaderPartial)
}
if len(cc.partials) != 2 || cc.partials["stats"] == nil || cc.partials["summary"] == nil {
t.Fatalf("partials = %v", cc.partials)
}
if !cc.formPartials["summary"] || cc.formPartials["stats"] {
t.Fatalf("form partials = %v", cc.formPartials)
}
var summary FormField
for _, field := range cc.Form.Fields {
if field.Name == "summary" {
summary = field
}
}
if summary.Type != "partial" || summary.Path != "summary" {
t.Fatalf("summary field = %+v", summary)
}
})
listFile := []string{extPluginID, extID, extList}
fieldsFile := []string{extPluginID, extID, extFields}
cases := []bootCase{
{name: "headerPartial not an identifier", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extList, "headerPartial: stats", "headerPartial: stats-strip")
}, want: append(listFile, `headerPartial "stats-strip"`, partialPathHint)},
{name: "headerPartial as a Winter path", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extList, "headerPartial: stats", "headerPartial: $/acme/demo/controllers/gadgets/_stats.htm")
}, want: append(listFile, partialPathHint)},
{name: "header template missing", mutate: func(t *testing.T, fsys fstest.MapFS) {
delete(fsys, extStats)
}, want: []string{extPluginID, extID, extStats, "partial stats: template is not in the plugin's embedded files"}},
{name: "form template missing", mutate: func(t *testing.T, fsys fstest.MapFS) {
delete(fsys, extSummary)
}, want: []string{extPluginID, extID, extSummary, "partial summary: template is not in the plugin's embedded files"}},
{name: "template parse error", mutate: func(t *testing.T, fsys fstest.MapFS) {
fsys[extStats] = &fstest.MapFile{Data: []byte("<dl>{{ range .Data.Items }}</dl>\n")}
}, want: []string{extPluginID, extID, extStats, "partial stats:"}},
{name: "template calls an unknown function", mutate: func(t *testing.T, fsys fstest.MapFS) {
fsys[extSummary] = &fstest.MapFile{Data: []byte("<p>{{ raw .Data.Name }}</p>\n")}
}, want: []string{extPluginID, extID, extSummary, `function "raw" not defined`}},
{name: "controller without AdminPartialData", ctl: extAssets{extBase: extBase{actions: extActions()}, js: []string{extJS}},
want: []string{extPluginID, extID, extStats, "partial stats needs the controller to implement pact.AdminPartialData"}},
{name: "partial without path", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, " path: summary\n", "")
}, want: append(fieldsFile, "type partial needs a path", partialPathHint)},
{name: "partial path with $/", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "path: summary", "path: $/acme/demo/controllers/gadgets/_summary.htm")
}, want: append(fieldsFile, partialPathHint)},
{name: "partial path with ~/", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "path: summary", "path: ~/plugins/acme/demo/controllers/gadgets/_summary.htm")
}, want: append(fieldsFile, partialPathHint)},
{name: "partial path with a directory", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "path: summary", "path: gadgets/summary")
}, want: append(fieldsFile, `partial "gadgets/summary"`, partialPathHint)},
{name: "path on another type", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, " type: text\n", " type: text\n path: summary\n")
}, want: append(fieldsFile, "path is only valid on type: partial")},
}
runBootCases(t, cases)
}
// TestPhase101Toolbar covers registered toolbar actions (D-12) and the
// assumption-delta invariant: every toolbar.buttons name resolves to exactly
// one built-in or registered action.
func TestPhase101Toolbar(t *testing.T) {
t.Run("registered names compile in declared order", func(t *testing.T) {
fsys := extFS(t)
edit(t, fsys, extList, "buttons: [create, delete, recount]", "buttons: [recount, create, delete]")
_, cc := mustCompileExt(t, newExtController(), fsys)
if got := strings.Join(cc.List.ToolbarButtons, ","); got != "recount,create,delete" {
t.Fatalf("toolbarButtons = %s", got)
}
if len(cc.List.ToolbarActions) != 1 || cc.List.ToolbarActions[0] != (ToolbarAction{Name: "recount", Label: "acme.demo::lang.gadgets.recount"}) {
t.Fatalf("toolbarActions = %+v", cc.List.ToolbarActions)
}
})
t.Run("invariant: each name is exactly one built-in or registered action", func(t *testing.T) {
_, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir))
if len(cc.List.ToolbarButtons) != 3 {
t.Fatalf("toolbarButtons = %v", cc.List.ToolbarButtons)
}
for _, name := range cc.List.ToolbarButtons {
matches := 0
if builtinToolbarActions[name] {
matches++
}
if _, ok := cc.Actions[name]; ok {
matches++
}
if matches != 1 {
t.Fatalf("toolbar name %s resolves to %d actions", name, matches)
}
if _, ok := toolbarActionOf(cc, name); ok == builtinToolbarActions[name] {
t.Fatalf("toolbarActionOf(%s) = %v, built-in %v", name, ok, builtinToolbarActions[name])
}
}
})
cases := []bootCase{
{name: "unknown name", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extList, "buttons: [create, delete, recount]", "buttons: [create, delete, launch]")
}, want: []string{extPluginID, extID, extList, "unsupported action launch"}},
{name: "Winter partial name", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extList, "buttons: [create, delete, recount]", "buttons: list_toolbar")
}, want: []string{extPluginID, extID, extList, "not supported"}},
{name: "delete without showCheckboxes", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extList, "showCheckboxes: true\n", "")
}, want: []string{extPluginID, extID, extList, "delete needs showCheckboxes: true"}},
{name: "toolbar action without a label", ctl: func() pact.AdminController {
ctl := newExtController()
ctl.actions = extActions()
ctl.actions[1].Label = " "
return ctl
}(), want: []string{extPluginID, extID, extList, "action recount needs a label"}},
{name: "registered action named create", ctl: func() pact.AdminController {
ctl := newExtController()
ctl.actions = append(extActions(), pact.AdminAction{Name: "create", Label: "Create", Run: extRun("")})
return ctl
}(), want: []string{extPluginID, extID, "reserved built-in name"}},
{name: "registered action named delete", ctl: func() pact.AdminController {
ctl := newExtController()
ctl.actions = append(extActions(), pact.AdminAction{Name: "delete", Label: "Delete", Run: extRun("")})
return ctl
}(), want: []string{extPluginID, extID, "reserved built-in name"}},
}
runBootCases(t, cases)
t.Run("create is dropped without a form, custom names stay", func(t *testing.T) {
fsys := extFS(t)
delete(fsys, extForm)
ctl := newExtController()
ctl.formless = true
_, cc := mustCompileExt(t, ctl, fsys)
if cc.Form != nil {
t.Fatal("form compiled without config_form.yaml")
}
if got := strings.Join(cc.List.ToolbarButtons, ","); got != "delete,recount" {
t.Fatalf("toolbarButtons = %s", got)
}
if len(cc.List.ToolbarActions) != 1 || cc.List.ToolbarActions[0].Name != "recount" {
t.Fatalf("toolbarActions = %+v", cc.List.ToolbarActions)
}
})
t.Run("labels localize per request and follow the principal's permissions", func(t *testing.T) {
app, _ := extTranslator(t)
fsys := extFS(t)
edit(t, fsys, extList, "buttons: [create, delete, recount]", "buttons: [create, delete, recount, archive]")
ctl := newExtController()
ctl.actions = append(extActions(), pact.AdminAction{Name: "archive", Label: "Archive", Permissions: []string{"acme.demo.archive"}, Run: extRun("")})
reg, _ := mustCompileExt(t, ctl, fsys)
svc := &service{app: app, reg: reg}
read := func(principal *bouncer.Principal, locale string) []ToolbarAction {
t.Helper()
req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/gadgets/schema/list"), nil)
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "gadgets")
req = req.WithContext(towel.WithLocale(bouncer.WithUser(req.Context(), principal), locale))
rec := httptest.NewRecorder()
svc.listSchema(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("list schema status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data ListSchema `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if got := strings.Join(body.Data.ToolbarButtons, ","); got != "create,delete,recount,archive" {
t.Fatalf("toolbarButtons = %s", got)
}
return body.Data.ToolbarActions
}
runner := &bouncer.Principal{ID: 7, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true, "acme.demo.run": true}}
if got := read(runner, "en"); len(got) != 1 || got[0] != (ToolbarAction{Name: "recount", Label: "Recount"}) {
t.Fatalf("en actions = %+v", got)
}
if got := read(runner, "pl"); len(got) != 1 || got[0] != (ToolbarAction{Name: "recount", Label: "Przelicz"}) {
t.Fatalf("pl actions = %+v", got)
}
viewer := &bouncer.Principal{ID: 8, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}}
if got := read(viewer, "en"); len(got) != 0 {
t.Fatalf("viewer sees actions it may not run: %+v", got)
}
super := &bouncer.Principal{ID: 9, Backend: true, IsSuperuser: true}
if got := read(super, "en"); len(got) != 2 || got[0].Name != "recount" || got[1] != (ToolbarAction{Name: "archive", Label: "Archive"}) {
t.Fatalf("superuser actions = %+v", got)
}
// The compiled schema keeps its source labels; localizing never
// writes back into the cache.
cc, _ := reg.Get(extID)
if cc.List.ToolbarActions[0].Label != "acme.demo::lang.gadgets.recount" {
t.Fatalf("cached label mutated: %+v", cc.List.ToolbarActions)
}
})
}

View File

@@ -0,0 +1,4 @@
acme-demo-lookup button {
font: inherit;
color: var(--c-text);
}

View File

@@ -0,0 +1,18 @@
// A plain custom element: one light-DOM button that asks the admin SPA to
// run the field's action. It makes no request and reads no cookie or
// storage; the SPA owns HTTP.
class AcmeDemoLookup extends HTMLElement {
connectedCallback() {
if (this.firstChild) return
const button = document.createElement('button')
button.type = 'button'
button.textContent = this.getAttribute('label') || ''
button.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('summer-action', { bubbles: true, composed: true }))
})
this.append(button)
}
}
if (!customElements.get('acme-demo-lookup')) {
customElements.define('acme-demo-lookup', AcmeDemoLookup)
}

View File

@@ -0,0 +1,5 @@
<dl class="summer-stats">
{{- range .Data.Items -}}
<div class="summer-stat"><dt class="summer-stat__label">{{ trans .Label }}</dt><dd class="summer-stat__value">{{ .Count }}</dd></div>
{{- end -}}
</dl>

View File

@@ -0,0 +1 @@
<section class="summer-partial" aria-label="{{ trans "acme.demo::lang.gadgets.summary" }}"><p>{{ .Data.Name }}</p></section>

View File

@@ -0,0 +1,10 @@
name: acme.demo::lang.gadgets.form
form: ~/plugins/acme/demo/models/gadget/fields.yaml
modelClass: Gadget
defaultRedirect: acme/demo/gadgets
create:
redirect: acme/demo/gadgets/update/:id
redirectClose: acme/demo/gadgets
update:
redirect: acme/demo/gadgets
redirectClose: acme/demo/gadgets

View File

@@ -0,0 +1,11 @@
list: ~/plugins/acme/demo/models/gadget/columns.yaml
modelClass: Gadget
title: acme.demo::lang.gadgets.title
recordUrl: acme/demo/gadgets/update/:id
recordsPerPage: 20
showCheckboxes: true
headerPartial: stats
toolbar:
buttons: [create, delete, recount]
search:
prompt: backend::lang.list.search_prompt

View File

@@ -0,0 +1,12 @@
gadgets:
title: Gadgets
form: Gadget
name: Name
active: Active
group: Group
lookup: Lookup
summary: Summary
total: All gadgets
recount: Recount
recounted: Gadgets were recounted.
looked_up: Name and Active were filled in.

View File

@@ -0,0 +1,12 @@
gadgets:
title: Gadżety
form: Gadżet
name: Nazwa
active: Aktywny
group: Grupa
lookup: Wyszukaj
summary: Podsumowanie
total: Wszystkie gadżety
recount: Przelicz
recounted: Gadżety zostały przeliczone.
looked_up: Uzupełniono Nazwę i Aktywny.

View File

@@ -0,0 +1,7 @@
columns:
name:
label: acme.demo::lang.gadgets.name
searchable: true
active:
label: acme.demo::lang.gadgets.active
type: switch

View File

@@ -0,0 +1,23 @@
fields:
name:
label: acme.demo::lang.gadgets.name
type: text
span: left
active:
label: acme.demo::lang.gadgets.active
type: switch
span: right
group:
label: acme.demo::lang.gadgets.group
type: relation
nameFrom: title
lookup:
label: acme.demo::lang.gadgets.lookup
type: widget
widget: acme-demo-lookup
action: lookup
fill: [name, active]
summary:
label: acme.demo::lang.gadgets.summary
type: partial
path: summary