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,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)
}
}
}

View File

@@ -0,0 +1,238 @@
package cabana_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// TestPasswordFieldNeverProjected: no response of any route carries the
// password key, the submitted text or the stored hash, and the schema serves
// the field without a value (D-27 G1; T-12.1-10).
func TestPasswordFieldNeverProjected(t *testing.T) {
env, gdb := newRosterEnv(t)
const plain = "s3cret-plain-text"
pair := fmt.Sprintf(`"password":%q,"password_confirmation":%q`, plain, plain)
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Pat",`+pair+`}`, "bearer")
created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
id := uint(created)
hash := rosterHash(plain)
if rosterLoad(t, gdb, id).Password != hash {
t.Fatal("the hook did not store the hash")
}
bodies := map[string]string{
"create": rec.Body.String(),
"show": env.expect(t, http.StatusOK, http.MethodGet, rosterPath(id, ""), "", "bearer").Body.String(),
"list": env.expect(t, http.StatusOK, http.MethodGet, rosterPeople, "", "bearer").Body.String(),
"list search": env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"?search="+plain, "", "bearer").Body.String(),
"update": env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Pat B",`+pair+`}`, "bearer").Body.String(),
"update, plain": env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Pat C"}`, "bearer").Body.String(),
"record action": env.expect(t, http.StatusOK, http.MethodPost, rosterPath(id, "/actions/activate"), `{}`, "bearer").Body.String(),
"bulk action": env.expect(t, http.StatusOK, http.MethodPost, rosterArchive, rosterIDs(id), "bearer").Body.String(),
"refusal": env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(id, ""), `{"name":"Reserved",`+pair+`}`, "bearer").Body.String(),
"failure": env.expect(t, http.StatusInternalServerError, http.MethodPut, rosterPath(id, ""), `{"name":"Boom",`+pair+`}`, "bearer").Body.String(),
}
for route, body := range bodies {
if strings.Contains(body, "password") || strings.Contains(body, plain) || strings.Contains(body, hash) || strings.Contains(body, "sha256:") {
t.Fatalf("the %s response carries the password: %s", route, body)
}
}
// A validation failure names the field and still carries no value.
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), fmt.Sprintf(`{"password":%q,"password_confirmation":"other-enough-1"}`, plain), "bearer")
if strings.Contains(rec.Body.String(), plain) || strings.Contains(rec.Body.String(), "other-enough-1") {
t.Fatalf("a 422 echoes the password: %s", rec.Body.String())
}
// The search above did not match on the password column either.
if strings.Contains(bodies["list search"], `"name":"Pat"`) {
t.Fatalf("the list searches the password column: %s", bodies["list search"])
}
_, raw := rosterFormSchema(t, env, "bearer")
for _, field := range []string{
`{"name":"password","type":"password","label":"Password","span":"left","context":["create","update"]}`,
`{"name":"password_confirmation","type":"password","label":"Repeat the password","span":"right","context":["create","update"]}`,
} {
if !strings.Contains(raw, field) {
t.Fatalf("the schema does not serve %s without a value: %s", field, raw)
}
}
}
// TestVirtualFieldsContext: a virtual value reaches the hooks only when the
// field's context allows the operation, as a copy, and never outside a save
// (D-27 G2; T-12.1-09).
func TestVirtualFieldsContext(t *testing.T) {
env, gdb := newRosterEnv(t)
if values, ok := cabana.VirtualFieldsFromContext(context.Background()); ok || values != nil {
t.Fatalf("virtual fields outside a save: %v %v", values, ok)
}
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Vic","notify":false,`+rosterPair+`}`, "bearer")
created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
id := uint(created)
seen := env.spy.takeVirtual()
if len(seen) != 2 || seen[0].Hook != "before-create" || seen[1].Hook != "after-create" {
t.Fatalf("create hooks = %+v", seen)
}
for _, hook := range seen {
// The before hook removed notify from its own copy.
if !hook.Found || len(hook.Values) != 3 || hook.Values["notify"] != false || hook.Values["password"] != "long-enough-1" {
t.Fatalf("%s saw %+v", hook.Hook, hook.Values)
}
}
// A create that does not submit a virtual field passes no entry for it.
env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Val",`+rosterPair+`}`, "bearer")
if seen = env.spy.takeVirtual(); len(seen) != 2 || len(seen[0].Values) != 2 {
t.Fatalf("create without notify: %+v", seen)
}
if _, ok := seen[0].Values["notify"]; ok {
t.Fatalf("an absent virtual field got an entry: %+v", seen[0].Values)
}
// notify has context create: an update never passes it. The password pair
// has context create and update: it is passed.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Vic B","notify":true,"password":"another-plain-9","password_confirmation":"another-plain-9"}`, "bearer")
seen = env.spy.takeVirtual()
if len(seen) != 1 || seen[0].Hook != "before-update" || !seen[0].Found || len(seen[0].Values) != 2 || seen[0].Values["password"] != "another-plain-9" {
t.Fatalf("update hook saw %+v", seen)
}
if rosterLoad(t, gdb, id).Password != rosterHash("another-plain-9") {
t.Fatal("the update hook did not receive the password")
}
// An update without virtual values still reports a save: an empty map.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Vic C"}`, "bearer")
if seen = env.spy.takeVirtual(); len(seen) != 1 || !seen[0].Found || len(seen[0].Values) != 0 {
t.Fatalf("update without virtual values: %+v", seen)
}
// A number arrives as decoded from JSON, a json.Number: the fixture's hook
// takes a string only, so the stored hash stays.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"password":12345678,"password_confirmation":12345678}`, "bearer")
seen = env.spy.takeVirtual()
if number, ok := seen[0].Values["password"].(json.Number); len(seen) != 1 || !ok || number.String() != "12345678" {
t.Fatalf("a numeric virtual value arrived as %T %v", seen[0].Values["password"], seen[0].Values["password"])
}
if rosterLoad(t, gdb, id).Password != rosterHash("another-plain-9") {
t.Fatal("a numeric password was stored")
}
}
// TestVirtualFieldsNested: a nested value for a virtual field is 422 on the
// field and reaches no hook.
func TestVirtualFieldsNested(t *testing.T) {
env, gdb := newRosterEnv(t)
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Nest", Password: rosterHash("stored-before")})
for field, value := range map[string]string{
"password": `{"$ne":""}`,
"password_confirmation": `["a","b"]`,
} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), fmt.Sprintf(`{"name":"Changed",%q:%s}`, field, value), "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", field, "The "+field+" field has an invalid value.")
}
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"Nested","notify":[true],`+rosterPair+`}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "notify", "The notify field has an invalid value.")
if stored := rosterLoad(t, gdb, id); stored.Name != "Nest" || stored.Password != rosterHash("stored-before") {
t.Fatalf("a refused save wrote: %+v", stored)
}
if n := rosterCount(t, env, "Nested"); n != 0 {
t.Fatalf("a refused create left %d rows", n)
}
if seen := env.spy.takeVirtual(); len(seen) != 0 {
t.Fatalf("a refused save reached a hook: %+v", seen)
}
}
// TestFormRulesReplaceModelRules: the controller's FormRules are the rules of
// an admin save, per operation; without them the model's Rules apply (D-28
// G5).
func TestFormRulesReplaceModelRules(t *testing.T) {
env, gdb := newRosterEnv(t)
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Rae", Password: rosterHash("stored-before")})
// The model demands a confirmed password on every save; the controller's
// update rules do not.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Rae B"}`, "bearer")
// The create rules do.
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"No password"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password field is required.")
// Both operations keep the name rule.
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), `{"name":""}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "name", "The name field is required.")
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{`+rosterPair+`}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "name", "The name field is required.")
// The same form on a controller without FormRules: the model's rules.
bare, bareDB := newRosterBareEnv(t)
other := rosterInsert(t, bareDB, rosterPerson{Tenant: "acme", Name: "Rae", Password: rosterHash("stored-before")})
rec = bare.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(other, ""), `{"name":"Rae B"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password field is required.")
bare.expect(t, http.StatusOK, http.MethodPut, rosterPath(other, ""), `{"name":"Rae B",`+rosterPair+`}`, "bearer")
if stored := rosterLoad(t, bareDB, other); stored.Name != "Rae B" || stored.Password != rosterHash("long-enough-1") {
t.Fatalf("stored = %+v", stored)
}
}
// TestFormRulesRequiredMerge: `required: true` in fields.yaml is merged into
// the rules of a column field and of a virtual field, for the operations the
// field's context allows.
func TestFormRulesRequiredMerge(t *testing.T) {
fields := rosterFields(t, " type: checkbox\n default: true\n", " type: checkbox\n default: true\n required: true\n")
fields = strings.Replace(fields, " type: text\n span: right\n", " type: text\n span: right\n required: true\n", 1)
if !strings.Contains(fields, "span: right\n required: true") {
t.Fatal("the email field was not made required")
}
env, gdb := newRosterEnvWith(t, func(p *rosterPlugin) {
p.fsys = rosterTree(t, map[string]string{rosterFieldsFile: fields})
})
const email = `"email":"req@example.test"`
// The virtual field notify is required on create, where its context is.
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"Req",`+email+`,`+rosterPair+`}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "notify", "The notify field is required.")
// The column field email is required although FormRules does not name it.
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"Req","notify":true,`+rosterPair+`}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "email", "The email field is required.")
if n := rosterCount(t, env, "Req"); n != 0 {
t.Fatalf("a refused create left %d rows", n)
}
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Req","notify":true,`+email+`,`+rosterPair+`}`, "bearer")
created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
// On update notify is outside its context and is not asked for; email is.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(uint(created), ""), `{"name":"Req B"}`, "bearer")
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(uint(created), ""), `{"email":""}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "email", "The email field is required.")
if stored := rosterLoad(t, gdb, uint(created)); stored.Name != "Req B" || stored.Email != "req@example.test" {
t.Fatalf("stored = %+v", stored)
}
// The schema tells the client.
_, raw := rosterFormSchema(t, env, "bearer")
if strings.Count(raw, `"required":true`) != 2 {
t.Fatalf("required flags in the schema: %s", raw)
}
}
// TestPresetSchemaShapes: the preset key is served as field and type in both
// of its YAML shapes (D-27 G7).
func TestPresetSchemaShapes(t *testing.T) {
for _, tc := range []struct{ name, yaml, want string }{
{"a field name", " preset: name\n", `"preset":{"field":"name","type":"slug"}`},
{"a mapping with slug", " preset:\n field: name\n type: slug\n", `"preset":{"field":"name","type":"slug"}`},
{"a mapping with exact", " preset:\n field: name\n type: exact\n", `"preset":{"field":"name","type":"exact"}`},
{"a mapping without a type", " preset:\n field: email\n", `"preset":{"field":"email","type":"slug"}`},
} {
t.Run(tc.name, func(t *testing.T) {
env, gdb := newRosterEnvWith(t, func(p *rosterPlugin) {
p.fsys = rosterTree(t, map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", tc.yaml)})
})
_, raw := rosterFormSchema(t, env, "bearer")
if !strings.Contains(raw, `"name":"slug","type":"text","label":"Slug",`+tc.want) || strings.Count(raw, `"preset"`) != 1 {
t.Fatalf("schema = %s, want %s", raw, tc.want)
}
// The preset is a client rule: the server stores what it is sent.
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Zażółć Gęślą","slug":"sent-by-client",`+rosterPair+`}`, "bearer")
created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
if stored := rosterLoad(t, gdb, uint(created)); stored.Slug != "sent-by-client" {
t.Fatalf("stored slug = %q", stored.Slug)
}
})
}
}

View File

@@ -10,6 +10,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -200,9 +201,9 @@ type rosterKnobs struct {
permissionValues atomic.Bool
// relationLocks makes AdminRelationLocks fail.
relationLocks atomic.Bool
// slowArchive, when set, runs inside the archive bulk action after the
// rows were locked (concurrency tests).
slowArchive atomic.Pointer[func()]
// slowBulk, when set, runs inside each bulk action after the rows were
// locked (concurrency tests).
slowBulk atomic.Pointer[func()]
}
// The sentinel names below make one hook of the roster controller misbehave
@@ -241,6 +242,8 @@ type rosterPlugin struct {
// relations, when set, rewrites the controller's relation contracts
// (boot tests).
relations func([]cabana.FieldRelationContract) []cabana.FieldRelationContract
// wrap, when set, replaces the controller the plugin registers.
wrap func(rosterController) pact.AdminController
}
func (rosterPlugin) ID() string { return "acme.roster" }
@@ -248,7 +251,11 @@ func (rosterPlugin) Requires() []string { return nil }
func (rosterPlugin) Register(*backpack.App) error { return nil }
func (rosterPlugin) Boot(*backpack.App) error { return nil }
func (p rosterPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{rosterController{spy: p.spy, knobs: p.knobs, db: p.db, relations: p.relations}}
ctl := rosterController{spy: p.spy, knobs: p.knobs, db: p.db, relations: p.relations}
if p.wrap != nil {
return []pact.AdminController{p.wrap(ctl)}
}
return []pact.AdminController{ctl}
}
func (rosterPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.roster.access", Roles: []string{"developer"}}, {Code: "acme.roster.manage", Roles: []string{"developer"}}}
@@ -313,14 +320,25 @@ func (c rosterController) handle(ctx context.Context) *gorm.DB {
return c.db.WithContext(ctx)
}
// AdminRelationLocks locks the staff tag for an administrator without
// acme.roster.manage.
// AdminRelationLocks locks the staff tag and the vault team for an
// administrator without acme.roster.manage.
func (c rosterController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
if c.knobs != nil && c.knobs.relationLocks.Load() {
return cabana.RelationLock{}, fmt.Errorf("the lock table said hunter2")
}
principal, _ := bouncer.User(ctx)
if field != "tags" || cabana.Allows(principal, []string{"acme.roster.manage"}) {
if cabana.Allows(principal, []string{"acme.roster.manage"}) {
return cabana.RelationLock{}, nil
}
if field == "team" {
// The team named vault is locked, without a message of its own.
var ids []uint
if err := c.handle(ctx).Model(&rosterTeam{}).Where("name = ?", "vault").Pluck("id", &ids).Error; err != nil {
return cabana.RelationLock{}, err
}
return cabana.RelationLock{IDs: ids}, nil
}
if field != "tags" {
return cabana.RelationLock{}, nil
}
var ids []uint
@@ -588,6 +606,16 @@ func (rosterController) FormAfterDelete(ctx context.Context, model any) error {
return nil
}
// slow runs the slowBulk knob, when one is set.
func (c rosterController) slow() {
if c.knobs == nil {
return
}
if wait := c.knobs.slowBulk.Load(); wait != nil {
(*wait)()
}
}
// AdminBulkActions: activate needs acme.roster.manage and sets active on the
// rows that are not active yet, reporting how many it changed; archive needs
// only the controller permission and soft-deletes the rows.
@@ -601,6 +629,7 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
if !ok {
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
}
c.slow()
changed := 0
for _, record := range in.Records {
person := record.(*rosterPerson)
@@ -624,11 +653,7 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
if !ok {
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
}
if c.knobs != nil {
if wait := c.knobs.slowArchive.Load(); wait != nil {
(*wait)()
}
}
c.slow()
for _, record := range in.Records {
// A refusal after earlier rows were written: the whole
// selection must roll back.
@@ -847,3 +872,96 @@ func rosterLoad(t *testing.T, gdb *gorm.DB, id uint) rosterPerson {
}
return person
}
// rosterBare is the roster controller with an explicit method set: it has no
// relation lock provider, no FormRules, no FilterOptions and no row states,
// so the framework's behaviour without those seams is observable. newRecord,
// when set, replaces the model.
type rosterBare struct {
inner rosterController
newRecord func() any
}
func (b rosterBare) ID() string { return b.inner.ID() }
func (b rosterBare) ModelName() string { return b.inner.ModelName() }
func (b rosterBare) ConfigDir() string { return b.inner.ConfigDir() }
func (b rosterBare) RequiredPermissions() []string { return b.inner.RequiredPermissions() }
func (b rosterBare) NewRecord() any {
if b.newRecord != nil {
return b.newRecord()
}
return b.inner.NewRecord()
}
func (b rosterBare) ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
return b.inner.ListExtendQuery(ctx, db)
}
func (b rosterBare) FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
return b.inner.FormExtendQuery(ctx, db)
}
func (b rosterBare) AdminFieldRelations() []cabana.FieldRelationContract {
return b.inner.AdminFieldRelations()
}
func (b rosterBare) RelationExtendOptionsQuery(ctx context.Context, field string, db *gorm.DB) *gorm.DB {
return b.inner.RelationExtendOptionsQuery(ctx, field, db)
}
func (b rosterBare) FormVirtualFields() []string { return b.inner.FormVirtualFields() }
func (b rosterBare) FormBeforeCreate(ctx context.Context, model any) error {
return b.inner.FormBeforeCreate(ctx, model)
}
func (b rosterBare) FormBeforeUpdate(ctx context.Context, model any) error {
return b.inner.FormBeforeUpdate(ctx, model)
}
func (b rosterBare) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
return b.inner.AdminPermissionOptions(ctx, field)
}
func (b rosterBare) AdminPermissionValues(ctx context.Context, field string, record any) (map[string]int, error) {
return b.inner.AdminPermissionValues(ctx, field, record)
}
func (b rosterBare) AdminSetPermissionValues(ctx context.Context, field string, record any, values map[string]int) error {
return b.inner.AdminSetPermissionValues(ctx, field, record, values)
}
func (b rosterBare) AdminBulkActions() []pact.AdminBulkAction { return b.inner.AdminBulkActions() }
func (b rosterBare) AdminRecordActions() []pact.AdminRecordAction {
return b.inner.AdminRecordActions()
}
func (b rosterBare) PartialData(ctx context.Context, name string, record any) (any, error) {
return b.inner.PartialData(ctx, name, record)
}
// rosterOptionsPerson is the roster model that serves its tagged filter's
// choices itself (the model fallback of pact.FilterOptions).
type rosterOptionsPerson struct {
rosterPerson
}
func (rosterOptionsPerson) FilterOptions(scope string) []pact.Option {
if scope != "tagged" {
return nil
}
return []pact.Option{{Value: "1", Label: "acme.roster::lang.people.tags"}, {Value: "2", Label: "Plain label"}}
}
// rosterListNoFilter is the fixture's config_list.yaml without its filter.
func rosterListNoFilter(t *testing.T) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(rosterDir, "controllers/people/config_list.yaml"))
if err != nil {
t.Fatal(err)
}
const line = "filter: config_filter.yaml\n"
if !strings.Contains(string(raw), line) {
t.Fatal("the fixture list has no filter line")
}
return strings.Replace(string(raw), line, "", 1)
}
// newRosterBareEnv assembles the roster fixture around rosterBare, without
// the list filter (which needs FilterOptions).
func newRosterBareEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
t.Helper()
list := rosterListNoFilter(t)
return newRosterEnvWith(t, func(p *rosterPlugin) {
p.fsys = rosterTree(t, map[string]string{"controllers/people/config_list.yaml": list})
p.wrap = func(inner rosterController) pact.AdminController { return rosterBare{inner: inner} }
})
}

View File

@@ -0,0 +1,192 @@
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)
}
}

View File

@@ -0,0 +1,101 @@
package cabana_test
import (
"fmt"
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestInvisibleColumnSearchAndRows: a column with invisible: true is flagged
// in the schema, searched and sorted on the server, and never part of a row
// (D-27 G6).
func TestInvisibleColumnSearchAndRows(t *testing.T) {
env, gdb := newRosterEnv(t)
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@hidden.example.test", Slug: "ada", Active: true})
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob", Email: "bob@example.test", Slug: "bob", Active: true})
rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed", Email: "zed@hidden.example.test", Active: true})
schema := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", "bearer").Body.String()
if !strings.Contains(schema, `{"key":"email","label":"Email","searchable":true,"sortable":true,"invisible":true}`) || strings.Count(schema, `"invisible"`) != 1 {
t.Fatalf("list schema = %s", schema)
}
for _, query := range []string{"", "?search=ada", "?search=hidden.example", "?sort=email&dir=asc"} {
raw := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+query, "", "bearer").Body.String()
if strings.Contains(raw, `"email"`) || strings.Contains(raw, "example.test") {
t.Fatalf("rows of %q carry the invisible column: %s", query, raw)
}
// The visible columns are there.
if !strings.Contains(raw, `"slug":"ada"`) {
t.Fatalf("rows of %q lack a visible column: %s", query, raw)
}
}
// Search by the invisible column finds the row, inside the list scope only.
found := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"?search=hidden.example", "", "bearer").Body.String()
if !strings.Contains(found, `"name":"Ada"`) || strings.Contains(found, `"name":"Bob"`) || strings.Contains(found, `"name":"Zed"`) || !strings.Contains(found, `"total":1`) {
t.Fatalf("search by the invisible column = %s", found)
}
// It sorts in both directions.
asc := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"?sort=email&dir=asc", "", "bearer").Body.String()
desc := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"?sort=email&dir=desc", "", "bearer").Body.String()
if strings.Index(asc, `"name":"Ada"`) > strings.Index(asc, `"name":"Bob"`) || strings.Index(desc, `"name":"Ada"`) < strings.Index(desc, `"name":"Bob"`) {
t.Fatalf("sort by the invisible column:\nasc %s\ndesc %s", asc, desc)
}
// The record response is the form's, not the list's: email is a form field.
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cy", Email: "cy@example.test", Active: true})
if shown := env.expect(t, http.StatusOK, http.MethodGet, rosterPath(id, ""), "", "bearer").Body.String(); !strings.Contains(shown, `"email":"cy@example.test"`) {
t.Fatalf("show = %s", shown)
}
}
// TestFilterOptionsControllerFirst: a controller that implements
// pact.FilterOptions serves a scope filter's choices, from the database,
// although the model does not.
func TestFilterOptionsControllerFirst(t *testing.T) {
env, gdb := newRosterEnv(t)
news, beta := rosterTag{Name: "news"}, rosterTag{Name: "beta"}
rosterSeed(t, gdb, &news)
rosterSeed(t, gdb, &beta)
tagged := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Tagged", Active: true})
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bare", Active: true})
rosterSeed(t, gdb, &rosterPersonTag{PersonID: tagged, TagID: news.ID})
options := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/filters/tagged/options", "", "limited").Body.String()
want := fmt.Sprintf(`"data":[{"value":"%d","label":"beta"},{"value":"%d","label":"news"}]`, beta.ID, news.ID)
if !strings.Contains(options, want) {
t.Fatalf("options = %s, want %s", options, want)
}
// A row added after boot is offered: the choices are read per request.
late := rosterTag{Name: "alpha"}
rosterSeed(t, gdb, &late)
if options = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/filters/tagged/options", "", "bearer").Body.String(); !strings.Contains(options, `"label":"alpha"`) {
t.Fatalf("options after an insert = %s", options)
}
// The scope is still the model's.
filtered := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s?filter[tagged]=%d", rosterPeople, news.ID), "", "bearer").Body.String()
if !strings.Contains(filtered, `"name":"Tagged"`) || strings.Contains(filtered, `"name":"Bare"`) {
t.Fatalf("filtered list = %s", filtered)
}
env.expect(t, http.StatusNotFound, http.MethodGet, rosterPeople+"/filters/missing/options", "", "bearer")
if _, ok := any(rosterPerson{}).(pact.FilterOptions); ok {
t.Fatal("the fixture model serves filter options itself")
}
}
// TestFilterOptionsModelFallback: without FilterOptions on the controller the
// model serves the choices, and its labels are translated.
func TestFilterOptionsModelFallback(t *testing.T) {
env, _ := newRosterEnvWith(t, func(p *rosterPlugin) {
p.wrap = func(inner rosterController) pact.AdminController {
return rosterBare{inner: inner, newRecord: func() any { return &rosterOptionsPerson{} }}
}
})
options := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/filters/tagged/options", "", "bearer").Body.String()
if !strings.Contains(options, `"data":[{"value":"1","label":"Tags"},{"value":"2","label":"Plain label"}]`) {
t.Fatalf("model options = %s", options)
}
if _, ok := any(rosterBare{}).(pact.FilterOptions); ok {
t.Fatal("the bare controller serves filter options")
}
}

View File

@@ -0,0 +1,268 @@
package cabana_test
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
"testing"
"gorm.io/gorm"
)
const (
permUnknown = "The permissions field contains an unknown permission."
permInvalid = "The permissions field contains an invalid value."
permShape = "The permissions field must be an object of permission codes."
permLocked = "You cannot change this permission."
)
// rosterPermissions reads a person's stored permission object.
func rosterPermissions(t *testing.T, gdb *gorm.DB, id uint) map[string]int {
t.Helper()
out := map[string]int{}
if raw := rosterLoad(t, gdb, id).Permissions; raw != nil {
if err := json.Unmarshal([]byte(*raw), &out); err != nil {
t.Fatalf("stored permissions %q: %v", *raw, err)
}
}
return out
}
// newRosterCheckboxEnv is the roster fixture with the permission editor in
// checkbox mode.
func newRosterCheckboxEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
t.Helper()
return newRosterEnvWith(t, func(p *rosterPlugin) {
p.fsys = rosterTree(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: checkbox\n")})
})
}
// TestPermissionEditorModes: radio accepts 1 and -1, checkbox accepts 1
// only, and 0 means "no value" in both (D-16; T-12.1-13).
func TestPermissionEditorModes(t *testing.T) {
t.Run("radio", func(t *testing.T) {
env, gdb := newRosterEnv(t)
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Radio", Active: true})
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.edit":1,"posts.publish":-1,"misc.beta":0}}`, "bearer")
if got := rosterPermissions(t, gdb, id); !reflect.DeepEqual(got, map[string]int{"posts.edit": 1, "posts.publish": -1}) {
t.Fatalf("stored = %v, want 1 and -1 kept and 0 left out", got)
}
// 0 for a stored code removes it.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.edit":0,"posts.publish":-1}}`, "bearer")
if got := rosterPermissions(t, gdb, id); !reflect.DeepEqual(got, map[string]int{"posts.publish": -1}) {
t.Fatalf("stored = %v", got)
}
for _, value := range []string{"2", "-2", "100"} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.edit":`+value+`}}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", permInvalid)
}
_, raw := rosterFormSchema(t, env, "bearer")
if !strings.Contains(raw, `"mode":"radio"`) {
t.Fatalf("schema mode: %s", raw)
}
})
t.Run("checkbox", func(t *testing.T) {
env, gdb := newRosterCheckboxEnv(t)
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Check", Active: true})
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.edit":1,"posts.publish":0}}`, "bearer")
if got := rosterPermissions(t, gdb, id); !reflect.DeepEqual(got, map[string]int{"posts.edit": 1}) {
t.Fatalf("stored = %v", got)
}
// A denial is not a checkbox value.
for _, value := range []string{"-1", "2"} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.publish":`+value+`}}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", permInvalid)
}
if got := rosterPermissions(t, gdb, id); !reflect.DeepEqual(got, map[string]int{"posts.edit": 1}) {
t.Fatalf("a refused save changed the stored set: %v", got)
}
// An empty object unchecks everything that is offered.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"permissions":{}}`, "bearer")
if got := rosterPermissions(t, gdb, id); len(got) != 0 {
t.Fatalf("stored after unchecking everything = %v", got)
}
_, raw := rosterFormSchema(t, env, "bearer")
if !strings.Contains(raw, `"mode":"checkbox"`) {
t.Fatalf("schema mode: %s", raw)
}
})
}
// TestPermissionEditorUnknownCode: a code the controller does not offer is
// 422, also when it is stored on the record, and the value must be an object
// of integers.
func TestPermissionEditorUnknownCode(t *testing.T) {
env, gdb := newRosterEnv(t)
stored := `{"legacy.code":1,"posts.edit":1}`
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Perm", Active: true, Permissions: &stored})
for body, message := range map[string]string{
`{"permissions":{"admin.root":1}}`: permUnknown,
`{"permissions":{"posts.edit":1,"admin.root":0}}`: permUnknown,
`{"permissions":{"legacy.code":1}}`: permUnknown,
`{"permissions":{"POSTS.EDIT":1}}`: permUnknown,
`{"permissions":{"":1}}`: permUnknown,
`{"permissions":{"posts.edit":"1"}}`: permShape,
`{"permissions":{"posts.edit":1.5}}`: permShape,
`{"permissions":{"posts.edit":null}}`: permShape,
`{"permissions":{"posts.edit":99999999999}}`: permShape,
`{"permissions":{"posts.edit":[1]}}`: permShape,
`{"permissions":[]}`: permShape,
`{"permissions":1}`: permShape,
`{"name":"Renamed","permissions":{"admin.root":1}}`: permUnknown,
} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), body, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", message)
}
if got := rosterLoad(t, gdb, id); got.Name != "Perm" || got.Permissions == nil || *got.Permissions != stored {
t.Fatalf("a refused save wrote: %+v", got)
}
}
// TestPermissionEditorLocked: a locked code's stored and submitted value must
// be equal for the administrator it is locked for; otherwise 403 and nothing
// is written.
func TestPermissionEditorLocked(t *testing.T) {
env, gdb := newRosterEnv(t)
stored := `{"reports.export":1}`
holder := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Holder", Active: true, Permissions: &stored})
bare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bare", Active: true})
refused := func(id uint, body string) {
t.Helper()
rec := env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(id, ""), body, "limited")
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", permLocked)
}
refused(holder, `{"permissions":{}}`)
refused(holder, `{"permissions":{"reports.export":-1}}`)
refused(holder, `{"name":"Sneaky","permissions":{"reports.export":0,"posts.edit":1}}`)
refused(bare, `{"permissions":{"reports.export":1}}`)
refused(bare, `{"permissions":{"reports.export":-1}}`)
if got := rosterLoad(t, gdb, holder); got.Name != "Holder" || *got.Permissions != stored {
t.Fatalf("a refused save wrote: %+v", got)
}
if got := rosterLoad(t, gdb, bare); got.Permissions != nil {
t.Fatalf("a refused save wrote %q", *got.Permissions)
}
// The stored value sent back unchanged passes, with other codes changed.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(holder, ""), `{"permissions":{"reports.export":1,"posts.edit":-1}}`, "limited")
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(bare, ""), `{"permissions":{"posts.edit":1}}`, "limited")
if got := rosterPermissions(t, gdb, holder); !reflect.DeepEqual(got, map[string]int{"reports.export": 1, "posts.edit": -1}) {
t.Fatalf("stored = %v", got)
}
// A save without the field is not checked.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(holder, ""), `{"name":"Holder B"}`, "limited")
// The administrator it is not locked for changes it.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(holder, ""), `{"permissions":{}}`, "bearer")
if got := rosterPermissions(t, gdb, holder); len(got) != 0 {
t.Fatalf("the full admin's change was not stored: %v", got)
}
// Known property of the contract: a locked code whose stored value is
// outside the mode's set cannot be sent back, so every save that carries
// the field is refused for that administrator; a save without the field
// still passes. The value has to be repaired by an administrator the code
// is not locked for.
t.Run("a locked code stored outside the mode's set", func(t *testing.T) {
env, gdb := newRosterCheckboxEnv(t)
denied := `{"reports.export":-1}`
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Legacy", Active: true, Permissions: &denied})
rec := env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.edit":1}}`, "limited")
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", permLocked)
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), `{"permissions":{"reports.export":-1}}`, "limited")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", permInvalid)
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Legacy B"}`, "limited")
if got := rosterLoad(t, gdb, id); got.Name != "Legacy B" || *got.Permissions != denied {
t.Fatalf("stored = %+v", got)
}
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.edit":1}}`, "bearer")
if got := rosterPermissions(t, gdb, id); !reflect.DeepEqual(got, map[string]int{"posts.edit": 1}) {
t.Fatalf("the full admin's repair stored %v", got)
}
})
}
// TestPermissionEditorKeepsUnoffered: stored codes the controller does not
// offer survive every save, are shown, and cannot be invented.
func TestPermissionEditorKeepsUnoffered(t *testing.T) {
env, gdb := newRosterEnv(t)
stored := `{"legacy.code":1,"legacy.denied":-1,"posts.edit":1}`
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Legacy", Active: true, Permissions: &stored})
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPath(id, ""), "", "bearer")
if !strings.Contains(rec.Body.String(), `"permissions":{"legacy.code":1,"legacy.denied":-1,"posts.edit":1}`) {
t.Fatalf("show = %s", rec.Body.String())
}
for _, body := range []string{`{"permissions":{}}`, `{"permissions":{"posts.publish":1}}`, `{"permissions":{"posts.publish":0}}`} {
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), body, "bearer")
got := rosterPermissions(t, gdb, id)
if got["legacy.code"] != 1 || got["legacy.denied"] != -1 {
t.Fatalf("after %s the stored codes that are not offered are %v", body, got)
}
if _, kept := got["posts.edit"]; kept {
t.Fatalf("after %s an offered code that was left out is still stored: %v", body, got)
}
}
// A record without stored permissions shows an empty object, never null.
blank := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Blank", Active: true})
rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPath(blank, ""), "", "bearer")
if !strings.Contains(rec.Body.String(), `"permissions":{}`) {
t.Fatalf("show without stored permissions = %s", rec.Body.String())
}
}
// TestPermissionEditorOptionsPerRequest: options, with their locked flag and
// localized texts, are asked from the controller for each request and are
// never written into the cached schema.
func TestPermissionEditorOptionsPerRequest(t *testing.T) {
env, _ := newRosterEnv(t)
for range 2 {
_, limited := rosterFormSchema(t, env, "limited")
if strings.Count(limited, `"locked":true`) != 1 || !strings.Contains(limited, `{"code":"reports.export","label":"Export reports","tab":"Reports","locked":true}`) {
t.Fatalf("limited admin's options = %s", limited)
}
_, full := rosterFormSchema(t, env, "bearer")
if strings.Contains(full, `"locked"`) {
t.Fatalf("an option is locked for the full admin after a limited request: %s", full)
}
}
pl := rosterLocale(env, http.MethodGet, rosterPeople+"/schema/form", "", "pl")
if !strings.Contains(pl.Body.String(), `{"code":"posts.edit","label":"Edycja wpisów","tab":"Treści","comment":"Zmiana treści dowolnego wpisu."}`) {
t.Fatalf("pl options = %s", pl.Body.String())
}
_, en := rosterFormSchema(t, env, "bearer")
if !strings.Contains(en, `"label":"Edit posts","tab":"Content"`) {
t.Fatalf("the cached schema kept another locale's labels: %s", en)
}
}
// TestPermissionEditorProviderError: an error from the provider is the
// generic 500 on every route that asks it, and nothing is written.
func TestPermissionEditorProviderError(t *testing.T) {
env, gdb := newRosterEnv(t)
stored := `{"posts.edit":1}`
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Perm", Active: true, Permissions: &stored})
opaque := func(what, method, rel, body string) {
t.Helper()
rec := env.expect(t, http.StatusInternalServerError, method, rel, body, "bearer")
if got := rosterError(t, rec); got.Code != "error" || len(got.Details) != 0 || strings.Contains(rec.Body.String(), "hunter2") || strings.Contains(rec.Body.String(), "permission table") || strings.Contains(rec.Body.String(), "permission column") {
t.Fatalf("%s = %s", what, rec.Body.String())
}
}
env.knobs.permissionOptions.Store(true)
opaque("the form schema", http.MethodGet, rosterPeople+"/schema/form", "")
opaque("an update with the field", http.MethodPut, rosterPath(id, ""), `{"name":"Changed","permissions":{"posts.publish":1}}`)
// A save that does not carry the field does not ask for the options.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Perm B"}`, "bearer")
env.knobs.permissionOptions.Store(false)
env.knobs.permissionValues.Store(true)
opaque("show", http.MethodGet, rosterPath(id, ""), "")
opaque("an update that reads the stored values", http.MethodPut, rosterPath(id, ""), `{"name":"Changed","permissions":{"posts.publish":1}}`)
env.knobs.permissionValues.Store(false)
if got := rosterLoad(t, gdb, id); got.Name != "Perm B" || *got.Permissions != stored {
t.Fatalf("a failed save wrote: %+v", got)
}
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), fmt.Sprintf(`{"permissions":%s}`, stored), "bearer")
}

View File

@@ -0,0 +1,144 @@
package cabana_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// TestPreviewSchema: the form schema reports the preview block, the
// preview-only field with its context, and nothing of either for a form
// without a preview (D-11).
func TestPreviewSchema(t *testing.T) {
env, _ := newRosterEnv(t)
view, raw := rosterFormSchema(t, env, "bearer")
if view.Preview == nil || view.Preview.HeaderPartial != "status" || !strings.Contains(raw, `"preview":{"headerPartial":"status"}`) {
t.Fatalf("preview block = %+v in %s", view.Preview, raw)
}
if !strings.Contains(raw, `"name":"joined_ip","type":"text","label":"Joined from IP address","context":"preview"}`) {
t.Fatalf("the preview-only field is not in the schema: %s", raw)
}
if !strings.Contains(raw, `{"name":"name","type":"text","label":"Name","span":"left"}`) {
t.Fatalf("a field without a context got one: %s", raw)
}
// The same schema for the limited administrator: the preview is part of
// the form, not a permission.
if limited, _ := rosterFormSchema(t, env, "limited"); limited.Preview == nil {
t.Fatal("the limited administrator gets no preview block")
}
// preview: {} enables the screen without a header partial.
bare, _ := newRosterEnvWith(t, func(p *rosterPlugin) {
p.fsys = rosterTree(t, map[string]string{"controllers/people/config_form.yaml": rosterFormHead + "preview: {}\n"})
})
view, raw = rosterFormSchema(t, bare, "bearer")
if view.Preview == nil || view.Preview.HeaderPartial != "" || !strings.Contains(raw, `"preview":{}`) {
t.Fatalf("preview: {} = %+v in %s", view.Preview, raw)
}
// A form without the key has no preview at all.
none, _ := newRosterEnvWith(t, func(p *rosterPlugin) {
p.fsys = rosterTree(t, map[string]string{"controllers/people/config_form.yaml": rosterFormHead})
})
view, raw = rosterFormSchema(t, none, "bearer")
if view.Preview != nil || strings.Contains(raw, `"preview":{}`) || strings.Contains(raw, "headerPartial") {
t.Fatalf("a form without preview reports one: %s", raw)
}
}
// TestPreviewFieldNeverWritten: a field with context preview is returned by
// show and ignored by create and update (T-12.1-14).
func TestPreviewFieldNeverWritten(t *testing.T) {
env, gdb := newRosterEnv(t)
ip := "203.0.113.7"
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Active: true, JoinedIP: &ip})
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPath(id, ""), "", "bearer")
if got := rosterRecord(t, rec.Body.Bytes()).Data["joined_ip"]; got != ip {
t.Fatalf("show joined_ip = %v", got)
}
for _, body := range []string{`{"joined_ip":"198.51.100.1"}`, `{"name":"Ada L","joined_ip":null}`, `{"name":"Ada L","joined_ip":""}`} {
rec = env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), body, "bearer")
if got := rosterRecord(t, rec.Body.Bytes()).Data["joined_ip"]; got != ip {
t.Fatalf("update %s answered joined_ip = %v", body, got)
}
if stored := rosterLoad(t, gdb, id); stored.JoinedIP == nil || *stored.JoinedIP != ip {
t.Fatalf("update %s wrote joined_ip = %v", body, stored.JoinedIP)
}
}
if rosterLoad(t, gdb, id).Name != "Ada L" {
t.Fatal("the writable part of the body was not saved")
}
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"New","joined_ip":"198.51.100.2",`+rosterPair+`}`, "bearer")
created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
if stored := rosterLoad(t, gdb, uint(created)); stored.JoinedIP != nil {
t.Fatalf("create wrote joined_ip = %q", *stored.JoinedIP)
}
// A nested value for it is ignored like any other value.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"joined_ip":{"x":1}}`, "bearer")
}
// TestPreviewHeaderPartialScoped: the status hint is served through the
// partial route with the form scope and the controller's permission, as
// nodes (T-12.1-15).
func TestPreviewHeaderPartialScoped(t *testing.T) {
env, gdb := newRosterEnv(t)
banned := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bea", Active: true, Banned: true})
gone := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Gone", Active: true, DeletedAt: rosterDeleted()})
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed", Banned: true})
hint := func(id uint) string { return fmt.Sprintf("%s/partials/status?id=%d", rosterPeople, id) }
for id, want := range map[uint][2]string{
banned: {"summer-callout--danger", "This person is banned"},
gone: {"summer-callout--danger", "This person is archived"},
idle: {"summer-callout--warning", "This person is not active"},
} {
rec := env.expect(t, http.StatusOK, http.MethodGet, hint(id), "", "limited")
if body := rec.Body.String(); !strings.Contains(body, want[0]) || !strings.Contains(body, want[1]) || strings.Contains(body, "<") {
t.Fatalf("hint of %d = %s", id, body)
}
var view cabana.Envelope[cabana.PartialView]
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil || len(view.Data.Nodes) != 1 || view.Data.Nodes[0].Attrs["role"] != "status" {
t.Fatalf("hint nodes = %+v (%v)", view.Data.Nodes, err)
}
}
rec := env.expect(t, http.StatusNotFound, http.MethodGet, hint(foreign), "", "bearer")
actErrorCode(t, rec.Body.Bytes(), "not_found")
env.expect(t, http.StatusNotFound, http.MethodGet, hint(999999), "", "bearer")
env.expect(t, http.StatusNotFound, http.MethodGet, fmt.Sprintf("%s/partials/missing?id=%d", rosterPeople, banned), "", "bearer")
// The Polish request gets Polish text.
if pl := rosterLocale(env, http.MethodGet, hint(banned), "", "pl"); !strings.Contains(pl.Body.String(), "Ta osoba jest zablokowana") {
t.Fatalf("pl hint = %s", pl.Body.String())
}
// Without a token there is no hint.
req := httptest.NewRequest(http.MethodGet, adminAPI(hint(banned)), nil)
anonymous := httptest.NewRecorder()
env.h.ServeHTTP(anonymous, req)
if anonymous.Code != http.StatusUnauthorized {
t.Fatalf("anonymous hint = %d", anonymous.Code)
}
}
// TestPreviewMessagesDefaults: a form that names no preview or edit message
// gets the framework's, in the request locale.
func TestPreviewMessagesDefaults(t *testing.T) {
env, _ := newRosterEnvWith(t, func(p *rosterPlugin) {
p.fsys = rosterTree(t, map[string]string{"controllers/people/config_form.yaml": rosterFormHead + "preview: {}\n"})
})
view, _ := rosterFormSchema(t, env, "bearer")
if view.Messages.Preview["other"] != "Record preview" || view.Messages.Edit["other"] != "Edit record" {
t.Fatalf("default messages = %v / %v", view.Messages.Preview, view.Messages.Edit)
}
pl := rosterLocale(env, http.MethodGet, rosterPeople+"/schema/form", "", "pl")
if !strings.Contains(pl.Body.String(), `"preview":{"other":"Podgląd rekordu"}`) {
t.Fatalf("pl default messages = %s", pl.Body.String())
}
// The fixture's own messages replace them.
own, _ := newRosterEnv(t)
view, _ = rosterFormSchema(t, own, "bearer")
if view.Messages.Preview["other"] != "Person details" || view.Messages.Edit["other"] != "Edit person" {
t.Fatalf("plugin messages = %v / %v", view.Messages.Preview, view.Messages.Edit)
}
}

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")
}

View File

@@ -0,0 +1,313 @@
package cabana_test
import (
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
"gorm.io/gorm"
)
const rosterTagLocked = "You need an additional permission to change the staff tag."
// rosterLockSeed stores the staff, news and beta tags.
func rosterLockSeed(t *testing.T, gdb *gorm.DB) (staff, news, beta rosterTag) {
t.Helper()
staff, news, beta = rosterTag{Name: "staff"}, rosterTag{Name: "news"}, rosterTag{Name: "beta"}
for _, tag := range []*rosterTag{&staff, &news, &beta} {
rosterSeed(t, gdb, tag)
}
return staff, news, beta
}
// rosterTags is the tags key of a save body.
func rosterTags(ids ...uint) string {
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = fmt.Sprint(id)
}
return `"tags":[` + strings.Join(parts, ",") + `]`
}
// rosterLockRefused asserts the 403 of a locked relation field.
func rosterLockRefused(t *testing.T, env *rosterEnv, method, rel, body, field, message string) {
t.Helper()
rec := env.expect(t, http.StatusForbidden, method, rel, body, "limited")
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", field, message)
}
// TestRelationLockCreate: a create that carries a locked id is 403 and
// creates nothing; the pivot is written only by a create that leaves the
// locked subset empty (D-07; T-12.1-12).
func TestRelationLockCreate(t *testing.T) {
env, gdb := newRosterEnv(t)
staff, news, _ := rosterLockSeed(t, gdb)
create := func(name string, ids ...uint) string {
return fmt.Sprintf(`{"name":%q,%s,%s}`, name, rosterPair, rosterTags(ids...))
}
rosterLockRefused(t, env, http.MethodPost, rosterPeople, create("Smuggled", staff.ID), "tags", rosterTagLocked)
rosterLockRefused(t, env, http.MethodPost, rosterPeople, create("Smuggled", news.ID, staff.ID), "tags", rosterTagLocked)
if n := rosterCount(t, env, "Smuggled"); n != 0 {
t.Fatalf("a refused create left %d rows", n)
}
var pivots int64
if err := gdb.Model(&rosterPersonTag{}).Count(&pivots).Error; err != nil || pivots != 0 {
t.Fatalf("a refused create left %d pivot rows (%v)", pivots, err)
}
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, create("Plain", news.ID), "limited")
created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
if got := rosterPivot(t, gdb, uint(created)); !reflect.DeepEqual(got, []uint{news.ID}) {
t.Fatalf("pivot of the created person = %v", got)
}
// The administrator the id is not locked for creates with it.
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, create("Staffer", staff.ID), "bearer")
created, _ = rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
if got := rosterPivot(t, gdb, uint(created)); !reflect.DeepEqual(got, []uint{staff.ID}) {
t.Fatalf("pivot of the full admin's person = %v", got)
}
}
// TestRelationLockUpdate: adding, removing or replacing a locked id on update
// is 403 and writes nothing; a provider error is the generic 500.
func TestRelationLockUpdate(t *testing.T) {
env, gdb := newRosterEnv(t)
staff, news, beta := rosterLockSeed(t, gdb)
plain := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Plain", Active: true})
member := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Member", Active: true})
rosterSeed(t, gdb, &rosterPersonTag{PersonID: plain, TagID: news.ID})
rosterSeed(t, gdb, &rosterPersonTag{PersonID: member, TagID: staff.ID})
rosterSeed(t, gdb, &rosterPersonTag{PersonID: member, TagID: news.ID})
for _, tc := range []struct {
person uint
body string
}{
{plain, `{"name":"Sneaky",` + rosterTags(news.ID, staff.ID) + `}`},
{plain, `{` + rosterTags(staff.ID) + `}`},
{member, `{` + rosterTags(news.ID) + `}`},
{member, `{` + rosterTags() + `}`},
{member, `{"name":"Sneaky",` + rosterTags(beta.ID) + `}`},
} {
rosterLockRefused(t, env, http.MethodPut, rosterPath(tc.person, ""), tc.body, "tags", rosterTagLocked)
}
if got := rosterPivot(t, gdb, plain); !reflect.DeepEqual(got, []uint{news.ID}) {
t.Fatalf("pivot of the plain person = %v", got)
}
if got := rosterPivot(t, gdb, member); !reflect.DeepEqual(got, []uint{staff.ID, news.ID}) {
t.Fatalf("pivot of the member = %v", got)
}
if rosterLoad(t, gdb, plain).Name != "Plain" || rosterLoad(t, gdb, member).Name != "Member" {
t.Fatal("a refused save wrote another field of its body")
}
// The locked subset unchanged: the rest of the pivot may change.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(member, ""), `{`+rosterTags(beta.ID, staff.ID)+`}`, "limited")
if got := rosterPivot(t, gdb, member); !reflect.DeepEqual(got, []uint{staff.ID, beta.ID}) {
t.Fatalf("pivot after an allowed change = %v", got)
}
// The refusal follows the request locale.
req := httptest.NewRequest(http.MethodPut, adminAPI(rosterPath(member, "")), strings.NewReader(`{`+rosterTags()+`}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", "pl")
req.Header.Set("Authorization", "Bearer "+env.limited)
pl := httptest.NewRecorder()
env.h.ServeHTTP(pl, req)
if pl.Code != http.StatusForbidden {
t.Fatalf("pl refusal = %d %s", pl.Code, pl.Body.String())
}
rosterErrorDetail(t, pl.Body.Bytes(), "forbidden", "tags", "Zmiana tagu staff wymaga dodatkowego uprawnienia.")
// A provider that fails: the generic 500, and nothing is written.
env.knobs.relationLocks.Store(true)
rec := env.expect(t, http.StatusInternalServerError, http.MethodPut, rosterPath(plain, ""), `{"name":"Changed",`+rosterTags(beta.ID)+`}`, "limited")
if got := rosterError(t, rec); got.Code != "error" || strings.Contains(rec.Body.String(), "hunter2") || strings.Contains(rec.Body.String(), "lock table") {
t.Fatalf("500 body = %s", rec.Body.String())
}
env.expect(t, http.StatusInternalServerError, http.MethodGet, rosterPeople+"/fields/tags/options", "", "limited")
env.knobs.relationLocks.Store(false)
if got := rosterPivot(t, gdb, plain); !reflect.DeepEqual(got, []uint{news.ID}) || rosterLoad(t, gdb, plain).Name != "Plain" {
t.Fatalf("a failed save wrote: pivot %v", got)
}
}
// TestRelationLockBelongsTo: for a belongsTo field a change is refused when
// the current or the submitted id is locked. A lock without a message of its
// own answers the framework's text.
func TestRelationLockBelongsTo(t *testing.T) {
env, gdb := newRosterEnv(t)
vault, open, other := rosterTeam{Tenant: "acme", Name: "vault"}, rosterTeam{Tenant: "acme", Name: "open"}, rosterTeam{Tenant: "acme", Name: "other"}
for _, team := range []*rosterTeam{&vault, &open, &other} {
rosterSeed(t, gdb, team)
}
inside := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Inside", Active: true, OrganisationID: &vault.ID})
outside := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Outside", Active: true, OrganisationID: &open.ID})
const message = "You do not have permission to make this change. Nothing was saved."
team := func(id uint) uint {
t.Helper()
if stored := rosterLoad(t, gdb, id); stored.OrganisationID != nil {
return *stored.OrganisationID
}
return 0
}
// Into the locked team, out of it, and clearing it.
rosterLockRefused(t, env, http.MethodPut, rosterPath(outside, ""), fmt.Sprintf(`{"name":"Sneaky","team":%d}`, vault.ID), "team", message)
rosterLockRefused(t, env, http.MethodPut, rosterPath(inside, ""), fmt.Sprintf(`{"team":%d}`, open.ID), "team", message)
rosterLockRefused(t, env, http.MethodPut, rosterPath(inside, ""), `{"team":null}`, "team", message)
rosterLockRefused(t, env, http.MethodPost, rosterPeople, fmt.Sprintf(`{"name":"Born inside",%s,"team":%d}`, rosterPair, vault.ID), "team", message)
if team(outside) != open.ID || team(inside) != vault.ID || rosterLoad(t, gdb, outside).Name != "Outside" || rosterCount(t, env, "Born inside") != 0 {
t.Fatal("a refused save changed a team")
}
// A refusal without a message has an empty message and the framework's
// text on the field.
rec := env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(inside, ""), `{"team":null}`, "limited")
if got := rosterError(t, rec); got.Message != "" {
t.Fatalf("message of a lock without one = %q", got.Message)
}
// The locked id sent back unchanged, and a change between unlocked teams.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(inside, ""), fmt.Sprintf(`{"name":"Inside B","team":%d}`, vault.ID), "limited")
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(outside, ""), fmt.Sprintf(`{"team":%d}`, other.ID), "limited")
if team(inside) != vault.ID || team(outside) != other.ID {
t.Fatalf("teams after the allowed saves = %d and %d", team(inside), team(outside))
}
// The options and the label carry the flag for the limited administrator.
options := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/team/options", "", "limited").Body.String()
if !strings.Contains(options, fmt.Sprintf(`{"value":%d,"label":"vault","locked":true}`, vault.ID)) || strings.Count(options, `"locked"`) != 1 {
t.Fatalf("team options = %s", options)
}
if shown := env.expect(t, http.StatusOK, http.MethodGet, rosterPath(inside, ""), "", "limited").Body.String(); strings.Count(shown, `"locked":true`) != 1 {
t.Fatalf("the locked team label is not flagged: %s", shown)
}
// The full administrator moves a person out of the locked team.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(inside, ""), `{"team":null}`, "bearer")
if team(inside) != 0 {
t.Fatal("the full admin could not clear the locked team")
}
}
// TestRelationLockAbsentField: only relation fields present in the body are
// checked, so an ordinary update of a record that holds a locked id passes.
func TestRelationLockAbsentField(t *testing.T) {
env, gdb := newRosterEnv(t)
staff, _, _ := rosterLockSeed(t, gdb)
vault := rosterTeam{Tenant: "acme", Name: "vault"}
rosterSeed(t, gdb, &vault)
member := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Member", Active: true, OrganisationID: &vault.ID})
rosterSeed(t, gdb, &rosterPersonTag{PersonID: member, TagID: staff.ID})
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(member, ""), `{"name":"Member B","email":"member@example.test"}`, "limited")
stored := rosterLoad(t, gdb, member)
if stored.Name != "Member B" || stored.OrganisationID == nil || *stored.OrganisationID != vault.ID {
t.Fatalf("stored = %+v", stored)
}
if got := rosterPivot(t, gdb, member); !reflect.DeepEqual(got, []uint{staff.ID}) {
t.Fatalf("pivot = %v", got)
}
// A record action and a bulk action on the same record pass too: they do
// not write the relation.
env.expect(t, http.StatusOK, http.MethodPost, rosterArchive, rosterIDs(member), "limited")
if got := rosterPivot(t, gdb, member); !reflect.DeepEqual(got, []uint{staff.ID}) {
t.Fatalf("pivot after a bulk action = %v", got)
}
}
// TestRelationLockNoProvider: a controller without cabana.RelationLockProvider
// has no locked option and no refusal.
func TestRelationLockNoProvider(t *testing.T) {
env, gdb := newRosterBareEnv(t)
staff, news, _ := rosterLockSeed(t, gdb)
person := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Free", Active: true, Password: rosterHash("stored-before")})
options := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/tags/options", "", "limited").Body.String()
if strings.Contains(options, `"locked"`) || !strings.Contains(options, `"label":"staff"`) {
t.Fatalf("options without a provider = %s", options)
}
rec := env.expect(t, http.StatusOK, http.MethodPut, rosterPath(person, ""), `{"name":"Free",`+rosterPair+`,`+rosterTags(staff.ID, news.ID)+`}`, "limited")
if strings.Contains(rec.Body.String(), `"locked"`) {
t.Fatalf("a label is flagged without a provider: %s", rec.Body.String())
}
if got := rosterPivot(t, gdb, person); !reflect.DeepEqual(got, []uint{staff.ID, news.ID}) {
t.Fatalf("pivot = %v", got)
}
}
// TestWritableForeignKeyOptIn: a belongsTo field over a protected foreign key
// is writable only when its contract says so (D-27 G3; T-12.1-11).
func TestWritableForeignKeyOptIn(t *testing.T) {
for _, writable := range []bool{true, false} {
t.Run(fmt.Sprintf("WritableForeignKey=%v", writable), func(t *testing.T) {
env, gdb := newRosterEnvWith(t, func(p *rosterPlugin) {
p.relations = func(in []cabana.FieldRelationContract) []cabana.FieldRelationContract {
in[0].WritableForeignKey = writable
return in
}
})
team := rosterTeam{Tenant: "acme", Name: "Home"}
rosterSeed(t, gdb, &team)
person := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ro", Active: true})
_, raw := rosterFormSchema(t, env, "bearer")
readOnly := strings.Contains(raw, `"emptyOption":"No team","readOnly":true}`)
if readOnly == writable {
t.Fatalf("readOnly = %v for WritableForeignKey = %v: %s", readOnly, writable, raw)
}
options := env.call(t, http.MethodGet, rosterPeople+"/fields/team/options", "", "bearer")
if (options.Code == http.StatusOK) != writable {
t.Fatalf("options status = %d", options.Code)
}
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(person, ""), fmt.Sprintf(`{"team":%d}`, team.ID), "bearer")
stored := rosterLoad(t, gdb, person).OrganisationID
if writable && (stored == nil || *stored != team.ID) {
t.Fatalf("the opted-in field did not write the key: %v", stored)
}
if !writable && stored != nil {
t.Fatalf("the field wrote the protected key without the opt-in: %d", *stored)
}
// The scalar column itself is never a fill key.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(person, ""), `{"organisation_id":987654}`, "bearer")
if after := rosterLoad(t, gdb, person).OrganisationID; !reflect.DeepEqual(after, stored) {
t.Fatalf("a scalar organisation_id was written: %v", after)
}
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPath(person, ""), "", "bearer")
if _, leaked := rosterRecord(t, rec.Body.Bytes()).Data["organisation_id"]; leaked {
t.Fatalf("the record carries organisation_id: %s", rec.Body.String())
}
})
}
}
// TestWritableForeignKeyScope: a submitted id passes the scoped options query
// before it is written.
func TestWritableForeignKeyScope(t *testing.T) {
env, gdb := newRosterEnv(t)
home, away := rosterTeam{Tenant: "acme", Name: "Home"}, rosterTeam{Tenant: "other", Name: "Away"}
rosterSeed(t, gdb, &home)
rosterSeed(t, gdb, &away)
person := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Tess", Active: true, OrganisationID: &home.ID})
for _, value := range []string{fmt.Sprint(away.ID), "999999", `"x"`, `[1]`, `{"id":1}`, "-1", "1.5"} {
rec := env.call(t, http.MethodPut, rosterPath(person, ""), `{"name":"Changed","team":`+value+`}`, "bearer")
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("team %s = %d, want 422: %s", value, rec.Code, rec.Body.String())
}
var details map[string]any = rosterError(t, rec).Details
if _, ok := details["team"]; !ok {
t.Fatalf("team %s: no detail on the field: %s", value, rec.Body.String())
}
}
if stored := rosterLoad(t, gdb, person); stored.Name != "Tess" || stored.OrganisationID == nil || *stored.OrganisationID != home.ID {
t.Fatalf("a refused save wrote: %+v", stored)
}
// The out-of-scope team is not offered either.
options := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/team/options", "", "bearer").Body.String()
if strings.Contains(options, "Away") || !strings.Contains(options, "Home") {
t.Fatalf("options = %s", options)
}
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(person, ""), `{"team":null}`, "bearer")
if stored := rosterLoad(t, gdb, person); stored.OrganisationID != nil {
t.Fatalf("null did not clear the key: %d", *stored.OrganisationID)
}
// On create as well.
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, fmt.Sprintf(`{"name":"Away born",%s,"team":%d}`, rosterPair, away.ID), "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "team", "The selected team is invalid.")
if n := rosterCount(t, env, "Away born"); n != 0 {
t.Fatalf("a refused create left %d rows", n)
}
}

View File

@@ -0,0 +1,134 @@
package cabana_test
import (
"fmt"
"net/http"
"reflect"
"strings"
"testing"
)
// TestRowStateOrder: states are sent in the fixed order deleted, negative,
// disabled, whatever order the hook answers in, and a repeated state is sent
// once (D-12).
func TestRowStateOrder(t *testing.T) {
env, gdb := newRosterEnv(t)
// The fixture answers disabled twice, then negative, then deleted.
all := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "All", Banned: true, DeletedAt: rosterDeleted()})
two := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Two", Banned: true})
one := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "One"})
body, raw := rosterList(t, env, "")
want := map[string][]string{
fmt.Sprint(all): {"deleted", "negative", "disabled"},
fmt.Sprint(two): {"negative", "disabled"},
fmt.Sprint(one): {"disabled"},
}
if !reflect.DeepEqual(body.Meta.RowStates, want) {
t.Fatalf("row_states = %v, want %v\n%s", body.Meta.RowStates, want, raw)
}
}
// TestRowStateUnknownDropped: a value outside the fixed set is never sent
// (T-12.1-07); the known states of the same row stay.
func TestRowStateUnknownDropped(t *testing.T) {
env, gdb := newRosterEnv(t)
logs := captureLog(t)
only := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Odd", Active: true})
mixed := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Odd", Banned: true})
body, raw := rosterList(t, env, "")
if strings.Contains(raw, "starred") {
t.Fatalf("an unknown state was sent: %s", raw)
}
if _, ok := body.Meta.RowStates[fmt.Sprint(only)]; ok {
t.Fatalf("a row whose only state is unknown is listed: %v", body.Meta.RowStates)
}
if got := body.Meta.RowStates[fmt.Sprint(mixed)]; !reflect.DeepEqual(got, []string{"negative", "disabled"}) {
t.Fatalf("known states next to an unknown one = %v", got)
}
if dropped := logs.matching("cabana: unknown list row state dropped"); len(dropped) != 2 || !strings.Contains(dropped[0], "state=starred") {
t.Fatalf("server log = %q", dropped)
}
}
// TestRowStateLengthMismatch: a hook result that is not aligned with the page
// fails the list with the generic 500.
func TestRowStateLengthMismatch(t *testing.T) {
env, gdb := newRosterEnv(t)
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada"})
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterShort})
rec := env.expect(t, http.StatusInternalServerError, http.MethodGet, rosterPeople, "", "bearer")
got := rosterError(t, rec)
if got.Code != "error" || len(got.Details) != 0 || strings.Contains(rec.Body.String(), "row states") || strings.Contains(rec.Body.String(), "Ada") {
t.Fatalf("500 body = %s", rec.Body.String())
}
// A page without the misaligned answer still lists.
env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"?search=Ada", "", "bearer")
}
// TestRowStateHookError: a hook error fails the list with the generic 500 and
// no error text.
func TestRowStateHookError(t *testing.T) {
env, gdb := newRosterEnv(t)
logs := captureLog(t)
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterStateErr})
rec := env.expect(t, http.StatusInternalServerError, http.MethodGet, rosterPeople, "", "bearer")
if got := rosterError(t, rec); got.Code != "error" || strings.Contains(rec.Body.String(), "hunter2") || strings.Contains(rec.Body.String(), "state table") {
t.Fatalf("500 body = %s", rec.Body.String())
}
if failed := logs.matching("cabana: list row states failed"); len(failed) != 1 || !strings.Contains(failed[0], "controller=acme.roster.people") {
t.Fatalf("server log = %q", failed)
}
}
// TestRowStateAbsentKey: meta has no row_states key when no row has a state,
// when the page is empty and when the controller has no hook.
func TestRowStateAbsentKey(t *testing.T) {
env, gdb := newRosterEnv(t)
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Plain", Active: true})
for _, query := range []string{"", "?search=nobody-matches-this"} {
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+query, "", "bearer")
if strings.Contains(rec.Body.String(), "row_states") {
t.Fatalf("row_states sent for %q: %s", query, rec.Body.String())
}
}
demo, demoDB := newActEnv(t)
actInsert(t, demoDB, "plain", "acme")
rec := demo.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets", "", "bearer")
if strings.Contains(rec.Body.String(), "row_states") {
t.Fatalf("a controller without the hook sent row_states: %s", rec.Body.String())
}
// Row states are meta, never a column of the row.
body, _ := rosterList(t, env, "")
for _, row := range body.Data {
if _, ok := row["row_states"]; ok {
t.Fatalf("a row carries row_states: %v", row)
}
}
}
// TestRowStateOncePerPage: the hook is called once per served page with that
// page's records, and not at all for an empty page.
func TestRowStateOncePerPage(t *testing.T) {
env, gdb := newRosterEnv(t)
for i := range 21 {
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: fmt.Sprintf("Person %02d", i)})
}
rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Foreign"})
env.spy.takeStates()
first, _ := rosterList(t, env, "")
second, _ := rosterList(t, env, "?page=2")
rosterList(t, env, "?search=nobody-matches-this")
if calls := env.spy.takeStates(); !reflect.DeepEqual(calls, []int{20, 1}) {
t.Fatalf("ListRowStates calls = %v, want one call of 20 and one of 1", calls)
}
if len(first.Meta.RowStates) != 20 || len(second.Meta.RowStates) != 1 {
t.Fatalf("row_states sizes = %d and %d", len(first.Meta.RowStates), len(second.Meta.RowStates))
}
// The keys of a page are the ids of that page's rows.
for _, row := range second.Data {
id, _ := row["id"].(float64)
if _, ok := second.Meta.RowStates[fmt.Sprint(uint(id))]; !ok {
t.Fatalf("page 2 row %v has no state entry: %v", row["id"], second.Meta.RowStates)
}
}
}

View File

@@ -0,0 +1,218 @@
package cabana_test
import (
"context"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
// rosterActions is the roster controller with its registered bulk and record
// actions replaced (boot-error tests).
type rosterActions struct {
rosterController
bulk func([]pact.AdminBulkAction) []pact.AdminBulkAction
record func([]pact.AdminRecordAction) []pact.AdminRecordAction
}
func (a rosterActions) AdminBulkActions() []pact.AdminBulkAction {
out := a.rosterController.AdminBulkActions()
if a.bulk != nil {
out = a.bulk(out)
}
return out
}
func (a rosterActions) AdminRecordActions() []pact.AdminRecordAction {
out := a.rosterController.AdminRecordActions()
if a.record != nil {
out = a.record(out)
}
return out
}
// TestPhase121BootErrors is the table of every boot error Phase 12.1 added
// to the framework: each stops activation and names the plugin, the
// controller and, where the mistake is in a file, that file.
func TestPhase121BootErrors(t *testing.T) {
const (
listFile = "controllers/people/config_list.yaml"
formFile = "controllers/people/config_form.yaml"
listHead = "list: ~/plugins/acme/roster/models/person/columns.yaml\nmodelClass: Person\n"
)
noRun := func(context.Context, pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
return pact.AdminBulkActionResult{}, nil
}
bulk := func(edit func([]pact.AdminBulkAction) []pact.AdminBulkAction) func(*rosterPlugin) {
return func(p *rosterPlugin) {
p.wrap = func(inner rosterController) pact.AdminController {
return rosterActions{rosterController: inner, bulk: edit}
}
}
}
record := func(edit func([]pact.AdminRecordAction) []pact.AdminRecordAction) func(*rosterPlugin) {
return func(p *rosterPlugin) {
p.wrap = func(inner rosterController) pact.AdminController {
return rosterActions{rosterController: inner, record: edit}
}
}
}
relations := func(edit func([]cabana.FieldRelationContract)) func(*rosterPlugin) {
return func(p *rosterPlugin) {
p.relations = func(in []cabana.FieldRelationContract) []cabana.FieldRelationContract {
edit(in)
return in
}
}
}
notifyBlock := " notify:\n label: acme.roster::lang.people.notify\n type: checkbox\n default: true\n context: create\n"
for _, tc := range []struct {
name string
files map[string]string
plugin func(*rosterPlugin)
want string
file string // "" when the mistake is in Go code, not in a file
}{
// config_list.yaml bulkActions (D-09).
{name: "bulkActions without showCheckboxes", files: map[string]string{listFile: listHead + "bulkActions: [activate]\n"},
want: "bulkActions needs showCheckboxes: true", file: listFile},
{name: "bulkActions names an unregistered action", files: map[string]string{listFile: listHead + "showCheckboxes: true\nbulkActions: [activate, promote]\n"},
want: "bulkActions: unsupported action promote (want a bulk action the controller registers)", file: listFile},
{name: "bulkActions names the built-in delete", files: map[string]string{listFile: listHead + "showCheckboxes: true\nbulkActions: [delete]\n"},
want: "bulkActions: unsupported action delete (want a bulk action the controller registers)", file: listFile},
{name: "bulkActions names an action twice", files: map[string]string{listFile: listHead + "showCheckboxes: true\nbulkActions: [activate, activate]\n"},
want: "bulkActions: duplicate action activate", file: listFile},
{name: "bulkActions is a scalar", files: map[string]string{listFile: listHead + "showCheckboxes: true\nbulkActions: activate\n"},
want: "bulkActions must be a list of bulk action names the controller registers", file: listFile},
{name: "a declared bulk action has no label", plugin: bulk(func(in []pact.AdminBulkAction) []pact.AdminBulkAction {
in[0].Label = ""
return in
}), want: "bulkActions: action activate needs a label", file: listFile},
// Registered bulk actions.
{name: "a bulk action with a reserved name", plugin: bulk(func(in []pact.AdminBulkAction) []pact.AdminBulkAction {
return append(in, pact.AdminBulkAction{Name: "delete", Label: "x", Run: noRun})
}), want: "bulk action delete uses a reserved built-in name (create, delete)"},
{name: "a bulk action registered twice", plugin: bulk(func(in []pact.AdminBulkAction) []pact.AdminBulkAction {
return append(in, in[0])
}), want: "duplicate bulk action activate"},
{name: "a bulk action without Run", plugin: bulk(func(in []pact.AdminBulkAction) []pact.AdminBulkAction {
in[1].Run = nil
return in
}), want: "bulk action archive has no Run function"},
{name: "a bulk action name that is not an identifier", plugin: bulk(func(in []pact.AdminBulkAction) []pact.AdminBulkAction {
return append(in, pact.AdminBulkAction{Name: "ban users", Label: "x", Run: noRun})
}), want: `bulk action name "ban users" is not an identifier`},
// config_form.yaml recordActions and preview (D-10, D-11).
{name: "recordActions without preview", files: map[string]string{formFile: rosterFormHead + "recordActions: [activate]\n"},
want: "recordActions needs a preview block (record actions are offered on the preview screen)", file: formFile},
{name: "recordActions names an unregistered action", files: map[string]string{formFile: rosterFormHead + "preview: {}\nrecordActions: [activate, promote]\n"},
want: "recordActions: unsupported action promote (want a record action the controller registers)", file: formFile},
{name: "recordActions names an action twice", files: map[string]string{formFile: rosterFormHead + "preview: {}\nrecordActions: [activate, activate]\n"},
want: "recordActions: duplicate action activate", file: formFile},
{name: "recordActions is a scalar", files: map[string]string{formFile: rosterFormHead + "preview: {}\nrecordActions: activate\n"},
want: "recordActions must be a list of record action names the controller registers", file: formFile},
{name: "a declared record action has no label", plugin: record(func(in []pact.AdminRecordAction) []pact.AdminRecordAction {
in[0].Label = ""
return in
}), want: "recordActions: action activate needs a label", file: formFile},
{name: "a record action with a reserved name", plugin: record(func(in []pact.AdminRecordAction) []pact.AdminRecordAction {
in[1].Name = "create"
return in
}), want: "record action create uses a reserved built-in name (create, delete)"},
{name: "a record action registered twice", plugin: record(func(in []pact.AdminRecordAction) []pact.AdminRecordAction {
return append(in, in[0])
}), want: "duplicate record action activate"},
{name: "a record action without Run", plugin: record(func(in []pact.AdminRecordAction) []pact.AdminRecordAction {
in[0].Run = nil
return in
}), want: "record action activate has no Run function"},
{name: "a null preview", files: map[string]string{formFile: rosterFormHead + "preview:\n"},
want: "preview must be a mapping; write preview: {} to enable the preview screen without a header partial", file: formFile},
{name: "a scalar preview", files: map[string]string{formFile: rosterFormHead + "preview: true\n"},
want: "preview must be a mapping", file: formFile},
{name: "an unknown preview key", files: map[string]string{formFile: rosterFormHead + "preview:\n toolbar: x\n"},
want: "preview: unknown field toolbar", file: formFile},
{name: "a preview header partial that is a path", files: map[string]string{formFile: rosterFormHead + "preview:\n headerPartial: $/acme/status.htm\n"},
want: "path must be a partial name", file: formFile},
{name: "a preview header partial without a template", files: map[string]string{formFile: rosterFormHead + "preview:\n headerPartial: missing\n"},
want: "partial missing", file: "controllers/people/_missing.htm"},
// Password and form-only fields (D-27 G1, G2).
{name: "a password field outside FormVirtualFields", files: map[string]string{rosterFieldsFile: rosterFields(t, "", " secret:\n type: password\n")},
want: "field secret: type password needs the controller to list it in FormVirtualFields", file: rosterFieldsFile},
{name: "a listed virtual field that is not on the form", files: map[string]string{rosterFieldsFile: rosterFields(t, notifyBlock, "")},
want: "FormVirtualFields: field notify is not a field of this form", file: rosterFieldsFile},
{name: "a virtual field of a wrong type", files: map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: partial\n path: status\n")},
want: "FormVirtualFields: field notify has type partial (want password, text, textarea, number, checkbox, switch or dropdown)", file: rosterFieldsFile},
{name: "a form-only field the controller does not list", files: map[string]string{rosterFieldsFile: rosterFields(t, "", " nickname:\n type: text\n")},
want: "field nickname is not a model column"},
// preset (D-27 G7).
{name: "an unsupported preset type", files: map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", " preset:\n field: name\n type: camel\n")},
want: "preset type camel is not supported (want slug or exact)", file: rosterFieldsFile},
{name: "an unknown preset key", files: map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", " preset:\n field: name\n prefix: x\n")},
want: "preset: unknown field prefix", file: rosterFieldsFile},
{name: "preset on a field that is not text", files: map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: checkbox\n default: true\n preset: name\n")},
want: "preset is only valid on type: text", file: rosterFieldsFile},
{name: "preset from an unknown field", files: map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", " preset: title\n")},
want: "field slug: preset field title is not a field of this form", file: rosterFieldsFile},
{name: "preset from a field that is not text", files: map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", " preset: notify\n")},
want: "field slug: preset field notify must be a text field", file: rosterFieldsFile},
{name: "preset from the field itself", files: map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", " preset: slug\n")},
want: "field slug: preset names the field itself", file: rosterFieldsFile},
// permissioneditor (D-16).
{name: "permissioneditor without mode", files: map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", "")},
want: "field permissions: mode must be radio or checkbox on type: permissioneditor", file: rosterFieldsFile},
{name: "permissioneditor with an unknown mode", files: map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: tabs\n")},
want: "mode must be radio or checkbox on type: permissioneditor", file: rosterFieldsFile},
{name: "permissioneditor with a default", files: map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: radio\n default: 1\n")},
want: "default is not valid on type: permissioneditor", file: rosterFieldsFile},
{name: "mode on another type", files: map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: checkbox\n default: true\n mode: radio\n")},
want: "mode is only valid on type: fileupload, datepicker or permissioneditor", file: rosterFieldsFile},
// Relation fields (D-27 G3) and controller filter choices.
{name: "WritableForeignKey on belongsToMany", plugin: relations(func(in []cabana.FieldRelationContract) { in[1].WritableForeignKey = true }),
want: "field tags: WritableForeignKey is only valid on belongsTo"},
{name: "a scope filter without choices", plugin: func(p *rosterPlugin) {
p.wrap = func(inner rosterController) pact.AdminController { return rosterBare{inner: inner} }
}, want: "scope filter tagged needs FilterOptions on the controller or the model to serve its choices (D-27)", file: "controllers/people/config_filter.yaml"},
} {
t.Run(tc.name, func(t *testing.T) {
plugin := rosterPlugin{spy: &rosterSpy{}}
if tc.files != nil {
plugin.fsys = rosterTree(t, tc.files)
}
if tc.plugin != nil {
tc.plugin(&plugin)
}
err := rosterBootWith(t, plugin)
if err == nil {
t.Fatalf("the plugin booted, want an error with %q", tc.want)
}
parts := []string{tc.want, "acme.roster", "acme.roster.people"}
if tc.file != "" {
parts = append(parts, tc.file)
}
for _, part := range parts {
if !strings.Contains(err.Error(), part) {
t.Fatalf("error %q does not name %q", err, part)
}
}
})
}
// The provider-less permission editor is reported for the controller
// that lacks the provider.
t.Run("permissioneditor without the provider", func(t *testing.T) {
err := activateFields(t, datepickerFields(" rights:\n type: permissioneditor\n mode: checkbox\n"))
const want = "field rights: type permissioneditor needs the controller to implement cabana.PermissionEditorProvider"
if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "fields.yaml") {
t.Fatalf("error = %v, want %q", err, want)
}
})
// The unchanged fixture boots, so every failure above is its one change.
if err := rosterBoot(t, rosterTree(t, nil)); err != nil {
t.Fatalf("the unchanged fixture does not boot: %v", err)
}
}

View File

@@ -136,6 +136,8 @@ type dfRecorder struct {
mu sync.Mutex
calls []string
fail map[string]bool
// refuse makes a named hook answer a cabana.ForbiddenError.
refuse map[string]bool
// probe, when set, runs inside the Form hooks with the write's
// transaction and its result is recorded after the hook name.
probe func(tx *gorm.DB) string
@@ -152,6 +154,7 @@ func (r *dfRecorder) reset() {
defer r.mu.Unlock()
r.calls = nil
r.fail = map[string]bool{}
r.refuse = map[string]bool{}
r.probe = nil
}
@@ -161,6 +164,19 @@ func (r *dfRecorder) failOn(name string) {
r.fail[name] = true
}
// refuseOn makes the named hook refuse with a ForbiddenError.
func (r *dfRecorder) refuseOn(name string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.refuse == nil {
r.refuse = map[string]bool{}
}
r.refuse[name] = true
}
// dfRefusal is the message of a hook refusal asked for with refuseOn.
const dfRefusal = "This change is not allowed here."
func (r *dfRecorder) snapshot() []string {
r.mu.Lock()
defer r.mu.Unlock()
@@ -172,6 +188,7 @@ func (r *dfRecorder) hook(ctx context.Context, name string) error {
r.mu.Lock()
probe := r.probe
fail := r.fail[name]
refuse := r.refuse[name]
r.mu.Unlock()
call := name
if probe != nil {
@@ -180,6 +197,9 @@ func (r *dfRecorder) hook(ctx context.Context, name string) error {
}
}
r.add(call)
if refuse {
return &cabana.ForbiddenError{Message: dfRefusal, Details: map[string]any{"hook": []string{name}}}
}
if fail {
return errors.New("fixture hook " + name + " failed")
}