diff --git a/modules/boardwalk/boardwalk_test.go b/modules/boardwalk/boardwalk_test.go index 4443138..911f85a 100644 --- a/modules/boardwalk/boardwalk_test.go +++ b/modules/boardwalk/boardwalk_test.go @@ -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) + } + } +} diff --git a/modules/cabana/phase101_actions_test.go b/modules/cabana/phase101_actions_test.go new file mode 100644 index 0000000..34e20cc --- /dev/null +++ b/modules/cabana/phase101_actions_test.go @@ -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) + } +} diff --git a/modules/cabana/phase101_assets_test.go b/modules/cabana/phase101_assets_test.go new file mode 100644 index 0000000..70039e2 --- /dev/null +++ b/modules/cabana/phase101_assets_test.go @@ -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{} diff --git a/modules/cabana/phase101_render_test.go b/modules/cabana/phase101_render_test.go new file mode 100644 index 0000000..c8e6ed7 --- /dev/null +++ b/modules/cabana/phase101_render_test.go @@ -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 = `
keep
<` + tag + ` value="gone" href="/gone" content="gone">tail
` + case "svg", "math": + src = `keep
<` + tag + `>tail
` + case "select": + src = `keep
tail
` + default: + src = `keep
<` + tag + `>gone gone` + tag + `>tail
` + } + 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, `| c |
`, "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, `ab
`)) + if strings.Contains(got, "secret") || strings.Contains(got, "also") || strings.Contains(got, "html") { + t.Fatalf("comment leaked: %s", got) + } + nodes, err := renderPartial(t, "ab
", 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 := `bold` + nodes, err := renderPartial(t, `{{ .Data.Name }}
t`, 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, `{{ .Data }}
`, big); !errors.Is(err, errPartialTooLarge) { + t.Fatalf("size cap err = %v", err) + } + if _, err := renderPartial(t, `{{ .Data }}
`, strings.Repeat("x", partialMaxBytes-len(""))); err != nil { + t.Fatalf("output at the size cap: %v", err) + } + // Every x is two nodes: the element and its text. + atCap := strings.Repeat("x", 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("{{ raw .Data.Name }}
\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) + } + }) +} diff --git a/modules/cabana/testdata/extension/assets/css/gadgets.css b/modules/cabana/testdata/extension/assets/css/gadgets.css new file mode 100644 index 0000000..97f1b07 --- /dev/null +++ b/modules/cabana/testdata/extension/assets/css/gadgets.css @@ -0,0 +1,4 @@ +acme-demo-lookup button { + font: inherit; + color: var(--c-text); +} diff --git a/modules/cabana/testdata/extension/assets/js/lookup.js b/modules/cabana/testdata/extension/assets/js/lookup.js new file mode 100644 index 0000000..b3981df --- /dev/null +++ b/modules/cabana/testdata/extension/assets/js/lookup.js @@ -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) +} diff --git a/modules/cabana/testdata/extension/controllers/gadgets/_stats.htm b/modules/cabana/testdata/extension/controllers/gadgets/_stats.htm new file mode 100644 index 0000000..d8b743f --- /dev/null +++ b/modules/cabana/testdata/extension/controllers/gadgets/_stats.htm @@ -0,0 +1,5 @@ +{{ .Data.Name }}