test(12.1-05): unit tests for bulk and record actions, row state, forbidden, preview and the form seams
- bulk action: empty, duplicate, unordered, absent, partial, out-of-scope, rollback, concurrent runs, permissions, CSRF, body cap - record action: scope, Applies, strict body, offered order, rollback, Applies error - ForbiddenError from every Form hook, the bulk delete and the relation link and child hooks - permission editor modes, locked codes and provider errors; relation locks on create, update and belongsTo - TestPhase121BootErrors: every boot error of plans 01 and 02 with plugin, controller and file - pact: the action, row state and filter contracts on a sample controller
This commit is contained in:
369
modules/cabana/phase121_bulk_test.go
Normal file
369
modules/cabana/phase121_bulk_test.go
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rosterLocale sends one bearer request with an Accept-Language header.
|
||||||
|
func rosterLocale(env *rosterEnv, method, rel, body, locale string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(method, adminAPI(rel), strings.NewReader(body))
|
||||||
|
if body != "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept-Language", locale)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
env.h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
rosterActivate = rosterPeople + "/bulk/activate"
|
||||||
|
rosterArchive = rosterPeople + "/bulk/archive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rosterNames are the names of the records one bulk Run received, in order.
|
||||||
|
func rosterNames(records []any) []string {
|
||||||
|
names := make([]string, len(records))
|
||||||
|
for i, record := range records {
|
||||||
|
names[i] = record.(*rosterPerson).Name
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkActionEmpty: a request without ids is 422 and never reaches Run
|
||||||
|
// (D-09).
|
||||||
|
func TestBulkActionEmpty(t *testing.T) {
|
||||||
|
env, _ := newRosterEnv(t)
|
||||||
|
for _, body := range []string{`{"ids":[]}`, `{}`, `{"ids":null}`, `{"ids":["x"]}`, `{"ids":[-1]}`, `{"ids":[1.5]}`, `[1]`, `{`, ``} {
|
||||||
|
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterActivate, body, "bearer")
|
||||||
|
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
|
||||||
|
}
|
||||||
|
if calls := env.spy.takeBulk(); len(calls) != 0 {
|
||||||
|
t.Fatalf("a request without ids reached Run: %+v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkActionDuplicates: repeated ids are one record each.
|
||||||
|
func TestBulkActionDuplicates(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada"})
|
||||||
|
bob := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob"})
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(bob, ada, bob, ada, ada), "bearer")
|
||||||
|
if result := rosterBulkResult(t, rec); result.Affected != 2 {
|
||||||
|
t.Fatalf("affected = %d, want 2", result.Affected)
|
||||||
|
}
|
||||||
|
calls := env.spy.takeBulk()
|
||||||
|
if len(calls) != 1 || !reflect.DeepEqual(rosterNames(calls[0].Records), []string{"Ada", "Bob"}) {
|
||||||
|
t.Fatalf("Run received %+v, want Ada and Bob once each", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkActionOrder: records reach Run ordered by primary key, whatever the
|
||||||
|
// order of the posted ids.
|
||||||
|
func TestBulkActionOrder(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
var ids []uint
|
||||||
|
for _, name := range []string{"One", "Two", "Three", "Four"} {
|
||||||
|
ids = append(ids, rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: name}))
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(ids[3], ids[0], ids[2], ids[1]), "bearer")
|
||||||
|
calls := env.spy.takeBulk()
|
||||||
|
if len(calls) != 1 || !reflect.DeepEqual(rosterNames(calls[0].Records), []string{"One", "Two", "Three", "Four"}) {
|
||||||
|
t.Fatalf("Run received %v", calls)
|
||||||
|
}
|
||||||
|
for i, record := range calls[0].Records {
|
||||||
|
if record.(*rosterPerson).ID != ids[i] {
|
||||||
|
t.Fatalf("record %d has id %d, want %d", i, record.(*rosterPerson).ID, ids[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkActionAbsent: a selection that matches no row answers affected 0
|
||||||
|
// and does not call Run.
|
||||||
|
func TestBulkActionAbsent(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
kept := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Kept"})
|
||||||
|
// Id 0 is a well-formed id that no row has.
|
||||||
|
for _, body := range []string{rosterIDs(999998, 999999), rosterIDs(0)} {
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, body, "bearer")
|
||||||
|
if result := rosterBulkResult(t, rec); result.Affected != 0 || result.Message != "" {
|
||||||
|
t.Fatalf("%s: result = %+v", body, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls := env.spy.takeBulk(); len(calls) != 0 {
|
||||||
|
t.Fatalf("Run was called for an absent selection: %+v", calls)
|
||||||
|
}
|
||||||
|
if rosterLoad(t, gdb, kept).Active {
|
||||||
|
t.Fatal("an unselected row was changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkActionPartial: a selection with a present and an absent id is a 409
|
||||||
|
// and changes nothing.
|
||||||
|
func TestBulkActionPartial(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada"})
|
||||||
|
bob := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob"})
|
||||||
|
rec := env.expect(t, http.StatusConflict, http.MethodPost, rosterActivate, rosterIDs(ada, 999999, bob), "bearer")
|
||||||
|
actErrorCode(t, rec.Body.Bytes(), "conflict")
|
||||||
|
if rosterLoad(t, gdb, ada).Active || rosterLoad(t, gdb, bob).Active {
|
||||||
|
t.Fatal("a partial selection changed a row")
|
||||||
|
}
|
||||||
|
if calls := env.spy.takeBulk(); len(calls) != 0 {
|
||||||
|
t.Fatalf("Run was called for a partial selection: %+v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkActionScope: ids are resolved through the list scope
|
||||||
|
// (ListExtendQuery), so a row of another tenant is absent (T-12.1-01).
|
||||||
|
func TestBulkActionScope(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
own := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Own"})
|
||||||
|
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Foreign"})
|
||||||
|
trashed := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Trashed", DeletedAt: rosterDeleted()})
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(foreign), "bearer")
|
||||||
|
if result := rosterBulkResult(t, rec); result.Affected != 0 {
|
||||||
|
t.Fatalf("out-of-scope selection: %+v", result)
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusConflict, http.MethodPost, rosterActivate, rosterIDs(own, foreign), "bearer")
|
||||||
|
if calls := env.spy.takeBulk(); len(calls) != 0 {
|
||||||
|
t.Fatalf("Run saw a selection with an out-of-scope id: %+v", calls)
|
||||||
|
}
|
||||||
|
if rosterLoad(t, gdb, foreign).Active || rosterLoad(t, gdb, own).Active {
|
||||||
|
t.Fatal("a row was changed")
|
||||||
|
}
|
||||||
|
// The scope of this controller includes soft-deleted rows.
|
||||||
|
rec = env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(own, trashed), "bearer")
|
||||||
|
if result := rosterBulkResult(t, rec); result.Affected != 2 {
|
||||||
|
t.Fatalf("in-scope selection: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkActionRollback: an error from Run, refusal or failure, rolls every
|
||||||
|
// row of the selection back (T-12.1-05).
|
||||||
|
func TestBulkActionRollback(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
for _, last := range []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
}{{rosterCrash, http.StatusInternalServerError}, {rosterLocked, http.StatusForbidden}} {
|
||||||
|
first := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "First"})
|
||||||
|
second := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Second"})
|
||||||
|
failing := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: last.name})
|
||||||
|
rec := env.expect(t, last.status, http.MethodPost, rosterArchive, rosterIDs(first, second, failing), "bearer")
|
||||||
|
if strings.Contains(rec.Body.String(), "hunter2") {
|
||||||
|
t.Fatalf("the error text of Run is in the body: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, id := range []uint{first, second, failing} {
|
||||||
|
if rosterLoad(t, gdb, id).DeletedAt.Valid {
|
||||||
|
t.Fatalf("%s: row %d kept the write of a failed bulk action", last.name, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Run did see all three rows: the first two were written before the
|
||||||
|
// failure.
|
||||||
|
if calls := env.spy.takeBulk(); len(calls) != 1 || len(calls[0].Records) != 3 {
|
||||||
|
t.Fatalf("%s: Run received %+v", last.name, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkActionConcurrent: two runs over the same rows, posted in opposite
|
||||||
|
// id order, do not deadlock: the second waits for the row locks of the first
|
||||||
|
// and then sees its result.
|
||||||
|
func TestBulkActionConcurrent(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada"})
|
||||||
|
bob := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob"})
|
||||||
|
|
||||||
|
inside, release := make(chan struct{}), make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
wait := func() {
|
||||||
|
blocked := false
|
||||||
|
once.Do(func() { blocked = true })
|
||||||
|
if blocked {
|
||||||
|
close(inside)
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env.knobs.slowBulk.Store(&wait)
|
||||||
|
t.Cleanup(func() { env.knobs.slowBulk.Store(nil) })
|
||||||
|
|
||||||
|
type answer struct {
|
||||||
|
code int
|
||||||
|
affected int
|
||||||
|
}
|
||||||
|
run := func(body string, out chan<- answer) {
|
||||||
|
rec := env.call(t, http.MethodPost, rosterActivate, body, "bearer")
|
||||||
|
out <- answer{rec.Code, rosterBulkResult(t, rec).Affected}
|
||||||
|
}
|
||||||
|
first, second := make(chan answer, 1), make(chan answer, 1)
|
||||||
|
go run(rosterIDs(ada, bob), first)
|
||||||
|
select {
|
||||||
|
case <-inside:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("the first run never reached its action")
|
||||||
|
}
|
||||||
|
// The first run holds the row locks. The second must wait for them.
|
||||||
|
go run(rosterIDs(bob, ada), second)
|
||||||
|
select {
|
||||||
|
case got := <-second:
|
||||||
|
t.Fatalf("the second run finished while the first held the rows: %+v", got)
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
var answers []answer
|
||||||
|
for _, ch := range []chan answer{first, second} {
|
||||||
|
select {
|
||||||
|
case got := <-ch:
|
||||||
|
answers = append(answers, got)
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
t.Fatal("a concurrent bulk action did not finish (deadlock)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Both answer 200; the rows were activated exactly once.
|
||||||
|
if answers[0].code != http.StatusOK || answers[1].code != http.StatusOK {
|
||||||
|
t.Fatalf("answers = %+v", answers)
|
||||||
|
}
|
||||||
|
if answers[0].affected != 2 || answers[1].affected != 0 {
|
||||||
|
t.Fatalf("affected = %d and %d, want 2 and 0: the second run must see the first one's result", answers[0].affected, answers[1].affected)
|
||||||
|
}
|
||||||
|
if !rosterLoad(t, gdb, ada).Active || !rosterLoad(t, gdb, bob).Active {
|
||||||
|
t.Fatal("the rows are not active after both runs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkActionPermissions: the controller's permission and the action's own
|
||||||
|
// (T-12.1-02).
|
||||||
|
func TestBulkActionPermissions(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
|
||||||
|
rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterActivate, rosterIDs(idle), "limited")
|
||||||
|
actErrorCode(t, rec.Body.Bytes(), "forbidden")
|
||||||
|
// The permission is checked before the body is read.
|
||||||
|
env.expect(t, http.StatusForbidden, http.MethodPost, rosterActivate, `{`, "limited")
|
||||||
|
if rosterLoad(t, gdb, idle).Active || len(env.spy.takeBulk()) != 0 {
|
||||||
|
t.Fatal("an action ran without its permission")
|
||||||
|
}
|
||||||
|
// Without a token the request is not authenticated at all.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, adminAPI(rosterActivate), strings.NewReader(rosterIDs(idle)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
anonymous := httptest.NewRecorder()
|
||||||
|
env.h.ServeHTTP(anonymous, req)
|
||||||
|
if anonymous.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("anonymous bulk action = %d", anonymous.Code)
|
||||||
|
}
|
||||||
|
// An action that asks for the controller's permission only runs for the
|
||||||
|
// limited administrator.
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, rosterArchive, rosterIDs(idle), "limited")
|
||||||
|
if !rosterLoad(t, gdb, idle).DeletedAt.Valid {
|
||||||
|
t.Fatal("archive did not run for the limited administrator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkActionUndeclared: a name the list does not declare is 404, whatever
|
||||||
|
// else is registered under it.
|
||||||
|
func TestBulkActionUndeclared(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle", Banned: true})
|
||||||
|
// missing: nowhere. reinstate: a record action. delete and create: the
|
||||||
|
// built-in names, which are never declared actions.
|
||||||
|
for _, name := range []string{"missing", "reinstate", "delete", "create", "Activate", "activate%20"} {
|
||||||
|
rec := env.call(t, http.MethodPost, rosterPeople+"/bulk/"+name, rosterIDs(idle), "bearer")
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("bulk/%s = %d, want 404", name, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusNotFound, http.MethodPost, "/acme/roster/nobody/bulk/activate", rosterIDs(idle), "bearer")
|
||||||
|
// The route is POST only.
|
||||||
|
if rec := env.call(t, http.MethodGet, rosterActivate, "", "bearer"); rec.Code == http.StatusOK {
|
||||||
|
t.Fatalf("GET on the bulk action route = %d", rec.Code)
|
||||||
|
}
|
||||||
|
if stored := rosterLoad(t, gdb, idle); stored.Active || !stored.Banned || len(env.spy.takeBulk()) != 0 {
|
||||||
|
t.Fatal("an undeclared name ran an action")
|
||||||
|
}
|
||||||
|
// A controller without declared bulk actions has none to run.
|
||||||
|
demo, demoDB := newActEnv(t)
|
||||||
|
gadget := actInsert(t, demoDB, "plain", "acme")
|
||||||
|
demo.expect(t, http.StatusNotFound, http.MethodPost, "/acme/demo/gadgets/bulk/activate", rosterIDs(gadget), "bearer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkActionCSRF: a cookie request needs X-Requested-With (T-12.1-03).
|
||||||
|
func TestBulkActionCSRF(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
|
||||||
|
rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterActivate, rosterIDs(idle), "cookie-only")
|
||||||
|
actErrorCode(t, rec.Body.Bytes(), "forbidden")
|
||||||
|
if rosterLoad(t, gdb, idle).Active || len(env.spy.takeBulk()) != 0 {
|
||||||
|
t.Fatal("a cookie request without the header ran the action")
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(idle), "cookie")
|
||||||
|
if !rosterLoad(t, gdb, idle).Active {
|
||||||
|
t.Fatal("a cookie request with the header did not run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkActionMessageLocalized: the action's message is a phrase key
|
||||||
|
// resolved in the request locale; an action without one answers an empty
|
||||||
|
// message.
|
||||||
|
func TestBulkActionMessageLocalized(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
for locale, want := range map[string]string{
|
||||||
|
"en": "The selected people were archived.",
|
||||||
|
"pl": "Zaznaczone osoby zostały zarchiwizowane.",
|
||||||
|
} {
|
||||||
|
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Spare " + locale})
|
||||||
|
rec := rosterLocale(env, http.MethodPost, rosterArchive, rosterIDs(id), locale)
|
||||||
|
if result := rosterBulkResult(t, rec); rec.Code != http.StatusOK || result.Message != want || result.Affected != 1 {
|
||||||
|
t.Fatalf("%s: status=%d result=%+v, want %q", locale, rec.Code, result, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Quiet"})
|
||||||
|
rec := rosterLocale(env, http.MethodPost, rosterActivate, rosterIDs(id), "pl")
|
||||||
|
if result := rosterBulkResult(t, rec); result.Message != "" || !strings.Contains(rec.Body.String(), `"message":""`) {
|
||||||
|
t.Fatalf("an action without a message answered %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
// The list schema localizes label and confirm the same way.
|
||||||
|
schema := rosterLocale(env, http.MethodGet, rosterPeople+"/schema/list", "", "pl")
|
||||||
|
if !strings.Contains(schema.Body.String(), `"label":"Aktywuj","confirm":"Aktywować zaznaczone osoby?"`) {
|
||||||
|
t.Fatalf("pl list schema = %s", schema.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkActionBodyCap: a body past http.body_limits.default_bytes is
|
||||||
|
// refused before the action runs.
|
||||||
|
func TestBulkActionBodyCap(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
|
||||||
|
huge := fmt.Sprintf(`{"ids":[%d],"pad":"%s"}`, idle, strings.Repeat("x", 1100<<10))
|
||||||
|
rec := env.expect(t, http.StatusRequestEntityTooLarge, http.MethodPost, rosterActivate, huge, "bearer")
|
||||||
|
if rosterLoad(t, gdb, idle).Active || len(env.spy.takeBulk()) != 0 {
|
||||||
|
t.Fatalf("an oversized request ran the action: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListSchemaBulkActionsFiltered: the list schema offers each
|
||||||
|
// administrator the bulk actions they may run, and a filtered answer leaves
|
||||||
|
// the cached schema whole.
|
||||||
|
func TestListSchemaBulkActionsFiltered(t *testing.T) {
|
||||||
|
env, _ := newRosterEnv(t)
|
||||||
|
full := []string{"delete", "activate", "archive"}
|
||||||
|
for range 2 {
|
||||||
|
if got := rosterBulkNames(t, env, "limited"); !reflect.DeepEqual(got, []string{"delete", "archive"}) {
|
||||||
|
t.Fatalf("limited admin = %v", got)
|
||||||
|
}
|
||||||
|
if got := rosterBulkNames(t, env, "bearer"); !reflect.DeepEqual(got, full) {
|
||||||
|
t.Fatalf("full admin after a filtered request = %v, want %v", got, full)
|
||||||
|
}
|
||||||
|
if got := rosterBulkNames(t, env, "cookie"); !reflect.DeepEqual(got, full) {
|
||||||
|
t.Fatalf("full admin by cookie = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
238
modules/cabana/phase121_fields_test.go
Normal file
238
modules/cabana/phase121_fields_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -200,9 +201,9 @@ type rosterKnobs struct {
|
|||||||
permissionValues atomic.Bool
|
permissionValues atomic.Bool
|
||||||
// relationLocks makes AdminRelationLocks fail.
|
// relationLocks makes AdminRelationLocks fail.
|
||||||
relationLocks atomic.Bool
|
relationLocks atomic.Bool
|
||||||
// slowArchive, when set, runs inside the archive bulk action after the
|
// slowBulk, when set, runs inside each bulk action after the rows were
|
||||||
// rows were locked (concurrency tests).
|
// locked (concurrency tests).
|
||||||
slowArchive atomic.Pointer[func()]
|
slowBulk atomic.Pointer[func()]
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sentinel names below make one hook of the roster controller misbehave
|
// 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
|
// relations, when set, rewrites the controller's relation contracts
|
||||||
// (boot tests).
|
// (boot tests).
|
||||||
relations func([]cabana.FieldRelationContract) []cabana.FieldRelationContract
|
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" }
|
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) Register(*backpack.App) error { return nil }
|
||||||
func (rosterPlugin) Boot(*backpack.App) error { return nil }
|
func (rosterPlugin) Boot(*backpack.App) error { return nil }
|
||||||
func (p rosterPlugin) AdminControllers() []pact.AdminController {
|
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 {
|
func (rosterPlugin) Permissions() []pact.Permission {
|
||||||
return []pact.Permission{{Code: "acme.roster.access", Roles: []string{"developer"}}, {Code: "acme.roster.manage", Roles: []string{"developer"}}}
|
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)
|
return c.db.WithContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminRelationLocks locks the staff tag for an administrator without
|
// AdminRelationLocks locks the staff tag and the vault team for an
|
||||||
// acme.roster.manage.
|
// administrator without acme.roster.manage.
|
||||||
func (c rosterController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
|
func (c rosterController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
|
||||||
if c.knobs != nil && c.knobs.relationLocks.Load() {
|
if c.knobs != nil && c.knobs.relationLocks.Load() {
|
||||||
return cabana.RelationLock{}, fmt.Errorf("the lock table said hunter2")
|
return cabana.RelationLock{}, fmt.Errorf("the lock table said hunter2")
|
||||||
}
|
}
|
||||||
principal, _ := bouncer.User(ctx)
|
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
|
return cabana.RelationLock{}, nil
|
||||||
}
|
}
|
||||||
var ids []uint
|
var ids []uint
|
||||||
@@ -588,6 +606,16 @@ func (rosterController) FormAfterDelete(ctx context.Context, model any) error {
|
|||||||
return nil
|
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
|
// AdminBulkActions: activate needs acme.roster.manage and sets active on the
|
||||||
// rows that are not active yet, reporting how many it changed; archive needs
|
// rows that are not active yet, reporting how many it changed; archive needs
|
||||||
// only the controller permission and soft-deletes the rows.
|
// only the controller permission and soft-deletes the rows.
|
||||||
@@ -601,6 +629,7 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
|
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
|
||||||
}
|
}
|
||||||
|
c.slow()
|
||||||
changed := 0
|
changed := 0
|
||||||
for _, record := range in.Records {
|
for _, record := range in.Records {
|
||||||
person := record.(*rosterPerson)
|
person := record.(*rosterPerson)
|
||||||
@@ -624,11 +653,7 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
|
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
|
||||||
}
|
}
|
||||||
if c.knobs != nil {
|
c.slow()
|
||||||
if wait := c.knobs.slowArchive.Load(); wait != nil {
|
|
||||||
(*wait)()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, record := range in.Records {
|
for _, record := range in.Records {
|
||||||
// A refusal after earlier rows were written: the whole
|
// A refusal after earlier rows were written: the whole
|
||||||
// selection must roll back.
|
// selection must roll back.
|
||||||
@@ -847,3 +872,96 @@ func rosterLoad(t *testing.T, gdb *gorm.DB, id uint) rosterPerson {
|
|||||||
}
|
}
|
||||||
return person
|
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} }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
192
modules/cabana/phase121_forbidden_test.go
Normal file
192
modules/cabana/phase121_forbidden_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
101
modules/cabana/phase121_list_test.go
Normal file
101
modules/cabana/phase121_list_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
268
modules/cabana/phase121_permission_test.go
Normal file
268
modules/cabana/phase121_permission_test.go
Normal 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")
|
||||||
|
}
|
||||||
144
modules/cabana/phase121_preview_test.go
Normal file
144
modules/cabana/phase121_preview_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
189
modules/cabana/phase121_record_test.go
Normal file
189
modules/cabana/phase121_record_test.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRecordActionScope: the record is loaded through the form scope, so a
|
||||||
|
// missing id and an id outside the scope are the same 404 (T-12.1-04).
|
||||||
|
func TestRecordActionScope(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Foreign"})
|
||||||
|
trashed := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Trashed", DeletedAt: rosterDeleted()})
|
||||||
|
var bodies []string
|
||||||
|
for _, id := range []uint{foreign, 999999} {
|
||||||
|
rec := env.expect(t, http.StatusNotFound, http.MethodPost, rosterPath(id, "/actions/activate"), `{}`, "bearer")
|
||||||
|
actErrorCode(t, rec.Body.Bytes(), "not_found")
|
||||||
|
bodies = append(bodies, rec.Body.String())
|
||||||
|
}
|
||||||
|
if bodies[0] != bodies[1] {
|
||||||
|
t.Fatalf("an out-of-scope id and a missing id answer differently:\n%s\n%s", bodies[0], bodies[1])
|
||||||
|
}
|
||||||
|
for _, id := range []string{"abc", "0", "-1", "1.5"} {
|
||||||
|
if rec := env.call(t, http.MethodPost, rosterPeople+"/"+id+"/actions/activate", `{}`, "bearer"); rec.Code/100 == 2 {
|
||||||
|
t.Fatalf("id %q answered %d", id, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rosterLoad(t, gdb, foreign).Active || len(env.spy.takeRecord()) != 0 {
|
||||||
|
t.Fatal("an out-of-scope record was changed")
|
||||||
|
}
|
||||||
|
// The form scope of this controller includes soft-deleted records.
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, rosterPath(trashed, "/actions/activate"), `{}`, "bearer")
|
||||||
|
if !rosterLoad(t, gdb, trashed).Active {
|
||||||
|
t.Fatal("an in-scope soft-deleted record was not reached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordActionApplies: Applies is checked again inside the transaction;
|
||||||
|
// an action that does not apply is a 409 and does not run.
|
||||||
|
func TestRecordActionApplies(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
active := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Active", Active: true})
|
||||||
|
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
|
||||||
|
rec := env.expect(t, http.StatusConflict, http.MethodPost, rosterPath(active, "/actions/activate"), `{}`, "bearer")
|
||||||
|
actErrorCode(t, rec.Body.Bytes(), "conflict")
|
||||||
|
rec = env.expect(t, http.StatusConflict, http.MethodPost, rosterPath(idle, "/actions/reinstate"), `{}`, "bearer")
|
||||||
|
actErrorCode(t, rec.Body.Bytes(), "conflict")
|
||||||
|
if len(env.spy.takeRecord()) != 0 {
|
||||||
|
t.Fatal("an action ran on a record it does not apply to")
|
||||||
|
}
|
||||||
|
// It runs once; the second request finds it no longer applies.
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "bearer")
|
||||||
|
env.expect(t, http.StatusConflict, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "bearer")
|
||||||
|
if calls := env.spy.takeRecord(); len(calls) != 1 || calls[0].RecordID != uint64(idle) {
|
||||||
|
t.Fatalf("Run calls = %+v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordActionBody: the body is a strict, empty JSON object.
|
||||||
|
func TestRecordActionBody(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
|
||||||
|
for _, body := range []string{
|
||||||
|
fmt.Sprintf(`{"record_id":%d}`, idle), `{"record_id":null,"values":{}}`, `{"values":{"active":true}}`,
|
||||||
|
`{"extra":1}`, `{"field":"name"}x`, `{} {}`, `{}{}`, `null {}`, `[]`, `"activate"`, `{`, ``,
|
||||||
|
} {
|
||||||
|
rec := env.call(t, http.MethodPost, rosterPath(idle, "/actions/activate"), body, "bearer")
|
||||||
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("body %q = %d, want 422: %s", body, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
|
||||||
|
}
|
||||||
|
if rosterLoad(t, gdb, idle).Active || len(env.spy.takeRecord()) != 0 {
|
||||||
|
t.Fatal("a malformed body ran the action")
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "bearer")
|
||||||
|
// A JSON null is read as the empty object: it carries no input either.
|
||||||
|
other := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Other"})
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, rosterPath(other, "/actions/activate"), `null`, "bearer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordActionPermissions: the controller's permission and the action's
|
||||||
|
// own, and the CSRF header for cookie requests (T-12.1-02, T-12.1-03).
|
||||||
|
func TestRecordActionPermissions(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle", Banned: true})
|
||||||
|
rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "limited")
|
||||||
|
actErrorCode(t, rec.Body.Bytes(), "forbidden")
|
||||||
|
env.expect(t, http.StatusForbidden, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "cookie-only")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, adminAPI(rosterPath(idle, "/actions/activate")), strings.NewReader(`{}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
anonymous := httptest.NewRecorder()
|
||||||
|
env.h.ServeHTTP(anonymous, req)
|
||||||
|
if anonymous.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("anonymous record action = %d", anonymous.Code)
|
||||||
|
}
|
||||||
|
if rosterLoad(t, gdb, idle).Active || len(env.spy.takeRecord()) != 0 {
|
||||||
|
t.Fatal("a refused request ran the action")
|
||||||
|
}
|
||||||
|
// reinstate asks for no permission of its own.
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, rosterPath(idle, "/actions/reinstate"), `{}`, "limited")
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "cookie")
|
||||||
|
if stored := rosterLoad(t, gdb, idle); stored.Banned || !stored.Active {
|
||||||
|
t.Fatalf("after the permitted actions: %+v", stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordActionOffered: meta.actions of the show response lists the
|
||||||
|
// actions in declared order and leaves out the denied and the non-applicable.
|
||||||
|
func TestRecordActionOffered(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
both := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Both", Banned: true})
|
||||||
|
neither := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Neither", Active: true})
|
||||||
|
if got := rosterOffered(t, env, both, "bearer"); !reflect.DeepEqual(got, []string{"activate", "reinstate"}) {
|
||||||
|
t.Fatalf("both apply, full admin: %v (declared order is activate, reinstate)", got)
|
||||||
|
}
|
||||||
|
if got := rosterOffered(t, env, both, "limited"); !reflect.DeepEqual(got, []string{"reinstate"}) {
|
||||||
|
t.Fatalf("both apply, limited admin: %v", got)
|
||||||
|
}
|
||||||
|
if got := rosterOffered(t, env, neither, "bearer"); len(got) != 0 {
|
||||||
|
t.Fatalf("none applies: %v", got)
|
||||||
|
}
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPath(neither, ""), "", "bearer")
|
||||||
|
if strings.Contains(rec.Body.String(), `"actions"`) {
|
||||||
|
t.Fatalf("meta.actions sent without an offered action: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
// Labels and confirm texts follow the request locale; an action without a
|
||||||
|
// confirm has no confirm key.
|
||||||
|
rec = rosterLocale(env, http.MethodGet, rosterPath(both, ""), "", "pl")
|
||||||
|
if !strings.Contains(rec.Body.String(), `"actions":[{"name":"activate","label":"Aktywuj"},{"name":"reinstate","label":"Przywróć","confirm":"Zdjąć blokadę z tej osoby?"}]`) {
|
||||||
|
t.Fatalf("pl actions = %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
// The list rows never carry actions.
|
||||||
|
list := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople, "", "bearer")
|
||||||
|
if strings.Contains(list.Body.String(), `"actions"`) {
|
||||||
|
t.Fatalf("the list carries actions: %s", list.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordActionRollback: a refusal or a failure after the action's write
|
||||||
|
// rolls the write back.
|
||||||
|
func TestRecordActionRollback(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
for name, status := range map[string]int{rosterLocked: http.StatusForbidden, rosterRunErr: http.StatusInternalServerError} {
|
||||||
|
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: name, Active: true, Banned: true})
|
||||||
|
rec := env.expect(t, status, http.MethodPost, rosterPath(id, "/actions/reinstate"), `{}`, "bearer")
|
||||||
|
if strings.Contains(rec.Body.String(), "hunter2") {
|
||||||
|
t.Fatalf("%s: the error text of Run is in the body: %s", name, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !rosterLoad(t, gdb, id).Banned {
|
||||||
|
t.Fatalf("%s: the action's write survived its error", name)
|
||||||
|
}
|
||||||
|
if calls := env.spy.takeRecord(); len(calls) != 1 {
|
||||||
|
t.Fatalf("%s: Run calls = %d", name, len(calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordActionAppliesError: an error from Applies is the generic 500, on
|
||||||
|
// the action route and on the show route that offers actions.
|
||||||
|
func TestRecordActionAppliesError(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
logs := captureLog(t)
|
||||||
|
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterAppliesErr})
|
||||||
|
for _, call := range []struct{ method, rel, body string }{
|
||||||
|
{http.MethodPost, rosterPath(id, "/actions/activate"), `{}`},
|
||||||
|
{http.MethodGet, rosterPath(id, ""), ""},
|
||||||
|
} {
|
||||||
|
rec := env.expect(t, http.StatusInternalServerError, call.method, call.rel, call.body, "bearer")
|
||||||
|
got := rosterError(t, rec)
|
||||||
|
if got.Code != "error" || len(got.Details) != 0 || strings.Contains(rec.Body.String(), "hunter2") || strings.Contains(rec.Body.String(), "applies check") {
|
||||||
|
t.Fatalf("%s %s = %s", call.method, call.rel, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rosterLoad(t, gdb, id).Active || len(env.spy.takeRecord()) != 0 {
|
||||||
|
t.Fatal("the action ran although Applies failed")
|
||||||
|
}
|
||||||
|
// The cause is logged on the server, with the controller and the action.
|
||||||
|
failed := logs.matching("cabana: admin record action failed")
|
||||||
|
if len(failed) != 2 || !strings.Contains(failed[0], "action=activate") || !strings.Contains(failed[0], "hunter2") {
|
||||||
|
t.Fatalf("server log = %q", failed)
|
||||||
|
}
|
||||||
|
// An administrator who is not offered the action never triggers Applies.
|
||||||
|
env.expect(t, http.StatusOK, http.MethodGet, rosterPath(id, ""), "", "limited")
|
||||||
|
}
|
||||||
313
modules/cabana/phase121_relation_lock_test.go
Normal file
313
modules/cabana/phase121_relation_lock_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
134
modules/cabana/phase121_rowstate_test.go
Normal file
134
modules/cabana/phase121_rowstate_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
modules/cabana/phase121_schema_boot_test.go
Normal file
218
modules/cabana/phase121_schema_boot_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,6 +136,8 @@ type dfRecorder struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
calls []string
|
calls []string
|
||||||
fail map[string]bool
|
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
|
// probe, when set, runs inside the Form hooks with the write's
|
||||||
// transaction and its result is recorded after the hook name.
|
// transaction and its result is recorded after the hook name.
|
||||||
probe func(tx *gorm.DB) string
|
probe func(tx *gorm.DB) string
|
||||||
@@ -152,6 +154,7 @@ func (r *dfRecorder) reset() {
|
|||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
r.calls = nil
|
r.calls = nil
|
||||||
r.fail = map[string]bool{}
|
r.fail = map[string]bool{}
|
||||||
|
r.refuse = map[string]bool{}
|
||||||
r.probe = nil
|
r.probe = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +164,19 @@ func (r *dfRecorder) failOn(name string) {
|
|||||||
r.fail[name] = true
|
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 {
|
func (r *dfRecorder) snapshot() []string {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
@@ -172,6 +188,7 @@ func (r *dfRecorder) hook(ctx context.Context, name string) error {
|
|||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
probe := r.probe
|
probe := r.probe
|
||||||
fail := r.fail[name]
|
fail := r.fail[name]
|
||||||
|
refuse := r.refuse[name]
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
call := name
|
call := name
|
||||||
if probe != nil {
|
if probe != nil {
|
||||||
@@ -180,6 +197,9 @@ func (r *dfRecorder) hook(ctx context.Context, name string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
r.add(call)
|
r.add(call)
|
||||||
|
if refuse {
|
||||||
|
return &cabana.ForbiddenError{Message: dfRefusal, Details: map[string]any{"hook": []string{name}}}
|
||||||
|
}
|
||||||
if fail {
|
if fail {
|
||||||
return errors.New("fixture hook " + name + " failed")
|
return errors.New("fixture hook " + name + " failed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
"github.com/go-gormigrate/gormigrate/v2"
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type configOnly struct{}
|
type configOnly struct{}
|
||||||
@@ -291,3 +293,140 @@ func TestFormSeamsDiscoveredByTypeAssertion(t *testing.T) {
|
|||||||
t.Fatal("a plugin without the method implements FormVirtualFields")
|
t.Fatal("a plugin without the method implements FormVirtualFields")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sampleRoster is a controller value that implements every admin contract of
|
||||||
|
// Phase 12.1: declared bulk and record actions, row states and controller
|
||||||
|
// filter choices.
|
||||||
|
type sampleRoster struct{ ran *[]string }
|
||||||
|
|
||||||
|
func (s sampleRoster) AdminBulkActions() []AdminBulkAction {
|
||||||
|
return []AdminBulkAction{{
|
||||||
|
Name: "activate", Label: "acme::lang.activate", Confirm: "acme::lang.activate_confirm",
|
||||||
|
Permissions: []string{"acme.manage"},
|
||||||
|
Run: func(_ context.Context, in AdminBulkActionInput) (AdminBulkActionResult, error) {
|
||||||
|
*s.ran = append(*s.ran, "bulk")
|
||||||
|
return AdminBulkActionResult{Message: "acme::lang.activated", Affected: len(in.Records)}, nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s sampleRoster) AdminRecordActions() []AdminRecordAction {
|
||||||
|
return []AdminRecordAction{{
|
||||||
|
Name: "reinstate", Label: "acme::lang.reinstate",
|
||||||
|
Applies: func(_ context.Context, record any) (bool, error) {
|
||||||
|
if record == nil {
|
||||||
|
return false, errors.New("no record")
|
||||||
|
}
|
||||||
|
return record.(string) == "banned", nil
|
||||||
|
},
|
||||||
|
Run: func(_ context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error) {
|
||||||
|
*s.ran = append(*s.ran, "record")
|
||||||
|
return AdminRecordActionResult{Message: "acme::lang.reinstated"}, nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sampleRoster) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]RowState, error) {
|
||||||
|
out := make([][]RowState, len(records))
|
||||||
|
for i, record := range records {
|
||||||
|
if record.(string) == "banned" {
|
||||||
|
out[i] = []RowState{RowStateNegative}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sampleRoster) FilterOptions(scope string) []Option {
|
||||||
|
if scope != "tagged" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []Option{{Value: "1", Label: "acme::lang.tag"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ HasAdminBulkActions = sampleRoster{}
|
||||||
|
_ HasAdminRecordActions = sampleRoster{}
|
||||||
|
_ ListRowStates = sampleRoster{}
|
||||||
|
_ FilterOptions = sampleRoster{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPhase121ContractsOnSampleController: the bulk action, record action,
|
||||||
|
// row state and filter choice contracts are discovered by a type assertion
|
||||||
|
// on a controller value and carry exactly the documented fields.
|
||||||
|
func TestPhase121ContractsOnSampleController(t *testing.T) {
|
||||||
|
var ran []string
|
||||||
|
var ctl any = sampleRoster{ran: &ran}
|
||||||
|
|
||||||
|
bulk, ok := ctl.(HasAdminBulkActions)
|
||||||
|
if !ok || len(bulk.AdminBulkActions()) != 1 {
|
||||||
|
t.Fatal("HasAdminBulkActions is not discovered")
|
||||||
|
}
|
||||||
|
action := bulk.AdminBulkActions()[0]
|
||||||
|
result, err := action.Run(t.Context(), AdminBulkActionInput{Records: []any{"a", "b"}})
|
||||||
|
if err != nil || result.Affected != 2 || result.Message != "acme::lang.activated" {
|
||||||
|
t.Fatalf("bulk result = %+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
if action.Name != "activate" || action.Confirm == "" || len(action.Permissions) != 1 {
|
||||||
|
t.Fatalf("bulk action = %+v", action)
|
||||||
|
}
|
||||||
|
// The input of a bulk action is the loaded records and nothing else: an id
|
||||||
|
// list would let a plugin skip the list scope.
|
||||||
|
if n := reflect.TypeOf(AdminBulkActionInput{}).NumField(); n != 1 {
|
||||||
|
t.Fatalf("AdminBulkActionInput has %d fields, want only Records", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
record, ok := ctl.(HasAdminRecordActions)
|
||||||
|
if !ok || len(record.AdminRecordActions()) != 1 {
|
||||||
|
t.Fatal("HasAdminRecordActions is not discovered")
|
||||||
|
}
|
||||||
|
one := record.AdminRecordActions()[0]
|
||||||
|
for input, want := range map[string]bool{"banned": true, "active": false} {
|
||||||
|
if applies, err := one.Applies(t.Context(), input); err != nil || applies != want {
|
||||||
|
t.Fatalf("Applies(%s) = %v err=%v", input, applies, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := one.Applies(t.Context(), nil); err == nil {
|
||||||
|
t.Fatal("Applies did not pass its error back")
|
||||||
|
}
|
||||||
|
out, err := one.Run(t.Context(), AdminRecordActionInput{RecordID: 7, Record: "banned"})
|
||||||
|
if err != nil || out.Message != "acme::lang.reinstated" || one.Confirm != "" {
|
||||||
|
t.Fatalf("record result = %+v err=%v", out, err)
|
||||||
|
}
|
||||||
|
if fields := reflect.TypeOf(AdminRecordActionInput{}).NumField(); fields != 2 {
|
||||||
|
t.Fatalf("AdminRecordActionInput has %d fields, want RecordID and Record", fields)
|
||||||
|
}
|
||||||
|
if strings.Join(ran, ",") != "bulk,record" {
|
||||||
|
t.Fatalf("ran = %v", ran)
|
||||||
|
}
|
||||||
|
|
||||||
|
states, ok := ctl.(ListRowStates)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ListRowStates is not discovered")
|
||||||
|
}
|
||||||
|
got, err := states.ListRowStates(t.Context(), nil, []any{"active", "banned"})
|
||||||
|
if err != nil || len(got) != 2 || got[0] != nil || len(got[1]) != 1 || got[1][0] != RowStateNegative {
|
||||||
|
t.Fatalf("row states = %v err=%v", got, err)
|
||||||
|
}
|
||||||
|
// The fixed set has exactly three values, each its own string.
|
||||||
|
set := map[RowState]bool{RowStateDeleted: true, RowStateNegative: true, RowStateDisabled: true}
|
||||||
|
if len(set) != 3 || string(RowStateDeleted) != "deleted" || string(RowStateNegative) != "negative" || string(RowStateDisabled) != "disabled" {
|
||||||
|
t.Fatalf("row state set = %v", set)
|
||||||
|
}
|
||||||
|
|
||||||
|
options, ok := ctl.(FilterOptions)
|
||||||
|
if !ok || len(options.FilterOptions("tagged")) != 1 || options.FilterOptions("other") != nil {
|
||||||
|
t.Fatal("FilterOptions is not discovered or answers an unknown scope")
|
||||||
|
}
|
||||||
|
|
||||||
|
var plain any = neither{}
|
||||||
|
for name, implemented := range map[string]bool{
|
||||||
|
"HasAdminBulkActions": func() bool { _, ok := plain.(HasAdminBulkActions); return ok }(),
|
||||||
|
"HasAdminRecordActions": func() bool { _, ok := plain.(HasAdminRecordActions); return ok }(),
|
||||||
|
"ListRowStates": func() bool { _, ok := plain.(ListRowStates); return ok }(),
|
||||||
|
"FilterOptions": func() bool { _, ok := plain.(FilterOptions); return ok }(),
|
||||||
|
} {
|
||||||
|
if implemented {
|
||||||
|
t.Fatalf("a plain value implements %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user