formSchema now filters type: widget fields by the action's permissions, the same D-12 filtering listSchema applies to toolbarActions, so an admin without the action permission no longer gets a button that always answers 403, and the action name is not revealed. The filtered fields are a new slice, so the cached schema is never modified.
560 lines
21 KiB
Go
560 lines
21 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"math"
|
|
"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" }
|
|
|
|
// actTags is a string kind that encodes as a JSON array.
|
|
type actTags string
|
|
|
|
func (t actTags) MarshalJSON() ([]byte, error) { return json.Marshal(strings.Split(string(t), ",")) }
|
|
|
|
// 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
|
|
case "encoded":
|
|
// Scalar kinds that do not encode as JSON scalars (WR-04).
|
|
return pact.AdminActionResult{Fill: map[string]any{"name": actTags("a,b"), "active": math.NaN()}}, 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("fill is judged by its JSON encoding, not its kind", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusOK, http.MethodPost, widget, `{"values":{"name":"encoded"}}`, "bearer")
|
|
if result := actResult(t, rec); !reflect.DeepEqual(result.Fill, map[string]any{}) {
|
|
t.Fatalf("fill = %#v", result.Fill)
|
|
}
|
|
env.spy.take()
|
|
})
|
|
|
|
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")
|
|
// WR-05: the form schema offers no widget whose action the admin
|
|
// cannot run, and keeps every other field; an admin who may run it
|
|
// still gets the widget.
|
|
widgets := func(token string) (widgets, others []string) {
|
|
t.Helper()
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets/schema/form", "", token)
|
|
var form cabana.Envelope[cabana.FormView]
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &form); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, field := range form.Data.Fields {
|
|
if field.Type == "widget" {
|
|
widgets = append(widgets, field.Name+":"+field.Action)
|
|
} else {
|
|
others = append(others, field.Name)
|
|
}
|
|
}
|
|
return widgets, others
|
|
}
|
|
limitedWidgets, limitedOthers := widgets("limited")
|
|
fullWidgets, fullOthers := widgets("bearer")
|
|
if len(limitedWidgets) != 0 || strings.Contains(strings.Join(limitedOthers, ","), "lookup") {
|
|
t.Fatalf("limited form widgets = %v", limitedWidgets)
|
|
}
|
|
if !reflect.DeepEqual(fullWidgets, []string{"lookup:lookup"}) || !reflect.DeepEqual(limitedOthers, fullOthers) {
|
|
t.Fatalf("full widgets = %v, others limited %v full %v", fullWidgets, limitedOthers, fullOthers)
|
|
}
|
|
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)
|
|
}
|
|
}
|