- 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)
444 lines
18 KiB
Go
444 lines
18 KiB
Go
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")
|
|
}
|
|
}
|