- 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
325 lines
19 KiB
Go
325 lines
19 KiB
Go
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)
|
|
}
|
|
}
|