diff --git a/modules/cabana/phase122_fixture_test.go b/modules/cabana/phase122_fixture_test.go new file mode 100644 index 0000000..003e015 --- /dev/null +++ b/modules/cabana/phase122_fixture_test.go @@ -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 +} diff --git a/modules/cabana/protected_file_test.go b/modules/cabana/protected_file_test.go new file mode 100644 index 0000000..246cccf --- /dev/null +++ b/modules/cabana/protected_file_test.go @@ -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(``) + html := []byte("") + 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) + } + } +} diff --git a/modules/cabana/relation_child_scope_test.go b/modules/cabana/relation_child_scope_test.go new file mode 100644 index 0000000..0f5fdcb --- /dev/null +++ b/modules/cabana/relation_child_scope_test.go @@ -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) + } +} diff --git a/modules/cabana/testdata/deferred/controllers/gadgets/config_form.yaml b/modules/cabana/testdata/deferred/controllers/gadgets/config_form.yaml new file mode 100644 index 0000000..b071530 --- /dev/null +++ b/modules/cabana/testdata/deferred/controllers/gadgets/config_form.yaml @@ -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 diff --git a/modules/cabana/testdata/deferred/controllers/gadgets/config_list.yaml b/modules/cabana/testdata/deferred/controllers/gadgets/config_list.yaml new file mode 100644 index 0000000..7c3cb52 --- /dev/null +++ b/modules/cabana/testdata/deferred/controllers/gadgets/config_list.yaml @@ -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] diff --git a/modules/cabana/testdata/deferred/controllers/gadgets/config_relation.yaml b/modules/cabana/testdata/deferred/controllers/gadgets/config_relation.yaml new file mode 100644 index 0000000..439940e --- /dev/null +++ b/modules/cabana/testdata/deferred/controllers/gadgets/config_relation.yaml @@ -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 diff --git a/modules/cabana/testdata/deferred/controllers/locked/config_form.yaml b/modules/cabana/testdata/deferred/controllers/locked/config_form.yaml new file mode 100644 index 0000000..a26889f --- /dev/null +++ b/modules/cabana/testdata/deferred/controllers/locked/config_form.yaml @@ -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 diff --git a/modules/cabana/testdata/deferred/controllers/locked/config_list.yaml b/modules/cabana/testdata/deferred/controllers/locked/config_list.yaml new file mode 100644 index 0000000..430906f --- /dev/null +++ b/modules/cabana/testdata/deferred/controllers/locked/config_list.yaml @@ -0,0 +1,4 @@ +list: ~/plugins/acme/deferred/models/gadget/columns.yaml +modelClass: Gadget +title: Locked gadgets +recordUrl: acme/deferred/locked/update/:id diff --git a/modules/cabana/testdata/deferred/controllers/locked/config_relation.yaml b/modules/cabana/testdata/deferred/controllers/locked/config_relation.yaml new file mode 100644 index 0000000..d3328cd --- /dev/null +++ b/modules/cabana/testdata/deferred/controllers/locked/config_relation.yaml @@ -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 diff --git a/modules/cabana/testdata/deferred/models/gadget/columns.yaml b/modules/cabana/testdata/deferred/models/gadget/columns.yaml new file mode 100644 index 0000000..253789e --- /dev/null +++ b/modules/cabana/testdata/deferred/models/gadget/columns.yaml @@ -0,0 +1,10 @@ +columns: + name: + label: Name + searchable: true + released_on: + label: Released on + type: date + opens_at: + label: Opens at + type: time diff --git a/modules/cabana/testdata/deferred/models/gadget/fields.yaml b/modules/cabana/testdata/deferred/models/gadget/fields.yaml new file mode 100644 index 0000000..4085d28 --- /dev/null +++ b/modules/cabana/testdata/deferred/models/gadget/fields.yaml @@ -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 diff --git a/modules/cabana/testdata/deferred/models/gadget/locked_fields.yaml b/modules/cabana/testdata/deferred/models/gadget/locked_fields.yaml new file mode 100644 index 0000000..4557b4b --- /dev/null +++ b/modules/cabana/testdata/deferred/models/gadget/locked_fields.yaml @@ -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 diff --git a/modules/cabana/testdata/deferred/models/member/fields.yaml b/modules/cabana/testdata/deferred/models/member/fields.yaml new file mode 100644 index 0000000..3a160d6 --- /dev/null +++ b/modules/cabana/testdata/deferred/models/member/fields.yaml @@ -0,0 +1,5 @@ +fields: + email: + label: Email + type: text + required: true diff --git a/modules/cabana/testdata/deferred/models/member/pivot_fields.yaml b/modules/cabana/testdata/deferred/models/member/pivot_fields.yaml new file mode 100644 index 0000000..5d310d8 --- /dev/null +++ b/modules/cabana/testdata/deferred/models/member/pivot_fields.yaml @@ -0,0 +1,4 @@ +fields: + pivot[note]: + label: Note + type: text diff --git a/modules/cabana/testdata/deferred/models/part/fields.yaml b/modules/cabana/testdata/deferred/models/part/fields.yaml new file mode 100644 index 0000000..ff5f72f --- /dev/null +++ b/modules/cabana/testdata/deferred/models/part/fields.yaml @@ -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]