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:
189
modules/cabana/phase121_record_test.go
Normal file
189
modules/cabana/phase121_record_test.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user