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:
369
modules/cabana/phase121_bulk_test.go
Normal file
369
modules/cabana/phase121_bulk_test.go
Normal file
@@ -0,0 +1,369 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user