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)
This commit is contained in:
Jakub Zych
2026-10-22 12:09:12 +02:00
parent 83240706b2
commit 1a56294d9b
5 changed files with 902 additions and 0 deletions

View File

@@ -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{}, "")
}

View File

@@ -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)
}
})
}

View File

@@ -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")
}
}

View File

@@ -173,3 +173,66 @@ func recordActionOfName(cc *CompiledController, name string) bool {
_, ok := recordActionOf(cc, name) _, ok := recordActionOf(cc, name)
return ok 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)
}
}

View File

@@ -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
}