Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/e2e-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions tools/simulation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions tools/simulation/docker-compose.sim.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 35 additions & 10 deletions tools/simulation/ui/specs/session-refresh.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<void>): Promise<number> {
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 }) => {
Expand Down Expand Up @@ -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);

await signIn(owner());
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
// 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",
Expand Down
4 changes: 4 additions & 0 deletions tools/simulation/verify-harness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading