package cabana_test import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" "strings" "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" // 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) } }) }