- 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
193 lines
9.6 KiB
Go
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)
|
|
}
|
|
}
|