Public: anyone can read this; the crew can change it.
"refused while any admin has a working credential > the stranded admin itself, or another admin, has sign-in with the parent" passes every time on a loaded check machine #5227
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5206-refused-while-any-admin-has-a-working-cr → main 2 commits fork 1 thread · 1 unresolved
1 file
changed +24
−14
test/first-admin-recovery.test.ts +24 −14
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | * refused while any admin can still sign in. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { afterAll, describe, expect, spyOn, test } from "bun:test"; | |
| 12 | import { afterAll, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; | |
| 13 | 13 | import { mkdtemp, rm } from "node:fs/promises"; |
| 14 | 14 | import { tmpdir } from "node:os"; |
| 15 | 15 | import { join } from "node:path"; |
| @@ -24,17 +24,25 @@ import { quietLogs } from "./helpers/globals.ts"; | ||
| 24 | 24 | |
| 25 | 25 | quietLogs(); |
| 26 | 26 | |
| 27 | // Each test boots a database and, for the HTTP ones, a listener. That is tens of | |
| 28 | // milliseconds alone but passed 5 s on a check machine running many suites at once | |
| 29 | // (seen: "timed out after 5000ms"), so the budget is for the boot, not the code. | |
| 30 | setDefaultTimeout(30_000); | |
| 31 | ||
| 27 | 32 | const dirs: string[] = []; |
| 28 | 33 | afterAll(async () => { |
| 29 | 34 | for (const d of dirs) await rm(d, { recursive: true, force: true }); |
| 30 | 35 | }); |
| 31 | 36 | |
| 32 | const past = () => new Date(Date.now() - 86_400_000).toISOString(); | |
| 33 | const future = () => new Date(Date.now() + 3_600_000).toISOString(); | |
| 37 | // One instant for the file: expiries are built from it and the CLI is asked at it, | |
| 38 | // so a slow machine cannot move a "future" expiry into the past between the two. | |
| 39 | const NOW = Date.now(); | |
| 40 | const past = () => new Date(NOW - 86_400_000).toISOString(); | |
| 41 | const future = () => new Date(NOW + 3_600_000).toISOString(); | |
| 34 | 42 | const CODE = /"code": "([A-Za-z0-9_-]{43})"|#([A-Za-z0-9_-]{43})|\b([A-Za-z0-9_-]{43})\b/; |
| 35 | 43 | |
| 36 | 44 | /** A forge with one admin and no credential at all: the shape a lapsed headless admin leaves. */ |
| 37 | async function stranded(name = "opsbot"): Promise<{ dir: string; app: App; servers: Servers; userId: string }> { | |
| 45 | async function stranded(name = "opsbot", listen = true): Promise<{ dir: string; app: App; servers: Servers; userId: string }> { | |
| 38 | 46 | const dir = await mkdtemp(join(tmpdir(), "forge-first-admin-recovery-")); |
| 39 | 47 | dirs.push(dir); |
| 40 | 48 | const app = createApp({ dataDir: dir, port: 0, passkeyOnlySignup: true }); |
| @@ -42,7 +50,9 @@ async function stranded(name = "opsbot"): Promise<{ dir: string; app: App; serve | ||
| 42 | 50 | if (!made.ok) throw new Error("user"); |
| 43 | 51 | // Its token from the first-admin redemption, lapsed. |
| 44 | 52 | createToken(app.db, made.value.id, "first-admin", "admin", { expiresAt: past() }); |
| 45 | return { dir, app, servers: await serve(app), userId: made.value.id }; | |
| 53 | // The CLI reads the database file alone; only the HTTP tests need a listener. | |
| 54 | const servers = listen ? await serve(app) : { url: "", stop: () => {} }; | |
| 55 | return { dir, app, servers: servers as Servers, userId: made.value.id }; | |
| 46 | 56 | } |
| 47 | 57 | |
| 48 | 58 | const post = (base: string, path: string, body: Record<string, unknown>, headers: Record<string, string> = {}) => |
| @@ -259,20 +269,20 @@ describe("refused while any admin has a working credential", () => { | ||
| 259 | 269 | |
| 260 | 270 | for (const [what, give] of Object.entries(credentials)) { |
| 261 | 271 | test(`the stranded admin itself, or another admin, has ${what}`, async () => { |
| 262 | const { dir, app, servers, userId } = await stranded(); | |
| 272 | const { dir, app, servers, userId } = await stranded("opsbot", false); | |
| 263 | 273 | try { |
| 264 | 274 | give(app, userId); |
| 265 | expect(firstAdminCli([], { FORGE_DATA: dir }).exit).toBe(1); | |
| 275 | expect(firstAdminCli([], { FORGE_DATA: dir }, NOW).exit).toBe(1); | |
| 266 | 276 | } finally { |
| 267 | 277 | servers.stop(); |
| 268 | 278 | app.close(); |
| 269 | 279 | } |
| 270 | const other = await stranded(); | |
| 280 | const other = await stranded("opsbot", false); | |
| 271 | 281 | try { |
| 272 | 282 | const second = await createUser(other.app.db, { origin: "seed", trusted: true, username: "second", email: "[email protected]", isAdmin: true }); |
| 273 | 283 | if (!second.ok) throw new Error("second"); |
| 274 | 284 | give(other.app, second.value.id); |
| 275 | const refused = firstAdminCli([], { FORGE_DATA: other.dir }); | |
| 285 | const refused = firstAdminCli([], { FORGE_DATA: other.dir }, NOW); | |
| 276 | 286 | expect(refused.exit).toBe(1); |
| 277 | 287 | expect(refused.out).toHaveLength(0); |
| 278 | 288 | } finally { |
| @@ -283,12 +293,12 @@ describe("refused while any admin has a working credential", () => { | ||
| 283 | 293 | } |
| 284 | 294 | |
| 285 | 295 | test("an expired session and an expired token are not working credentials", async () => { |
| 286 | const { dir, app, servers, userId } = await stranded(); | |
| 296 | const { dir, app, servers, userId } = await stranded("opsbot", false); | |
| 287 | 297 | try { |
| 288 | 298 | app.db |
| 289 | 299 | .query("INSERT INTO session (token_hash, user_id, expires_at, id) VALUES (?1, ?2, ?3, ?4)") |
| 290 | 300 | .run(sha256("old"), userId, past(), "sess-old"); |
| 291 | expect(firstAdminCli([], { FORGE_DATA: dir }).exit).toBe(0); | |
| 301 | expect(firstAdminCli([], { FORGE_DATA: dir }, NOW).exit).toBe(0); | |
| 292 | 302 | } finally { |
| 293 | 303 | servers.stop(); |
| 294 | 304 | app.close(); |
| @@ -298,7 +308,7 @@ describe("refused while any admin has a working credential", () => { | ||
| 298 | 308 | test("a credential that appears after the code is minted kills it", async () => { |
| 299 | 309 | const { dir, app, servers, userId } = await stranded(); |
| 300 | 310 | try { |
| 301 | const code = codeOf(firstAdminCli([], { FORGE_DATA: dir }).out); | |
| 311 | const code = codeOf(firstAdminCli([], { FORGE_DATA: dir }, NOW).out); | |
| 302 | 312 | credentials["a passkey"]!(app, userId); |
| 303 | 313 | expect((await recover(servers.url, { code, scope: "rw" })).status).toBe(403); |
| 304 | 314 | } finally { |
| @@ -315,7 +325,7 @@ describe("which admin is recovered", () => { | ||
| 315 | 325 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "builder", email: "[email protected]", isAdmin: true }); |
| 316 | 326 | const plain = await createUser(app.db, { origin: "seed", trusted: true, username: "plain", email: "[email protected]" }); |
| 317 | 327 | if (!b.ok || !plain.ok) throw new Error("users"); |
| 318 | const ambiguous = firstAdminCli([], { FORGE_DATA: dir }); | |
| 328 | const ambiguous = firstAdminCli([], { FORGE_DATA: dir }, NOW); | |
| 319 | 329 | expect(ambiguous.exit).toBe(1); |
| 320 | 330 | expect(ambiguous.err.join("\n")).toContain("--user"); |
| 321 | 331 | expect(firstAdminCli(["--user", "plain"], { FORGE_DATA: dir }).exit).toBe(1); |
| @@ -334,7 +344,7 @@ describe("which admin is recovered", () => { | ||
| 334 | 344 | const { dir, app, servers, userId } = await stranded(); |
| 335 | 345 | try { |
| 336 | 346 | app.db.query("UPDATE user SET suspended_at = ?1 WHERE id = ?2").run(new Date().toISOString(), userId); |
| 337 | expect(firstAdminCli([], { FORGE_DATA: dir }).exit).toBe(1); | |
| 347 | expect(firstAdminCli([], { FORGE_DATA: dir }, NOW).exit).toBe(1); | |
| 338 | 348 | } finally { |
| 339 | 349 | servers.stop(); |
| 340 | 350 | app.close(); |
new above
offline
Pinning the clock here only helps if the clocks can drift by about an hour, and they cannot. The failure that was never reproduced is not explained by it. Look for a timeout or shared state instead.