feat(12.1-01): declared record actions with an applicability rule
- pact.HasAdminRecordActions with AdminRecordAction (Applies, Run)
- config_form.yaml recordActions, compiled fail-loud
- show response meta.actions lists the permitted actions that apply
- POST .../{controller}/{id}/actions/{action}: record loaded and locked
through the form scope; 404 out of scope, 409 when it does not apply
- RecordActions.vue with confirm and request flow (mounted by plan 02)
- roster fixture, smoke tests, OpenAPI, TS types, READMEs, docs
This commit is contained in:
@@ -12,7 +12,10 @@ import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"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"
|
||||
@@ -249,3 +252,205 @@ func TestListSchemaBulkActionsBoot(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user