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
This commit is contained in:
Jakub Zych
2026-10-05 14:48:34 +02:00
parent c076b4c059
commit 2e94cbf9f8
13 changed files with 2455 additions and 12 deletions

View File

@@ -0,0 +1,189 @@
package cabana_test
import (
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
// TestRecordActionScope: the record is loaded through the form scope, so a
// missing id and an id outside the scope are the same 404 (T-12.1-04).
func TestRecordActionScope(t *testing.T) {
env, gdb := newRosterEnv(t)
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Foreign"})
trashed := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Trashed", DeletedAt: rosterDeleted()})
var bodies []string
for _, id := range []uint{foreign, 999999} {
rec := env.expect(t, http.StatusNotFound, http.MethodPost, rosterPath(id, "/actions/activate"), `{}`, "bearer")
actErrorCode(t, rec.Body.Bytes(), "not_found")
bodies = append(bodies, rec.Body.String())
}
if bodies[0] != bodies[1] {
t.Fatalf("an out-of-scope id and a missing id answer differently:\n%s\n%s", bodies[0], bodies[1])
}
for _, id := range []string{"abc", "0", "-1", "1.5"} {
if rec := env.call(t, http.MethodPost, rosterPeople+"/"+id+"/actions/activate", `{}`, "bearer"); rec.Code/100 == 2 {
t.Fatalf("id %q answered %d", id, rec.Code)
}
}
if rosterLoad(t, gdb, foreign).Active || len(env.spy.takeRecord()) != 0 {
t.Fatal("an out-of-scope record was changed")
}
// The form scope of this controller includes soft-deleted records.
env.expect(t, http.StatusOK, http.MethodPost, rosterPath(trashed, "/actions/activate"), `{}`, "bearer")
if !rosterLoad(t, gdb, trashed).Active {
t.Fatal("an in-scope soft-deleted record was not reached")
}
}
// TestRecordActionApplies: Applies is checked again inside the transaction;
// an action that does not apply is a 409 and does not run.
func TestRecordActionApplies(t *testing.T) {
env, gdb := newRosterEnv(t)
active := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Active", Active: true})
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
rec := env.expect(t, http.StatusConflict, http.MethodPost, rosterPath(active, "/actions/activate"), `{}`, "bearer")
actErrorCode(t, rec.Body.Bytes(), "conflict")
rec = env.expect(t, http.StatusConflict, http.MethodPost, rosterPath(idle, "/actions/reinstate"), `{}`, "bearer")
actErrorCode(t, rec.Body.Bytes(), "conflict")
if len(env.spy.takeRecord()) != 0 {
t.Fatal("an action ran on a record it does not apply to")
}
// It runs once; the second request finds it no longer applies.
env.expect(t, http.StatusOK, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "bearer")
env.expect(t, http.StatusConflict, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "bearer")
if calls := env.spy.takeRecord(); len(calls) != 1 || calls[0].RecordID != uint64(idle) {
t.Fatalf("Run calls = %+v", calls)
}
}
// TestRecordActionBody: the body is a strict, empty JSON object.
func TestRecordActionBody(t *testing.T) {
env, gdb := newRosterEnv(t)
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
for _, body := range []string{
fmt.Sprintf(`{"record_id":%d}`, idle), `{"record_id":null,"values":{}}`, `{"values":{"active":true}}`,
`{"extra":1}`, `{"field":"name"}x`, `{} {}`, `{}{}`, `null {}`, `[]`, `"activate"`, `{`, ``,
} {
rec := env.call(t, http.MethodPost, rosterPath(idle, "/actions/activate"), body, "bearer")
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("body %q = %d, want 422: %s", body, rec.Code, rec.Body.String())
}
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
}
if rosterLoad(t, gdb, idle).Active || len(env.spy.takeRecord()) != 0 {
t.Fatal("a malformed body ran the action")
}
env.expect(t, http.StatusOK, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "bearer")
// A JSON null is read as the empty object: it carries no input either.
other := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Other"})
env.expect(t, http.StatusOK, http.MethodPost, rosterPath(other, "/actions/activate"), `null`, "bearer")
}
// TestRecordActionPermissions: the controller's permission and the action's
// own, and the CSRF header for cookie requests (T-12.1-02, T-12.1-03).
func TestRecordActionPermissions(t *testing.T) {
env, gdb := newRosterEnv(t)
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle", Banned: true})
rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "limited")
actErrorCode(t, rec.Body.Bytes(), "forbidden")
env.expect(t, http.StatusForbidden, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "cookie-only")
req := httptest.NewRequest(http.MethodPost, adminAPI(rosterPath(idle, "/actions/activate")), strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
anonymous := httptest.NewRecorder()
env.h.ServeHTTP(anonymous, req)
if anonymous.Code != http.StatusUnauthorized {
t.Fatalf("anonymous record action = %d", anonymous.Code)
}
if rosterLoad(t, gdb, idle).Active || len(env.spy.takeRecord()) != 0 {
t.Fatal("a refused request ran the action")
}
// reinstate asks for no permission of its own.
env.expect(t, http.StatusOK, http.MethodPost, rosterPath(idle, "/actions/reinstate"), `{}`, "limited")
env.expect(t, http.StatusOK, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "cookie")
if stored := rosterLoad(t, gdb, idle); stored.Banned || !stored.Active {
t.Fatalf("after the permitted actions: %+v", stored)
}
}
// TestRecordActionOffered: meta.actions of the show response lists the
// actions in declared order and leaves out the denied and the non-applicable.
func TestRecordActionOffered(t *testing.T) {
env, gdb := newRosterEnv(t)
both := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Both", Banned: true})
neither := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Neither", Active: true})
if got := rosterOffered(t, env, both, "bearer"); !reflect.DeepEqual(got, []string{"activate", "reinstate"}) {
t.Fatalf("both apply, full admin: %v (declared order is activate, reinstate)", got)
}
if got := rosterOffered(t, env, both, "limited"); !reflect.DeepEqual(got, []string{"reinstate"}) {
t.Fatalf("both apply, limited admin: %v", got)
}
if got := rosterOffered(t, env, neither, "bearer"); len(got) != 0 {
t.Fatalf("none applies: %v", got)
}
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPath(neither, ""), "", "bearer")
if strings.Contains(rec.Body.String(), `"actions"`) {
t.Fatalf("meta.actions sent without an offered action: %s", rec.Body.String())
}
// Labels and confirm texts follow the request locale; an action without a
// confirm has no confirm key.
rec = rosterLocale(env, http.MethodGet, rosterPath(both, ""), "", "pl")
if !strings.Contains(rec.Body.String(), `"actions":[{"name":"activate","label":"Aktywuj"},{"name":"reinstate","label":"Przywróć","confirm":"Zdjąć blokadę z tej osoby?"}]`) {
t.Fatalf("pl actions = %s", rec.Body.String())
}
// The list rows never carry actions.
list := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople, "", "bearer")
if strings.Contains(list.Body.String(), `"actions"`) {
t.Fatalf("the list carries actions: %s", list.Body.String())
}
}
// TestRecordActionRollback: a refusal or a failure after the action's write
// rolls the write back.
func TestRecordActionRollback(t *testing.T) {
env, gdb := newRosterEnv(t)
for name, status := range map[string]int{rosterLocked: http.StatusForbidden, rosterRunErr: http.StatusInternalServerError} {
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: name, Active: true, Banned: true})
rec := env.expect(t, status, http.MethodPost, rosterPath(id, "/actions/reinstate"), `{}`, "bearer")
if strings.Contains(rec.Body.String(), "hunter2") {
t.Fatalf("%s: the error text of Run is in the body: %s", name, rec.Body.String())
}
if !rosterLoad(t, gdb, id).Banned {
t.Fatalf("%s: the action's write survived its error", name)
}
if calls := env.spy.takeRecord(); len(calls) != 1 {
t.Fatalf("%s: Run calls = %d", name, len(calls))
}
}
}
// TestRecordActionAppliesError: an error from Applies is the generic 500, on
// the action route and on the show route that offers actions.
func TestRecordActionAppliesError(t *testing.T) {
env, gdb := newRosterEnv(t)
logs := captureLog(t)
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterAppliesErr})
for _, call := range []struct{ method, rel, body string }{
{http.MethodPost, rosterPath(id, "/actions/activate"), `{}`},
{http.MethodGet, rosterPath(id, ""), ""},
} {
rec := env.expect(t, http.StatusInternalServerError, call.method, call.rel, call.body, "bearer")
got := rosterError(t, rec)
if got.Code != "error" || len(got.Details) != 0 || strings.Contains(rec.Body.String(), "hunter2") || strings.Contains(rec.Body.String(), "applies check") {
t.Fatalf("%s %s = %s", call.method, call.rel, rec.Body.String())
}
}
if rosterLoad(t, gdb, id).Active || len(env.spy.takeRecord()) != 0 {
t.Fatal("the action ran although Applies failed")
}
// The cause is logged on the server, with the controller and the action.
failed := logs.matching("cabana: admin record action failed")
if len(failed) != 2 || !strings.Contains(failed[0], "action=activate") || !strings.Contains(failed[0], "hunter2") {
t.Fatalf("server log = %q", failed)
}
// An administrator who is not offered the action never triggers Applies.
env.expect(t, http.StatusOK, http.MethodGet, rosterPath(id, ""), "", "limited")
}