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:
Jakub Zych
2026-10-02 20:10:10 +02:00
parent 162a8ec5a1
commit 9d2b1c1848
15 changed files with 1336 additions and 0 deletions

View File

@@ -0,0 +1,324 @@
package cabana_test
import (
"fmt"
"net/http"
"net/http/httptest"
"slices"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// childRoute is one relation child route of the D-15 security suite: it
// is called through parent P for child C, pivot member M and child file F.
type childRoute struct {
name string
call func(t *testing.T, c dfClient, p, child, member, file uint, key string) *httptest.ResponseRecorder
}
// childRoutes are every child route of plan 03: records GET and PUT,
// delete, pivot GET and PUT, and the seven child file routes under
// records/{child}/files/{field}. The order lets a positive control run
// them all on one parent (the delete comes last).
func childRoutes(t *testing.T) []childRoute {
png := conformPNG(t)
filePath := func(p, child uint, rest string) string {
return dfPath(p, fmt.Sprintf("/relations/parts/records/%d/files/images%s", child, rest))
}
keys := func(key string) map[string]string {
h := ck(key)
return h
}
return []childRoute{
{"GET records/{child}", func(t *testing.T, c dfClient, p, child, _, _ uint, _ string) *httptest.ResponseRecorder {
return c.do(t, http.MethodGet, dfPath(p, fmt.Sprintf("/relations/parts/records/%d", child)), nil, nil)
}},
{"PUT records/{child}", func(t *testing.T, c dfClient, p, child, _, _ uint, _ string) *httptest.ResponseRecorder {
return c.do(t, http.MethodPut, dfPath(p, fmt.Sprintf("/relations/parts/records/%d", child)), map[string]any{"label": "stolen"}, nil)
}},
{"GET pivot/{child}", func(t *testing.T, c dfClient, p, _, member, _ uint, _ string) *httptest.ResponseRecorder {
return c.do(t, http.MethodGet, dfPath(p, fmt.Sprintf("/relations/members/pivot/%d", member)), nil, nil)
}},
{"PUT pivot/{child}", func(t *testing.T, c dfClient, p, _, member, _ uint, _ string) *httptest.ResponseRecorder {
return c.do(t, http.MethodPut, dfPath(p, fmt.Sprintf("/relations/members/pivot/%d", member)), map[string]any{"note": "stolen"}, nil)
}},
{"GET records/{child}/files/{field}", func(t *testing.T, c dfClient, p, child, _, _ uint, key string) *httptest.ResponseRecorder {
return c.do(t, http.MethodGet, filePath(p, child, ""), nil, keys(key))
}},
{"POST records/{child}/files/{field}/reorder", func(t *testing.T, c dfClient, p, child, _, file uint, key string) *httptest.ResponseRecorder {
return c.do(t, http.MethodPost, filePath(p, child, "/reorder"), map[string]any{"ids": []uint{file}}, keys(key))
}},
{"PUT records/{child}/files/{field}/{file}", func(t *testing.T, c dfClient, p, child, _, file uint, key string) *httptest.ResponseRecorder {
return c.do(t, http.MethodPut, filePath(p, child, fmt.Sprintf("/%d", file)), map[string]any{"title": "stolen"}, keys(key))
}},
{"GET records/{child}/files/{field}/{file}/download", func(t *testing.T, c dfClient, p, child, _, file uint, key string) *httptest.ResponseRecorder {
return c.do(t, http.MethodGet, filePath(p, child, fmt.Sprintf("/%d/download", file)), nil, keys(key))
}},
{"GET records/{child}/files/{field}/{file}/thumb", func(t *testing.T, c dfClient, p, child, _, file uint, key string) *httptest.ResponseRecorder {
return c.do(t, http.MethodGet, filePath(p, child, fmt.Sprintf("/%d/thumb", file)), nil, keys(key))
}},
{"POST records/{child}/files/{field}", func(t *testing.T, c dfClient, p, child, _, _ uint, key string) *httptest.ResponseRecorder {
return c.upload(t, filePath(p, child, ""), "stolen.png", png, keys(key))
}},
{"DELETE records/{child}/files/{field}/{file}", func(t *testing.T, c dfClient, p, child, _, file uint, key string) *httptest.ResponseRecorder {
return c.do(t, http.MethodDelete, filePath(p, child, fmt.Sprintf("/%d", file)), nil, keys(key))
}},
{"POST delete", func(t *testing.T, c dfClient, p, child, _, _ uint, _ string) *httptest.ResponseRecorder {
return c.do(t, http.MethodPost, dfPath(p, "/relations/parts/delete"), map[string]any{"ids": []uint{child}}, nil)
}},
}
}
// TestRelationChildScope proves D-15 through the assembled router: a part,
// a member pivot row or a part's file of gadget G2 requested through G1,
// and a child of a gadget FormExtendQuery hides, answer 404 not_found on
// every child route and change nothing. A positive control runs the same
// routes on the owning parent, so each 404 comes from the parent scope.
func TestRelationChildScope(t *testing.T) {
env := newDeferredEnv(t)
g1 := env.gadget(t, "g1-"+env.stamp, false)
g2 := env.gadget(t, "g2-"+env.stamp, false)
g3 := env.gadget(t, "g3-"+env.stamp, true)
env.part(t, g1, "p1")
p2 := env.part(t, g2, "p2")
p3 := env.part(t, g3, "p3")
m := env.member(t, "m-"+env.stamp+"@example.test")
env.pivot(t, g2, m, "n2")
env.pivot(t, g3, m, "n3")
f2 := env.storeFile(t, dfPartMorph, p2, "images", "p2.png", conformPNG(t), false)
f3 := env.storeFile(t, dfPartMorph, p3, "images", "p3.png", conformPNG(t), false)
routes := childRoutes(t)
t.Run("another parent", func(t *testing.T) {
for _, r := range routes {
before := env.state(t)
rec := r.call(t, env.a, g1, p2, m, f2.ID, newSessionKey(t))
want(t, r.name+" through G1", rec, http.StatusNotFound)
if code := errorCode(t, rec); code != "not_found" {
t.Fatalf("%s code=%q want not_found", r.name, code)
}
env.unchanged(t, r.name+" through G1", before)
}
})
t.Run("hidden parent", func(t *testing.T) {
for _, r := range routes {
before := env.state(t)
rec := r.call(t, env.a, g3, p3, m, f3.ID, newSessionKey(t))
want(t, r.name+" through hidden G3", rec, http.StatusNotFound)
if code := errorCode(t, rec); code != "not_found" {
t.Fatalf("%s code=%q want not_found", r.name, code)
}
env.unchanged(t, r.name+" through hidden G3", before)
}
})
t.Run("owning parent control", func(t *testing.T) {
g4 := env.gadget(t, "g4-"+env.stamp, false)
p4 := env.part(t, g4, "p4")
m4 := env.member(t, "m4-"+env.stamp+"@example.test")
env.pivot(t, g4, m4, "n4")
f4 := env.storeFile(t, dfPartMorph, p4, "images", "p4.png", conformPNG(t), false)
key := newSessionKey(t)
statuses := map[string]int{"POST records/{child}/files/{field}": http.StatusCreated}
for _, r := range routes {
status := http.StatusOK
if s, ok := statuses[r.name]; ok {
status = s
}
want(t, r.name+" through the owner", r.call(t, env.a, g4, p4, m4, f4.ID, key), status)
}
if p, ok := env.partRow(t, p4); !ok || !p.DeletedAt.Valid {
t.Fatalf("control delete left part %+v (present=%v), want soft deleted", p, ok)
}
})
}
// TestRelationChildScopeSessionKey covers record id 0 (D-02, D-15): without
// X-Session-Key every relation route is 404, a malformed key is 422 on
// session_key, and admin B replaying admin A's key sees an empty linked
// list, gets 404 on A's pending part and pivot on every child route, and
// commits nothing of A's on its own save.
func TestRelationChildScopeSessionKey(t *testing.T) {
env := newDeferredEnv(t)
key := newSessionKey(t)
m := env.member(t, "m-"+env.stamp+"@example.test")
created := env.a.do(t, http.MethodPost, dfPath(0, "/relations/parts/records"), map[string]any{"label": "pending-" + env.stamp}, sk(key))
want(t, "A creates a pending part", created, http.StatusCreated)
pp := dataID(t, created.Body.Bytes())
want(t, "A links a member with a pivot note", env.a.do(t, http.MethodPost, dfPath(0, "/relations/members/link"), map[string]any{"ids": []uint{m}, "pivot": map[string]any{"note": "pending"}}, sk(key)), http.StatusOK)
childKey := newSessionKey(t)
upHeaders := map[string]string{cabana.SessionKeyHeader: key, cabana.ChildSessionKeyHeader: childKey}
up := env.a.upload(t, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images", pp)), "pending.png", conformPNG(t), upHeaders)
want(t, "A uploads a file to the pending part", up, http.StatusCreated)
pendingFile := dataID(t, up.Body.Bytes())
if got := dfIDs(t, env.a.do(t, http.MethodGet, dfPath(0, "/relations/parts"), nil, sk(key))); !slices.Equal(got, []uint{pp}) {
t.Fatalf("A's pending parts = %v, want [%d]", got, pp)
}
t.Run("no key", func(t *testing.T) {
before := env.state(t)
for name, rec := range map[string]*httptest.ResponseRecorder{
"linked parts": env.a.do(t, http.MethodGet, dfPath(0, "/relations/parts"), nil, nil),
"candidates": env.a.do(t, http.MethodGet, dfPath(0, "/relations/parts/candidates"), nil, nil),
"create part": env.a.do(t, http.MethodPost, dfPath(0, "/relations/parts/records"), map[string]any{"label": "x"}, nil),
"show part": env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d", pp)), nil, nil),
"update part": env.a.do(t, http.MethodPut, dfPath(0, fmt.Sprintf("/relations/parts/records/%d", pp)), map[string]any{"label": "x"}, nil),
"delete part": env.a.do(t, http.MethodPost, dfPath(0, "/relations/parts/delete"), map[string]any{"ids": []uint{pp}}, nil),
"link member": env.a.do(t, http.MethodPost, dfPath(0, "/relations/members/link"), map[string]any{"ids": []uint{m}}, nil),
"unlink member": env.a.do(t, http.MethodPost, dfPath(0, "/relations/members/unlink"), map[string]any{"ids": []uint{m}}, nil),
"pivot show": env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), nil, nil),
"pivot update": env.a.do(t, http.MethodPut, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), map[string]any{"note": "x"}, nil),
"child files": env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images", pp)), nil, ck(childKey)),
"record files": env.a.do(t, http.MethodGet, dfPath(0, "/files/photos"), nil, nil),
"child download": env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images/%d/download", pp, pendingFile)), nil, ck(childKey)),
} {
want(t, name+" on id 0 without a key", rec, http.StatusNotFound)
}
env.unchanged(t, "id 0 without a key", before)
})
t.Run("malformed key", func(t *testing.T) {
bad := sk("short")
for name, rec := range map[string]*httptest.ResponseRecorder{
"linked parts": env.a.do(t, http.MethodGet, dfPath(0, "/relations/parts"), nil, bad),
"create part": env.a.do(t, http.MethodPost, dfPath(0, "/relations/parts/records"), map[string]any{"label": "x"}, bad),
"show part": env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d", pp)), nil, bad),
"pivot show": env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), nil, bad),
"record files": env.a.do(t, http.MethodGet, dfPath(0, "/files/photos"), nil, bad),
} {
want(t, name+" with a malformed key", rec, http.StatusUnprocessableEntity)
if d := errorDetails(t, rec); len(d["session_key"]) == 0 {
t.Fatalf("%s details = %v, want session_key", name, d)
}
}
})
t.Run("foreign admin", func(t *testing.T) {
before := env.state(t)
if got := dfIDs(t, env.b.do(t, http.MethodGet, dfPath(0, "/relations/parts"), nil, sk(key))); len(got) != 0 {
t.Fatalf("B sees A's pending parts %v", got)
}
if got := dfIDs(t, env.b.do(t, http.MethodGet, dfPath(0, "/relations/members"), nil, sk(key))); len(got) != 0 {
t.Fatalf("B sees A's pending members %v", got)
}
both := map[string]string{cabana.SessionKeyHeader: key, cabana.ChildSessionKeyHeader: childKey}
for name, rec := range map[string]*httptest.ResponseRecorder{
"show": env.b.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d", pp)), nil, sk(key)),
"update": env.b.do(t, http.MethodPut, dfPath(0, fmt.Sprintf("/relations/parts/records/%d", pp)), map[string]any{"label": "stolen"}, sk(key)),
"delete": env.b.do(t, http.MethodPost, dfPath(0, "/relations/parts/delete"), map[string]any{"ids": []uint{pp}}, sk(key)),
"pivot show": env.b.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), nil, sk(key)),
"pivot update": env.b.do(t, http.MethodPut, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), map[string]any{"note": "stolen"}, sk(key)),
"child files": env.b.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images", pp)), nil, both),
"child upload": env.b.upload(t, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images", pp)), "x.png", conformPNG(t), both),
"child remove": env.b.do(t, http.MethodDelete, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images/%d", pp, pendingFile)), nil, both),
"child caption": env.b.do(t, http.MethodPut, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images/%d", pp, pendingFile)),
map[string]any{"title": "stolen"}, both),
"child download": env.b.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images/%d/download", pp, pendingFile)), nil, both),
"child thumb": env.b.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/parts/records/%d/files/images/%d/thumb", pp, pendingFile)), nil, both),
} {
want(t, "B "+name+" of A's pending part", rec, http.StatusNotFound)
}
// Unlink on an unsaved parent only cancels the caller's own binds.
want(t, "B unlinks A's pending part", env.b.do(t, http.MethodPost, dfPath(0, "/relations/parts/unlink"), map[string]any{"ids": []uint{pp}}, sk(key)), http.StatusOK)
env.unchanged(t, "B's requests with A's key", before)
// B's save with A's key applies none of A's bindings.
saved := env.b.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "b-" + env.stamp}, sk(key))
want(t, "B saves with A's key", saved, http.StatusCreated)
bg := dataID(t, saved.Body.Bytes())
if p, _ := env.partRow(t, pp); p.GadgetID != nil {
t.Fatalf("B's save adopted A's pending part into %d", *p.GadgetID)
}
var pivots int64
if err := env.db.Model(&dfGadgetMember{}).Where("gadget_id = ?", bg).Count(&pivots).Error; err != nil || pivots != 0 {
t.Fatalf("B's save linked %d members (%v)", pivots, err)
}
after := env.state(t)
if len(after.Bindings) != len(before.Bindings) {
t.Fatalf("bindings %d -> %d after B's save", len(before.Bindings), len(after.Bindings))
}
})
t.Run("owner commits", func(t *testing.T) {
saved := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "a-" + env.stamp}, sk(key))
want(t, "A saves with its key", saved, http.StatusCreated)
ag := dataID(t, saved.Body.Bytes())
if p, _ := env.partRow(t, pp); p.GadgetID == nil || *p.GadgetID != ag {
t.Fatalf("A's pending part owner = %v, want %d", p.GadgetID, ag)
}
var row dfGadgetMember
if err := env.db.Where("gadget_id = ? AND member_id = ?", ag, m).Take(&row).Error; err != nil || row.Note != "pending" || row.Role != "linked" {
t.Fatalf("A's pivot row = %+v (%v)", row, err)
}
})
}
// TestRelationChildScopeToolbar: on the locked controller (parts: link;
// members: unlink) every route of an undeclared button answers 403 before
// any SQL runs: the parent id does not exist, so a route that reached the
// database would answer 404. A relation without a manage form has no child
// file routes (404).
func TestRelationChildScopeToolbar(t *testing.T) {
env := newDeferredEnv(t)
const missing = 999999
before := env.state(t)
for name, rec := range map[string]*httptest.ResponseRecorder{
"parts create": env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/parts/records"), map[string]any{"label": "x"}, nil),
"parts show": env.a.do(t, http.MethodGet, dfLockedPath(missing, "/relations/parts/records/1"), nil, nil),
"parts update": env.a.do(t, http.MethodPut, dfLockedPath(missing, "/relations/parts/records/1"), map[string]any{"label": "x"}, nil),
"parts delete": env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/parts/delete"), map[string]any{"ids": []uint{1}}, nil),
"parts unlink": env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/parts/unlink"), map[string]any{"ids": []uint{1}}, nil),
"members create": env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/members/records"), map[string]any{"email": "x"}, nil),
"members update": env.a.do(t, http.MethodPut, dfLockedPath(missing, "/relations/members/records/1"), map[string]any{"email": "x"}, nil),
"members delete": env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/members/delete"), map[string]any{"ids": []uint{1}}, nil),
"members link": env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/members/link"), map[string]any{"ids": []uint{1}}, nil),
"pivot show": env.a.do(t, http.MethodGet, dfLockedPath(missing, "/relations/members/pivot/1"), nil, nil),
"pivot update": env.a.do(t, http.MethodPut, dfLockedPath(missing, "/relations/members/pivot/1"), map[string]any{"note": "x"}, nil),
} {
want(t, name+" without its button", rec, http.StatusForbidden)
if code := errorCode(t, rec); code != "forbidden" {
t.Fatalf("%s code=%q want forbidden", name, code)
}
}
want(t, "child files without a manage form", env.a.do(t, http.MethodGet, dfLockedPath(missing, "/relations/parts/records/1/files/images"), nil, ck(newSessionKey(t))), http.StatusNotFound)
env.unchanged(t, "refused toolbar routes", before)
// The declared buttons pass the gate and reach the parent lookup.
want(t, "declared link on a missing parent", env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/parts/link"), map[string]any{"ids": []uint{1}}, nil), http.StatusNotFound)
want(t, "declared unlink on a missing parent", env.a.do(t, http.MethodPost, dfLockedPath(missing, "/relations/members/unlink"), map[string]any{"ids": []uint{1}}, nil), http.StatusNotFound)
}
// TestRelationChildScopePivotWhitelist: a pivot PUT naming a pivot foreign
// key, the id, a timestamp or a HookPivotColumns column answers 422 and
// leaves the pivot row unchanged (D-14).
func TestRelationChildScopePivotWhitelist(t *testing.T) {
env := newDeferredEnv(t)
g := env.gadget(t, "g-"+env.stamp, false)
other := env.gadget(t, "o-"+env.stamp, false)
m := env.member(t, "m-"+env.stamp+"@example.test")
m2 := env.member(t, "m2-"+env.stamp+"@example.test")
env.pivot(t, g, m, "original")
for _, body := range []map[string]any{
{"gadget_id": other},
{"member_id": m2},
{"id": 4242},
{"created_at": "2020-01-01T00:00:00Z"},
{"role": "admin"},
{"note": "changed", "role": "admin"},
} {
before := env.state(t)
rec := env.a.do(t, http.MethodPut, dfPath(g, fmt.Sprintf("/relations/members/pivot/%d", m)), body, nil)
want(t, fmt.Sprintf("pivot PUT %v", body), rec, http.StatusUnprocessableEntity)
env.unchanged(t, fmt.Sprintf("pivot PUT %v", body), before)
}
var row dfGadgetMember
if err := env.db.Where("gadget_id = ? AND member_id = ?", g, m).Take(&row).Error; err != nil || row.Note != "original" || row.Role != "seed" {
t.Fatalf("pivot row = %+v (%v)", row, err)
}
want(t, "pivot PUT note", env.a.do(t, http.MethodPut, dfPath(g, fmt.Sprintf("/relations/members/pivot/%d", m)), map[string]any{"note": "changed"}, nil), http.StatusOK)
if err := env.db.Where("gadget_id = ? AND member_id = ?", g, m).Take(&row).Error; err != nil || row.Note != "changed" || row.Role != "seed" || row.MemberID != m {
t.Fatalf("pivot row after a valid PUT = %+v (%v)", row, err)
}
}