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