diff --git a/modules/cabana/phase121_bulk_test.go b/modules/cabana/phase121_bulk_test.go new file mode 100644 index 0000000..50503a3 --- /dev/null +++ b/modules/cabana/phase121_bulk_test.go @@ -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) + } + } +} diff --git a/modules/cabana/phase121_fields_test.go b/modules/cabana/phase121_fields_test.go new file mode 100644 index 0000000..52e5a02 --- /dev/null +++ b/modules/cabana/phase121_fields_test.go @@ -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) + } + }) + } +} diff --git a/modules/cabana/phase121_fixture_test.go b/modules/cabana/phase121_fixture_test.go index 468fb40..8849844 100644 --- a/modules/cabana/phase121_fixture_test.go +++ b/modules/cabana/phase121_fixture_test.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "sync" "sync/atomic" "testing" @@ -200,9 +201,9 @@ type rosterKnobs struct { permissionValues atomic.Bool // relationLocks makes AdminRelationLocks fail. relationLocks atomic.Bool - // slowArchive, when set, runs inside the archive bulk action after the - // rows were locked (concurrency tests). - slowArchive atomic.Pointer[func()] + // slowBulk, when set, runs inside each bulk action after the rows were + // locked (concurrency tests). + slowBulk atomic.Pointer[func()] } // The sentinel names below make one hook of the roster controller misbehave @@ -241,6 +242,8 @@ type rosterPlugin struct { // relations, when set, rewrites the controller's relation contracts // (boot tests). relations func([]cabana.FieldRelationContract) []cabana.FieldRelationContract + // wrap, when set, replaces the controller the plugin registers. + wrap func(rosterController) pact.AdminController } func (rosterPlugin) ID() string { return "acme.roster" } @@ -248,7 +251,11 @@ func (rosterPlugin) Requires() []string { return nil } func (rosterPlugin) Register(*backpack.App) error { return nil } func (rosterPlugin) Boot(*backpack.App) error { return nil } func (p rosterPlugin) AdminControllers() []pact.AdminController { - return []pact.AdminController{rosterController{spy: p.spy, knobs: p.knobs, db: p.db, relations: p.relations}} + ctl := rosterController{spy: p.spy, knobs: p.knobs, db: p.db, relations: p.relations} + if p.wrap != nil { + return []pact.AdminController{p.wrap(ctl)} + } + return []pact.AdminController{ctl} } func (rosterPlugin) Permissions() []pact.Permission { return []pact.Permission{{Code: "acme.roster.access", Roles: []string{"developer"}}, {Code: "acme.roster.manage", Roles: []string{"developer"}}} @@ -313,14 +320,25 @@ func (c rosterController) handle(ctx context.Context) *gorm.DB { return c.db.WithContext(ctx) } -// AdminRelationLocks locks the staff tag for an administrator without -// acme.roster.manage. +// AdminRelationLocks locks the staff tag and the vault team for an +// administrator without acme.roster.manage. func (c rosterController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) { if c.knobs != nil && c.knobs.relationLocks.Load() { return cabana.RelationLock{}, fmt.Errorf("the lock table said hunter2") } principal, _ := bouncer.User(ctx) - if field != "tags" || cabana.Allows(principal, []string{"acme.roster.manage"}) { + if cabana.Allows(principal, []string{"acme.roster.manage"}) { + return cabana.RelationLock{}, nil + } + if field == "team" { + // The team named vault is locked, without a message of its own. + var ids []uint + if err := c.handle(ctx).Model(&rosterTeam{}).Where("name = ?", "vault").Pluck("id", &ids).Error; err != nil { + return cabana.RelationLock{}, err + } + return cabana.RelationLock{IDs: ids}, nil + } + if field != "tags" { return cabana.RelationLock{}, nil } var ids []uint @@ -588,6 +606,16 @@ func (rosterController) FormAfterDelete(ctx context.Context, model any) error { return nil } +// slow runs the slowBulk knob, when one is set. +func (c rosterController) slow() { + if c.knobs == nil { + return + } + if wait := c.knobs.slowBulk.Load(); wait != nil { + (*wait)() + } +} + // AdminBulkActions: activate needs acme.roster.manage and sets active on the // rows that are not active yet, reporting how many it changed; archive needs // only the controller permission and soft-deletes the rows. @@ -601,6 +629,7 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction { if !ok { return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context") } + c.slow() changed := 0 for _, record := range in.Records { person := record.(*rosterPerson) @@ -624,11 +653,7 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction { if !ok { return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context") } - if c.knobs != nil { - if wait := c.knobs.slowArchive.Load(); wait != nil { - (*wait)() - } - } + c.slow() for _, record := range in.Records { // A refusal after earlier rows were written: the whole // selection must roll back. @@ -847,3 +872,96 @@ func rosterLoad(t *testing.T, gdb *gorm.DB, id uint) rosterPerson { } return person } + +// rosterBare is the roster controller with an explicit method set: it has no +// relation lock provider, no FormRules, no FilterOptions and no row states, +// so the framework's behaviour without those seams is observable. newRecord, +// when set, replaces the model. +type rosterBare struct { + inner rosterController + newRecord func() any +} + +func (b rosterBare) ID() string { return b.inner.ID() } +func (b rosterBare) ModelName() string { return b.inner.ModelName() } +func (b rosterBare) ConfigDir() string { return b.inner.ConfigDir() } +func (b rosterBare) RequiredPermissions() []string { return b.inner.RequiredPermissions() } +func (b rosterBare) NewRecord() any { + if b.newRecord != nil { + return b.newRecord() + } + return b.inner.NewRecord() +} +func (b rosterBare) ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB { + return b.inner.ListExtendQuery(ctx, db) +} +func (b rosterBare) FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB { + return b.inner.FormExtendQuery(ctx, db) +} +func (b rosterBare) AdminFieldRelations() []cabana.FieldRelationContract { + return b.inner.AdminFieldRelations() +} +func (b rosterBare) RelationExtendOptionsQuery(ctx context.Context, field string, db *gorm.DB) *gorm.DB { + return b.inner.RelationExtendOptionsQuery(ctx, field, db) +} +func (b rosterBare) FormVirtualFields() []string { return b.inner.FormVirtualFields() } +func (b rosterBare) FormBeforeCreate(ctx context.Context, model any) error { + return b.inner.FormBeforeCreate(ctx, model) +} +func (b rosterBare) FormBeforeUpdate(ctx context.Context, model any) error { + return b.inner.FormBeforeUpdate(ctx, model) +} +func (b rosterBare) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) { + return b.inner.AdminPermissionOptions(ctx, field) +} +func (b rosterBare) AdminPermissionValues(ctx context.Context, field string, record any) (map[string]int, error) { + return b.inner.AdminPermissionValues(ctx, field, record) +} +func (b rosterBare) AdminSetPermissionValues(ctx context.Context, field string, record any, values map[string]int) error { + return b.inner.AdminSetPermissionValues(ctx, field, record, values) +} +func (b rosterBare) AdminBulkActions() []pact.AdminBulkAction { return b.inner.AdminBulkActions() } +func (b rosterBare) AdminRecordActions() []pact.AdminRecordAction { + return b.inner.AdminRecordActions() +} +func (b rosterBare) PartialData(ctx context.Context, name string, record any) (any, error) { + return b.inner.PartialData(ctx, name, record) +} + +// rosterOptionsPerson is the roster model that serves its tagged filter's +// choices itself (the model fallback of pact.FilterOptions). +type rosterOptionsPerson struct { + rosterPerson +} + +func (rosterOptionsPerson) FilterOptions(scope string) []pact.Option { + if scope != "tagged" { + return nil + } + return []pact.Option{{Value: "1", Label: "acme.roster::lang.people.tags"}, {Value: "2", Label: "Plain label"}} +} + +// rosterListNoFilter is the fixture's config_list.yaml without its filter. +func rosterListNoFilter(t *testing.T) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(rosterDir, "controllers/people/config_list.yaml")) + if err != nil { + t.Fatal(err) + } + const line = "filter: config_filter.yaml\n" + if !strings.Contains(string(raw), line) { + t.Fatal("the fixture list has no filter line") + } + return strings.Replace(string(raw), line, "", 1) +} + +// newRosterBareEnv assembles the roster fixture around rosterBare, without +// the list filter (which needs FilterOptions). +func newRosterBareEnv(t *testing.T) (*rosterEnv, *gorm.DB) { + t.Helper() + list := rosterListNoFilter(t) + return newRosterEnvWith(t, func(p *rosterPlugin) { + p.fsys = rosterTree(t, map[string]string{"controllers/people/config_list.yaml": list}) + p.wrap = func(inner rosterController) pact.AdminController { return rosterBare{inner: inner} } + }) +} diff --git a/modules/cabana/phase121_forbidden_test.go b/modules/cabana/phase121_forbidden_test.go new file mode 100644 index 0000000..aac20d9 --- /dev/null +++ b/modules/cabana/phase121_forbidden_test.go @@ -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) + } +} diff --git a/modules/cabana/phase121_list_test.go b/modules/cabana/phase121_list_test.go new file mode 100644 index 0000000..6f162f6 --- /dev/null +++ b/modules/cabana/phase121_list_test.go @@ -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") + } +} diff --git a/modules/cabana/phase121_permission_test.go b/modules/cabana/phase121_permission_test.go new file mode 100644 index 0000000..c92cd7c --- /dev/null +++ b/modules/cabana/phase121_permission_test.go @@ -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") +} diff --git a/modules/cabana/phase121_preview_test.go b/modules/cabana/phase121_preview_test.go new file mode 100644 index 0000000..abd7a9e --- /dev/null +++ b/modules/cabana/phase121_preview_test.go @@ -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) + } +} diff --git a/modules/cabana/phase121_record_test.go b/modules/cabana/phase121_record_test.go new file mode 100644 index 0000000..8737e2f --- /dev/null +++ b/modules/cabana/phase121_record_test.go @@ -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") +} diff --git a/modules/cabana/phase121_relation_lock_test.go b/modules/cabana/phase121_relation_lock_test.go new file mode 100644 index 0000000..31b9b56 --- /dev/null +++ b/modules/cabana/phase121_relation_lock_test.go @@ -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) + } +} diff --git a/modules/cabana/phase121_rowstate_test.go b/modules/cabana/phase121_rowstate_test.go new file mode 100644 index 0000000..f067794 --- /dev/null +++ b/modules/cabana/phase121_rowstate_test.go @@ -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) + } + } +} diff --git a/modules/cabana/phase121_schema_boot_test.go b/modules/cabana/phase121_schema_boot_test.go new file mode 100644 index 0000000..5fe8493 --- /dev/null +++ b/modules/cabana/phase121_schema_boot_test.go @@ -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) + } +} diff --git a/modules/cabana/phase122_fixture_test.go b/modules/cabana/phase122_fixture_test.go index a1e62d6..a04b9f4 100644 --- a/modules/cabana/phase122_fixture_test.go +++ b/modules/cabana/phase122_fixture_test.go @@ -136,6 +136,8 @@ type dfRecorder struct { mu sync.Mutex calls []string fail map[string]bool + // refuse makes a named hook answer a cabana.ForbiddenError. + refuse map[string]bool // probe, when set, runs inside the Form hooks with the write's // transaction and its result is recorded after the hook name. probe func(tx *gorm.DB) string @@ -152,6 +154,7 @@ func (r *dfRecorder) reset() { defer r.mu.Unlock() r.calls = nil r.fail = map[string]bool{} + r.refuse = map[string]bool{} r.probe = nil } @@ -161,6 +164,19 @@ func (r *dfRecorder) failOn(name string) { r.fail[name] = true } +// refuseOn makes the named hook refuse with a ForbiddenError. +func (r *dfRecorder) refuseOn(name string) { + r.mu.Lock() + defer r.mu.Unlock() + if r.refuse == nil { + r.refuse = map[string]bool{} + } + r.refuse[name] = true +} + +// dfRefusal is the message of a hook refusal asked for with refuseOn. +const dfRefusal = "This change is not allowed here." + func (r *dfRecorder) snapshot() []string { r.mu.Lock() defer r.mu.Unlock() @@ -172,6 +188,7 @@ func (r *dfRecorder) hook(ctx context.Context, name string) error { r.mu.Lock() probe := r.probe fail := r.fail[name] + refuse := r.refuse[name] r.mu.Unlock() call := name if probe != nil { @@ -180,6 +197,9 @@ func (r *dfRecorder) hook(ctx context.Context, name string) error { } } r.add(call) + if refuse { + return &cabana.ForbiddenError{Message: dfRefusal, Details: map[string]any{"hook": []string{name}}} + } if fail { return errors.New("fixture hook " + name + " failed") } diff --git a/modules/pact/capabilities_test.go b/modules/pact/capabilities_test.go index 29e417d..9521e17 100644 --- a/modules/pact/capabilities_test.go +++ b/modules/pact/capabilities_test.go @@ -4,12 +4,14 @@ import ( "context" "errors" "io/fs" + "reflect" "strings" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/modules/bonfire" "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" ) type configOnly struct{} @@ -291,3 +293,140 @@ func TestFormSeamsDiscoveredByTypeAssertion(t *testing.T) { 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) + } + } +}