From 1a56294d9b8a37130a2cf34ea2242e00a41314a1 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 22 Oct 2026 12:09:12 +0200 Subject: [PATCH] test(19.6.1-03): last-superuser race, lifecycle, revocation, seam and roles tests - TestAdministratorsLastSuperuser (D-09): A and B reduce each other on the five paths while a test transaction holds the guard's advisory lock until pg_locks shows both requests waiting; one succeeds, one gets last_superuser - TestAdministratorsLastSuperuserConcurrent: ten unsynchronized rounds - TestAdministratorsLifecycle (D-10) and TestAdministratorsRevocation (D-12), two-row bulk selections included - TestActionExtensionBoot (-short) and TestActionExtensionRun (D-14, D-15) - TestRolesLifecycle (D-16, D-17), TestRolesPermissionCeiling (D-07) - TestBackendScreensActivation (-short, D-01, D-04) --- .../cabana/action_extensions_postgres_test.go | 63 +++ modules/cabana/action_extensions_test.go | 155 ++++++ .../administrators_lifecycle_postgres_test.go | 443 ++++++++++++++++++ modules/cabana/backend_screens_test.go | 63 +++ modules/cabana/roles_postgres_test.go | 178 +++++++ 5 files changed, 902 insertions(+) create mode 100644 modules/cabana/action_extensions_postgres_test.go create mode 100644 modules/cabana/action_extensions_test.go create mode 100644 modules/cabana/administrators_lifecycle_postgres_test.go create mode 100644 modules/cabana/roles_postgres_test.go diff --git a/modules/cabana/action_extensions_postgres_test.go b/modules/cabana/action_extensions_postgres_test.go new file mode 100644 index 00000000..b95a6f60 --- /dev/null +++ b/modules/cabana/action_extensions_postgres_test.go @@ -0,0 +1,63 @@ +package cabana_test + +import ( + "net/http" + "testing" + + "git.golem15.com/golem15/summercms/modules/cabana" +) + +// TestActionExtensionRun (D-14, D-15): a contributed record action on the +// Administrators screen is offered and runs only for principals holding both +// the screen's and the action's permissions, answers 409 once it no longer +// applies, and runs in the row-locked transaction with the record. +func TestActionExtensionRun(t *testing.T) { + env := newScreensEnv(t) + both := env.seedAdmin(t, adminSeed{Name: "ext-both", RolePermissions: map[string]int{cabana.PermissionManageUsers: 1, pingPermission: 1}}) + bothToken := env.login(t, both.Login) + screenOnly := env.seedAdmin(t, adminSeed{Name: "ext-screen", RolePermissions: manageUsers}) + screenToken := env.login(t, screenOnly.Login) + actionOnly := env.seedAdmin(t, adminSeed{Name: "ext-action", RolePermissions: map[string]int{pingPermission: 1}}) + actionToken := env.login(t, actionOnly.Login) + target := env.seedAdmin(t, adminSeed{Name: "ext-target"}) + ping := userURL(target.ID, "actions", "acme_ping") + + names := recordActionNames(env.expect(t, http.StatusOK, http.MethodGet, userURL(target.ID), bothToken, nil)) + if len(names) == 0 || names[len(names)-1] != "acme_ping" || names[0] != "deactivate" { + t.Fatalf("offered to the holder of both = %v, want the owner's actions then acme_ping", names) + } + if names := recordActionNames(env.expect(t, http.StatusOK, http.MethodGet, userURL(target.ID), screenToken, nil)); containsName(names, "acme_ping") { + t.Fatalf("offered without %s = %v", pingPermission, names) + } + env.expectRefusal(t, http.StatusForbidden, http.MethodPost, ping, screenToken, map[string]any{}, "") + env.expectRefusal(t, http.StatusForbidden, http.MethodGet, userURL(target.ID), actionToken, nil, "") + env.expectRefusal(t, http.StatusForbidden, http.MethodPost, ping, actionToken, map[string]any{}, "") + if runs, _ := env.spy.runs(); len(runs) != 0 { + t.Fatalf("refused requests ran the action: %v", runs) + } + + // Shown while it applies, then Applies turns false before the POST. + env.spy.refuse.Store(true) + if rec := env.call(t, http.MethodPost, ping, bothToken, map[string]any{}); rec.Code != http.StatusConflict { + t.Fatalf("POST after Applies turned false status=%d body=%s", rec.Code, rec.Body.String()) + } + if names := recordActionNames(env.expect(t, http.StatusOK, http.MethodGet, userURL(target.ID), bothToken, nil)); containsName(names, "acme_ping") { + t.Fatalf("offered while it does not apply: %v", names) + } + env.spy.refuse.Store(false) + if runs, _ := env.spy.runs(); len(runs) != 0 { + t.Fatalf("a 409 ran the action: %v", runs) + } + + result := dataOf(env.expect(t, http.StatusOK, http.MethodPost, ping, bothToken, map[string]any{})) + if result["message"] != "Pinged" { + t.Fatalf("result = %v", result) + } + runs, withTx := env.spy.runs() + if len(runs) != 1 || runs[0] != uint64(target.ID) || !withTx[0] { + t.Fatalf("runs = %v tx = %v, want one run on %d inside the transaction", runs, withTx, target.ID) + } + // A record the screen hides is not found for the action either. + hidden := env.seedAdmin(t, adminSeed{Name: "ext-hidden", Superuser: true}) + env.expectRefusal(t, http.StatusNotFound, http.MethodPost, userURL(hidden.ID, "actions", "acme_ping"), bothToken, map[string]any{}, "") +} diff --git a/modules/cabana/action_extensions_test.go b/modules/cabana/action_extensions_test.go new file mode 100644 index 00000000..ed6e5271 --- /dev/null +++ b/modules/cabana/action_extensions_test.go @@ -0,0 +1,155 @@ +package cabana + +import ( + "context" + "fmt" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" +) + +// actionContributor owns no admin controller; it declares permissions and +// adds record actions to other controllers. +type actionContributor struct { + id string + permissions []pact.Permission + extensions []pact.AdminActionExtension +} + +func (p actionContributor) ID() string { return p.id } +func (actionContributor) Requires() []string { return nil } +func (actionContributor) Register(*backpack.App) error { return nil } +func (actionContributor) Boot(*backpack.App) error { return nil } +func (p actionContributor) Permissions() []pact.Permission { return p.permissions } +func (p actionContributor) AdminActionExtensions() []pact.AdminActionExtension { + return p.extensions +} + +// namedAction is a valid record action named name behind permission. +func namedAction(name, permission string) pact.AdminRecordAction { + action := pingAction() + action.Name = name + action.Label = "Run " + name + action.Permissions = []string{permission} + return action +} + +func toUsers(actions ...pact.AdminRecordAction) []pact.AdminActionExtension { + return []pact.AdminActionExtension{{Controller: AdministratorsControllerID, RecordActions: actions}} +} + +// TestActionExtensionBoot (D-14, SC-3): every refusal of the seam stops boot +// with an error naming the plugin, the action and the controller; valid +// contributions land after the owner's actions in plugin and declared order. +func TestActionExtensionBoot(t *testing.T) { + base := screensExtender{} // owns acme.ext.items (list only) and acme.ext.ping + contributor := func(id string, ext []pact.AdminActionExtension) actionContributor { + return actionContributor{id: id, permissions: []pact.Permission{{Code: id + ".run"}}, extensions: ext} + } + noRun := namedAction("acme_norun", "acme.one.run") + noRun.Run = nil + noLabel := namedAction("acme_nolabel", "acme.one.run") + noLabel.Label = " " + + for _, tc := range []struct { + name string + plugins []party.Plugin + want []string + }{ + {"unknown controller", []party.Plugin{base, contributor("acme.one", []pact.AdminActionExtension{{ + Controller: "acme.missing.things", RecordActions: []pact.AdminRecordAction{namedAction("acme_x", "acme.one.run")}, + }})}, []string{"acme.one", "acme.missing.things", "unknown admin controller"}}, + {"target without a preview block", []party.Plugin{base, contributor("acme.one", []pact.AdminActionExtension{{ + Controller: "acme.ext.items", RecordActions: []pact.AdminRecordAction{namedAction("acme_x", "acme.one.run")}, + }})}, []string{"acme.one", "acme.ext.items", "no preview block"}}, + {"duplicate of the owner's action", []party.Plugin{base, contributor("acme.one", toUsers(namedAction("deactivate", "acme.one.run")))}, + []string{"acme.one", `"deactivate"`, AdministratorsControllerID, "plugin summercms.cabana already registers"}}, + {"duplicate between two contributors", []party.Plugin{base, + contributor("acme.one", toUsers(namedAction("acme_same", "acme.one.run"))), + contributor("acme.two", toUsers(namedAction("acme_same", "acme.two.run"))), + }, []string{"plugin acme.two adds", `"acme_same"`, "plugin acme.one already registers"}}, + {"reserved name create", []party.Plugin{base, contributor("acme.one", toUsers(namedAction("create", "acme.one.run")))}, + []string{"acme.one", `"create"`, "reserved"}}, + {"reserved name delete", []party.Plugin{base, contributor("acme.one", toUsers(namedAction("delete", "acme.one.run")))}, + []string{"acme.one", `"delete"`, "reserved"}}, + {"name is not an identifier", []party.Plugin{base, contributor("acme.one", toUsers(namedAction("acme-ping", "acme.one.run")))}, + []string{"acme.one", `"acme-ping"`, "not an identifier"}}, + {"nil Run", []party.Plugin{base, contributor("acme.one", toUsers(noRun))}, + []string{"acme.one", `"acme_norun"`, "no Run function"}}, + {"empty label", []party.Plugin{base, contributor("acme.one", toUsers(noLabel))}, + []string{"acme.one", `"acme_nolabel"`, "needs a label"}}, + {"unknown permission", []party.Plugin{base, contributor("acme.one", toUsers(namedAction("acme_x", "acme.nobody.run")))}, + []string{"acme.nobody.run"}}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := compileWithScreens(t, tc.plugins) + if err == nil { + t.Fatalf("compiled; want an error naming %v", tc.want) + } + for _, want := range tc.want { + if !strings.Contains(err.Error(), want) { + t.Fatalf("err = %v, want it to contain %q", err, want) + } + } + }) + } + + t.Run("valid contributions land after the owner's in order", func(t *testing.T) { + plugins := []party.Plugin{base, + contributor("acme.one", toUsers(namedAction("acme_a", "acme.one.run"), namedAction("acme_b", "acme.one.run"))), + contributor("acme.two", toUsers(namedAction("acme_c", "acme.two.run"))), + } + reg, err := compileWithScreens(t, plugins) + if err != nil { + t.Fatal(err) + } + cc, _ := reg.Get(AdministratorsControllerID) + if got := fmt.Sprint(cc.Form.recordActions); got != "[activate deactivate restore acme_a acme_b acme_c]" { + t.Fatalf("record actions = %s", got) + } + for name, plugin := range map[string]string{"acme_a": "acme.one", "acme_b": "acme.one", "acme_c": "acme.two"} { + if cc.contributed[name] != plugin || !recordActionOfName(cc, name) { + t.Fatalf("%s contributed by %q (want %s), resolvable %v", name, cc.contributed[name], plugin, recordActionOfName(cc, name)) + } + } + if _, ok := cc.contributed["activate"]; ok { + t.Fatal("the owner's action is marked contributed") + } + // The roles screen is untouched. + roles, _ := reg.Get(RolesControllerID) + if roles.contributed != nil { + t.Fatalf("roles contributed = %v", roles.contributed) + } + }) + + t.Run("an extension without record actions is ignored", func(t *testing.T) { + plugins := []party.Plugin{base, contributor("acme.one", []pact.AdminActionExtension{{Controller: "acme.missing.things"}})} + if _, err := compileWithScreens(t, plugins); err != nil { + t.Fatal(err) + } + }) + + t.Run("a contributed action runs with the owner's record", func(t *testing.T) { + var got pact.AdminRecordActionInput + action := namedAction("acme_spy", "acme.one.run") + action.Run = func(_ context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + got = in + return pact.AdminRecordActionResult{}, nil + } + reg, err := compileWithScreens(t, []party.Plugin{base, contributor("acme.one", toUsers(action))}) + if err != nil { + t.Fatal(err) + } + cc, _ := reg.Get(AdministratorsControllerID) + resolved, ok := recordActionOf(cc, "acme_spy") + if !ok { + t.Fatal("acme_spy not resolvable") + } + if _, err := resolved.Run(context.Background(), pact.AdminRecordActionInput{RecordID: 7, Record: &BackendUser{ID: 7}}); err != nil || got.RecordID != 7 { + t.Fatalf("run = %+v, %v", got, err) + } + }) +} diff --git a/modules/cabana/administrators_lifecycle_postgres_test.go b/modules/cabana/administrators_lifecycle_postgres_test.go new file mode 100644 index 00000000..64475c88 --- /dev/null +++ b/modules/cabana/administrators_lifecycle_postgres_test.go @@ -0,0 +1,443 @@ +package cabana_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/cabana" +) + +// superuserPath is one write that could remove the last active superuser: +// actor (signed in with token) acts on target. +type superuserPath func(e *screensEnv, token string, target cabana.BackendUser) *httptest.ResponseRecorder + +// The five reducing paths of D-09. +func demoteUpdate(e *screensEnv, token string, target cabana.BackendUser) *httptest.ResponseRecorder { + return e.serve(http.MethodPut, userURL(target.ID), token, map[string]any{"login": target.Login, "email": target.Email, "is_superuser": false}) +} + +func deactivateRecord(e *screensEnv, token string, target cabana.BackendUser) *httptest.ResponseRecorder { + return e.serve(http.MethodPost, userURL(target.ID, "actions", "deactivate"), token, map[string]any{}) +} + +func deactivateBulk(e *screensEnv, token string, target cabana.BackendUser) *httptest.ResponseRecorder { + return e.serve(http.MethodPost, usersPath+"/bulk/deactivate", token, map[string]any{"ids": []uint{target.ID}}) +} + +func deleteForm(e *screensEnv, token string, target cabana.BackendUser) *httptest.ResponseRecorder { + return e.serve(http.MethodDelete, userURL(target.ID), token, nil) +} + +func deleteBulk(e *screensEnv, token string, target cabana.BackendUser) *httptest.ResponseRecorder { + return e.serve(http.MethodPost, usersPath+"/bulk-delete", token, map[string]any{"ids": []uint{target.ID}}) +} + +// waitingAdvisory counts the advisory lock requests that wait. +func (e *screensEnv) waitingAdvisory(t *testing.T) int64 { + t.Helper() + var n int64 + if err := e.gdb.Raw(`SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND NOT granted`).Scan(&n).Error; err != nil { + t.Fatal(err) + } + return n +} + +// activeSuperusers counts which of ids are activated, undeleted superusers. +func (e *screensEnv) activeSuperusers(t *testing.T, ids ...uint) int64 { + t.Helper() + var n int64 + if err := e.gdb.Raw(`SELECT count(*) FROM backend_users WHERE id IN ? AND is_superuser AND is_activated AND deleted_at IS NULL`, ids).Scan(&n).Error; err != nil { + t.Fatal(err) + } + return n +} + +// noServerFailure fails on a 5xx or a body naming a deadlock. +func noServerFailure(t *testing.T, label string, rec *httptest.ResponseRecorder) { + t.Helper() + if rec.Code >= 500 || strings.Contains(strings.ToLower(rec.Body.String()), "deadlock") { + t.Fatalf("%s status=%d body=%s", label, rec.Code, rec.Body.String()) + } +} + +// raceLastSuperusers runs path for A on B and for B on A while a test +// transaction holds the guard's advisory lock, releases the lock once both +// requests wait on it, and returns both responses. +func (e *screensEnv) raceLastSuperusers(t *testing.T, path superuserPath, a, b cabana.BackendUser, aToken, bToken string) (*httptest.ResponseRecorder, *httptest.ResponseRecorder) { + t.Helper() + hold := e.gdb.Begin() + if hold.Error != nil { + t.Fatal(hold.Error) + } + released := false + release := func() { + if !released { + released = true + hold.Rollback() + } + } + defer release() + if err := hold.Exec("SELECT pg_advisory_xact_lock(?)", cabana.SuperuserLockKey).Error; err != nil { + t.Fatal(err) + } + results := make(chan [2]any, 2) + go func() { results <- [2]any{"a", path(e, aToken, b)} }() + go func() { results <- [2]any{"b", path(e, bToken, a)} }() + + deadline := time.Now().Add(10 * time.Second) + for e.waitingAdvisory(t) < 2 { + if time.Now().After(deadline) { + release() + for i := 0; i < 2; i++ { + r := <-results + rec := r[1].(*httptest.ResponseRecorder) + t.Logf("%s: status=%d body=%s", r[0], rec.Code, rec.Body.String()) + } + t.Fatal("the two requests did not both wait on the advisory lock within 10s") + } + time.Sleep(20 * time.Millisecond) + } + release() + var aRec, bRec *httptest.ResponseRecorder + for i := 0; i < 2; i++ { + r := <-results + if r[0] == "a" { + aRec = r[1].(*httptest.ResponseRecorder) + } else { + bRec = r[1].(*httptest.ResponseRecorder) + } + } + return aRec, bRec +} + +// TestAdministratorsLastSuperuser (D-09): with A and B the only active +// superusers, A and B reduce each other at the same time on every path. +// Both requests queue on the guard's advisory lock; exactly one succeeds, +// the other is refused with the last_superuser text, and one superuser +// stays. +func TestAdministratorsLastSuperuser(t *testing.T) { + env := newScreensEnv(t) + t.Run("demote_update", func(t *testing.T) { lastSuperuserRace(t, env, demoteUpdate) }) + t.Run("deactivate_record", func(t *testing.T) { lastSuperuserRace(t, env, deactivateRecord) }) + t.Run("deactivate_bulk", func(t *testing.T) { lastSuperuserRace(t, env, deactivateBulk) }) + t.Run("delete_form", func(t *testing.T) { lastSuperuserRace(t, env, deleteForm) }) + t.Run("delete_bulk", func(t *testing.T) { lastSuperuserRace(t, env, deleteBulk) }) +} + +// lastSuperuserRace seeds fresh superusers A and B, makes them the only +// active ones, races path for A on B and B on A on the held lock, and checks +// the outcome. +func lastSuperuserRace(t *testing.T, env *screensEnv, path superuserPath) { + t.Helper() + a := env.seedAdmin(t, adminSeed{Name: "last-a", Superuser: true}) + b := env.seedAdmin(t, adminSeed{Name: "last-b", Superuser: true}) + aToken, bToken := env.login(t, a.Login), env.login(t, b.Login) + env.isolateSuperusers(t, a.ID, b.ID) + + aRec, bRec := env.raceLastSuperusers(t, path, a, b, aToken, bToken) + noServerFailure(t, "A on B", aRec) + noServerFailure(t, "B on A", bRec) + ok, refused := 0, 0 + for _, rec := range []*httptest.ResponseRecorder{aRec, bRec} { + switch { + case rec.Code >= 200 && rec.Code < 300: + ok++ + case rec.Code == http.StatusForbidden: + if message, details := errorOf(t, rec); message != enLastSuperuser { + t.Fatalf("refusal message=%q details=%v, want the last_superuser text", message, details) + } + refused++ + default: + t.Fatalf("unexpected status=%d body=%s", rec.Code, rec.Body.String()) + } + } + if ok != 1 || refused != 1 { + t.Fatalf("A on B status=%d, B on A status=%d: want one success and one refusal", aRec.Code, bRec.Code) + } + if n := env.activeSuperusers(t, a.ID, b.ID); n != 1 { + t.Fatalf("%d active superusers left of A and B, want 1", n) + } +} + +// TestAdministratorsLastSuperuserConcurrent (D-09): unsynchronized rounds of +// A demoting B while B demotes A always leave an active superuser and never +// answer 500. +func TestAdministratorsLastSuperuserConcurrent(t *testing.T) { + env := newScreensEnv(t) + env.isolateSuperusers(t) + for round := 0; round < 10; round++ { + a := env.seedAdmin(t, adminSeed{Name: "conc-a", Superuser: true}) + b := env.seedAdmin(t, adminSeed{Name: "conc-b", Superuser: true}) + aToken, bToken := env.login(t, a.Login), env.login(t, b.Login) + demote := demoteUpdate + results := make(chan *httptest.ResponseRecorder, 2) + start := make(chan struct{}) + go func() { <-start; results <- demote(env, aToken, b) }() + go func() { <-start; results <- demote(env, bToken, a) }() + close(start) + for i := 0; i < 2; i++ { + noServerFailure(t, fmt.Sprintf("round %d", round), <-results) + } + if n := env.activeSuperusers(t, a.ID, b.ID); n < 1 { + t.Fatalf("round %d left no active superuser", round) + } + // Retire both before the next round, so A and B are again the only + // active superusers. + if err := env.gdb.Exec(`UPDATE backend_users SET is_superuser = FALSE WHERE id IN ?`, []uint{a.ID, b.ID}).Error; err != nil { + t.Fatal(err) + } + } +} + +// countUsers counts every backend_users row, deleted ones included. +func (e *screensEnv) countUsers(t *testing.T) int64 { + t.Helper() + var n int64 + if err := e.gdb.Raw(`SELECT count(*) FROM backend_users`).Scan(&n).Error; err != nil { + t.Fatal(err) + } + return n +} + +// lifecycleActions keeps only the lifecycle names of a preview's actions. +func lifecycleActions(body map[string]any) []string { + out := []string{} + for _, name := range recordActionNames(body) { + if name == "activate" || name == "deactivate" || name == "restore" { + out = append(out, name) + } + } + return out +} + +// hintText is the rendered hint partial of an administrator. +func (e *screensEnv) hintText(t *testing.T, token string, id uint) string { + t.Helper() + body := e.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/hint?id=%d", usersPath, id), token, nil) + return fmt.Sprint(body["data"]) +} + +// TestAdministratorsLifecycle (D-10): activate, deactivate and restore as +// record and bulk actions offered only where they apply, soft delete that +// keeps every row, the trashed filter and the preview hint. +func TestAdministratorsLifecycle(t *testing.T) { + env := newScreensEnv(t) + super := env.seedAdmin(t, adminSeed{Name: "life-super", Superuser: true}) + token := env.login(t, super.Login) + active := env.seedAdmin(t, adminSeed{Name: "life-active"}) + inactive := env.seedAdmin(t, adminSeed{Name: "life-inactive", Inactive: true}) + deleted := env.seedAdmin(t, adminSeed{Name: "life-deleted", Deleted: true, Inactive: true}) + + // Offered actions follow Applies. + for _, tc := range []struct { + user cabana.BackendUser + want string + }{{active, "[deactivate]"}, {inactive, "[activate]"}, {deleted, "[restore]"}, {super, "[]"}} { + got := lifecycleActions(env.expect(t, http.StatusOK, http.MethodGet, userURL(tc.user.ID), token, nil)) + if fmt.Sprint(got) != tc.want { + t.Fatalf("actions offered on %s = %v, want %s", tc.user.Login, got, tc.want) + } + } + // An action that does not apply answers 409. + for _, tc := range []struct { + user cabana.BackendUser + action string + }{{active, "activate"}, {active, "restore"}, {inactive, "deactivate"}, {deleted, "activate"}, {super, "deactivate"}} { + if rec := env.call(t, http.MethodPost, userURL(tc.user.ID, "actions", tc.action), token, map[string]any{}); rec.Code != http.StatusConflict { + t.Fatalf("%s on %s status=%d body=%s", tc.action, tc.user.Login, rec.Code, rec.Body.String()) + } + } + + // The hint follows deleted > deactivated > own account. + for _, tc := range []struct { + id uint + want string + }{{deleted.ID, "This account has been deleted"}, {inactive.ID, "This account is deactivated"}, {super.ID, "This is your account"}} { + if got := env.hintText(t, token, tc.id); !strings.Contains(got, tc.want) { + t.Fatalf("hint of %d = %q, want %q", tc.id, got, tc.want) + } + } + if got := env.hintText(t, token, active.ID); strings.Contains(got, "summer-callout") { + t.Fatalf("hint of an active administrator = %q, want none", got) + } + + // Record actions. + env.expect(t, http.StatusOK, http.MethodPost, userURL(inactive.ID, "actions", "activate"), token, map[string]any{}) + if got := env.storedUser(t, inactive.ID); !got.IsActivated || got.ActivatedAt == nil { + t.Fatalf("activated = %+v", got) + } + env.expect(t, http.StatusOK, http.MethodPost, userURL(inactive.ID, "actions", "deactivate"), token, map[string]any{}) + if got := env.storedUser(t, inactive.ID); got.IsActivated { + t.Fatal("not deactivated") + } + env.expect(t, http.StatusOK, http.MethodPost, userURL(deleted.ID, "actions", "restore"), token, map[string]any{}) + if got := env.storedUser(t, deleted.ID); got.DeletedAt.Valid || got.IsActivated { + t.Fatalf("restored = %+v, want undeleted and still deactivated", got) + } + + // Bulk actions skip rows they do not apply to and report Affected. + extra := env.seedAdmin(t, adminSeed{Name: "life-extra"}) + result := dataOf(env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk/deactivate", token, map[string]any{"ids": []uint{active.ID, extra.ID, inactive.ID}})) + if result["affected"] != float64(2) { + t.Fatalf("bulk deactivate result = %v", result) + } + for _, id := range []uint{active.ID, extra.ID} { + if got := env.storedUser(t, id); got.IsActivated { + t.Fatalf("admin %d still active after the bulk deactivate: %+v", id, got) + } + } + result = dataOf(env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk/activate", token, map[string]any{"ids": []uint{active.ID, extra.ID}})) + if result["affected"] != float64(2) || !strings.Contains(fmt.Sprint(result["message"]), "Selected administrators activated") { + t.Fatalf("bulk activate result = %v", result) + } + + // Soft delete keeps every row. + rows := env.countUsers(t) + env.expect(t, http.StatusOK, http.MethodDelete, userURL(active.ID), token, nil) + env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk-delete", token, map[string]any{"ids": []uint{extra.ID}}) + if got := env.countUsers(t); got != rows { + t.Fatalf("backend_users rows %d -> %d after deletes", rows, got) + } + for _, id := range []uint{active.ID, extra.ID} { + if got := env.storedUser(t, id); !got.DeletedAt.Valid { + t.Fatalf("admin %d not soft-deleted", id) + } + } + list := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, token, nil) + if ids := listIDs(t, list); !ids[active.ID] || !ids[inactive.ID] { + t.Fatalf("unfiltered list = %v", ids) + } + if states := rowStates(list, active.ID); fmt.Sprint(states) != "[deleted]" { + t.Fatalf("row states of a deleted admin = %v", states) + } + if states := rowStates(list, inactive.ID); fmt.Sprint(states) != "[disabled]" { + t.Fatalf("row states of a deactivated admin = %v", states) + } + + // The trashed filter: two options; hide and only narrow the list. + options := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/filters/trashed/options", token, nil) + raw, _ := json.Marshal(options["data"]) + if !strings.Contains(string(raw), "Hide deleted") || !strings.Contains(string(raw), "Only deleted") { + t.Fatalf("trashed filter options = %s", raw) + } + hidden := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp+"&filter[trashed]=hide", token, nil)) + if hidden[active.ID] || hidden[extra.ID] || !hidden[inactive.ID] { + t.Fatalf("trashed=hide list = %v", hidden) + } + only := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp+"&filter[trashed]=only", token, nil)) + if !only[active.ID] || !only[extra.ID] || only[inactive.ID] { + t.Fatalf("trashed=only list = %v", only) + } + // Bulk restore brings both back. + result = dataOf(env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk/restore", token, map[string]any{"ids": []uint{active.ID, extra.ID, inactive.ID}})) + if result["affected"] != float64(2) { + t.Fatalf("bulk restore result = %v", result) + } + if got := env.storedUser(t, extra.ID); got.DeletedAt.Valid { + t.Fatal("bulk restore left a row deleted") + } +} + +// meStatus is the status of GET /auth/me with token. +func (e *screensEnv) meStatus(t *testing.T, token string) int { + t.Helper() + return e.call(t, http.MethodGet, "/auth/me", token, nil).Code +} + +// TestAdministratorsRevocation (D-12): deactivate and delete end the +// target's sessions and personal tokens at once; a password set by another +// administrator ends its sessions; restore and activate do not move the +// cutoff. +func TestAdministratorsRevocation(t *testing.T) { + env := newScreensEnv(t) + super := env.seedAdmin(t, adminSeed{Name: "rev-super", Superuser: true}) + token := env.login(t, super.Login) + + type target struct { + user cabana.BackendUser + jwt, apiKey string + } + signIn := func(name string) target { + user := env.seedAdmin(t, adminSeed{Name: name}) + apiKey, err := cabana.IssueTestAPIToken(env.gdb, user.ID, []string{"*"}, nil) + if err != nil { + t.Fatal(err) + } + tg := target{user: user, jwt: env.login(t, user.Login), apiKey: apiKey} + if env.meStatus(t, tg.jwt) != http.StatusOK || env.meStatus(t, tg.apiKey) != http.StatusOK { + t.Fatalf("%s cannot use its session or token before the action", name) + } + return tg + } + ended := func(label string, tg target, apiKeyEnded bool) { + t.Helper() + if got := env.meStatus(t, tg.jwt); got != http.StatusUnauthorized { + t.Fatalf("%s: session status=%d, want 401", label, got) + } + want := http.StatusOK + if apiKeyEnded { + want = http.StatusUnauthorized + } + if got := env.meStatus(t, tg.apiKey); got != want { + t.Fatalf("%s: personal token status=%d, want %d", label, got, want) + } + } + + record := signIn("rev-record") + env.expect(t, http.StatusOK, http.MethodPost, userURL(record.user.ID, "actions", "deactivate"), token, map[string]any{}) + ended("record deactivate", record, true) + + // Two rows in one selection: both end. + bulk, bulk2 := signIn("rev-bulk"), signIn("rev-bulk-2") + env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk/deactivate", token, map[string]any{"ids": []uint{bulk.user.ID, bulk2.user.ID}}) + ended("bulk deactivate", bulk, true) + ended("bulk deactivate, second row", bulk2, true) + + deleted := signIn("rev-deleted") + env.expect(t, http.StatusOK, http.MethodDelete, userURL(deleted.user.ID), token, nil) + ended("delete", deleted, true) + + bulkDeleted, bulkDeleted2 := signIn("rev-bulk-deleted"), signIn("rev-bulk-deleted-2") + env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk-delete", token, map[string]any{"ids": []uint{bulkDeleted.user.ID, bulkDeleted2.user.ID}}) + ended("bulk delete", bulkDeleted, true) + ended("bulk delete, second row", bulkDeleted2, true) + + password := signIn("rev-password") + env.expect(t, http.StatusOK, http.MethodPut, userURL(password.user.ID), token, + map[string]any{"login": password.user.Login, "email": password.user.Email, "password": "pass-5678", "password_confirmation": "pass-5678"}) + // A password set by another administrator ends sessions; personal + // tokens stay valid (D-12). + ended("password set", password, false) + // A new session works once the cutoff (one second ahead, whole-second + // token times) has passed. + if cut := env.storedUser(t, password.user.ID).TokensValidAfter; cut != nil { + time.Sleep(time.Until(*cut) + 1100*time.Millisecond) + } + if fresh := env.loginWith(t, password.user.Login, "pass-5678"); env.meStatus(t, fresh) != http.StatusOK { + t.Fatal("a session after the password set does not work") + } + + // Restore and activate leave tokens_valid_after where it was. + cutoff := env.storedUser(t, deleted.user.ID).TokensValidAfter + if cutoff == nil { + t.Fatal("delete left no cutoff") + } + env.expect(t, http.StatusOK, http.MethodPost, userURL(deleted.user.ID, "actions", "restore"), token, map[string]any{}) + if got := env.storedUser(t, deleted.user.ID).TokensValidAfter; got == nil || !got.Equal(*cutoff) { + t.Fatalf("restore moved the cutoff %v -> %v", cutoff, got) + } + cutoff = env.storedUser(t, record.user.ID).TokensValidAfter + env.expect(t, http.StatusOK, http.MethodPost, userURL(record.user.ID, "actions", "activate"), token, map[string]any{}) + if got := env.storedUser(t, record.user.ID).TokensValidAfter; got == nil || cutoff == nil || !got.Equal(*cutoff) { + t.Fatalf("activate moved the cutoff %v -> %v", cutoff, got) + } + // The personal token works again after activation; the old session not. + if env.meStatus(t, record.apiKey) != http.StatusOK || env.meStatus(t, record.jwt) != http.StatusUnauthorized { + t.Fatal("after activate: want the personal token back and the old session still ended") + } +} diff --git a/modules/cabana/backend_screens_test.go b/modules/cabana/backend_screens_test.go index 01328910..2fb05b04 100644 --- a/modules/cabana/backend_screens_test.go +++ b/modules/cabana/backend_screens_test.go @@ -173,3 +173,66 @@ func recordActionOfName(cc *CompiledController, name string) bool { _, ok := recordActionOf(cc, name) return ok } + +// TestBackendScreensActivation (D-01, D-04): the admin's own screens join +// only an application that has an admin; then they bring both controllers, +// both settings links (Administrators before Roles), both permission codes +// granted to no role, and no navigation entry. +func TestBackendScreensActivation(t *testing.T) { + // No admin controllers: no admin, and no admin config is read (the app + // has no admin.jwt.secret). + app := backpack.New(nil) + plain := contributionPlugin{id: "acme.plain", permissions: []pact.Permission{{Code: "acme.plain.use"}}} + routes, err := Activate(app, []party.Plugin{plain}) + if routes != nil || err != nil { + t.Fatalf("Activate without admin controllers = %v, %v", routes, err) + } + // With a controller the same app needs the secret: the admin is active. + if _, err := Activate(app, []party.Plugin{screensExtender{}}); err == nil || !strings.Contains(err.Error(), "admin.jwt.secret") { + t.Fatalf("Activate with a controller and no secret = %v", err) + } + + reg, err := compileWithScreens(t, []party.Plugin{screensExtender{}}) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{AdministratorsControllerID, RolesControllerID} { + cc, ok := reg.Get(id) + if !ok || cc.PluginID != "summercms.cabana" || cc.List == nil || cc.Form == nil { + t.Fatalf("controller %s = %#v", id, cc) + } + } + for _, code := range []string{PermissionManageUsers, PermissionManageRoles} { + permission, ok := reg.permissions[code] + if !ok || len(permission.Roles) != 0 { + t.Fatalf("permission %s = %+v (present %v), want no role grants", code, permission, ok) + } + for _, role := range []string{"developer", "publisher"} { + if reg.rolePermissions(role)[code] { + t.Fatalf("role %s holds %s", role, code) + } + } + } + + ctx := context.Background() + navigation, settings := reg.Metadata(ctx, &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}, nil) + var codes []string + for _, entry := range settings { + if entry.Controller == AdministratorsControllerID || entry.Controller == RolesControllerID { + codes = append(codes, entry.Code) + } + } + if strings.Join(codes, ",") != "administrators,roles" { + t.Fatalf("superuser settings for the screens = %v, want administrators then roles", codes) + } + for _, entry := range navigation { + if strings.HasPrefix(entry.Controller, "summercms.cabana.") { + t.Fatalf("navigation entry %+v from the screens", entry) + } + } + // A holder of backend.manage_roles alone sees the Roles link only. + _, settings = reg.Metadata(ctx, &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{PermissionManageRoles: true}}, nil) + if len(settings) != 1 || settings[0].Code != "roles" { + t.Fatalf("holder of %s sees %+v", PermissionManageRoles, settings) + } +} diff --git a/modules/cabana/roles_postgres_test.go b/modules/cabana/roles_postgres_test.go new file mode 100644 index 00000000..273ca015 --- /dev/null +++ b/modules/cabana/roles_postgres_test.go @@ -0,0 +1,178 @@ +package cabana_test + +import ( + "fmt" + "net/http" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// roleURL is the record path of a role. +func roleURL(id uint) string { return fmt.Sprintf("%s/%d", rolesPath, id) } + +// roleBody is a role save body. +func roleBody(role cabana.BackendUserRole, extra map[string]any) map[string]any { + body := map[string]any{"name": role.Name, "code": role.Code, "description": role.Description} + for key, value := range extra { + body[key] = value + } + return body +} + +// roleExists reports whether a role row with id exists. +func (e *screensEnv) roleExists(t *testing.T, id uint) bool { + t.Helper() + var n int64 + if err := e.gdb.Raw(`SELECT count(*) FROM backend_user_roles WHERE id = ?`, id).Scan(&n).Error; err != nil { + t.Fatal(err) + } + return n == 1 +} + +// TestRolesLifecycle (D-16, D-17): role codes are unique; a system role (is_system +// or one code-declared grants attach to) keeps its code and cannot be +// deleted; a role any administrator holds, deleted ones included, cannot be +// deleted, alone or in a bulk selection. +func TestRolesLifecycle(t *testing.T) { + declared := fmt.Sprintf("plugrole-%d", time.Now().UnixNano()) + env := newScreensEnv(t, grantPlugin{id: "acme.granter", grants: []pact.Permission{{Code: "acme.granter.use", Roles: []string{declared}}}}) + super := env.seedAdmin(t, adminSeed{Name: "roles-super", Superuser: true}) + token := env.login(t, super.Login) + + // Create with the slug the form's preset makes from the name. + name := "Editors " + env.stamp + code := "editors-" + env.stamp + created := dataOf(env.expect(t, http.StatusCreated, http.MethodPost, rolesPath, token, + map[string]any{"name": name, "code": code, "description": "Edit things"})) + createdID := uint(created["id"].(float64)) + t.Cleanup(func() { env.gdb.Exec(`DELETE FROM backend_user_roles WHERE id = ?`, createdID) }) + var stored cabana.BackendUserRole + if err := env.gdb.First(&stored, createdID).Error; err != nil || stored.Code != code || stored.IsSystem { + t.Fatalf("created role = %+v, %v", stored, err) + } + env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, rolesPath, token, + map[string]any{"name": "Other " + env.stamp, "code": code}, "code", enCodeTaken, false) + before := env.roleRow(t, createdID) + other := env.seedRole(t, roleSeed{Name: "roles-other"}) + env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, roleURL(other.ID), token, + roleBody(other, map[string]any{"code": code}), "code", enCodeTaken, false) + if env.roleRow(t, createdID) != before { + t.Fatal("a refused code change touched the created role") + } + + // System roles: is_system, and a role a plugin's code-declared grant + // attaches to. + var developer cabana.BackendUserRole + if err := env.gdb.Where("code = ?", "developer").First(&developer).Error; err != nil { + t.Fatal(err) + } + pluginRole := env.seedRole(t, roleSeed{Name: "roles-declared", Code: declared}) + for _, role := range []cabana.BackendUserRole{developer, pluginRole} { + rowBefore := env.roleRow(t, role.ID) + env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, roleURL(role.ID), token, + roleBody(role, map[string]any{"code": role.Code + "-renamed"}), "code", enCodeLocked, false) + env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, roleURL(role.ID), token, nil, enDeleteSystem) + if env.roleRow(t, role.ID) != rowBefore { + t.Fatalf("a refused change touched system role %s", role.Code) + } + } + // A system role's name and description stay editable. + env.expect(t, http.StatusOK, http.MethodPut, roleURL(pluginRole.ID), token, roleBody(pluginRole, map[string]any{"description": "Changed"})) + + // A held role, one holder active and one soft-deleted. + held := env.seedRole(t, roleSeed{Name: "roles-held"}) + env.seedAdmin(t, adminSeed{Name: "roles-holder", Role: held.ID}) + env.seedAdmin(t, adminSeed{Name: "roles-gone", Role: held.ID, Deleted: true}) + env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, roleURL(held.ID), token, nil, + "The role “"+held.Name+"” is assigned to 2 administrators.") + if !env.roleExists(t, held.ID) { + t.Fatal("a refused delete removed the role") + } + + // A bulk delete of an unused role and the held one is refused as a whole. + unused := env.seedRole(t, roleSeed{Name: "roles-unused"}) + env.expectRefusal(t, http.StatusForbidden, http.MethodPost, rolesPath+"/bulk-delete", token, + map[string]any{"ids": []uint{unused.ID, held.ID}}, "is assigned to 2 administrators") + if !env.roleExists(t, unused.ID) || !env.roleExists(t, held.ID) { + t.Fatal("a refused bulk delete removed a role") + } + // The unused role alone deletes. + env.expect(t, http.StatusOK, http.MethodDelete, roleURL(unused.ID), token, nil) + if env.roleExists(t, unused.ID) { + t.Fatal("the unused role was not deleted") + } +} + +// TestRolesPermissionCeiling (D-07): a non-superuser holding +// backend.manage_roles can neither add nor remove a code it does not hold on +// any role; codes it holds toggle. +func TestRolesPermissionCeiling(t *testing.T) { + env := newScreensEnv(t) + editor := env.seedAdmin(t, adminSeed{Name: "rceil-editor", RolePermissions: map[string]int{cabana.PermissionManageRoles: 1, pingPermission: 1}}) + token := env.login(t, editor.Login) + + field := schemaFields(env.expect(t, http.StatusOK, http.MethodGet, rolesPath+"/schema/form", token, nil))["permissions"] + locked := permissionLocked(field) + if len(locked) < 4 { + t.Fatalf("permission options = %v", locked) + } + for code, isLocked := range locked { + held := code == cabana.PermissionManageRoles || code == pingPermission + if isLocked == held { + t.Fatalf("option %s locked=%v, want %v", code, isLocked, !held) + } + } + + strong := env.seedRole(t, roleSeed{Name: "rceil-strong", Permissions: map[string]int{"acme.demo.access": 1}}) + empty := env.seedRole(t, roleSeed{Name: "rceil-empty"}) + strong, empty = env.reloadRole(t, strong.ID), env.reloadRole(t, empty.ID) + + for _, tc := range []struct { + name string + role cabana.BackendUserRole + permissions map[string]any + }{ + {"add a locked code", empty, map[string]any{"acme.demo.access": 1}}, + {"add a locked code next to a held one", empty, map[string]any{"acme.demo.access": 1, pingPermission: 1}}, + {"remove a locked code", strong, map[string]any{}}, + {"remove a locked code, add a held one", strong, map[string]any{pingPermission: 1}}, + } { + t.Run(tc.name, func(t *testing.T) { + before := env.roleRow(t, tc.role.ID) + env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, roleURL(tc.role.ID), token, + roleBody(tc.role, map[string]any{"permissions": tc.permissions}), "permissions", enPermissionLocked, false) + if env.roleRow(t, tc.role.ID) != before { + t.Fatal("a refused permission change touched the role") + } + }) + } + + env.expect(t, http.StatusOK, http.MethodPut, roleURL(strong.ID), token, + roleBody(strong, map[string]any{"permissions": map[string]any{"acme.demo.access": 1, pingPermission: 1}})) + if got := decodeJSON(t, env.reloadRole(t, strong.ID).Permissions); got["acme.demo.access"] != 1 || got[pingPermission] != 1 || len(got) != 2 { + t.Fatalf("strong role after adding the held code = %v", got) + } + env.expect(t, http.StatusOK, http.MethodPut, roleURL(strong.ID), token, + roleBody(strong, map[string]any{"permissions": map[string]any{"acme.demo.access": 1}})) + if got := decodeJSON(t, env.reloadRole(t, strong.ID).Permissions); got["acme.demo.access"] != 1 || len(got) != 1 { + t.Fatalf("strong role after removing the held code = %v", got) + } + // Without the permissions key the stored grants stay. + env.expect(t, http.StatusOK, http.MethodPut, roleURL(strong.ID), token, roleBody(strong, map[string]any{"description": "Renamed"})) + if got := env.reloadRole(t, strong.ID); got.Description != "Renamed" || got.Permissions != `{"acme.demo.access":1}` { + t.Fatalf("strong role after a description change = %+v", got) + } +} + +// reloadRole reads a role row. +func (e *screensEnv) reloadRole(t *testing.T, id uint) cabana.BackendUserRole { + t.Helper() + var role cabana.BackendUserRole + if err := e.gdb.First(&role, id).Error; err != nil { + t.Fatal(err) + } + return role +}