Files
summercms/modules/cabana/phase121_forbidden_test.go
Jakub Zych 2e94cbf9f8 test(12.1-05): unit tests for bulk and record actions, row state, forbidden, preview and the form seams
- bulk action: empty, duplicate, unordered, absent, partial, out-of-scope, rollback, concurrent runs, permissions, CSRF, body cap
- record action: scope, Applies, strict body, offered order, rollback, Applies error
- ForbiddenError from every Form hook, the bulk delete and the relation link and child hooks
- permission editor modes, locked codes and provider errors; relation locks on create, update and belongsTo
- TestPhase121BootErrors: every boot error of plans 01 and 02 with plugin, controller and file
- pact: the action, row state and filter contracts on a sample controller
2026-10-05 14:48:34 +02:00

193 lines
9.6 KiB
Go

package cabana_test
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
)
const rosterLockedMessage = "This person is locked and cannot be changed."
// rosterCount counts the people named name, soft-deleted or not.
func rosterCount(t *testing.T, env *rosterEnv, name string) int {
t.Helper()
body, _ := rosterList(t, env, "?search="+url.QueryEscape(name))
n := 0
for _, row := range body.Data {
if row["name"] == name {
n++
}
}
return n
}
// TestForbiddenFromEveryHook: a cabana.ForbiddenError is a 403 with the
// plugin's message from every Form hook, from the bulk delete and from the
// relation link and child hooks, and the write it refuses is rolled back
// (D-27; T-12.1-05, T-12.1-06).
func TestForbiddenFromEveryHook(t *testing.T) {
t.Run("form hooks", func(t *testing.T) {
env, gdb := newRosterEnv(t)
refused := func(what string, rec *httptest.ResponseRecorder) {
t.Helper()
if got := rosterError(t, rec); rec.Code != http.StatusForbidden || got.Code != "forbidden" || got.Message != rosterLockedMessage {
t.Fatalf("%s = %d %s", what, rec.Code, rec.Body.String())
}
}
// Before and after create: no row is left.
for _, name := range []string{rosterDenyCreate, rosterDenyAfterCreate} {
refused("create "+name, env.call(t, http.MethodPost, rosterPeople, fmt.Sprintf(`{"name":%q,%s}`, name, rosterPair), "bearer"))
if n := rosterCount(t, env, name); n != 0 {
t.Fatalf("a refused create left %d rows named %s", n, name)
}
}
// After update: the row was written inside the transaction.
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@example.test"})
refused("after update", env.call(t, http.MethodPut, rosterPath(id, ""), fmt.Sprintf(`{"name":%q,"email":"changed@example.test"}`, rosterKeepAfter), "bearer"))
if stored := rosterLoad(t, gdb, id); stored.Name != "Ada" || stored.Email != "ada@example.test" {
t.Fatalf("a refusal after the update kept the write: %+v", stored)
}
// Before update is covered by the fixture's reserved name.
rec := env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(id, ""), `{"name":"Reserved"}`, "bearer")
if got := rosterError(t, rec); got.Message != "You may not rename this person." {
t.Fatalf("before update = %+v", got)
}
// Before delete, and after delete once the row is gone inside the
// transaction.
keep := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterKeep})
after := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterKeepAfter})
for _, target := range []uint{keep, after} {
refused("delete", env.call(t, http.MethodDelete, rosterPath(target, ""), "", "bearer"))
if stored := rosterLoad(t, gdb, target); stored.DeletedAt.Valid {
t.Fatalf("a refused delete removed or soft-deleted row %d", target)
}
}
// Bulk delete: one refused record keeps the whole selection.
first := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "First"})
last := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Last"})
refused("bulk delete", env.call(t, http.MethodPost, rosterPeople+"/bulk-delete", rosterIDs(first, keep, last), "bearer"))
refused("bulk delete, refusal after the row delete", env.call(t, http.MethodPost, rosterPeople+"/bulk-delete", rosterIDs(first, after), "bearer"))
for _, target := range []uint{first, keep, after, last} {
rosterLoad(t, gdb, target)
}
// The same selection without the refused record is deleted.
env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk-delete", rosterIDs(first, last), "bearer")
})
t.Run("relation link and child hooks", func(t *testing.T) {
env := newDeferredEnv(t)
g := env.gadget(t, "g-"+env.stamp, false)
parts := func(rest string) string { return dfPath(g, "/relations/parts"+rest) }
members := func(rest string) string { return dfPath(g, "/relations/members"+rest) }
part := env.part(t, g, "stays")
member := env.member(t, "refused-"+env.stamp+"@example.test")
linked := env.member(t, "linked-"+env.stamp+"@example.test")
want(t, "link", env.a.do(t, http.MethodPost, members("/link"), map[string]any{"ids": []uint{linked}}, nil), http.StatusOK)
for _, tc := range []struct {
hook, method, rel string
body map[string]any
}{
{"RelationBeforeLink:members", http.MethodPost, members("/link"), map[string]any{"ids": []uint{member}}},
{"RelationBeforeCreate:parts", http.MethodPost, parts("/records"), map[string]any{"label": "doomed"}},
{"RelationAfterCreate:parts", http.MethodPost, parts("/records"), map[string]any{"label": "doomed"}},
{"RelationBeforeUpdate:parts", http.MethodPut, parts(fmt.Sprintf("/records/%d", part)), map[string]any{"label": "changed"}},
{"RelationAfterUpdate:parts", http.MethodPut, parts(fmt.Sprintf("/records/%d", part)), map[string]any{"label": "changed"}},
{"RelationBeforeDelete:parts", http.MethodPost, parts("/delete"), map[string]any{"ids": []uint{part}}},
{"RelationAfterDelete:parts", http.MethodPost, parts("/delete"), map[string]any{"ids": []uint{part}}},
{"RelationBeforeCreate:members", http.MethodPost, members("/records"), map[string]any{"email": "new-" + env.stamp + "@example.test"}},
{"RelationAfterDelete:members", http.MethodPost, members("/delete"), map[string]any{"ids": []uint{linked}}},
} {
env.rec.reset()
env.rec.refuseOn(tc.hook)
before := env.state(t)
rec := env.a.do(t, tc.method, tc.rel, tc.body, nil)
got := rosterError(t, rec)
if rec.Code != http.StatusForbidden || got.Code != "forbidden" || got.Message != dfRefusal {
t.Fatalf("%s = %d %s, want the hook's 403", tc.hook, rec.Code, rec.Body.String())
}
if !reflect.DeepEqual(got.Details, map[string]any{"hook": []any{tc.hook}}) {
t.Fatalf("%s details = %#v", tc.hook, got.Details)
}
env.unchanged(t, "a refusal from "+tc.hook, before)
}
env.rec.reset()
// Without the refusal the same link runs.
want(t, "link after the refusals", env.a.do(t, http.MethodPost, members("/link"), map[string]any{"ids": []uint{member}}, nil), http.StatusOK)
})
}
// TestForbiddenLocalized: the message and every detail are phrase keys
// resolved in the request locale; plain text passes through.
func TestForbiddenLocalized(t *testing.T) {
env, gdb := newRosterEnv(t)
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cy"})
for locale, want := range map[string][2]string{
"en": {"You may not rename this person.", "This name is reserved."},
"pl": {"Nie możesz zmienić nazwy tej osoby.", "Ta nazwa jest zastrzeżona."},
} {
rec := rosterLocale(env, http.MethodPut, rosterPath(id, ""), `{"name":"Reserved"}`, locale)
got := rosterError(t, rec)
if rec.Code != http.StatusForbidden || got.Message != want[0] || !reflect.DeepEqual(got.Details, map[string]any{"name": []any{want[1]}}) {
t.Fatalf("%s: status=%d error=%+v", locale, rec.Code, got)
}
}
// The refusal of a bulk action is localized as well.
locked := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterLocked})
rec := rosterLocale(env, http.MethodPost, rosterArchive, rosterIDs(locked), "pl")
if got := rosterError(t, rec); got.Message != "Ta osoba jest zablokowana i nie można jej zmienić." {
t.Fatalf("pl bulk refusal = %+v", got)
}
// The plugin's shared error value is never written to.
if rosterRefused.Message != "acme.roster::lang.people.locked" || rosterRefused.Details != nil {
t.Fatalf("the plugin's error value was modified: %+v", rosterRefused)
}
}
// TestForbiddenRollsBack: nothing of a refused request stays, also when the
// refusal comes after rows were written.
func TestForbiddenRollsBack(t *testing.T) {
env, gdb := newRosterEnv(t)
first := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "First"})
second := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Second"})
locked := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterLocked, Active: true, Banned: true})
env.expect(t, http.StatusForbidden, http.MethodPost, rosterArchive, rosterIDs(first, second, locked), "bearer")
for _, id := range []uint{first, second, locked} {
if rosterLoad(t, gdb, id).DeletedAt.Valid {
t.Fatalf("row %d kept the write of a refused bulk action", id)
}
}
env.expect(t, http.StatusForbidden, http.MethodPost, rosterPath(locked, "/actions/reinstate"), `{}`, "bearer")
if !rosterLoad(t, gdb, locked).Banned {
t.Fatal("a refused record action kept its write")
}
// A refused update keeps no field of the body, not even the allowed ones.
env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(first, ""), `{"name":"Reserved","email":"kept@example.test","slug":"kept"}`, "bearer")
if stored := rosterLoad(t, gdb, first); stored.Name != "First" || stored.Email != "" || stored.Slug != "" {
t.Fatalf("a refused update kept a field: %+v", stored)
}
}
// TestForbiddenEmptyMessage: a refusal without a message answers an empty
// message and an object for details; the framework's own permission denial
// keeps its fixed text.
func TestForbiddenEmptyMessage(t *testing.T) {
env, gdb := newRosterEnv(t)
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bea"})
rec := env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(id, ""), `{"name":"Silent"}`, "bearer")
if !strings.Contains(rec.Body.String(), `"code":"forbidden"`) || !strings.Contains(rec.Body.String(), `"message":""`) || !strings.Contains(rec.Body.String(), `"details":{}`) {
t.Fatalf("body = %s", rec.Body.String())
}
if rosterLoad(t, gdb, id).Name != "Bea" {
t.Fatal("a refused update changed the row")
}
denied := env.expect(t, http.StatusForbidden, http.MethodPost, rosterActivate, rosterIDs(id), "limited")
if got := rosterError(t, denied); got.Code != "forbidden" || got.Message == "" {
t.Fatalf("permission denial = %+v", got)
}
}