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) } }