Files
summercms/modules/cabana/phase121_bulk_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

370 lines
15 KiB
Go

package cabana_test
import (
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// rosterLocale sends one bearer request with an Accept-Language header.
func rosterLocale(env *rosterEnv, method, rel, body, locale string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, adminAPI(rel), strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept-Language", locale)
req.Header.Set("Authorization", "Bearer "+env.token)
rec := httptest.NewRecorder()
env.h.ServeHTTP(rec, req)
return rec
}
const (
rosterActivate = rosterPeople + "/bulk/activate"
rosterArchive = rosterPeople + "/bulk/archive"
)
// rosterNames are the names of the records one bulk Run received, in order.
func rosterNames(records []any) []string {
names := make([]string, len(records))
for i, record := range records {
names[i] = record.(*rosterPerson).Name
}
return names
}
// TestBulkActionEmpty: a request without ids is 422 and never reaches Run
// (D-09).
func TestBulkActionEmpty(t *testing.T) {
env, _ := newRosterEnv(t)
for _, body := range []string{`{"ids":[]}`, `{}`, `{"ids":null}`, `{"ids":["x"]}`, `{"ids":[-1]}`, `{"ids":[1.5]}`, `[1]`, `{`, ``} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterActivate, body, "bearer")
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
}
if calls := env.spy.takeBulk(); len(calls) != 0 {
t.Fatalf("a request without ids reached Run: %+v", calls)
}
}
// TestBulkActionDuplicates: repeated ids are one record each.
func TestBulkActionDuplicates(t *testing.T) {
env, gdb := newRosterEnv(t)
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada"})
bob := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob"})
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(bob, ada, bob, ada, ada), "bearer")
if result := rosterBulkResult(t, rec); result.Affected != 2 {
t.Fatalf("affected = %d, want 2", result.Affected)
}
calls := env.spy.takeBulk()
if len(calls) != 1 || !reflect.DeepEqual(rosterNames(calls[0].Records), []string{"Ada", "Bob"}) {
t.Fatalf("Run received %+v, want Ada and Bob once each", calls)
}
}
// TestBulkActionOrder: records reach Run ordered by primary key, whatever the
// order of the posted ids.
func TestBulkActionOrder(t *testing.T) {
env, gdb := newRosterEnv(t)
var ids []uint
for _, name := range []string{"One", "Two", "Three", "Four"} {
ids = append(ids, rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: name}))
}
env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(ids[3], ids[0], ids[2], ids[1]), "bearer")
calls := env.spy.takeBulk()
if len(calls) != 1 || !reflect.DeepEqual(rosterNames(calls[0].Records), []string{"One", "Two", "Three", "Four"}) {
t.Fatalf("Run received %v", calls)
}
for i, record := range calls[0].Records {
if record.(*rosterPerson).ID != ids[i] {
t.Fatalf("record %d has id %d, want %d", i, record.(*rosterPerson).ID, ids[i])
}
}
}
// TestBulkActionAbsent: a selection that matches no row answers affected 0
// and does not call Run.
func TestBulkActionAbsent(t *testing.T) {
env, gdb := newRosterEnv(t)
kept := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Kept"})
// Id 0 is a well-formed id that no row has.
for _, body := range []string{rosterIDs(999998, 999999), rosterIDs(0)} {
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, body, "bearer")
if result := rosterBulkResult(t, rec); result.Affected != 0 || result.Message != "" {
t.Fatalf("%s: result = %+v", body, result)
}
}
if calls := env.spy.takeBulk(); len(calls) != 0 {
t.Fatalf("Run was called for an absent selection: %+v", calls)
}
if rosterLoad(t, gdb, kept).Active {
t.Fatal("an unselected row was changed")
}
}
// TestBulkActionPartial: a selection with a present and an absent id is a 409
// and changes nothing.
func TestBulkActionPartial(t *testing.T) {
env, gdb := newRosterEnv(t)
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada"})
bob := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob"})
rec := env.expect(t, http.StatusConflict, http.MethodPost, rosterActivate, rosterIDs(ada, 999999, bob), "bearer")
actErrorCode(t, rec.Body.Bytes(), "conflict")
if rosterLoad(t, gdb, ada).Active || rosterLoad(t, gdb, bob).Active {
t.Fatal("a partial selection changed a row")
}
if calls := env.spy.takeBulk(); len(calls) != 0 {
t.Fatalf("Run was called for a partial selection: %+v", calls)
}
}
// TestBulkActionScope: ids are resolved through the list scope
// (ListExtendQuery), so a row of another tenant is absent (T-12.1-01).
func TestBulkActionScope(t *testing.T) {
env, gdb := newRosterEnv(t)
own := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Own"})
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Foreign"})
trashed := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Trashed", DeletedAt: rosterDeleted()})
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(foreign), "bearer")
if result := rosterBulkResult(t, rec); result.Affected != 0 {
t.Fatalf("out-of-scope selection: %+v", result)
}
env.expect(t, http.StatusConflict, http.MethodPost, rosterActivate, rosterIDs(own, foreign), "bearer")
if calls := env.spy.takeBulk(); len(calls) != 0 {
t.Fatalf("Run saw a selection with an out-of-scope id: %+v", calls)
}
if rosterLoad(t, gdb, foreign).Active || rosterLoad(t, gdb, own).Active {
t.Fatal("a row was changed")
}
// The scope of this controller includes soft-deleted rows.
rec = env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(own, trashed), "bearer")
if result := rosterBulkResult(t, rec); result.Affected != 2 {
t.Fatalf("in-scope selection: %+v", result)
}
}
// TestBulkActionRollback: an error from Run, refusal or failure, rolls every
// row of the selection back (T-12.1-05).
func TestBulkActionRollback(t *testing.T) {
env, gdb := newRosterEnv(t)
for _, last := range []struct {
name string
status int
}{{rosterCrash, http.StatusInternalServerError}, {rosterLocked, http.StatusForbidden}} {
first := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "First"})
second := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Second"})
failing := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: last.name})
rec := env.expect(t, last.status, http.MethodPost, rosterArchive, rosterIDs(first, second, failing), "bearer")
if strings.Contains(rec.Body.String(), "hunter2") {
t.Fatalf("the error text of Run is in the body: %s", rec.Body.String())
}
for _, id := range []uint{first, second, failing} {
if rosterLoad(t, gdb, id).DeletedAt.Valid {
t.Fatalf("%s: row %d kept the write of a failed bulk action", last.name, id)
}
}
// Run did see all three rows: the first two were written before the
// failure.
if calls := env.spy.takeBulk(); len(calls) != 1 || len(calls[0].Records) != 3 {
t.Fatalf("%s: Run received %+v", last.name, calls)
}
}
}
// TestBulkActionConcurrent: two runs over the same rows, posted in opposite
// id order, do not deadlock: the second waits for the row locks of the first
// and then sees its result.
func TestBulkActionConcurrent(t *testing.T) {
env, gdb := newRosterEnv(t)
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada"})
bob := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob"})
inside, release := make(chan struct{}), make(chan struct{})
var once sync.Once
wait := func() {
blocked := false
once.Do(func() { blocked = true })
if blocked {
close(inside)
<-release
}
}
env.knobs.slowBulk.Store(&wait)
t.Cleanup(func() { env.knobs.slowBulk.Store(nil) })
type answer struct {
code int
affected int
}
run := func(body string, out chan<- answer) {
rec := env.call(t, http.MethodPost, rosterActivate, body, "bearer")
out <- answer{rec.Code, rosterBulkResult(t, rec).Affected}
}
first, second := make(chan answer, 1), make(chan answer, 1)
go run(rosterIDs(ada, bob), first)
select {
case <-inside:
case <-time.After(10 * time.Second):
t.Fatal("the first run never reached its action")
}
// The first run holds the row locks. The second must wait for them.
go run(rosterIDs(bob, ada), second)
select {
case got := <-second:
t.Fatalf("the second run finished while the first held the rows: %+v", got)
case <-time.After(300 * time.Millisecond):
}
close(release)
var answers []answer
for _, ch := range []chan answer{first, second} {
select {
case got := <-ch:
answers = append(answers, got)
case <-time.After(15 * time.Second):
t.Fatal("a concurrent bulk action did not finish (deadlock)")
}
}
// Both answer 200; the rows were activated exactly once.
if answers[0].code != http.StatusOK || answers[1].code != http.StatusOK {
t.Fatalf("answers = %+v", answers)
}
if answers[0].affected != 2 || answers[1].affected != 0 {
t.Fatalf("affected = %d and %d, want 2 and 0: the second run must see the first one's result", answers[0].affected, answers[1].affected)
}
if !rosterLoad(t, gdb, ada).Active || !rosterLoad(t, gdb, bob).Active {
t.Fatal("the rows are not active after both runs")
}
}
// TestBulkActionPermissions: the controller's permission and the action's own
// (T-12.1-02).
func TestBulkActionPermissions(t *testing.T) {
env, gdb := newRosterEnv(t)
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterActivate, rosterIDs(idle), "limited")
actErrorCode(t, rec.Body.Bytes(), "forbidden")
// The permission is checked before the body is read.
env.expect(t, http.StatusForbidden, http.MethodPost, rosterActivate, `{`, "limited")
if rosterLoad(t, gdb, idle).Active || len(env.spy.takeBulk()) != 0 {
t.Fatal("an action ran without its permission")
}
// Without a token the request is not authenticated at all.
req := httptest.NewRequest(http.MethodPost, adminAPI(rosterActivate), strings.NewReader(rosterIDs(idle)))
req.Header.Set("Content-Type", "application/json")
anonymous := httptest.NewRecorder()
env.h.ServeHTTP(anonymous, req)
if anonymous.Code != http.StatusUnauthorized {
t.Fatalf("anonymous bulk action = %d", anonymous.Code)
}
// An action that asks for the controller's permission only runs for the
// limited administrator.
env.expect(t, http.StatusOK, http.MethodPost, rosterArchive, rosterIDs(idle), "limited")
if !rosterLoad(t, gdb, idle).DeletedAt.Valid {
t.Fatal("archive did not run for the limited administrator")
}
}
// TestBulkActionUndeclared: a name the list does not declare is 404, whatever
// else is registered under it.
func TestBulkActionUndeclared(t *testing.T) {
env, gdb := newRosterEnv(t)
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle", Banned: true})
// missing: nowhere. reinstate: a record action. delete and create: the
// built-in names, which are never declared actions.
for _, name := range []string{"missing", "reinstate", "delete", "create", "Activate", "activate%20"} {
rec := env.call(t, http.MethodPost, rosterPeople+"/bulk/"+name, rosterIDs(idle), "bearer")
if rec.Code != http.StatusNotFound {
t.Fatalf("bulk/%s = %d, want 404", name, rec.Code)
}
}
env.expect(t, http.StatusNotFound, http.MethodPost, "/acme/roster/nobody/bulk/activate", rosterIDs(idle), "bearer")
// The route is POST only.
if rec := env.call(t, http.MethodGet, rosterActivate, "", "bearer"); rec.Code == http.StatusOK {
t.Fatalf("GET on the bulk action route = %d", rec.Code)
}
if stored := rosterLoad(t, gdb, idle); stored.Active || !stored.Banned || len(env.spy.takeBulk()) != 0 {
t.Fatal("an undeclared name ran an action")
}
// A controller without declared bulk actions has none to run.
demo, demoDB := newActEnv(t)
gadget := actInsert(t, demoDB, "plain", "acme")
demo.expect(t, http.StatusNotFound, http.MethodPost, "/acme/demo/gadgets/bulk/activate", rosterIDs(gadget), "bearer")
}
// TestBulkActionCSRF: a cookie request needs X-Requested-With (T-12.1-03).
func TestBulkActionCSRF(t *testing.T) {
env, gdb := newRosterEnv(t)
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterActivate, rosterIDs(idle), "cookie-only")
actErrorCode(t, rec.Body.Bytes(), "forbidden")
if rosterLoad(t, gdb, idle).Active || len(env.spy.takeBulk()) != 0 {
t.Fatal("a cookie request without the header ran the action")
}
env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(idle), "cookie")
if !rosterLoad(t, gdb, idle).Active {
t.Fatal("a cookie request with the header did not run")
}
}
// TestBulkActionMessageLocalized: the action's message is a phrase key
// resolved in the request locale; an action without one answers an empty
// message.
func TestBulkActionMessageLocalized(t *testing.T) {
env, gdb := newRosterEnv(t)
for locale, want := range map[string]string{
"en": "The selected people were archived.",
"pl": "Zaznaczone osoby zostały zarchiwizowane.",
} {
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Spare " + locale})
rec := rosterLocale(env, http.MethodPost, rosterArchive, rosterIDs(id), locale)
if result := rosterBulkResult(t, rec); rec.Code != http.StatusOK || result.Message != want || result.Affected != 1 {
t.Fatalf("%s: status=%d result=%+v, want %q", locale, rec.Code, result, want)
}
}
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Quiet"})
rec := rosterLocale(env, http.MethodPost, rosterActivate, rosterIDs(id), "pl")
if result := rosterBulkResult(t, rec); result.Message != "" || !strings.Contains(rec.Body.String(), `"message":""`) {
t.Fatalf("an action without a message answered %s", rec.Body.String())
}
// The list schema localizes label and confirm the same way.
schema := rosterLocale(env, http.MethodGet, rosterPeople+"/schema/list", "", "pl")
if !strings.Contains(schema.Body.String(), `"label":"Aktywuj","confirm":"Aktywować zaznaczone osoby?"`) {
t.Fatalf("pl list schema = %s", schema.Body.String())
}
}
// TestBulkActionBodyCap: a body past http.body_limits.default_bytes is
// refused before the action runs.
func TestBulkActionBodyCap(t *testing.T) {
env, gdb := newRosterEnv(t)
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
huge := fmt.Sprintf(`{"ids":[%d],"pad":"%s"}`, idle, strings.Repeat("x", 1100<<10))
rec := env.expect(t, http.StatusRequestEntityTooLarge, http.MethodPost, rosterActivate, huge, "bearer")
if rosterLoad(t, gdb, idle).Active || len(env.spy.takeBulk()) != 0 {
t.Fatalf("an oversized request ran the action: %s", rec.Body.String())
}
}
// TestListSchemaBulkActionsFiltered: the list schema offers each
// administrator the bulk actions they may run, and a filtered answer leaves
// the cached schema whole.
func TestListSchemaBulkActionsFiltered(t *testing.T) {
env, _ := newRosterEnv(t)
full := []string{"delete", "activate", "archive"}
for range 2 {
if got := rosterBulkNames(t, env, "limited"); !reflect.DeepEqual(got, []string{"delete", "archive"}) {
t.Fatalf("limited admin = %v", got)
}
if got := rosterBulkNames(t, env, "bearer"); !reflect.DeepEqual(got, full) {
t.Fatalf("full admin after a filtered request = %v, want %v", got, full)
}
if got := rosterBulkNames(t, env, "cookie"); !reflect.DeepEqual(got, full) {
t.Fatalf("full admin by cookie = %v", got)
}
}
}