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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user