From 982900fde0b3fc63829edcf08b734a4c2f1198de Mon Sep 17 00:00:00 2001 From: mforce Date: Wed, 16 Sep 2026 20:57:29 +0000 Subject: [PATCH 1/4] chore(sim): expose the access-token lifetime as a stack knob, default Production's 15 Jwt__AccessTokenMinutes passes through to the app service with the Production default; verify-harness.sh rejects anything outside 1 to 60, and the README records why CI shortens it while local resets and the k6 baseline keep 15. --- tools/simulation/README.md | 11 +++++++++++ tools/simulation/docker-compose.sim.yml | 7 +++++++ tools/simulation/verify-harness.sh | 4 ++++ 3 files changed, 22 insertions(+) diff --git a/tools/simulation/README.md b/tools/simulation/README.md index 009ff1db..3517846c 100644 --- a/tools/simulation/README.md +++ b/tools/simulation/README.md @@ -247,6 +247,17 @@ Every consumer of this harness's output (k6 results, findings docs, #277's Playwright suite) must carry this list forward rather than presenting sim-stack numbers as production-equivalent. +### The access-token lifetime knob + +`Jwt__AccessTokenMinutes` is passed through to the app service with Production's +default of 15. The CI e2e job boots its stack with `2`, so the real-expiry spec in +`ui/specs/session-refresh.spec.ts` waits under three minutes instead of sixteen and a +full dispatch run halves. The spec measures the lifetime from the login it performs +(`exp - nbf` on the token), so it follows whatever value booted the stack. Local +`reset.sh` and the k6 baseline keep 15: the k6 auth helper refreshes off the token's +own expiry, so it would work at 2, but the recorded findings were taken at 15 and stay +comparable. `verify-harness.sh` rejects anything outside 1 to 60. + ## Why `seed --profile simulation` needs a non-Production environment `SimulationDataSeeder` is registered in DI only when diff --git a/tools/simulation/docker-compose.sim.yml b/tools/simulation/docker-compose.sim.yml index 395d4879..6cfd38ac 100644 --- a/tools/simulation/docker-compose.sim.yml +++ b/tools/simulation/docker-compose.sim.yml @@ -103,6 +103,13 @@ services: Jwt__Audience: ${Jwt__Audience} Jwt__PublicKeyPem: ${Jwt__PublicKeyPem} Jwt__PrivateKeyPem: ${Jwt__PrivateKeyPem} + # Access-token lifetime in minutes. The default is Production's (15); + # the CI e2e job sets 2 in its environment so the real-expiry spec waits + # under three minutes instead of sixteen. Local reset.sh and the k6 + # baseline keep 15 so recorded findings stay comparable; the k6 auth + # helper and the SPA both read the expiry from the token, so neither + # cares which value booted the stack. + Jwt__AccessTokenMinutes: ${Jwt__AccessTokenMinutes:-15} # #283 — no Seed__* vars: the default account/roles/egg grades ship in # the EF migrations (this service's normal migrate-on-startup boot # provisions them), and the Owner is provisioned separately by diff --git a/tools/simulation/verify-harness.sh b/tools/simulation/verify-harness.sh index 47e8fe02..f93a2b98 100755 --- a/tools/simulation/verify-harness.sh +++ b/tools/simulation/verify-harness.sh @@ -376,6 +376,10 @@ else: # "this is a real key rather than a placeholder": armor present, and a body that # is substantial base64. Chasing app parity in a checker is how a checker drifts # from the thing it mirrors. +raw = env.get("Jwt__AccessTokenMinutes") +if raw is not None and not (str(raw).strip().isdigit() and 1 <= int(str(raw).strip()) <= 60): + fail.append(f"Jwt__AccessTokenMinutes is {raw!r}; the knob takes a whole number of minutes " + "from 1 to 60 (15 is Production's default, the CI e2e job uses 2)") for key in ("Jwt__PublicKeyPem", "Jwt__PrivateKeyPem"): raw = env.get(key) if raw is None or not str(raw).strip(): From ed3ad806307771a334497bef36c180fdb117aeb8 Mon Sep 17 00:00:00 2001 From: mforce Date: Wed, 16 Sep 2026 20:57:29 +0000 Subject: [PATCH 2/4] test(e2e): measure the access-token lifetime from the login the slow spec performs The real-expiry spec hardcoded 15 minutes. It now decodes exp - nbf from the token its own login returned and waits that long plus thirty seconds, so it proves the same boundary on whatever lifetime booted the stack. --- .../ui/specs/session-refresh.spec.ts | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/tools/simulation/ui/specs/session-refresh.spec.ts b/tools/simulation/ui/specs/session-refresh.spec.ts index 48273cd4..a6e6c5b7 100644 --- a/tools/simulation/ui/specs/session-refresh.spec.ts +++ b/tools/simulation/ui/specs/session-refresh.spec.ts @@ -35,7 +35,7 @@ // report that only ever ran the injected version overstates what was verified, // and that overstatement is exactly the kind this repo has been bitten by. -import { expect, test } from "../src/fixtures"; +import { expect, test, type Page } from "../src/fixtures"; import { owner } from "../src/cast"; import { findRefreshCookie, @@ -44,8 +44,31 @@ import { } from "../src/env"; import { tEn } from "../src/i18n"; -/** Measured from a real login against the sim stack, not assumed. */ -const ACCESS_TOKEN_LIFETIME_MS = 15 * 60 * 1000; +/** + * The access-token lifetime, measured from the login this spec performs rather + * than assumed: `exp - nbf` on the token the login response carries. Production + * boots with 15 minutes; the CI e2e job boots its stack with 2 + * (`Jwt__AccessTokenMinutes`, tools/simulation/docker-compose.sim.yml), and the + * slow spec below waits whichever it was handed. + */ +async function lifetimeFromLogin(page: Page, signIn: () => Promise): Promise { + const login = page.waitForResponse( + (r) => r.url().includes("/api/v1/auth/login") && r.request().method() === "POST" && r.ok(), + ); + await signIn(); + const body = await (await login).text(); + const payload = body.match(/eyJ[\w-]+\.([\w-]+)\.[\w-]+/)?.[1]; + if (!payload) throw new Error("the login response carried no JWT to measure the lifetime from"); + const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: number; nbf?: number }; + if (typeof claims.exp !== "number" || typeof claims.nbf !== "number") { + throw new Error(`the access token carries no exp/nbf pair to measure: ${JSON.stringify(claims)}`); + } + const lifetimeMs = (claims.exp - claims.nbf) * 1000; + if (lifetimeMs < 60_000 || lifetimeMs > 60 * 60 * 1000) { + throw new Error(`measured an access-token lifetime of ${lifetimeMs}ms; the stack is misconfigured`); + } + return lifetimeMs; +} test.describe("Session", () => { test("the access token is never written to browser storage (#145)", async ({ page, signIn }) => { @@ -139,20 +162,22 @@ test.describe("Session", () => { expect(injected, "the 401 was never injected — this spec proved nothing").toBe(true); }); - test("survives the real 15-minute boundary", async ({ page, signIn, nav }) => { + test("survives the real token-lifetime boundary", async ({ page, signIn, nav }) => { test.skip( !RUN_SLOW_SPECS, - "real-clock spec: set CLUCKWORK_E2E_SLOW=1 to wait out the true 15-minute token lifetime", + "real-clock spec: set CLUCKWORK_E2E_SLOW=1 to wait out the true token lifetime", ); - // 15 minutes of waiting, plus slack for the navigation and refresh after it. - test.setTimeout(ACCESS_TOKEN_LIFETIME_MS + 5 * 60 * 1000); + // The lifetime is not known until the login answers, and the test timeout + // must be set before the wait: budget the Production 15 minutes plus slack, + // then wait only as long as the token this stack issued actually lives. + test.setTimeout(20 * 60 * 1000); - await signIn(owner()); + const lifetimeMs = await lifetimeFromLogin(page, () => signIn(owner())); // Idle past expiry. Nothing should happen during this window — there is no // proactive refresh — so the page simply sits there holding a token that // quietly goes stale, exactly as a barn phone left on a counter would. - await page.waitForTimeout(ACCESS_TOKEN_LIFETIME_MS + 30_000); + await page.waitForTimeout(lifetimeMs + 30_000); const refreshed = page.waitForResponse( (r) => r.url().includes("/api/v1/auth/refresh") && r.request().method() === "POST", From 9906c15f3dc1fd41e93fbf1e3bf8a6a0aa9c43e2 Mon Sep 17 00:00:00 2001 From: mforce Date: Wed, 16 Sep 2026 20:57:29 +0000 Subject: [PATCH 3/4] ci(e2e): boot the smoke stack with two-minute access tokens The slow spec's wait drops from about sixteen minutes to under three; a full dispatch run halves. Nothing else in the job changes. --- .github/workflows/e2e-smoke.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/e2e-smoke.yml b/.github/workflows/e2e-smoke.yml index b4d3f1c0..255b8c15 100644 --- a/.github/workflows/e2e-smoke.yml +++ b/.github/workflows/e2e-smoke.yml @@ -151,6 +151,12 @@ jobs: - name: Reset and seed the simulation stack env: CLUCKWORK_SIM_REUSE_IMAGE: "1" + # Two-minute access tokens for this stack only: the real-expiry spec + # (session-refresh.spec.ts, behind the `slow` input) measures the + # lifetime from its own login and waits it out, so this is what turns + # a sixteen-minute wait into under three. The compose default and + # local reset.sh stay at Production's 15. + Jwt__AccessTokenMinutes: "2" run: bash tools/simulation/reset.sh - name: Install suite dependencies From f42f11e173971266ae5456845bf2a1cd4c8ab5a2 Mon Sep 17 00:00:00 2001 From: mforce Date: Wed, 16 Sep 2026 21:18:04 +0000 Subject: [PATCH 4/4] test(e2e): size the slow spec's timeout from the measured lifetime CodeRabbit on #891: a fixed 20-minute timeout covered lifetimes up to about 19 minutes while verify-harness.sh admits 60. The timeout is now the measured lifetime plus five minutes, set once the login has answered. --- tools/simulation/ui/specs/session-refresh.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/simulation/ui/specs/session-refresh.spec.ts b/tools/simulation/ui/specs/session-refresh.spec.ts index a6e6c5b7..96762aed 100644 --- a/tools/simulation/ui/specs/session-refresh.spec.ts +++ b/tools/simulation/ui/specs/session-refresh.spec.ts @@ -167,12 +167,12 @@ test.describe("Session", () => { !RUN_SLOW_SPECS, "real-clock spec: set CLUCKWORK_E2E_SLOW=1 to wait out the true token lifetime", ); - // The lifetime is not known until the login answers, and the test timeout - // must be set before the wait: budget the Production 15 minutes plus slack, - // then wait only as long as the token this stack issued actually lives. - test.setTimeout(20 * 60 * 1000); - const lifetimeMs = await lifetimeFromLogin(page, () => signIn(owner())); + // Sized from the measurement, so every lifetime the harness self-check + // admits (1 to 60 minutes) fits: the wait below plus slack for the + // navigation and refresh after it. Playwright lets a running test reset + // its own timeout, and the login that just completed took seconds. + test.setTimeout(lifetimeMs + 5 * 60 * 1000); // Idle past expiry. Nothing should happen during this window — there is no // proactive refresh — so the page simply sits there holding a token that