test(12.2-05): prove relation child and protected file scoping through the router
- acme.deferred fixture plugin over testdata/deferred (test-only), two controllers, recording Form and Relation hooks, two admins - TestRelationChildScope*: every child route answers 404 for another parent, a hidden parent and another admin's pending child, changes nothing; undeclared toolbar buttons 403 before SQL; pivot whitelist - TestProtectedFile*: foreign, pending and public files 404; only jpeg, png, gif and webp inline; nosniff, no-store and sandbox CSP everywhere
This commit is contained in:
623
modules/cabana/phase122_fixture_test.go
Normal file
623
modules/cabana/phase122_fixture_test.go
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"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/lagoon/attach"
|
||||||
|
"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"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
"gocloud.dev/blob/memblob"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The acme.deferred fixture plugin backs the Phase 12.2 unit, integration
|
||||||
|
// and security tests. It is defined only in this test file, with its YAML
|
||||||
|
// tree under testdata/deferred, and is never registered by an application.
|
||||||
|
//
|
||||||
|
// Controllers:
|
||||||
|
// - acme.deferred.gadgets: datepicker fields (date, datetime, time), a
|
||||||
|
// public attachMany `photos` (image, maxFiles 3) and a protected
|
||||||
|
// attachOne `manual` (file mode), a belongsToMany `members` relation
|
||||||
|
// with manage and pivot forms (RelationBeforeLink stamps `role`), and a
|
||||||
|
// deferrable hasMany `parts` relation whose manage form has a fileupload
|
||||||
|
// and a datepicker field. Every toolbar button is declared.
|
||||||
|
// - acme.deferred.locked: the same model and relations with a reduced
|
||||||
|
// toolbar (parts: link; members: unlink) and a required fileupload.
|
||||||
|
//
|
||||||
|
// FormExtendQuery hides gadgets whose `hidden` column is true. Every Form
|
||||||
|
// and Relation hook records its calls in the env's recorder, which can also
|
||||||
|
// make a named hook fail.
|
||||||
|
|
||||||
|
const (
|
||||||
|
dfGadgetMorph = "acme.deferred.gadget"
|
||||||
|
dfPartMorph = "acme.deferred.part"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dfGadget struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
Hidden bool `gorm:"column:hidden;not null;default:false"`
|
||||||
|
ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
|
||||||
|
StartsAt *time.Time `gorm:"column:starts_at"`
|
||||||
|
OpensAt lagoon.TimeOfDay `gorm:"column:opens_at;type:time"`
|
||||||
|
Members []dfMember `gorm:"-"`
|
||||||
|
Parts []dfPart `gorm:"-"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dfGadget) TableName() string { return "cabana_deferred_gadgets" }
|
||||||
|
func (dfGadget) MorphName() string { return dfGadgetMorph }
|
||||||
|
func (dfGadget) AttachRelations() []attach.Relation {
|
||||||
|
return []attach.Relation{{Name: "photos", Many: true, Public: true}, {Name: "manual"}}
|
||||||
|
}
|
||||||
|
func (dfGadget) Fillable() []string {
|
||||||
|
return []string{"name", "released_on", "starts_at", "opens_at"}
|
||||||
|
}
|
||||||
|
func (dfGadget) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||||
|
|
||||||
|
// dfPart is the hasMany child; gadget_id is nullable so the relation is
|
||||||
|
// deferrable, and DeletedAt makes a relation delete a soft delete.
|
||||||
|
type dfPart struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
GadgetID *uint `gorm:"column:gadget_id"`
|
||||||
|
Label string `gorm:"column:label"`
|
||||||
|
DueOn *lagoon.Date `gorm:"column:due_on;type:date"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dfPart) TableName() string { return "cabana_deferred_parts" }
|
||||||
|
func (dfPart) MorphName() string { return dfPartMorph }
|
||||||
|
func (dfPart) Fillable() []string { return []string{"label", "due_on"} }
|
||||||
|
func (dfPart) Rules() map[string]string { return map[string]string{"label": "required"} }
|
||||||
|
func (dfPart) AttachRelations() []attach.Relation {
|
||||||
|
return []attach.Relation{{Name: "images", Many: true}, {Name: "sheet"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dfPartHooks records the part model's delete hook calls; a relation
|
||||||
|
// delete must go through the model so the hook runs.
|
||||||
|
var dfPartHooks dfRecorder
|
||||||
|
|
||||||
|
// BeforeDelete is the part's model hook.
|
||||||
|
func (p *dfPart) BeforeDelete(*gorm.DB) error {
|
||||||
|
dfPartHooks.add(fmt.Sprintf("BeforeDelete:%d", p.ID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dfMember struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
Email string `gorm:"column:email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dfMember) TableName() string { return "cabana_deferred_members" }
|
||||||
|
func (dfMember) Fillable() []string { return []string{"email"} }
|
||||||
|
func (dfMember) Rules() map[string]string { return map[string]string{"email": "required"} }
|
||||||
|
|
||||||
|
// dfGadgetMember is the members pivot: note comes from the pivot form, role
|
||||||
|
// is the hook column RelationBeforeLink stamps.
|
||||||
|
type dfGadgetMember struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
GadgetID uint `gorm:"column:gadget_id"`
|
||||||
|
MemberID uint `gorm:"column:member_id"`
|
||||||
|
Note string `gorm:"column:note"`
|
||||||
|
Role string `gorm:"column:role"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dfGadgetMember) TableName() string { return "cabana_deferred_gadget_members" }
|
||||||
|
|
||||||
|
// dfRecorder records hook calls and can make a named hook fail.
|
||||||
|
type dfRecorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []string
|
||||||
|
fail map[string]bool
|
||||||
|
// probe, when set, runs inside the Form hooks with the write's
|
||||||
|
// transaction and its result is recorded after the hook name.
|
||||||
|
probe func(tx *gorm.DB) string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *dfRecorder) add(call string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.calls = append(r.calls, call)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *dfRecorder) reset() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.calls = nil
|
||||||
|
r.fail = map[string]bool{}
|
||||||
|
r.probe = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *dfRecorder) failOn(name string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.fail[name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *dfRecorder) snapshot() []string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return append([]string(nil), r.calls...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hook records name (with the probe's result) and fails when asked to.
|
||||||
|
func (r *dfRecorder) hook(ctx context.Context, name string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
probe := r.probe
|
||||||
|
fail := r.fail[name]
|
||||||
|
r.mu.Unlock()
|
||||||
|
call := name
|
||||||
|
if probe != nil {
|
||||||
|
if tx, ok := cabana.TxFromContext(ctx); ok {
|
||||||
|
call += ":" + probe(tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.add(call)
|
||||||
|
if fail {
|
||||||
|
return errors.New("fixture hook " + name + " failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dfPlugin struct{ rec *dfRecorder }
|
||||||
|
|
||||||
|
func (dfPlugin) ID() string { return "acme.deferred" }
|
||||||
|
func (dfPlugin) Requires() []string { return nil }
|
||||||
|
func (dfPlugin) Register(*backpack.App) error { return nil }
|
||||||
|
func (dfPlugin) Boot(*backpack.App) error { return nil }
|
||||||
|
func (p dfPlugin) AdminControllers() []pact.AdminController {
|
||||||
|
return []pact.AdminController{
|
||||||
|
dfController{rec: p.rec, id: "acme.deferred.gadgets", dir: "controllers/gadgets"},
|
||||||
|
dfController{rec: p.rec, id: "acme.deferred.locked", dir: "controllers/locked"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models lists every fixture model deferred:purge may delete.
|
||||||
|
func (dfPlugin) Models() []any { return []any{&dfGadget{}, &dfPart{}, &dfMember{}} }
|
||||||
|
|
||||||
|
func (dfPlugin) Permissions() []pact.Permission {
|
||||||
|
return []pact.Permission{{Code: "acme.deferred.access", Roles: []string{"developer"}}}
|
||||||
|
}
|
||||||
|
func (dfPlugin) Navigation() []pact.NavigationItem {
|
||||||
|
return []pact.NavigationItem{{Code: "deferred", Label: "Deferred", Icon: "box", Order: 10, Controller: "acme.deferred.gadgets",
|
||||||
|
Permissions: []string{"acme.deferred.access"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminFS is the YAML tree under testdata/deferred.
|
||||||
|
func (dfPlugin) AdminFS() fs.FS { return os.DirFS(filepath.Join("testdata", "deferred")) }
|
||||||
|
|
||||||
|
type dfController struct {
|
||||||
|
rec *dfRecorder
|
||||||
|
id string
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c dfController) ID() string { return c.id }
|
||||||
|
func (dfController) ModelName() string { return "Gadget" }
|
||||||
|
func (c dfController) ConfigDir() string { return c.dir }
|
||||||
|
func (dfController) RequiredPermissions() []string { return []string{"acme.deferred.access"} }
|
||||||
|
func (dfController) NewRecord() any { return &dfGadget{} }
|
||||||
|
func (dfController) AdminRelationContracts() []cabana.RelationContract {
|
||||||
|
return []cabana.RelationContract{{
|
||||||
|
Name: "members", NewRelated: func() any { return &dfMember{} }, NewPivot: func() any { return &dfGadgetMember{} },
|
||||||
|
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
|
||||||
|
HookPivotColumns: []string{"role"},
|
||||||
|
}, {
|
||||||
|
Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &dfPart{} },
|
||||||
|
ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormExtendQuery hides gadgets marked hidden.
|
||||||
|
func (dfController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Where("hidden = ?", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelationBeforeLink stamps the server-owned role of a members link.
|
||||||
|
func (c dfController) RelationBeforeLink(ctx context.Context, relation string, _, _ any, pivot map[string]any) error {
|
||||||
|
if relation == "members" {
|
||||||
|
pivot["role"] = "linked"
|
||||||
|
}
|
||||||
|
return c.rec.hook(ctx, "RelationBeforeLink:"+relation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c dfController) FormBeforeCreate(ctx context.Context, _ any) error {
|
||||||
|
return c.rec.hook(ctx, "FormBeforeCreate")
|
||||||
|
}
|
||||||
|
func (c dfController) FormAfterCreate(ctx context.Context, _ any) error {
|
||||||
|
return c.rec.hook(ctx, "FormAfterCreate")
|
||||||
|
}
|
||||||
|
func (c dfController) FormBeforeUpdate(ctx context.Context, _ any) error {
|
||||||
|
return c.rec.hook(ctx, "FormBeforeUpdate")
|
||||||
|
}
|
||||||
|
func (c dfController) FormAfterUpdate(ctx context.Context, _ any) error {
|
||||||
|
return c.rec.hook(ctx, "FormAfterUpdate")
|
||||||
|
}
|
||||||
|
func (c dfController) RelationBeforeCreate(ctx context.Context, relation string, _, _ any) error {
|
||||||
|
return c.rec.hook(ctx, "RelationBeforeCreate:"+relation)
|
||||||
|
}
|
||||||
|
func (c dfController) RelationAfterCreate(ctx context.Context, relation string, _, _ any) error {
|
||||||
|
return c.rec.hook(ctx, "RelationAfterCreate:"+relation)
|
||||||
|
}
|
||||||
|
func (c dfController) RelationBeforeUpdate(ctx context.Context, relation string, _, _ any) error {
|
||||||
|
return c.rec.hook(ctx, "RelationBeforeUpdate:"+relation)
|
||||||
|
}
|
||||||
|
func (c dfController) RelationAfterUpdate(ctx context.Context, relation string, _, _ any) error {
|
||||||
|
return c.rec.hook(ctx, "RelationAfterUpdate:"+relation)
|
||||||
|
}
|
||||||
|
func (c dfController) RelationBeforeDelete(ctx context.Context, relation string, _, _ any) error {
|
||||||
|
return c.rec.hook(ctx, "RelationBeforeDelete:"+relation)
|
||||||
|
}
|
||||||
|
func (c dfController) RelationAfterDelete(ctx context.Context, relation string, _, _ any) error {
|
||||||
|
return c.rec.hook(ctx, "RelationAfterDelete:"+relation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dfEnv is the assembled router over the acme.deferred fixture on the
|
||||||
|
// cabana Postgres harness, with two admins (A and B) holding the
|
||||||
|
// controller permission.
|
||||||
|
type dfEnv struct {
|
||||||
|
h http.Handler
|
||||||
|
db *gorm.DB
|
||||||
|
bucket *blob.Bucket
|
||||||
|
rec *dfRecorder
|
||||||
|
stamp string
|
||||||
|
a, b dfClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// dfClient sends requests as one admin.
|
||||||
|
type dfClient struct {
|
||||||
|
env *dfEnv
|
||||||
|
id uint
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
var dfModels = []any{&dfGadget{}, &dfPart{}, &dfMember{}, &dfGadgetMember{}}
|
||||||
|
|
||||||
|
func newDeferredEnv(t *testing.T) *dfEnv {
|
||||||
|
t.Helper()
|
||||||
|
gdb := adminGorm(t)
|
||||||
|
if err := gdb.Migrator().DropTable(dfModels...); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gdb.AutoMigrate(dfModels...); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Ids restart with the recreated tables: drop the files and bindings an
|
||||||
|
// earlier run left for them.
|
||||||
|
if err := gdb.Exec(`DELETE FROM system_files WHERE attachment_type IN (?, ?) OR attachment_id IS NULL OR attachment_id = ''`, dfGadgetMorph, dfPartMorph).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gdb.Exec(`DELETE FROM deferred_bindings WHERE master_type IN (?, ?)`, dfGadgetMorph, dfPartMorph).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stamp := fmt.Sprintf("d%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-deferred\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)
|
||||||
|
}
|
||||||
|
bucket := memblob.OpenBucket(nil)
|
||||||
|
t.Cleanup(func() { _ = bucket.Close() })
|
||||||
|
if err := attach.Publish(app, bucket); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := &dfRecorder{fail: map[string]bool{}}
|
||||||
|
plugins := []party.Plugin{dfPlugin{rec: rec}}
|
||||||
|
if err := phrasebook.Activate(app, plugins); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h, err := surf.Assemble(app, plugins)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env := &dfEnv{h: h, db: gdb, bucket: bucket, rec: rec, stamp: stamp}
|
||||||
|
env.a = env.login(t, "dfa-"+stamp)
|
||||||
|
env.b = env.login(t, "dfb-"+stamp)
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// login inserts an admin and logs it in.
|
||||||
|
func (e *dfEnv) login(t *testing.T, login string) dfClient {
|
||||||
|
t.Helper()
|
||||||
|
user := insertAdmin(t, e.db, login, login+"@example.test", adminTestPassword, true, false)
|
||||||
|
raw, _ := json.Marshal(map[string]string{"login": login, "password": adminTestPassword})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, adminAPI("/auth/login"), bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login %s status=%d body=%s", login, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
return dfClient{env: e, id: user.ID, token: accessToken(t, rec.Body.Bytes())}
|
||||||
|
}
|
||||||
|
|
||||||
|
// do sends a request as the client. body is nil, raw []byte (with
|
||||||
|
// contentType from headers["Content-Type"]) or a value sent as JSON.
|
||||||
|
func (c dfClient) do(t *testing.T, method, rel string, body any, headers map[string]string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var payload []byte
|
||||||
|
contentType := ""
|
||||||
|
switch b := body.(type) {
|
||||||
|
case nil:
|
||||||
|
case []byte:
|
||||||
|
payload = b
|
||||||
|
default:
|
||||||
|
raw, err := json.Marshal(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
payload = raw
|
||||||
|
contentType = "application/json"
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, adminAPI(rel), bytes.NewReader(payload))
|
||||||
|
if contentType != "" {
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept-Language", "en")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c.env.h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload posts one multipart file_data part to rel.
|
||||||
|
func (c dfClient) upload(t *testing.T, rel, name string, data []byte, headers map[string]string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
part, err := mw.CreateFormFile("file_data", name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := map[string]string{"Content-Type": mw.FormDataContentType()}
|
||||||
|
for k, v := range headers {
|
||||||
|
h[k] = v
|
||||||
|
}
|
||||||
|
return c.do(t, http.MethodPost, rel, buf.Bytes(), h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dfPath is a gadgets controller path: gadget id and the rest.
|
||||||
|
func dfPath(gadget uint, rest string) string {
|
||||||
|
return fmt.Sprintf("/acme/deferred/gadgets/%d%s", gadget, rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dfLockedPath is a locked controller path.
|
||||||
|
func dfLockedPath(gadget uint, rest string) string {
|
||||||
|
return fmt.Sprintf("/acme/deferred/locked/%d%s", gadget, rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sk is the X-Session-Key header map; ck adds X-Child-Session-Key.
|
||||||
|
func sk(key string) map[string]string { return map[string]string{cabana.SessionKeyHeader: key} }
|
||||||
|
func ck(key string) map[string]string { return map[string]string{cabana.ChildSessionKeyHeader: key} }
|
||||||
|
|
||||||
|
// want fails the test unless rec has the status.
|
||||||
|
func want(t *testing.T, what string, rec *httptest.ResponseRecorder, status int) {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != status {
|
||||||
|
t.Fatalf("%s: status=%d want %d body=%s", what, rec.Code, status, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorCode is the error envelope's code.
|
||||||
|
func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||||
|
t.Helper()
|
||||||
|
var body struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("error envelope: %v\n%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
return body.Error.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorDetails is the error envelope's details.
|
||||||
|
func errorDetails(t *testing.T, rec *httptest.ResponseRecorder) map[string][]string {
|
||||||
|
t.Helper()
|
||||||
|
var body struct {
|
||||||
|
Error struct {
|
||||||
|
Details map[string][]string `json:"details"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("error envelope: %v\n%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
return body.Error.Details
|
||||||
|
}
|
||||||
|
|
||||||
|
// gadget inserts a gadget straight into the table.
|
||||||
|
func (e *dfEnv) gadget(t *testing.T, name string, hidden bool) uint {
|
||||||
|
t.Helper()
|
||||||
|
g := dfGadget{Name: name, Hidden: hidden}
|
||||||
|
if err := e.db.Create(&g).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hidden {
|
||||||
|
if err := e.db.Model(&g).Update("hidden", true).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// member inserts a member straight into the table.
|
||||||
|
func (e *dfEnv) member(t *testing.T, email string) uint {
|
||||||
|
t.Helper()
|
||||||
|
m := dfMember{Email: email}
|
||||||
|
if err := e.db.Create(&m).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return m.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// part inserts a part owned by gadget (0 for none) straight into the table.
|
||||||
|
func (e *dfEnv) part(t *testing.T, gadget uint, label string) uint {
|
||||||
|
t.Helper()
|
||||||
|
p := dfPart{Label: label}
|
||||||
|
if gadget > 0 {
|
||||||
|
p.GadgetID = &gadget
|
||||||
|
}
|
||||||
|
if err := e.db.Create(&p).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// pivot links a member to a gadget straight in the pivot table.
|
||||||
|
func (e *dfEnv) pivot(t *testing.T, gadget, member uint, note string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := e.db.Create(&dfGadgetMember{GadgetID: gadget, MemberID: member, Note: note, Role: "seed"}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// partRow reads a part, soft-deleted rows included; ok is false when the
|
||||||
|
// row is gone.
|
||||||
|
func (e *dfEnv) partRow(t *testing.T, id uint) (dfPart, bool) {
|
||||||
|
t.Helper()
|
||||||
|
var p dfPart
|
||||||
|
err := e.db.Unscoped().Where("id = ?", id).Take(&p).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return dfPart{}, false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeFile stores data as a file of the owner (attached when ownerID is
|
||||||
|
// set) through attach.Store, bypassing the routes.
|
||||||
|
func (e *dfEnv) storeFile(t *testing.T, morph string, ownerID uint, field, name string, data []byte, public bool) attach.File {
|
||||||
|
t.Helper()
|
||||||
|
f, err := attach.Store(context.Background(), e.db, e.bucket, attach.Upload{FileName: name, Body: bytes.NewReader(data), Public: public}, attach.Limits{Extensions: []string{"png", "gif", "jpg", "webp", "svg", "html", "txt", "pdf"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if ownerID > 0 {
|
||||||
|
if err := e.db.Model(&attach.File{}).Where("id = ?", f.ID).
|
||||||
|
Updates(map[string]any{"attachment_type": morph, "attachment_id": fmt.Sprint(ownerID), "field": field}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out attach.File
|
||||||
|
if err := e.db.Where("id = ?", f.ID).Take(&out).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dfState is a snapshot of every fixture table, the files and the
|
||||||
|
// bindings, compared before and after a refused request.
|
||||||
|
type dfState struct {
|
||||||
|
Gadgets []dfGadget
|
||||||
|
Parts []dfPart
|
||||||
|
Members []dfMember
|
||||||
|
Pivots []dfGadgetMember
|
||||||
|
Files []attach.File
|
||||||
|
Bindings []lagoon.DeferredBinding
|
||||||
|
Blobs int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *dfEnv) state(t *testing.T) dfState {
|
||||||
|
t.Helper()
|
||||||
|
var s dfState
|
||||||
|
for _, q := range []struct {
|
||||||
|
dest any
|
||||||
|
order string
|
||||||
|
}{{&s.Gadgets, "id"}, {&s.Members, "id"}, {&s.Pivots, "id"}, {&s.Files, "id"}, {&s.Bindings, "id"}} {
|
||||||
|
if err := e.db.Order(q.order).Find(q.dest).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := e.db.Unscoped().Order("id").Find(&s.Parts).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
iter := e.bucket.List(nil)
|
||||||
|
for {
|
||||||
|
if _, err := iter.Next(context.Background()); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.Blobs++
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// unchanged fails unless the state equals before.
|
||||||
|
func (e *dfEnv) unchanged(t *testing.T, what string, before dfState) {
|
||||||
|
t.Helper()
|
||||||
|
after := e.state(t)
|
||||||
|
a, _ := json.Marshal(before)
|
||||||
|
b, _ := json.Marshal(after)
|
||||||
|
if !bytes.Equal(a, b) {
|
||||||
|
t.Fatalf("%s changed the database:\nbefore %s\nafter %s", what, a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dfIDs lists the data[].id values of a list response.
|
||||||
|
func dfIDs(t *testing.T, rec *httptest.ResponseRecorder) []uint {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Data []struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := make([]uint, 0, len(body.Data))
|
||||||
|
for _, row := range body.Data {
|
||||||
|
out = append(out, row.ID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
213
modules/cabana/protected_file_test.go
Normal file
213
modules/cabana/protected_file_test.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/gif"
|
||||||
|
"image/jpeg"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dfWebP is a 16x12 lossless WebP (the attach package's thumbnail fixture).
|
||||||
|
var dfWebP = []byte{
|
||||||
|
0x52, 0x49, 0x46, 0x46, 0x2a, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x4c,
|
||||||
|
0x1d, 0x00, 0x00, 0x00, 0x2f, 0x0f, 0xc0, 0x02, 0x00, 0x0f, 0x70, 0x14, 0xfb, 0x53, 0xd0, 0x5e,
|
||||||
|
0x88, 0x7b, 0xfe, 0x83, 0x07, 0x62, 0xc1, 0x64, 0xfe, 0xd2, 0xbd, 0x21, 0x44, 0xf4, 0x3f, 0x74,
|
||||||
|
0x01, 0x00,
|
||||||
|
}
|
||||||
|
|
||||||
|
func dfGIF(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewPaletted(image.Rect(0, 0, 4, 3), []color.Color{color.Black, color.White})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := gif.Encode(&buf, img, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dfJPEG(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 4, 3))
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// securityHeaders checks the D-10 headers every protected file response
|
||||||
|
// carries.
|
||||||
|
func securityHeaders(t *testing.T, what string, rec *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
h := rec.Header()
|
||||||
|
if h.Get("X-Content-Type-Options") != "nosniff" || h.Get("Cache-Control") != "private, no-store" ||
|
||||||
|
h.Get("Content-Security-Policy") != "default-src 'none'; sandbox" {
|
||||||
|
t.Fatalf("%s security headers = %v", what, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// manualPath is a gadget's protected manual file route.
|
||||||
|
func manualPath(gadget, file uint, rest string) string {
|
||||||
|
return dfPath(gadget, fmt.Sprintf("/files/manual/%d%s", file, rest))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtectedFileScope: the protected download and thumb routes answer
|
||||||
|
// 404 for another gadget's file, for another admin's pending file on id 0
|
||||||
|
// and for any is_public=true row; the caption, remove and reorder routes
|
||||||
|
// answer 404 for another gadget's file id and change nothing (D-10, D-15).
|
||||||
|
func TestProtectedFileScope(t *testing.T) {
|
||||||
|
env := newDeferredEnv(t)
|
||||||
|
g1 := env.gadget(t, "g1-"+env.stamp, false)
|
||||||
|
g2 := env.gadget(t, "g2-"+env.stamp, false)
|
||||||
|
f2 := env.storeFile(t, dfGadgetMorph, g2, "manual", "g2.png", conformPNG(t), false)
|
||||||
|
p2 := env.storeFile(t, dfGadgetMorph, g2, "photos", "g2-photo.png", conformPNG(t), true)
|
||||||
|
|
||||||
|
// The owner serves both (the thumb is generated and stored here, before
|
||||||
|
// the snapshot).
|
||||||
|
for _, rest := range []string{"/download", "/thumb"} {
|
||||||
|
want(t, "G2's manual through G2"+rest, env.a.do(t, http.MethodGet, manualPath(g2, f2.ID, rest), nil, nil), http.StatusOK)
|
||||||
|
}
|
||||||
|
before := env.state(t)
|
||||||
|
for _, rest := range []string{"/download", "/thumb"} {
|
||||||
|
want(t, "G2's manual through G1"+rest, env.a.do(t, http.MethodGet, manualPath(g1, f2.ID, rest), nil, nil), http.StatusNotFound)
|
||||||
|
}
|
||||||
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"caption": env.a.do(t, http.MethodPut, manualPath(g1, f2.ID, ""), map[string]any{"title": "stolen"}, sk(newSessionKey(t))),
|
||||||
|
"remove": env.a.do(t, http.MethodDelete, manualPath(g1, f2.ID, ""), nil, sk(newSessionKey(t))),
|
||||||
|
} {
|
||||||
|
want(t, name+" of G2's file through G1", rec, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
// Reorder takes the whole id set: G2's photo id is not in G1's set, so
|
||||||
|
// it is the same 422 set mismatch as an id that exists nowhere (no
|
||||||
|
// existence oracle), and nothing is reordered.
|
||||||
|
foreign := env.a.do(t, http.MethodPost, dfPath(g1, "/files/photos/reorder"), map[string]any{"ids": []uint{p2.ID}}, sk(newSessionKey(t)))
|
||||||
|
nowhere := env.a.do(t, http.MethodPost, dfPath(g1, "/files/photos/reorder"), map[string]any{"ids": []uint{p2.ID + 1000}}, sk(newSessionKey(t)))
|
||||||
|
want(t, "reorder with G2's photo through G1", foreign, http.StatusUnprocessableEntity)
|
||||||
|
if foreign.Body.String() != nowhere.Body.String() || nowhere.Code != foreign.Code {
|
||||||
|
t.Fatalf("reorder answers differ: foreign %d %s, nowhere %d %s", foreign.Code, foreign.Body.String(), nowhere.Code, nowhere.Body.String())
|
||||||
|
}
|
||||||
|
env.unchanged(t, "foreign file requests", before)
|
||||||
|
|
||||||
|
t.Run("public rows", func(t *testing.T) {
|
||||||
|
// A public photo, and a public row attached under the protected
|
||||||
|
// field, are never served by the protected routes.
|
||||||
|
mixed := env.storeFile(t, dfGadgetMorph, g2, "manual", "public.png", conformPNG(t), true)
|
||||||
|
for _, path := range []string{
|
||||||
|
dfPath(g2, fmt.Sprintf("/files/photos/%d/download", p2.ID)),
|
||||||
|
dfPath(g2, fmt.Sprintf("/files/photos/%d/thumb", p2.ID)),
|
||||||
|
manualPath(g2, mixed.ID, "/download"),
|
||||||
|
manualPath(g2, mixed.ID, "/thumb"),
|
||||||
|
} {
|
||||||
|
want(t, path, env.a.do(t, http.MethodGet, path, nil, nil), http.StatusNotFound)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pending file of another admin", func(t *testing.T) {
|
||||||
|
key := newSessionKey(t)
|
||||||
|
up := env.a.upload(t, dfPath(0, "/files/manual"), "pending.png", conformPNG(t), sk(key))
|
||||||
|
want(t, "A uploads to id 0", up, http.StatusCreated)
|
||||||
|
id := dataID(t, up.Body.Bytes())
|
||||||
|
want(t, "A downloads its pending file", env.a.do(t, http.MethodGet, manualPath(0, id, "/download"), nil, sk(key)), http.StatusOK)
|
||||||
|
before := env.state(t)
|
||||||
|
for _, rest := range []string{"/download", "/thumb"} {
|
||||||
|
want(t, "B downloads A's pending file"+rest, env.b.do(t, http.MethodGet, manualPath(0, id, rest), nil, sk(key)), http.StatusNotFound)
|
||||||
|
}
|
||||||
|
want(t, "B removes A's pending file", env.b.do(t, http.MethodDelete, manualPath(0, id, ""), nil, sk(key)), http.StatusNotFound)
|
||||||
|
want(t, "B captions A's pending file", env.b.do(t, http.MethodPut, manualPath(0, id, ""), map[string]any{"title": "x"}, sk(key)), http.StatusNotFound)
|
||||||
|
if got := fileList(t, env.b.do(t, http.MethodGet, dfPath(0, "/files/manual"), nil, sk(key))); len(got) != 0 {
|
||||||
|
t.Fatalf("B lists A's pending files %#v", got)
|
||||||
|
}
|
||||||
|
env.unchanged(t, "B's file requests with A's key", before)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtectedFileHeaders: only jpeg, png, gif and webp are served inline
|
||||||
|
// with their own type; SVG, HTML and text download as an octet-stream
|
||||||
|
// attachment. Every response carries nosniff, private no-store and the
|
||||||
|
// sandbox CSP (D-10).
|
||||||
|
func TestProtectedFileHeaders(t *testing.T) {
|
||||||
|
env := newDeferredEnv(t)
|
||||||
|
g := env.gadget(t, "g-"+env.stamp, false)
|
||||||
|
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
|
||||||
|
html := []byte("<!DOCTYPE html><html><body><script>alert(1)</script></body></html>")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
data []byte
|
||||||
|
inline string
|
||||||
|
filename string
|
||||||
|
}{
|
||||||
|
{"logo one.svg", svg, "", "logo%20one.svg"},
|
||||||
|
{"page.html", html, "", "page.html"},
|
||||||
|
{"notes.txt", []byte("plain text\n"), "", "notes.txt"},
|
||||||
|
{"shot.png", conformPNG(t), "image/png", ""},
|
||||||
|
{"anim.gif", dfGIF(t), "image/gif", ""},
|
||||||
|
{"photo.jpg", dfJPEG(t), "image/jpeg", ""},
|
||||||
|
{"pic.webp", dfWebP, "image/webp", ""},
|
||||||
|
} {
|
||||||
|
f := env.storeFile(t, dfGadgetMorph, g, "manual", tc.name, tc.data, false)
|
||||||
|
rec := env.a.do(t, http.MethodGet, manualPath(g, f.ID, "/download"), nil, nil)
|
||||||
|
want(t, tc.name, rec, http.StatusOK)
|
||||||
|
securityHeaders(t, tc.name, rec)
|
||||||
|
h := rec.Header()
|
||||||
|
if !bytes.Equal(rec.Body.Bytes(), tc.data) {
|
||||||
|
t.Fatalf("%s body differs", tc.name)
|
||||||
|
}
|
||||||
|
if tc.inline != "" {
|
||||||
|
if h.Get("Content-Type") != tc.inline || h.Get("Content-Disposition") != "" {
|
||||||
|
t.Fatalf("%s inline headers = %v", tc.name, h)
|
||||||
|
}
|
||||||
|
thumb := env.a.do(t, http.MethodGet, manualPath(g, f.ID, "/thumb"), nil, nil)
|
||||||
|
want(t, tc.name+" thumb", thumb, http.StatusOK)
|
||||||
|
securityHeaders(t, tc.name+" thumb", thumb)
|
||||||
|
if ct := thumb.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/") {
|
||||||
|
t.Fatalf("%s thumb content type %q", tc.name, ct)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if h.Get("Content-Type") != "application/octet-stream" || h.Get("Content-Disposition") != "attachment; filename*=UTF-8''"+tc.filename {
|
||||||
|
t.Fatalf("%s attachment headers = %v", tc.name, h)
|
||||||
|
}
|
||||||
|
want(t, tc.name+" thumb", env.a.do(t, http.MethodGet, manualPath(g, f.ID, "/thumb"), nil, nil), http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtectedFileListHasNoURLs: the file list of the protected manual
|
||||||
|
// field carries no url or thumb_url key, while the public photos list does.
|
||||||
|
func TestProtectedFileListHasNoURLs(t *testing.T) {
|
||||||
|
env := newDeferredEnv(t)
|
||||||
|
g := env.gadget(t, "g-"+env.stamp, false)
|
||||||
|
env.storeFile(t, dfGadgetMorph, g, "manual", "m.png", conformPNG(t), false)
|
||||||
|
env.storeFile(t, dfGadgetMorph, g, "photos", "p.png", conformPNG(t), true)
|
||||||
|
|
||||||
|
keys := func(field string) []map[string]any {
|
||||||
|
rec := env.a.do(t, http.MethodGet, dfPath(g, "/files/"+field), nil, nil)
|
||||||
|
want(t, field+" list", rec, http.StatusOK)
|
||||||
|
var body struct {
|
||||||
|
Data []map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body.Data) != 1 {
|
||||||
|
t.Fatalf("%s list = %s (%v)", field, rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return body.Data
|
||||||
|
}
|
||||||
|
for _, item := range keys("manual") {
|
||||||
|
if _, ok := item["url"]; ok {
|
||||||
|
t.Fatalf("protected item has url: %v", item)
|
||||||
|
}
|
||||||
|
if _, ok := item["thumb_url"]; ok {
|
||||||
|
t.Fatalf("protected item has thumb_url: %v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range keys("photos") {
|
||||||
|
if item["url"] == nil || item["thumb_url"] == nil {
|
||||||
|
t.Fatalf("public item lacks urls: %v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
324
modules/cabana/relation_child_scope_test.go
Normal file
324
modules/cabana/relation_child_scope_test.go
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
)
|
||||||
|
|
||||||
|
// childRoute is one relation child route of the D-15 security suite: it
|
||||||
|
// is called through parent P for child C, pivot member M and child file F.
|
||||||
|
type childRoute struct {
|
||||||
|
name string
|
||||||
|
call func(t *testing.T, c dfClient, p, child, member, file uint, key string) *httptest.ResponseRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// childRoutes are every child route of plan 03: records GET and PUT,
|
||||||
|
// delete, pivot GET and PUT, and the seven child file routes under
|
||||||
|
// records/{child}/files/{field}. The order lets a positive control run
|
||||||
|
// them all on one parent (the delete comes last).
|
||||||
|
func childRoutes(t *testing.T) []childRoute {
|
||||||
|
png := conformPNG(t)
|
||||||
|
filePath := func(p, child uint, rest string) string {
|
||||||
|
return dfPath(p, fmt.Sprintf("/relations/parts/records/%d/files/images%s", child, rest))
|
||||||
|
}
|
||||||
|
keys := func(key string) map[string]string {
|
||||||
|
h := ck(key)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
return []childRoute{
|
||||||
|
{"GET records/{child}", func(t *testing.T, c dfClient, p, child, _, _ uint, _ string) *httptest.ResponseRecorder {
|
||||||
|
return c.do(t, http.MethodGet, dfPath(p, fmt.Sprintf("/relations/parts/records/%d", child)), nil, nil)
|
||||||
|
}},
|
||||||
|
{"PUT records/{child}", func(t *testing.T, c dfClient, p, child, _, _ uint, _ string) *httptest.ResponseRecorder {
|
||||||
|
return c.do(t, http.MethodPut, dfPath(p, fmt.Sprintf("/relations/parts/records/%d", child)), map[string]any{"label": "stolen"}, nil)
|
||||||
|
}},
|
||||||
|
{"GET pivot/{child}", func(t *testing.T, c dfClient, p, _, member, _ uint, _ string) *httptest.ResponseRecorder {
|
||||||
|
return c.do(t, http.MethodGet, dfPath(p, fmt.Sprintf("/relations/members/pivot/%d", member)), nil, nil)
|
||||||
|
}},
|
||||||
|
{"PUT pivot/{child}", func(t *testing.T, c dfClient, p, _, member, _ uint, _ string) *httptest.ResponseRecorder {
|
||||||
|
return c.do(t, http.MethodPut, dfPath(p, fmt.Sprintf("/relations/members/pivot/%d", member)), map[string]any{"note": "stolen"}, nil)
|
||||||
|
}},
|
||||||
|
{"GET records/{child}/files/{field}", func(t *testing.T, c dfClient, p, child, _, _ uint, key string) *httptest.ResponseRecorder {
|
||||||
|
return c.do(t, http.MethodGet, filePath(p, child, ""), nil, keys(key))
|
||||||
|
}},
|
||||||
|
{"POST records/{child}/files/{field}/reorder", func(t *testing.T, c dfClient, p, child, _, file uint, key string) *httptest.ResponseRecorder {
|
||||||
|
return c.do(t, http.MethodPost, filePath(p, child, "/reorder"), map[string]any{"ids": []uint{file}}, keys(key))
|
||||||
|
}},
|
||||||
|
{"PUT records/{child}/files/{field}/{file}", func(t *testing.T, c dfClient, p, child, _, file uint, key string) *httptest.ResponseRecorder {
|
||||||
|
return c.do(t, http.MethodPut, filePath(p, child, fmt.Sprintf("/%d", file)), map[string]any{"title": "stolen"}, keys(key))
|
||||||
|
}},
|
||||||
|
{"GET records/{child}/files/{field}/{file}/download", func(t *testing.T, c dfClient, p, child, _, file uint, key string) *httptest.ResponseRecorder {
|
||||||
|
return c.do(t, http.MethodGet, filePath(p, child, fmt.Sprintf("/%d/download", file)), nil, keys(key))
|
||||||
|
}},
|
||||||
|
{"GET records/{child}/files/{field}/{file}/thumb", func(t *testing.T, c dfClient, p, child, _, file uint, key string) *httptest.ResponseRecorder {
|
||||||
|
return c.do(t, http.MethodGet, filePath(p, child, fmt.Sprintf("/%d/thumb", file)), nil, keys(key))
|
||||||
|
}},
|
||||||
|
{"POST records/{child}/files/{field}", func(t *testing.T, c dfClient, p, child, _, _ uint, key string) *httptest.ResponseRecorder {
|
||||||
|
return c.upload(t, filePath(p, child, ""), "stolen.png", png, keys(key))
|
||||||
|
}},
|
||||||
|
{"DELETE records/{child}/files/{field}/{file}", func(t *testing.T, c dfClient, p, child, _, file uint, key string) *httptest.ResponseRecorder {
|
||||||
|
return c.do(t, http.MethodDelete, filePath(p, child, fmt.Sprintf("/%d", file)), nil, keys(key))
|
||||||
|
}},
|
||||||
|
{"POST delete", func(t *testing.T, c dfClient, p, child, _, _ uint, _ string) *httptest.ResponseRecorder {
|
||||||
|
return c.do(t, http.MethodPost, dfPath(p, "/relations/parts/delete"), map[string]any{"ids": []uint{child}}, nil)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRelationChildScope proves D-15 through the assembled router: a part,
|
||||||
|
// a member pivot row or a part's file of gadget G2 requested through G1,
|
||||||
|
// and a child of a gadget FormExtendQuery hides, answer 404 not_found on
|
||||||
|
// every child route and change nothing. A positive control runs the same
|
||||||
|
// routes on the owning parent, so each 404 comes from the parent scope.
|
||||||
|
func TestRelationChildScope(t *testing.T) {
|
||||||
|
env := newDeferredEnv(t)
|
||||||
|
g1 := env.gadget(t, "g1-"+env.stamp, false)
|
||||||
|
g2 := env.gadget(t, "g2-"+env.stamp, false)
|
||||||
|
g3 := env.gadget(t, "g3-"+env.stamp, true)
|
||||||
|
env.part(t, g1, "p1")
|
||||||
|
p2 := env.part(t, g2, "p2")
|
||||||
|
p3 := env.part(t, g3, "p3")
|
||||||
|
m := env.member(t, "m-"+env.stamp+"@example.test")
|
||||||
|
env.pivot(t, g2, m, "n2")
|
||||||
|
env.pivot(t, g3, m, "n3")
|
||||||
|
f2 := env.storeFile(t, dfPartMorph, p2, "images", "p2.png", conformPNG(t), false)
|
||||||
|
f3 := env.storeFile(t, dfPartMorph, p3, "images", "p3.png", conformPNG(t), false)
|
||||||
|
routes := childRoutes(t)
|
||||||
|
|
||||||
|
t.Run("another parent", func(t *testing.T) {
|
||||||
|
for _, r := range routes {
|
||||||
|
before := env.state(t)
|
||||||
|
rec := r.call(t, env.a, g1, p2, m, f2.ID, newSessionKey(t))
|
||||||
|
want(t, r.name+" through G1", rec, http.StatusNotFound)
|
||||||
|
if code := errorCode(t, rec); code != "not_found" {
|
||||||
|
t.Fatalf("%s code=%q want not_found", r.name, code)
|
||||||
|
}
|
||||||
|
env.unchanged(t, r.name+" through G1", before)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("hidden parent", func(t *testing.T) {
|
||||||
|
for _, r := range routes {
|
||||||
|
before := env.state(t)
|
||||||
|
rec := r.call(t, env.a, g3, p3, m, f3.ID, newSessionKey(t))
|
||||||
|
want(t, r.name+" through hidden G3", rec, http.StatusNotFound)
|
||||||
|
if code := errorCode(t, rec); code != "not_found" {
|
||||||
|
t.Fatalf("%s code=%q want not_found", r.name, code)
|
||||||
|
}
|
||||||
|
env.unchanged(t, r.name+" through hidden G3", before)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("owning parent control", func(t *testing.T) {
|
||||||
|
g4 := env.gadget(t, "g4-"+env.stamp, false)
|
||||||
|
p4 := env.part(t, g4, "p4")
|
||||||
|
m4 := env.member(t, "m4-"+env.stamp+"@example.test")
|
||||||
|
env.pivot(t, g4, m4, "n4")
|
||||||
|
f4 := env.storeFile(t, dfPartMorph, p4, "images", "p4.png", conformPNG(t), false)
|
||||||
|
key := newSessionKey(t)
|
||||||
|
statuses := map[string]int{"POST records/{child}/files/{field}": http.StatusCreated}
|
||||||
|
for _, r := range routes {
|
||||||
|
status := http.StatusOK
|
||||||
|
if s, ok := statuses[r.name]; ok {
|
||||||
|
status = s
|
||||||
|
}
|
||||||
|
want(t, r.name+" through the owner", r.call(t, env.a, g4, p4, m4, f4.ID, key), status)
|
||||||
|
}
|
||||||
|
if p, ok := env.partRow(t, p4); !ok || !p.DeletedAt.Valid {
|
||||||
|
t.Fatalf("control delete left part %+v (present=%v), want soft deleted", p, ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRelationChildScopeSessionKey covers record id 0 (D-02, D-15): without
|
||||||
|
// X-Session-Key every relation route is 404, a malformed key is 422 on
|
||||||
|
// session_key, and admin B replaying admin A's key sees an empty linked
|
||||||
|
// list, gets 404 on A's pending part and pivot on every child route, and
|
||||||
|
// commits nothing of A's on its own save.
|
||||||
|
func TestRelationChildScopeSessionKey(t *testing.T) {
|
||||||
|
env := newDeferredEnv(t)
|
||||||
|
key := newSessionKey(t)
|
||||||
|
m := env.member(t, "m-"+env.stamp+"@example.test")
|
||||||
|
|
||||||
|
created := env.a.do(t, http.MethodPost, dfPath(0, "/relations/parts/records"), map[string]any{"label": "pending-" + env.stamp}, sk(key))
|
||||||
|
want(t, "A creates a pending part", created, http.StatusCreated)
|
||||||
|
pp := dataID(t, created.Body.Bytes())
|
||||||
|
want(t, "A links a member with a pivot note", env.a.do(t, http.MethodPost, dfPath(0, "/relations/members/link"), map[string]any{"ids": []uint{m}, "pivot": map[string]any{"note": "pending"}}, sk(key)), http.StatusOK)
|
||||||
|
childKey := newSessionKey(t)
|
||||||
|
upHeaders := map[string]string{cabana.SessionKeyHeader: key, cabana.ChildSessionKeyHeader: childKey}
|
||||||
|
up := env.a.upload(t, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images", pp)), "pending.png", conformPNG(t), upHeaders)
|
||||||
|
want(t, "A uploads a file to the pending part", up, http.StatusCreated)
|
||||||
|
pendingFile := dataID(t, up.Body.Bytes())
|
||||||
|
if got := dfIDs(t, env.a.do(t, http.MethodGet, dfPath(0, "/relations/parts"), nil, sk(key))); !slices.Equal(got, []uint{pp}) {
|
||||||
|
t.Fatalf("A's pending parts = %v, want [%d]", got, pp)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("no key", func(t *testing.T) {
|
||||||
|
before := env.state(t)
|
||||||
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"linked parts": env.a.do(t, http.MethodGet, dfPath(0, "/relations/parts"), nil, nil),
|
||||||
|
"candidates": env.a.do(t, http.MethodGet, dfPath(0, "/relations/parts/candidates"), nil, nil),
|
||||||
|
"create part": env.a.do(t, http.MethodPost, dfPath(0, "/relations/parts/records"), map[string]any{"label": "x"}, nil),
|
||||||
|
"show part": env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d", pp)), nil, nil),
|
||||||
|
"update part": env.a.do(t, http.MethodPut, dfPath(0, fmt.Sprintf("/relations/parts/records/%d", pp)), map[string]any{"label": "x"}, nil),
|
||||||
|
"delete part": env.a.do(t, http.MethodPost, dfPath(0, "/relations/parts/delete"), map[string]any{"ids": []uint{pp}}, nil),
|
||||||
|
"link member": env.a.do(t, http.MethodPost, dfPath(0, "/relations/members/link"), map[string]any{"ids": []uint{m}}, nil),
|
||||||
|
"unlink member": env.a.do(t, http.MethodPost, dfPath(0, "/relations/members/unlink"), map[string]any{"ids": []uint{m}}, nil),
|
||||||
|
"pivot show": env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), nil, nil),
|
||||||
|
"pivot update": env.a.do(t, http.MethodPut, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), map[string]any{"note": "x"}, nil),
|
||||||
|
"child files": env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images", pp)), nil, ck(childKey)),
|
||||||
|
"record files": env.a.do(t, http.MethodGet, dfPath(0, "/files/photos"), nil, nil),
|
||||||
|
"child download": env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images/%d/download", pp, pendingFile)), nil, ck(childKey)),
|
||||||
|
} {
|
||||||
|
want(t, name+" on id 0 without a key", rec, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
env.unchanged(t, "id 0 without a key", before)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("malformed key", func(t *testing.T) {
|
||||||
|
bad := sk("short")
|
||||||
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"linked parts": env.a.do(t, http.MethodGet, dfPath(0, "/relations/parts"), nil, bad),
|
||||||
|
"create part": env.a.do(t, http.MethodPost, dfPath(0, "/relations/parts/records"), map[string]any{"label": "x"}, bad),
|
||||||
|
"show part": env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d", pp)), nil, bad),
|
||||||
|
"pivot show": env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), nil, bad),
|
||||||
|
"record files": env.a.do(t, http.MethodGet, dfPath(0, "/files/photos"), nil, bad),
|
||||||
|
} {
|
||||||
|
want(t, name+" with a malformed key", rec, http.StatusUnprocessableEntity)
|
||||||
|
if d := errorDetails(t, rec); len(d["session_key"]) == 0 {
|
||||||
|
t.Fatalf("%s details = %v, want session_key", name, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("foreign admin", func(t *testing.T) {
|
||||||
|
before := env.state(t)
|
||||||
|
if got := dfIDs(t, env.b.do(t, http.MethodGet, dfPath(0, "/relations/parts"), nil, sk(key))); len(got) != 0 {
|
||||||
|
t.Fatalf("B sees A's pending parts %v", got)
|
||||||
|
}
|
||||||
|
if got := dfIDs(t, env.b.do(t, http.MethodGet, dfPath(0, "/relations/members"), nil, sk(key))); len(got) != 0 {
|
||||||
|
t.Fatalf("B sees A's pending members %v", got)
|
||||||
|
}
|
||||||
|
both := map[string]string{cabana.SessionKeyHeader: key, cabana.ChildSessionKeyHeader: childKey}
|
||||||
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"show": env.b.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d", pp)), nil, sk(key)),
|
||||||
|
"update": env.b.do(t, http.MethodPut, dfPath(0, fmt.Sprintf("/relations/parts/records/%d", pp)), map[string]any{"label": "stolen"}, sk(key)),
|
||||||
|
"delete": env.b.do(t, http.MethodPost, dfPath(0, "/relations/parts/delete"), map[string]any{"ids": []uint{pp}}, sk(key)),
|
||||||
|
"pivot show": env.b.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), nil, sk(key)),
|
||||||
|
"pivot update": env.b.do(t, http.MethodPut, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), map[string]any{"note": "stolen"}, sk(key)),
|
||||||
|
"child files": env.b.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images", pp)), nil, both),
|
||||||
|
"child upload": env.b.upload(t, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images", pp)), "x.png", conformPNG(t), both),
|
||||||
|
"child remove": env.b.do(t, http.MethodDelete, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images/%d", pp, pendingFile)), nil, both),
|
||||||
|
"child caption": env.b.do(t, http.MethodPut, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images/%d", pp, pendingFile)),
|
||||||
|
map[string]any{"title": "stolen"}, both),
|
||||||
|
"child download": env.b.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images/%d/download", pp, pendingFile)), nil, both),
|
||||||
|
"child thumb": env.b.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images/%d/thumb", pp, pendingFile)), nil, both),
|
||||||
|
} {
|
||||||
|
want(t, "B "+name+" of A's pending part", rec, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
// Unlink on an unsaved parent only cancels the caller's own binds.
|
||||||
|
want(t, "B unlinks A's pending part", env.b.do(t, http.MethodPost, dfPath(0, "/relations/parts/unlink"), map[string]any{"ids": []uint{pp}}, sk(key)), http.StatusOK)
|
||||||
|
env.unchanged(t, "B's requests with A's key", before)
|
||||||
|
|
||||||
|
// B's save with A's key applies none of A's bindings.
|
||||||
|
saved := env.b.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "b-" + env.stamp}, sk(key))
|
||||||
|
want(t, "B saves with A's key", saved, http.StatusCreated)
|
||||||
|
bg := dataID(t, saved.Body.Bytes())
|
||||||
|
if p, _ := env.partRow(t, pp); p.GadgetID != nil {
|
||||||
|
t.Fatalf("B's save adopted A's pending part into %d", *p.GadgetID)
|
||||||
|
}
|
||||||
|
var pivots int64
|
||||||
|
if err := env.db.Model(&dfGadgetMember{}).Where("gadget_id = ?", bg).Count(&pivots).Error; err != nil || pivots != 0 {
|
||||||
|
t.Fatalf("B's save linked %d members (%v)", pivots, err)
|
||||||
|
}
|
||||||
|
after := env.state(t)
|
||||||
|
if len(after.Bindings) != len(before.Bindings) {
|
||||||
|
t.Fatalf("bindings %d -> %d after B's save", len(before.Bindings), len(after.Bindings))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("owner commits", func(t *testing.T) {
|
||||||
|
saved := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "a-" + env.stamp}, sk(key))
|
||||||
|
want(t, "A saves with its key", saved, http.StatusCreated)
|
||||||
|
ag := dataID(t, saved.Body.Bytes())
|
||||||
|
if p, _ := env.partRow(t, pp); p.GadgetID == nil || *p.GadgetID != ag {
|
||||||
|
t.Fatalf("A's pending part owner = %v, want %d", p.GadgetID, ag)
|
||||||
|
}
|
||||||
|
var row dfGadgetMember
|
||||||
|
if err := env.db.Where("gadget_id = ? AND member_id = ?", ag, m).Take(&row).Error; err != nil || row.Note != "pending" || row.Role != "linked" {
|
||||||
|
t.Fatalf("A's pivot row = %+v (%v)", row, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRelationChildScopeToolbar: on the locked controller (parts: link;
|
||||||
|
// members: unlink) every route of an undeclared button answers 403 before
|
||||||
|
// any SQL runs: the parent id does not exist, so a route that reached the
|
||||||
|
// database would answer 404. A relation without a manage form has no child
|
||||||
|
// file routes (404).
|
||||||
|
func TestRelationChildScopeToolbar(t *testing.T) {
|
||||||
|
env := newDeferredEnv(t)
|
||||||
|
const missing = 999999
|
||||||
|
before := env.state(t)
|
||||||
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
|
"parts create": env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/parts/records"), map[string]any{"label": "x"}, nil),
|
||||||
|
"parts show": env.a.do(t, http.MethodGet, dfLockedPath(missing, "/relations/parts/records/1"), nil, nil),
|
||||||
|
"parts update": env.a.do(t, http.MethodPut, dfLockedPath(missing, "/relations/parts/records/1"), map[string]any{"label": "x"}, nil),
|
||||||
|
"parts delete": env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/parts/delete"), map[string]any{"ids": []uint{1}}, nil),
|
||||||
|
"parts unlink": env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/parts/unlink"), map[string]any{"ids": []uint{1}}, nil),
|
||||||
|
"members create": env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/members/records"), map[string]any{"email": "x"}, nil),
|
||||||
|
"members update": env.a.do(t, http.MethodPut, dfLockedPath(missing, "/relations/members/records/1"), map[string]any{"email": "x"}, nil),
|
||||||
|
"members delete": env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/members/delete"), map[string]any{"ids": []uint{1}}, nil),
|
||||||
|
"members link": env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/members/link"), map[string]any{"ids": []uint{1}}, nil),
|
||||||
|
"pivot show": env.a.do(t, http.MethodGet, dfLockedPath(missing, "/relations/members/pivot/1"), nil, nil),
|
||||||
|
"pivot update": env.a.do(t, http.MethodPut, dfLockedPath(missing, "/relations/members/pivot/1"), map[string]any{"note": "x"}, nil),
|
||||||
|
} {
|
||||||
|
want(t, name+" without its button", rec, http.StatusForbidden)
|
||||||
|
if code := errorCode(t, rec); code != "forbidden" {
|
||||||
|
t.Fatalf("%s code=%q want forbidden", name, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want(t, "child files without a manage form", env.a.do(t, http.MethodGet, dfLockedPath(missing, "/relations/parts/records/1/files/images"), nil, ck(newSessionKey(t))), http.StatusNotFound)
|
||||||
|
env.unchanged(t, "refused toolbar routes", before)
|
||||||
|
// The declared buttons pass the gate and reach the parent lookup.
|
||||||
|
want(t, "declared link on a missing parent", env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/parts/link"), map[string]any{"ids": []uint{1}}, nil), http.StatusNotFound)
|
||||||
|
want(t, "declared unlink on a missing parent", env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/members/unlink"), map[string]any{"ids": []uint{1}}, nil), http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRelationChildScopePivotWhitelist: a pivot PUT naming a pivot foreign
|
||||||
|
// key, the id, a timestamp or a HookPivotColumns column answers 422 and
|
||||||
|
// leaves the pivot row unchanged (D-14).
|
||||||
|
func TestRelationChildScopePivotWhitelist(t *testing.T) {
|
||||||
|
env := newDeferredEnv(t)
|
||||||
|
g := env.gadget(t, "g-"+env.stamp, false)
|
||||||
|
other := env.gadget(t, "o-"+env.stamp, false)
|
||||||
|
m := env.member(t, "m-"+env.stamp+"@example.test")
|
||||||
|
m2 := env.member(t, "m2-"+env.stamp+"@example.test")
|
||||||
|
env.pivot(t, g, m, "original")
|
||||||
|
for _, body := range []map[string]any{
|
||||||
|
{"gadget_id": other},
|
||||||
|
{"member_id": m2},
|
||||||
|
{"id": 4242},
|
||||||
|
{"created_at": "2020-01-01T00:00:00Z"},
|
||||||
|
{"role": "admin"},
|
||||||
|
{"note": "changed", "role": "admin"},
|
||||||
|
} {
|
||||||
|
before := env.state(t)
|
||||||
|
rec := env.a.do(t, http.MethodPut, dfPath(g, fmt.Sprintf("/relations/members/pivot/%d", m)), body, nil)
|
||||||
|
want(t, fmt.Sprintf("pivot PUT %v", body), rec, http.StatusUnprocessableEntity)
|
||||||
|
env.unchanged(t, fmt.Sprintf("pivot PUT %v", body), before)
|
||||||
|
}
|
||||||
|
var row dfGadgetMember
|
||||||
|
if err := env.db.Where("gadget_id = ? AND member_id = ?", g, m).Take(&row).Error; err != nil || row.Note != "original" || row.Role != "seed" {
|
||||||
|
t.Fatalf("pivot row = %+v (%v)", row, err)
|
||||||
|
}
|
||||||
|
want(t, "pivot PUT note", env.a.do(t, http.MethodPut, dfPath(g, fmt.Sprintf("/relations/members/pivot/%d", m)), map[string]any{"note": "changed"}, nil), http.StatusOK)
|
||||||
|
if err := env.db.Where("gadget_id = ? AND member_id = ?", g, m).Take(&row).Error; err != nil || row.Note != "changed" || row.Role != "seed" || row.MemberID != m {
|
||||||
|
t.Fatalf("pivot row after a valid PUT = %+v (%v)", row, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
modules/cabana/testdata/deferred/controllers/gadgets/config_form.yaml
vendored
Normal file
10
modules/cabana/testdata/deferred/controllers/gadgets/config_form.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
name: New gadget
|
||||||
|
form: ~/plugins/acme/deferred/models/gadget/fields.yaml
|
||||||
|
modelClass: Gadget
|
||||||
|
defaultRedirect: acme/deferred/gadgets
|
||||||
|
create:
|
||||||
|
redirect: acme/deferred/gadgets/update/:id
|
||||||
|
redirectClose: acme/deferred/gadgets
|
||||||
|
update:
|
||||||
|
redirect: acme/deferred/gadgets
|
||||||
|
redirectClose: acme/deferred/gadgets
|
||||||
8
modules/cabana/testdata/deferred/controllers/gadgets/config_list.yaml
vendored
Normal file
8
modules/cabana/testdata/deferred/controllers/gadgets/config_list.yaml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
list: ~/plugins/acme/deferred/models/gadget/columns.yaml
|
||||||
|
modelClass: Gadget
|
||||||
|
title: Gadgets
|
||||||
|
recordUrl: acme/deferred/gadgets/update/:id
|
||||||
|
recordsPerPage: 20
|
||||||
|
showCheckboxes: true
|
||||||
|
toolbar:
|
||||||
|
buttons: [create, delete]
|
||||||
36
modules/cabana/testdata/deferred/controllers/gadgets/config_relation.yaml
vendored
Normal file
36
modules/cabana/testdata/deferred/controllers/gadgets/config_relation.yaml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# A belongsToMany relation with a manage form and a pivot form, and a
|
||||||
|
# deferrable hasMany relation (nullable gadget_id) whose manage form lives
|
||||||
|
# in the plugin's models directory ($/ path) and carries a fileupload and a
|
||||||
|
# datepicker field. Both declare every toolbar button.
|
||||||
|
members:
|
||||||
|
label: Members
|
||||||
|
view:
|
||||||
|
list:
|
||||||
|
columns:
|
||||||
|
email:
|
||||||
|
label: Email
|
||||||
|
toolbarButtons: create|update|delete|link|unlink
|
||||||
|
showSearch: true
|
||||||
|
manage:
|
||||||
|
form: $/acme/deferred/models/member/fields.yaml
|
||||||
|
list:
|
||||||
|
columns:
|
||||||
|
email:
|
||||||
|
label: Email
|
||||||
|
showSearch: true
|
||||||
|
pivot:
|
||||||
|
form: $/acme/deferred/models/member/pivot_fields.yaml
|
||||||
|
parts:
|
||||||
|
label: Parts
|
||||||
|
view:
|
||||||
|
list:
|
||||||
|
columns:
|
||||||
|
label:
|
||||||
|
label: Label
|
||||||
|
toolbarButtons: create|update|delete|link|unlink
|
||||||
|
manage:
|
||||||
|
form: $/acme/deferred/models/part/fields.yaml
|
||||||
|
list:
|
||||||
|
columns:
|
||||||
|
label:
|
||||||
|
label: Label
|
||||||
8
modules/cabana/testdata/deferred/controllers/locked/config_form.yaml
vendored
Normal file
8
modules/cabana/testdata/deferred/controllers/locked/config_form.yaml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
name: New gadget
|
||||||
|
form: ~/plugins/acme/deferred/models/gadget/locked_fields.yaml
|
||||||
|
modelClass: Gadget
|
||||||
|
defaultRedirect: acme/deferred/locked
|
||||||
|
create:
|
||||||
|
redirect: acme/deferred/locked/update/:id
|
||||||
|
update:
|
||||||
|
redirect: acme/deferred/locked
|
||||||
4
modules/cabana/testdata/deferred/controllers/locked/config_list.yaml
vendored
Normal file
4
modules/cabana/testdata/deferred/controllers/locked/config_list.yaml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
list: ~/plugins/acme/deferred/models/gadget/columns.yaml
|
||||||
|
modelClass: Gadget
|
||||||
|
title: Locked gadgets
|
||||||
|
recordUrl: acme/deferred/locked/update/:id
|
||||||
20
modules/cabana/testdata/deferred/controllers/locked/config_relation.yaml
vendored
Normal file
20
modules/cabana/testdata/deferred/controllers/locked/config_relation.yaml
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# The same relations with a reduced toolbar: parts may only be linked and
|
||||||
|
# members only unlinked, so every other relation route answers 403.
|
||||||
|
members:
|
||||||
|
label: Members
|
||||||
|
view:
|
||||||
|
list:
|
||||||
|
columns:
|
||||||
|
email:
|
||||||
|
label: Email
|
||||||
|
toolbarButtons: unlink
|
||||||
|
pivot:
|
||||||
|
form: $/acme/deferred/models/member/pivot_fields.yaml
|
||||||
|
parts:
|
||||||
|
label: Parts
|
||||||
|
view:
|
||||||
|
list:
|
||||||
|
columns:
|
||||||
|
label:
|
||||||
|
label: Label
|
||||||
|
toolbarButtons: link
|
||||||
10
modules/cabana/testdata/deferred/models/gadget/columns.yaml
vendored
Normal file
10
modules/cabana/testdata/deferred/models/gadget/columns.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
columns:
|
||||||
|
name:
|
||||||
|
label: Name
|
||||||
|
searchable: true
|
||||||
|
released_on:
|
||||||
|
label: Released on
|
||||||
|
type: date
|
||||||
|
opens_at:
|
||||||
|
label: Opens at
|
||||||
|
type: time
|
||||||
37
modules/cabana/testdata/deferred/models/gadget/fields.yaml
vendored
Normal file
37
modules/cabana/testdata/deferred/models/gadget/fields.yaml
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
fields:
|
||||||
|
name:
|
||||||
|
label: Name
|
||||||
|
type: text
|
||||||
|
required: true
|
||||||
|
released_on:
|
||||||
|
label: Released on
|
||||||
|
type: datepicker
|
||||||
|
mode: date
|
||||||
|
minDate: 2000-01-01
|
||||||
|
maxDate: 2030-12-31
|
||||||
|
starts_at:
|
||||||
|
label: Starts at
|
||||||
|
type: datepicker
|
||||||
|
mode: datetime
|
||||||
|
minDate: 2000-01-01
|
||||||
|
maxDate: 2030-12-31
|
||||||
|
opens_at:
|
||||||
|
label: Opens at
|
||||||
|
type: datepicker
|
||||||
|
mode: time
|
||||||
|
members:
|
||||||
|
type: relation-manager
|
||||||
|
relation: members
|
||||||
|
parts:
|
||||||
|
type: relation-manager
|
||||||
|
relation: parts
|
||||||
|
photos:
|
||||||
|
label: Photos
|
||||||
|
type: fileupload
|
||||||
|
mode: image
|
||||||
|
maxFiles: 3
|
||||||
|
manual:
|
||||||
|
label: Manual
|
||||||
|
type: fileupload
|
||||||
|
fileTypes: [pdf, png, svg, txt, html]
|
||||||
|
useCaption: true
|
||||||
15
modules/cabana/testdata/deferred/models/gadget/locked_fields.yaml
vendored
Normal file
15
modules/cabana/testdata/deferred/models/gadget/locked_fields.yaml
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
fields:
|
||||||
|
name:
|
||||||
|
label: Name
|
||||||
|
type: text
|
||||||
|
required: true
|
||||||
|
members:
|
||||||
|
type: relation-manager
|
||||||
|
relation: members
|
||||||
|
parts:
|
||||||
|
type: relation-manager
|
||||||
|
relation: parts
|
||||||
|
manual:
|
||||||
|
label: Manual
|
||||||
|
type: fileupload
|
||||||
|
required: true
|
||||||
5
modules/cabana/testdata/deferred/models/member/fields.yaml
vendored
Normal file
5
modules/cabana/testdata/deferred/models/member/fields.yaml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fields:
|
||||||
|
email:
|
||||||
|
label: Email
|
||||||
|
type: text
|
||||||
|
required: true
|
||||||
4
modules/cabana/testdata/deferred/models/member/pivot_fields.yaml
vendored
Normal file
4
modules/cabana/testdata/deferred/models/member/pivot_fields.yaml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fields:
|
||||||
|
pivot[note]:
|
||||||
|
label: Note
|
||||||
|
type: text
|
||||||
19
modules/cabana/testdata/deferred/models/part/fields.yaml
vendored
Normal file
19
modules/cabana/testdata/deferred/models/part/fields.yaml
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
fields:
|
||||||
|
label:
|
||||||
|
label: Label
|
||||||
|
type: text
|
||||||
|
required: true
|
||||||
|
due_on:
|
||||||
|
label: Due on
|
||||||
|
type: datepicker
|
||||||
|
mode: date
|
||||||
|
minDate: 2020-01-01
|
||||||
|
images:
|
||||||
|
label: Images
|
||||||
|
type: fileupload
|
||||||
|
mode: image
|
||||||
|
useCaption: true
|
||||||
|
sheet:
|
||||||
|
label: Sheet
|
||||||
|
type: fileupload
|
||||||
|
fileTypes: [txt, pdf]
|
||||||
Reference in New Issue
Block a user