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