- hooks and bulk, record, toolbar and widget actions may return it - 403 forbidden with the localized message and field details; the write's transaction is rolled back; other errors stay the opaque 500 - form shows a refused save as a persistent banner and keeps the values; a refused delete is a toast - smoke tests, OpenAPI notes, dist, README, docs
772 lines
34 KiB
Go
772 lines
34 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
)
|
|
|
|
const rosterPeople = "/acme/roster/people"
|
|
|
|
// rosterBulkNames returns the bulk action names the list schema offers auth.
|
|
func rosterBulkNames(t *testing.T, env *rosterEnv, auth string) []string {
|
|
t.Helper()
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", auth)
|
|
var list cabana.Envelope[cabana.ListSchema]
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
|
t.Fatalf("list schema: %v\n%s", err, rec.Body.String())
|
|
}
|
|
names := make([]string, 0, len(list.Data.BulkActions))
|
|
for _, action := range list.Data.BulkActions {
|
|
names = append(names, action.Name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func rosterBulkResult(t *testing.T, rec *httptest.ResponseRecorder) cabana.BulkActionResult {
|
|
t.Helper()
|
|
var body cabana.Envelope[cabana.BulkActionResult]
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("bulk action body %s: %v", rec.Body.String(), err)
|
|
}
|
|
return body.Data
|
|
}
|
|
|
|
// TestBulkActionTracer drives a declared bulk action through the assembled
|
|
// router on PostgreSQL (D-09; T-12.1-01, T-12.1-02, T-12.1-03, T-12.1-05):
|
|
// the per-principal list schema, the scoped and locked id resolution, the
|
|
// loaded records the plugin receives, the action permission and the CSRF
|
|
// header.
|
|
func TestBulkActionTracer(t *testing.T) {
|
|
env, gdb := newRosterEnv(t)
|
|
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@example.test"})
|
|
bob := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob", Email: "bob@example.test"})
|
|
cy := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cy", Email: "cy@example.test", Active: true})
|
|
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed", Email: "zed@example.test"})
|
|
const activate = rosterPeople + "/bulk/activate"
|
|
ids := func(list ...uint) string {
|
|
raw, _ := json.Marshal(map[string]any{"ids": list})
|
|
return string(raw)
|
|
}
|
|
|
|
t.Run("list schema is per principal", func(t *testing.T) {
|
|
if got := rosterBulkNames(t, env, "bearer"); !reflect.DeepEqual(got, []string{"delete", "activate", "archive"}) {
|
|
t.Fatalf("full admin bulkActions = %v", got)
|
|
}
|
|
if got := rosterBulkNames(t, env, "limited"); !reflect.DeepEqual(got, []string{"delete", "archive"}) {
|
|
t.Fatalf("limited admin bulkActions = %v", got)
|
|
}
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", "bearer")
|
|
if !strings.Contains(rec.Body.String(), `"label":"Activate"`) || !strings.Contains(rec.Body.String(), `"confirm":"Activate the selected people?"`) {
|
|
t.Fatalf("label and confirm are not localized: %s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("runs on loaded records in the list scope", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusOK, http.MethodPost, activate, ids(bob, ada, bob), "bearer")
|
|
if result := rosterBulkResult(t, rec); result.Affected != 2 || result.Message != "" {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
if !rosterLoad(t, gdb, ada).Active || !rosterLoad(t, gdb, bob).Active {
|
|
t.Fatal("the selected people were not activated")
|
|
}
|
|
calls := env.spy.takeBulk()
|
|
if len(calls) != 1 || len(calls[0].Records) != 2 {
|
|
t.Fatalf("calls = %+v", calls)
|
|
}
|
|
// The plugin gets loaded, locked records ordered by primary key and
|
|
// no id list: AdminBulkActionInput has no other field.
|
|
first, ok := calls[0].Records[0].(*rosterPerson)
|
|
second, ok2 := calls[0].Records[1].(*rosterPerson)
|
|
if !ok || !ok2 || first.ID != ada || second.ID != bob || first.Name != "Ada" || first.Tenant != "acme" {
|
|
t.Fatalf("records = %+v %+v", calls[0].Records[0], calls[0].Records[1])
|
|
}
|
|
if n := reflect.TypeOf(calls[0]).NumField(); n != 1 {
|
|
t.Fatalf("AdminBulkActionInput has %d fields, want only Records", n)
|
|
}
|
|
})
|
|
|
|
t.Run("affected may be lower than the selection", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusOK, http.MethodPost, activate, ids(ada, cy), "bearer")
|
|
if result := rosterBulkResult(t, rec); result.Affected != 0 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
env.spy.takeBulk()
|
|
})
|
|
|
|
t.Run("server message is localized", func(t *testing.T) {
|
|
spare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Spare"})
|
|
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk/archive", ids(spare), "limited")
|
|
if result := rosterBulkResult(t, rec); result.Affected != 1 || result.Message != "The selected people were archived." {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
if !rosterLoad(t, gdb, spare).DeletedAt.Valid {
|
|
t.Fatal("archive did not soft-delete the person")
|
|
}
|
|
env.spy.takeBulk()
|
|
})
|
|
|
|
t.Run("a partial selection is a 409 and changes nothing", func(t *testing.T) {
|
|
fresh := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Fresh"})
|
|
for _, other := range []uint{foreign, 999999} {
|
|
rec := env.expect(t, http.StatusConflict, http.MethodPost, activate, ids(fresh, other), "bearer")
|
|
actErrorCode(t, rec.Body.Bytes(), "conflict")
|
|
}
|
|
if rosterLoad(t, gdb, fresh).Active || rosterLoad(t, gdb, foreign).Active {
|
|
t.Fatal("a refused selection changed a row")
|
|
}
|
|
if calls := env.spy.takeBulk(); len(calls) != 0 {
|
|
t.Fatalf("action ran for a partial selection: %+v", calls)
|
|
}
|
|
})
|
|
|
|
t.Run("a selection outside the scope is a no-op", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusOK, http.MethodPost, activate, ids(foreign, 999999), "bearer")
|
|
if result := rosterBulkResult(t, rec); result.Affected != 0 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
if rosterLoad(t, gdb, foreign).Active {
|
|
t.Fatal("an out-of-scope row was activated")
|
|
}
|
|
if calls := env.spy.takeBulk(); len(calls) != 0 {
|
|
t.Fatalf("action ran for out-of-scope ids: %+v", calls)
|
|
}
|
|
})
|
|
|
|
t.Run("body", func(t *testing.T) {
|
|
for _, body := range []string{`{"ids":[]}`, `{}`, `{"ids":["nope"]}`, `{`} {
|
|
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, activate, body, "bearer")
|
|
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
|
|
}
|
|
})
|
|
|
|
t.Run("undeclared and reserved names are 404", func(t *testing.T) {
|
|
for _, name := range []string{"missing", "delete", "create", "hidden"} {
|
|
env.expect(t, http.StatusNotFound, http.MethodPost, rosterPeople+"/bulk/"+name, ids(ada), "bearer")
|
|
}
|
|
env.expect(t, http.StatusNotFound, http.MethodPost, "/acme/roster/nope/bulk/activate", ids(ada), "bearer")
|
|
})
|
|
|
|
t.Run("action permission on top of the controller's", func(t *testing.T) {
|
|
fresh := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Denied"})
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodPost, activate, ids(fresh), "limited")
|
|
actErrorCode(t, rec.Body.Bytes(), "forbidden")
|
|
if rosterLoad(t, gdb, fresh).Active {
|
|
t.Fatal("a denied admin changed a row")
|
|
}
|
|
})
|
|
|
|
t.Run("cookie POSTs need X-Requested-With", func(t *testing.T) {
|
|
fresh := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cookie"})
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodPost, activate, ids(fresh), "cookie-only")
|
|
actErrorCode(t, rec.Body.Bytes(), "forbidden")
|
|
if rosterLoad(t, gdb, fresh).Active {
|
|
t.Fatal("a request without the CSRF header changed a row")
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodPost, activate, ids(fresh), "cookie")
|
|
if !rosterLoad(t, gdb, fresh).Active {
|
|
t.Fatal("the cookie request with the header did not run")
|
|
}
|
|
})
|
|
|
|
t.Run("bulk delete is unchanged", func(t *testing.T) {
|
|
spare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Gone"})
|
|
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk-delete", ids(spare), "bearer")
|
|
if !strings.Contains(rec.Body.String(), `"deleted":1`) {
|
|
t.Fatalf("bulk delete = %s", rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// rosterListFS is the roster fixture tree with config_list.yaml replaced.
|
|
func rosterListFS(t *testing.T, list string) fstest.MapFS {
|
|
t.Helper()
|
|
columns, err := os.ReadFile(filepath.Join(rosterDir, "models/person/columns.yaml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return fstest.MapFS{
|
|
"controllers/people/config_list.yaml": &fstest.MapFile{Data: []byte(list)},
|
|
"models/person/columns.yaml": &fstest.MapFile{Data: columns},
|
|
}
|
|
}
|
|
|
|
// TestListSchemaBulkActionsBoot checks the fail-loud compile of the
|
|
// bulkActions key (D-09): every mistake names the plugin, controller and file.
|
|
func TestListSchemaBulkActionsBoot(t *testing.T) {
|
|
const head = "list: ~/plugins/acme/roster/models/person/columns.yaml\nmodelClass: Person\n"
|
|
for _, tc := range []struct {
|
|
name, yaml, want string
|
|
}{
|
|
{"unregistered", head + "showCheckboxes: true\nbulkActions: [activate, promote]\n", "bulkActions: unsupported action promote (want a bulk action the controller registers)"},
|
|
{"reserved", head + "showCheckboxes: true\nbulkActions: [delete]\n", "bulkActions: unsupported action delete (want a bulk action the controller registers)"},
|
|
{"duplicate", head + "showCheckboxes: true\nbulkActions: [activate, activate]\n", "bulkActions: duplicate action activate"},
|
|
{"no checkboxes", head + "bulkActions: [activate]\n", "bulkActions needs showCheckboxes: true"},
|
|
{"scalar", head + "showCheckboxes: true\nbulkActions: activate\n", "bulkActions must be a list of bulk action names the controller registers"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := cabana.CompileList("acme.roster", rosterController{}, rosterListFS(t, tc.yaml))
|
|
if err == nil {
|
|
t.Fatal("the list compiled")
|
|
}
|
|
for _, part := range []string{tc.want, "acme.roster", "acme.roster.people", "controllers/people/config_list.yaml"} {
|
|
if !strings.Contains(err.Error(), part) {
|
|
t.Fatalf("error %q does not name %q", err, part)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("declared order after the built-in delete", func(t *testing.T) {
|
|
list, err := cabana.CompileList("acme.roster", rosterController{}, rosterListFS(t, head+"showCheckboxes: true\nbulkActions: [archive, activate]\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := fmt.Sprint(list.BulkActions)
|
|
want := fmt.Sprint([]cabana.BulkAction{
|
|
{Name: "delete", Label: "backend::lang.list.delete_selected"},
|
|
{Name: "archive", Label: "acme.roster::lang.people.archive"},
|
|
{Name: "activate", Label: "acme.roster::lang.people.activate", Confirm: "acme.roster::lang.people.activate_confirm"},
|
|
})
|
|
if got != want {
|
|
t.Fatalf("bulkActions = %s, want %s", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("a list without bulkActions is unchanged", func(t *testing.T) {
|
|
list, err := cabana.CompileList("acme.roster", rosterController{}, rosterListFS(t, head+"showCheckboxes: true\n"))
|
|
if err != nil || len(list.BulkActions) != 1 || list.BulkActions[0].Name != "delete" {
|
|
t.Fatalf("bulkActions = %+v err=%v", list.BulkActions, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// rosterOffered returns the record action names the show response offers.
|
|
func rosterOffered(t *testing.T, env *rosterEnv, id uint, auth string) []string {
|
|
t.Helper()
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", rosterPeople, id), "", auth)
|
|
var body cabana.RecordEnvelope
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("show body %s: %v", rec.Body.String(), err)
|
|
}
|
|
names := []string{}
|
|
for _, action := range body.Meta.Actions {
|
|
names = append(names, action.Name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// TestRecordActionSmoke drives a declared record action through the assembled
|
|
// router on PostgreSQL (D-10; T-12.1-02, T-12.1-03, T-12.1-04): the offered
|
|
// actions of a shown record, the form scope, Applies inside the transaction,
|
|
// the action permission, the strict body and the CSRF header.
|
|
func TestRecordActionSmoke(t *testing.T) {
|
|
env, gdb := newRosterEnv(t)
|
|
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle", Email: "idle@example.test"})
|
|
banned := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Banned", Active: true, Banned: true})
|
|
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed"})
|
|
action := func(id uint, name string) string {
|
|
return fmt.Sprintf("%s/%d/actions/%s", rosterPeople, id, name)
|
|
}
|
|
|
|
t.Run("show offers the permitted actions that apply", func(t *testing.T) {
|
|
if got := rosterOffered(t, env, idle, "bearer"); !reflect.DeepEqual(got, []string{"activate"}) {
|
|
t.Fatalf("idle person, full admin: %v", got)
|
|
}
|
|
if got := rosterOffered(t, env, banned, "bearer"); !reflect.DeepEqual(got, []string{"reinstate"}) {
|
|
t.Fatalf("banned person, full admin: %v", got)
|
|
}
|
|
// The limited admin lacks acme.roster.manage: no activate.
|
|
if got := rosterOffered(t, env, idle, "limited"); len(got) != 0 {
|
|
t.Fatalf("idle person, limited admin: %v", got)
|
|
}
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", rosterPeople, banned), "", "bearer")
|
|
if !strings.Contains(rec.Body.String(), `"actions":[{"name":"reinstate","label":"Reinstate","confirm":"Lift the ban on this person?"}]`) {
|
|
t.Fatalf("offered action is not localized: %s", rec.Body.String())
|
|
}
|
|
// A record with no offered action has no actions key at all.
|
|
rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", rosterPeople, idle), "", "limited")
|
|
if strings.Contains(rec.Body.String(), `"actions"`) {
|
|
t.Fatalf("meta.actions sent without an offered action: %s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("create and update responses carry no actions", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh"}`, "bearer")
|
|
if strings.Contains(rec.Body.String(), `"actions"`) {
|
|
t.Fatalf("create response: %s", rec.Body.String())
|
|
}
|
|
rec = env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", rosterPeople, idle), `{"name":"Idle"}`, "bearer")
|
|
if strings.Contains(rec.Body.String(), `"actions"`) {
|
|
t.Fatalf("update response: %s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("a controller without record actions sends no actions key", func(t *testing.T) {
|
|
demo, demoDB := newActEnv(t)
|
|
gadget := actInsert(t, demoDB, "plain", "acme")
|
|
rec := demo.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("/acme/demo/gadgets/%d", gadget), "", "bearer")
|
|
if strings.Contains(rec.Body.String(), `"actions"`) || !strings.Contains(rec.Body.String(), `"labels"`) {
|
|
t.Fatalf("show = %s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("limited admin is refused", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodPost, action(idle, "activate"), `{}`, "limited")
|
|
actErrorCode(t, rec.Body.Bytes(), "forbidden")
|
|
if rosterLoad(t, gdb, idle).Active {
|
|
t.Fatal("a denied admin changed the record")
|
|
}
|
|
})
|
|
|
|
t.Run("cookie POSTs need X-Requested-With", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodPost, action(idle, "activate"), `{}`, "cookie-only")
|
|
actErrorCode(t, rec.Body.Bytes(), "forbidden")
|
|
if rosterLoad(t, gdb, idle).Active {
|
|
t.Fatal("a request without the CSRF header changed the record")
|
|
}
|
|
})
|
|
|
|
t.Run("strict body", func(t *testing.T) {
|
|
for _, body := range []string{fmt.Sprintf(`{"record_id":%d}`, idle), `{"values":{}}`, `{"extra":1}`, `{} {}`, ``} {
|
|
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, action(idle, "activate"), body, "bearer")
|
|
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
|
|
}
|
|
if rosterLoad(t, gdb, idle).Active {
|
|
t.Fatal("a malformed body changed the record")
|
|
}
|
|
})
|
|
|
|
t.Run("out-of-scope and missing records are 404", func(t *testing.T) {
|
|
for _, id := range []uint{foreign, 999999} {
|
|
env.expect(t, http.StatusNotFound, http.MethodPost, action(id, "activate"), `{}`, "bearer")
|
|
}
|
|
if rosterLoad(t, gdb, foreign).Active {
|
|
t.Fatal("an out-of-scope record was activated")
|
|
}
|
|
})
|
|
|
|
t.Run("undeclared and reserved names are 404", func(t *testing.T) {
|
|
for _, name := range []string{"missing", "archive", "delete", "create"} {
|
|
env.expect(t, http.StatusNotFound, http.MethodPost, action(idle, name), `{}`, "bearer")
|
|
}
|
|
})
|
|
|
|
if calls := env.spy.takeRecord(); len(calls) != 0 {
|
|
t.Fatalf("a refused request reached the plugin: %+v", calls)
|
|
}
|
|
|
|
t.Run("runs once, then no longer applies", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusOK, http.MethodPost, action(idle, "activate"), `{}`, "bearer")
|
|
result := actResult(t, rec)
|
|
if result.Message != "The person was activated." || result.Fill == nil || len(result.Fill) != 0 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
if !rosterLoad(t, gdb, idle).Active {
|
|
t.Fatal("the record was not activated")
|
|
}
|
|
calls := env.spy.takeRecord()
|
|
person, ok := calls[0].Record.(*rosterPerson)
|
|
if len(calls) != 1 || calls[0].RecordID != uint64(idle) || !ok || person.ID != idle || person.Tenant != "acme" {
|
|
t.Fatalf("input = %+v", calls)
|
|
}
|
|
// Applies is checked again inside the transaction.
|
|
rec = env.expect(t, http.StatusConflict, http.MethodPost, action(idle, "activate"), `{}`, "bearer")
|
|
actErrorCode(t, rec.Body.Bytes(), "conflict")
|
|
if calls := env.spy.takeRecord(); len(calls) != 0 {
|
|
t.Fatalf("the action ran for a record it does not apply to: %+v", calls)
|
|
}
|
|
if got := rosterOffered(t, env, idle, "bearer"); len(got) != 0 {
|
|
t.Fatalf("offered after the run: %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("an action without its own permission runs for the limited admin", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusOK, http.MethodPost, action(banned, "reinstate"), `{}`, "limited")
|
|
if result := actResult(t, rec); result.Message != "" {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
if rosterLoad(t, gdb, banned).Banned {
|
|
t.Fatal("the ban was not lifted")
|
|
}
|
|
env.spy.takeRecord()
|
|
})
|
|
}
|
|
|
|
// TestFormSchemaRecordActionsBoot checks the fail-loud compile of the
|
|
// recordActions key (D-10).
|
|
func TestFormSchemaRecordActionsBoot(t *testing.T) {
|
|
fields, err := os.ReadFile(filepath.Join(rosterDir, "models/person/fields.yaml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const head = "form: ~/plugins/acme/roster/models/person/fields.yaml\nmodelClass: Person\n"
|
|
for _, tc := range []struct {
|
|
name, yaml, want string
|
|
}{
|
|
{"duplicate", head + "recordActions: [activate, activate]\n", "recordActions: duplicate action activate"},
|
|
{"scalar", head + "recordActions: activate\n", "recordActions must be a list of record action names the controller registers"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fsys := fstest.MapFS{
|
|
"controllers/people/config_form.yaml": &fstest.MapFile{Data: []byte(tc.yaml)},
|
|
"models/person/fields.yaml": &fstest.MapFile{Data: fields},
|
|
}
|
|
_, err := cabana.CompileForm("acme.roster", rosterController{}, fsys)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "controllers/people/config_form.yaml") {
|
|
t.Fatalf("error = %v, want %q", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A name the controller does not register is refused when the controller
|
|
// is activated, where the form meets its registered actions.
|
|
t.Run("unregistered", func(t *testing.T) {
|
|
fsys := fstest.MapFS{}
|
|
for _, name := range []string{"controllers/people/config_list.yaml", "models/person/columns.yaml", "models/person/fields.yaml"} {
|
|
data, err := os.ReadFile(filepath.Join(rosterDir, name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fsys[name] = &fstest.MapFile{Data: data}
|
|
}
|
|
fsys["controllers/people/config_form.yaml"] = &fstest.MapFile{Data: []byte(head + "recordActions: [activate, promote]\n")}
|
|
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{rosterPlugin{spy: &rosterSpy{}, fsys: fsys}})
|
|
const want = "recordActions: unsupported action promote (want a record action the controller registers)"
|
|
if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "acme.roster.people") || !strings.Contains(err.Error(), "controllers/people/config_form.yaml") {
|
|
t.Fatalf("error = %v, want %q", err, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
// rosterList fetches the people list and returns its rows and row states.
|
|
func rosterList(t *testing.T, env *rosterEnv, query string) (cabana.ListEnvelope[[]cabana.AdminRecord], string) {
|
|
t.Helper()
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+query, "", "bearer")
|
|
var body cabana.ListEnvelope[[]cabana.AdminRecord]
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("list body %s: %v", rec.Body.String(), err)
|
|
}
|
|
return body, rec.Body.String()
|
|
}
|
|
|
|
func rosterDeleted() gorm.DeletedAt {
|
|
return gorm.DeletedAt{Time: time.Now().UTC(), Valid: true}
|
|
}
|
|
|
|
// TestRowStateSmoke checks the row-state batch hook through the assembled
|
|
// router on PostgreSQL (D-12; T-12.1-07): one hook call per page, the fixed
|
|
// set in the fixed order, unknown values dropped, and no key without states.
|
|
func TestRowStateSmoke(t *testing.T) {
|
|
env, gdb := newRosterEnv(t)
|
|
plain := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Plain", Active: true})
|
|
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
|
|
gone := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Gone", Active: true, Banned: true, DeletedAt: rosterDeleted()})
|
|
all := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "All", Banned: true, DeletedAt: rosterDeleted()})
|
|
odd := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Odd", Active: true})
|
|
rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed"})
|
|
key := func(id uint) string { return fmt.Sprint(id) }
|
|
|
|
t.Run("one call per page, fixed set and order", func(t *testing.T) {
|
|
env.spy.takeStates()
|
|
body, raw := rosterList(t, env, "")
|
|
if len(body.Data) != 5 {
|
|
t.Fatalf("rows = %d, want the five acme people including the soft-deleted: %s", len(body.Data), raw)
|
|
}
|
|
if calls := env.spy.takeStates(); !reflect.DeepEqual(calls, []int{5}) {
|
|
t.Fatalf("ListRowStates calls = %v, want one call with the page's five records", calls)
|
|
}
|
|
want := map[string][]string{
|
|
key(idle): {"disabled"},
|
|
key(gone): {"deleted", "negative"},
|
|
key(all): {"deleted", "negative", "disabled"},
|
|
}
|
|
if !reflect.DeepEqual(body.Meta.RowStates, want) {
|
|
t.Fatalf("row_states = %v, want %v", body.Meta.RowStates, want)
|
|
}
|
|
// A row without a state, and one whose only value is unknown, are
|
|
// left out; the unknown value is never sent.
|
|
if _, ok := body.Meta.RowStates[key(plain)]; ok {
|
|
t.Fatalf("a row without states is listed: %v", body.Meta.RowStates)
|
|
}
|
|
if _, ok := body.Meta.RowStates[key(odd)]; ok || strings.Contains(raw, "starred") {
|
|
t.Fatalf("an unknown state was sent: %s", raw)
|
|
}
|
|
// States are not row data: a column key can never collide with them.
|
|
for _, row := range body.Data {
|
|
if _, ok := row["row_states"]; ok {
|
|
t.Fatalf("row carries row_states: %v", row)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("each page gets its own call", func(t *testing.T) {
|
|
env.spy.takeStates()
|
|
body, _ := rosterList(t, env, "?search=Idle")
|
|
if calls := env.spy.takeStates(); !reflect.DeepEqual(calls, []int{1}) {
|
|
t.Fatalf("ListRowStates calls = %v", calls)
|
|
}
|
|
if !reflect.DeepEqual(body.Meta.RowStates, map[string][]string{key(idle): {"disabled"}}) {
|
|
t.Fatalf("row_states = %v", body.Meta.RowStates)
|
|
}
|
|
})
|
|
|
|
t.Run("no key when no row has a state", func(t *testing.T) {
|
|
_, raw := rosterList(t, env, "?search=Plain")
|
|
if strings.Contains(raw, "row_states") {
|
|
t.Fatalf("row_states sent for a page without states: %s", raw)
|
|
}
|
|
env.spy.takeStates()
|
|
// An empty page does not call the hook.
|
|
_, raw = rosterList(t, env, "?search=nobody-matches-this")
|
|
if calls := env.spy.takeStates(); len(calls) != 0 || strings.Contains(raw, "row_states") {
|
|
t.Fatalf("empty page: calls=%v body=%s", calls, raw)
|
|
}
|
|
})
|
|
|
|
t.Run("a controller without the hook sends no row_states", func(t *testing.T) {
|
|
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") || !strings.Contains(rec.Body.String(), `"total":1`) {
|
|
t.Fatalf("list = %s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("the list schema carries the badge labels", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", "bearer")
|
|
for _, want := range []string{`"rowStateDeleted":{"other":"Deleted"}`, `"rowStateNegative":{"other":"Blocked"}`, `"rowStateDisabled":{"other":"Not active"}`} {
|
|
if !strings.Contains(rec.Body.String(), want) {
|
|
t.Fatalf("list schema lacks %s: %s", want, rec.Body.String())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestSoftDeletedRecordSmoke checks D-13's framework side: a soft-deleted
|
|
// record that the controller's list and form scopes include can be shown,
|
|
// updated, targeted by bulk and record actions and permanently deleted
|
|
// through the admin API.
|
|
func TestSoftDeletedRecordSmoke(t *testing.T) {
|
|
env, gdb := newRosterEnv(t)
|
|
trashed := func(name string) uint {
|
|
return rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: name, DeletedAt: rosterDeleted()})
|
|
}
|
|
exists := func(id uint) bool {
|
|
var n int64
|
|
if err := gdb.Unscoped().Model(&rosterPerson{}).Where("id = ?", id).Count(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n == 1
|
|
}
|
|
path := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) }
|
|
|
|
t.Run("show and update keep it soft-deleted", func(t *testing.T) {
|
|
id := trashed("Trashed")
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, path(id), "", "bearer")
|
|
if !strings.Contains(rec.Body.String(), `"name":"Trashed"`) {
|
|
t.Fatalf("show = %s", rec.Body.String())
|
|
}
|
|
rec = env.expect(t, http.StatusOK, http.MethodPut, path(id), `{"name":"Renamed","email":"renamed@example.test"}`, "bearer")
|
|
if !strings.Contains(rec.Body.String(), `"name":"Renamed"`) {
|
|
t.Fatalf("update = %s", rec.Body.String())
|
|
}
|
|
person := rosterLoad(t, gdb, id)
|
|
if person.Name != "Renamed" || person.Email != "renamed@example.test" || !person.DeletedAt.Valid {
|
|
t.Fatalf("stored person = %+v", person)
|
|
}
|
|
var rows int64
|
|
if err := gdb.Unscoped().Model(&rosterPerson{}).Where("name = ?", "Renamed").Count(&rows).Error; err != nil || rows != 1 {
|
|
t.Fatalf("rows named Renamed = %d err=%v, want the one updated row", rows, err)
|
|
}
|
|
})
|
|
|
|
t.Run("bulk and record actions reach it", func(t *testing.T) {
|
|
id := trashed("Dormant")
|
|
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk/activate", fmt.Sprintf(`{"ids":[%d]}`, id), "bearer")
|
|
if result := rosterBulkResult(t, rec); result.Affected != 1 {
|
|
t.Fatalf("bulk result = %+v", result)
|
|
}
|
|
if person := rosterLoad(t, gdb, id); !person.Active || !person.DeletedAt.Valid {
|
|
t.Fatalf("after the bulk action: %+v", person)
|
|
}
|
|
banned := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Barred", Active: true, Banned: true, DeletedAt: rosterDeleted()})
|
|
if got := rosterOffered(t, env, banned, "bearer"); !reflect.DeepEqual(got, []string{"reinstate"}) {
|
|
t.Fatalf("offered = %v", got)
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodPost, path(banned)+"/actions/reinstate", `{}`, "bearer")
|
|
if person := rosterLoad(t, gdb, banned); person.Banned || !person.DeletedAt.Valid {
|
|
t.Fatalf("after the record action: %+v", person)
|
|
}
|
|
})
|
|
|
|
t.Run("the form delete removes it for good", func(t *testing.T) {
|
|
id := trashed("Purged")
|
|
rec := env.expect(t, http.StatusOK, http.MethodDelete, path(id), "", "bearer")
|
|
if !strings.Contains(rec.Body.String(), `"deleted":1`) || exists(id) {
|
|
t.Fatalf("delete = %s, row still exists: %v", rec.Body.String(), exists(id))
|
|
}
|
|
// A live person is removed for good as well: the hook is the
|
|
// controller's rule.
|
|
live := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Live"})
|
|
env.expect(t, http.StatusOK, http.MethodDelete, path(live), "", "bearer")
|
|
if exists(live) {
|
|
t.Fatal("the live person still exists")
|
|
}
|
|
})
|
|
|
|
t.Run("bulk delete removes it for good", func(t *testing.T) {
|
|
first, second := trashed("First"), rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Second"})
|
|
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk-delete", fmt.Sprintf(`{"ids":[%d,%d]}`, first, second), "bearer")
|
|
if !strings.Contains(rec.Body.String(), `"deleted":2`) || exists(first) || exists(second) {
|
|
t.Fatalf("bulk delete = %s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("a controller that hides soft-deleted rows behaves as before", func(t *testing.T) {
|
|
// Outside the acme scope nothing changes either: a soft-deleted
|
|
// person of another tenant stays invisible.
|
|
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed", DeletedAt: rosterDeleted()})
|
|
env.expect(t, http.StatusNotFound, http.MethodGet, path(foreign), "", "bearer")
|
|
env.expect(t, http.StatusNotFound, http.MethodPut, path(foreign), `{"name":"x"}`, "bearer")
|
|
if !exists(foreign) || rosterLoad(t, gdb, foreign).Name != "Zed" {
|
|
t.Fatal("an out-of-scope soft-deleted person was changed")
|
|
}
|
|
})
|
|
}
|
|
|
|
// rosterError decodes a D-10 error envelope.
|
|
func rosterError(t *testing.T, rec *httptest.ResponseRecorder) cabana.ErrorBody {
|
|
t.Helper()
|
|
var body cabana.ErrorEnvelope
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("error body %s: %v", rec.Body.String(), err)
|
|
}
|
|
return body.Error
|
|
}
|
|
|
|
// TestForbiddenSmoke checks cabana.ForbiddenError through the assembled
|
|
// router on PostgreSQL (D-27; T-12.1-05, T-12.1-06): a hook, a bulk action, a
|
|
// record action and a widget action that refuse a write are answered 403
|
|
// with the localized message and details and change nothing, and every other
|
|
// error stays the opaque 500.
|
|
func TestForbiddenSmoke(t *testing.T) {
|
|
env, gdb := newRosterEnv(t)
|
|
path := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) }
|
|
|
|
t.Run("a hook refuses an update", func(t *testing.T) {
|
|
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@example.test"})
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodPut, path(id), `{"name":"Reserved","email":"new@example.test"}`, "bearer")
|
|
got := rosterError(t, rec)
|
|
if got.Code != "forbidden" || got.Message != "You may not rename this person." {
|
|
t.Fatalf("error = %+v", got)
|
|
}
|
|
if !reflect.DeepEqual(got.Details, map[string]any{"name": []any{"This name is reserved."}}) {
|
|
t.Fatalf("details = %#v", got.Details)
|
|
}
|
|
if person := rosterLoad(t, gdb, id); person.Name != "Ada" || person.Email != "ada@example.test" {
|
|
t.Fatalf("a refused update changed the row: %+v", person)
|
|
}
|
|
})
|
|
|
|
t.Run("an empty message stays empty and details stay an object", func(t *testing.T) {
|
|
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bea"})
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodPut, path(id), `{"name":"Silent"}`, "bearer")
|
|
if !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")
|
|
}
|
|
})
|
|
|
|
t.Run("a bulk action refuses and rolls every row back", func(t *testing.T) {
|
|
first := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "First"})
|
|
locked := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterLocked})
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterPeople+"/bulk/archive", fmt.Sprintf(`{"ids":[%d,%d]}`, first, locked), "bearer")
|
|
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message != "This person is locked and cannot be changed." || len(got.Details) != 0 {
|
|
t.Fatalf("error = %+v", got)
|
|
}
|
|
// The first row was already soft-deleted inside the transaction.
|
|
if rosterLoad(t, gdb, first).DeletedAt.Valid || rosterLoad(t, gdb, locked).DeletedAt.Valid {
|
|
t.Fatal("a refused bulk action changed a selected row")
|
|
}
|
|
if rosterRefused.Message != "acme.roster::lang.people.locked" {
|
|
t.Fatalf("the plugin's error value was modified: %+v", rosterRefused)
|
|
}
|
|
})
|
|
|
|
t.Run("a record action refuses and rolls its write back", func(t *testing.T) {
|
|
locked := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterLocked, Active: true, Banned: true})
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodPost, path(locked)+"/actions/reinstate", `{}`, "bearer")
|
|
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message != "This person is locked and cannot be changed." {
|
|
t.Fatalf("error = %+v", got)
|
|
}
|
|
if !rosterLoad(t, gdb, locked).Banned {
|
|
t.Fatal("a refused record action changed the row")
|
|
}
|
|
})
|
|
|
|
t.Run("the message follows the request locale", func(t *testing.T) {
|
|
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cy"})
|
|
req := httptest.NewRequest(http.MethodPut, adminAPI(path(id)), strings.NewReader(`{"name":"Reserved"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept-Language", "pl")
|
|
req.Header.Set("Authorization", "Bearer "+env.token)
|
|
rec := httptest.NewRecorder()
|
|
env.h.ServeHTTP(rec, req)
|
|
if got := rosterError(t, rec); rec.Code != http.StatusForbidden || got.Message != "Nie możesz zmienić nazwy tej osoby." ||
|
|
!reflect.DeepEqual(got.Details, map[string]any{"name": []any{"Ta nazwa jest zastrzeżona."}}) {
|
|
t.Fatalf("status=%d error=%+v", rec.Code, got)
|
|
}
|
|
})
|
|
|
|
t.Run("a widget action refuses", func(t *testing.T) {
|
|
demo, _ := newActEnv(t)
|
|
rec := demo.expect(t, http.StatusForbidden, http.MethodPost, "/acme/demo/gadgets/widgets/lookup", `{"values":{"name":"refused"}}`, "bearer")
|
|
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message != "Name and Active were filled in." ||
|
|
!reflect.DeepEqual(got.Details, map[string]any{"name": []any{"Name"}}) {
|
|
t.Fatalf("error = %+v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("a plain hook error is the opaque 500", func(t *testing.T) {
|
|
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Dee"})
|
|
rec := env.expect(t, http.StatusInternalServerError, http.MethodPut, path(id), `{"name":"Boom"}`, "bearer")
|
|
got := rosterError(t, rec)
|
|
if got.Code != "error" || len(got.Details) != 0 || strings.Contains(rec.Body.String(), "hunter2") || strings.Contains(rec.Body.String(), "roster database") {
|
|
t.Fatalf("500 body = %s", rec.Body.String())
|
|
}
|
|
if rosterLoad(t, gdb, id).Name != "Dee" {
|
|
t.Fatal("a failed update changed the row")
|
|
}
|
|
})
|
|
|
|
t.Run("a permission denial keeps the framework text", func(t *testing.T) {
|
|
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Eve"})
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterPeople+"/bulk/activate", fmt.Sprintf(`{"ids":[%d]}`, id), "limited")
|
|
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message == "" {
|
|
t.Fatalf("error = %+v", got)
|
|
}
|
|
})
|
|
}
|