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:
515
modules/cabana/phase101_actions_test.go
Normal file
515
modules/cabana/phase101_actions_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
226
modules/cabana/phase101_assets_test.go
Normal file
226
modules/cabana/phase101_assets_test.go
Normal 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{}
|
||||
284
modules/cabana/phase101_render_test.go
Normal file
284
modules/cabana/phase101_render_test.go
Normal 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="/ /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
|
||||
}
|
||||
667
modules/cabana/phase101_schema_test.go
Normal file
667
modules/cabana/phase101_schema_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
4
modules/cabana/testdata/extension/assets/css/gadgets.css
vendored
Normal file
4
modules/cabana/testdata/extension/assets/css/gadgets.css
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
acme-demo-lookup button {
|
||||
font: inherit;
|
||||
color: var(--c-text);
|
||||
}
|
||||
18
modules/cabana/testdata/extension/assets/js/lookup.js
vendored
Normal file
18
modules/cabana/testdata/extension/assets/js/lookup.js
vendored
Normal 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)
|
||||
}
|
||||
5
modules/cabana/testdata/extension/controllers/gadgets/_stats.htm
vendored
Normal file
5
modules/cabana/testdata/extension/controllers/gadgets/_stats.htm
vendored
Normal 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>
|
||||
1
modules/cabana/testdata/extension/controllers/gadgets/_summary.htm
vendored
Normal file
1
modules/cabana/testdata/extension/controllers/gadgets/_summary.htm
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<section class="summer-partial" aria-label="{{ trans "acme.demo::lang.gadgets.summary" }}"><p>{{ .Data.Name }}</p></section>
|
||||
10
modules/cabana/testdata/extension/controllers/gadgets/config_form.yaml
vendored
Normal file
10
modules/cabana/testdata/extension/controllers/gadgets/config_form.yaml
vendored
Normal 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
|
||||
11
modules/cabana/testdata/extension/controllers/gadgets/config_list.yaml
vendored
Normal file
11
modules/cabana/testdata/extension/controllers/gadgets/config_list.yaml
vendored
Normal 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
|
||||
12
modules/cabana/testdata/extension/lang/en/lang.yaml
vendored
Normal file
12
modules/cabana/testdata/extension/lang/en/lang.yaml
vendored
Normal 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.
|
||||
12
modules/cabana/testdata/extension/lang/pl/lang.yaml
vendored
Normal file
12
modules/cabana/testdata/extension/lang/pl/lang.yaml
vendored
Normal 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.
|
||||
7
modules/cabana/testdata/extension/models/gadget/columns.yaml
vendored
Normal file
7
modules/cabana/testdata/extension/models/gadget/columns.yaml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
columns:
|
||||
name:
|
||||
label: acme.demo::lang.gadgets.name
|
||||
searchable: true
|
||||
active:
|
||||
label: acme.demo::lang.gadgets.active
|
||||
type: switch
|
||||
23
modules/cabana/testdata/extension/models/gadget/fields.yaml
vendored
Normal file
23
modules/cabana/testdata/extension/models/gadget/fields.yaml
vendored
Normal 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
|
||||
Reference in New Issue
Block a user