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