Skip to content

test(e2e): pre-authenticated session harness to stay under the nonce rate limit - #125

Merged
fionnachan merged 7 commits into
mainfrom
e2e/session-harness
Sep 3, 2026
Merged

test(e2e): pre-authenticated session harness to stay under the nonce rate limit#125
fionnachan merged 7 commits into
mainfrom
e2e/session-harness

Conversation

@douglance

Copy link
Copy Markdown
Contributor

First of five in the SIWE drafts stack, reopened clean off main. Supersedes #104.

Not tied to any one feature: every later spec in this stack signs in through this harness, and the act-as spec will too when that stack is reopened.

The problem this fixes is measured, not theoretical

POST /api/auth/nonce is rate-limited to 10/min per IP, and every Playwright test shares one IP. Signing in per test does not scale — a six-test suite already failed on back-to-back runs while every test passed in isolation:

 1: 201   5: 201
 2: 201   6: 201
 3: 201   7: 201
 4: 201   8: 429  <- and sustained 429 after

So the ~30-test target was unreachable before this.

Fix, in two parts

The first part alone was not enough, which is worth knowing:

  1. A setup project signs each dev wallet in once through the real UI (still exercising nonce → verify → cookie) and saves its session cookie. Nonce spend becomes a function of wallet count, not test count — adding tests is now free.
  2. Reuse a still-valid saved session. With only (1), a run still cost 5 setup + 1 from profile.spec = 6 nonces, so two runs inside a minute still tripped the limit. Sessions last 7 days, so setup now probes /api/me and re-authenticates only on 401. Repeat runs cost zero nonces.

Result — three consecutive runs, no pause:

=== run 1 ===  11 passed (11.5s)
=== run 2 ===  11 passed (10.9s)
=== run 3 ===  11 passed (11.4s)

A useful side effect: the 401 probe makes the harness self-healing. When reset:siwe truncated sessions out from under it, setup noticed and re-authenticated on its own.

Also here

A dev-wallet allocation per spec. The SIWE tables live in one shared app schema that is not namespaced by PONDER_SCHEMA, so two specs sharing a key collide on the same owned_profile/draft rows. Key #9 is called out specifically: it has delegated all its ARB away, making it the only key that can exercise the avatar gate's 403 not_delegate branch.

…rate limit

POST /api/auth/nonce is rate-limited to 10 per minute per IP, and every
Playwright test shares one IP. Signing in per test does not scale: a
six-test suite already failed on back-to-back runs (measured 7x 201 then
sustained 429) while each test passed in isolation, so the ~30-test target
was unreachable.

Add a 'setup' project that signs each dev wallet in once through the real UI
and saves its session cookie; specs replay that state via signedInPage().
Nonce spend is now bounded by the number of wallets (5) rather than the
number of tests, so adding tests costs nothing.

Also allocates a dev wallet per spec. The SIWE tables live in a single
shared 'app' schema that is NOT namespaced by PONDER_SCHEMA, so two specs
sharing a key would collide on the same owned_profile/draft rows. Key #9 is
called out specifically: it has delegated all its ARB away, so it is the
only key that can exercise the avatar gate's 403 not_delegate branch.
@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
tally-zero Ready Ready Preview Sep 3, 2026 4:20pm UTC

Request Review

fionnachan and others added 5 commits September 3, 2026 16:53
Context:
The session harness signed in by navigating to /profile, but that route
does not exist. It was deleted in 16499c1 (feat: delegate profile
registration page), which is an ancestor of this branch's own merge
base, so the page was already gone when the harness was written. The
only page that mounts the SIWE gate, and so renders the siwe-sign-in
control, is /delegates/register.

Nothing caught it because no spec calls the harness yet: profile.spec
signs itself in and already navigates to /delegates/register.

Why:
Every setup test would otherwise land on the not-found page and hang on
the sign-in click until the 60s test timeout. The chromium project
declares dependencies: ["setup"], so that failure takes the whole suite
down with it. The constant is renamed rather than just repointed so it
stops naming a page that no longer exists, which is what let the stale
value survive the route rename in the first place.

What:
- Rename PROFILE_PATH to REGISTER_PATH and point it at
  /delegates/register. Both use sites (signIn, and signedInPage's
  default path) follow.
- Replace the "controls live on the profile page" comment with a
  pointer to the component that actually mounts the gate.

Verified: /profile has no page.tsx, no entry in the generated route
manifest, no rewrite or redirect in next.config.mjs, and no middleware.
app/not-found.tsx renders no sign-in control either.

References:
- PR #125 (test(e2e): pre-authenticated session harness)
- 16499c1 feat: delegate profile registration page (#101)
- code-review-e2e-session-harness.md, finding F1

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Context:
The setup project skips signing in when a saved session is still valid,
which is what makes repeat runs cost no nonces. It probed that by
opening a context from the saved storageState file and calling
/api/me, wrapped in a try/catch documented as making the harness
self-healing.

The catch never saw the most likely failure. browser.newContext() was
called above the try, and it is newContext(), not the request, that
throws when the file exists but does not parse. Confirmed against
Playwright 1.58.2: it throws on a truncated file, on a structurally
wrong one ("storageState.cookies: expected array, got string"), and on
non-JSON. existsSync() proves the file exists, not that it parses, and
a run interrupted during storageState({ path }) leaves exactly such a
partial file.

Why:
The result was a setup failure rather than a re-authentication, which
blocks the whole suite (chromium depends on setup) and can only be
cleared by deleting e2e/.auth by hand, guided by a Playwright internal
error message. Re-signing in costs one nonce; failing the run costs the
run. The comment explaining the placement is deliberate: the position
of that call is load-bearing and looks arbitrary, so it is the kind of
thing a later edit would quietly undo.

What:
- Declare context as BrowserContext | undefined, move
  browser.newContext() inside the try, and close conditionally with
  context?.close() in the finally.
- Record why the call has to sit inside the try.

Verified: a probe replicating the patched shape returns false, without
throwing, for truncated, wrong-typed, and non-JSON saved state, plus a
missing file. Every unusable state now means "re-authenticate".

References:
- PR #125 (test(e2e): pre-authenticated session harness)
- code-review-e2e-session-harness.md, finding F3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Context:
The setup project was declared as name plus testMatch only, with no
`use` block, while the chromium project that consumes its output
carries devices["Desktop Chrome"]. So sign-in ran in a default
Chromium context and the saved state was replayed into Desktop Chrome
contexts.

Why:
Nothing is broken today, because what is saved is a cookie and cookies
do not vary by user agent or viewport. It is worth closing anyway: the
moment sign-in depends on either, for instance a control that only
renders at desktop widths, setup and the specs would diverge and
present as a confusing setup/spec mismatch rather than as a config
difference. Applied per project rather than hoisted into the top-level
`use` because that is the smaller change and matches how chromium
already declares it.

What:
- Give the setup project use: { ...devices["Desktop Chrome"] }.

References:
- PR #125 (test(e2e): pre-authenticated session harness)
- code-review-e2e-session-harness.md, finding F5

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Context:
The harness landed with no callers. signedInPage(), AUTH_WALLETS and
DEV_WALLETS were referenced only by the harness's own files, and
profile.spec still injected its key inline and signed itself in. So the
advertised win ("adding tests is now free") was not in effect, and the
cold-run nonce cost had gone up rather than down: 1 readiness probe
plus 5 setup plus 1 from the spec. An unexercised fixture is also an
unverified one, which is how the /profile route bug fixed earlier in
this branch survived in the first place.

Why:
Migrating the spec wholesale does not work. It ends by clicking
siwe-sign-out, and a session that is signed out is spent, so replaying
the shared saved session and then destroying it would send the profile
wallet back through the handshake on every run and defeat session reuse
for it permanently. Splitting the sign-out assertion onto a wallet that
setup never pre-authenticates keeps both properties: reuse holds for
all five shared wallets, and the full nonce to verify to cookie to
logout lifecycle stays covered. Dropping the sign-out assertion instead
was considered and rejected: it is the only coverage of that path, and
the specs that would replace it are four PRs away.

signedInPage becomes a fixture rather than staying a plain helper
because it created a BrowserContext and returned only the Page, leaking
one context per call for the worker's lifetime. A fixture closes them
in teardown. test.use({ storageState }) would be the more usual
Playwright shape but fixes one wallet per file, and specs in this stack
need several wallets in a single test.

What:
- Add e2e/fixtures/test.ts: the spec-facing `test`, carrying a
  signedInPage(name, path?) fixture that tracks every context it opens
  and closes them all in teardown.
- Remove the standalone signedInPage from fixtures/session.ts, export
  REGISTER_PATH for the fixture's default, and point signIn's docstring
  at the fixture while naming the one legitimate exception: a spec
  testing the handshake or sign-out itself.
- Split profile.spec.ts. Profile management now takes
  signedInPage("profile") and costs no nonce; "signing out clears the
  session" keeps the lifecycle on its own wallet.
- Drop the duplicated wallet key literal from profile.spec.ts in favour
  of DEV_WALLETS.profile, which also removes a comment that
  contradicted wallets.ts about whether the key was shared.
- Add DEV_WALLETS.signOut (anvil account #5), deliberately absent from
  AUTH_WALLETS, and record why on AUTH_WALLETS so it does not get added
  later and silently undo reuse.
- Refresh the nonce arithmetic in auth.setup.ts, which still described
  a six-wallet suite. Cold run is now 7, warm run 2.

The key was derived rather than recalled: all five existing
key/address pairs were confirmed with viem, which also identified #5 as
the next unused account after #1 to #4 and #9. It needs no ARB and no
delegation, since SIWE is an off-chain signature and this wallet never
reaches a voting-power gate.

Not verified end to end: the local indexer was unreachable, so the
suite was not run and these two specs have not executed against the
real app. tsc is clean and test discovery lists the expected 7 tests,
with signOut correctly getting no setup test. The one open risk is
whether the indexer's SIWE verify accepts an address with no on-chain
governance history; SiweGate gates on connection plus session only, and
noVotingPower is already expected to sign in, but the local indexer
checkout carries no SIWE code to confirm it against.

References:
- PR #125 (test(e2e): pre-authenticated session harness)
- code-review-e2e-session-harness.md, findings F2 and F4
- fix-plan-e2e-session-harness.md, Option A

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Context:
global-setup waits for the backend stack by polling both /api/health
and POST /api/auth/nonce once a second for up to 30s, treating a 201
from the nonce endpoint as proof that the real SIWE mount (ponder start
plus a writable Postgres pool) is serving, not just that the process is
up. That guarantee is worth keeping.

The cost is not. The nonce endpoint is the rate-limited one, 10 per
minute per IP, and it is the budget this whole branch exists to manage.
Polling it meant a healthy stack spent a nonce before the suite began
and a slow boot could spend up to 30, exhausting the limit and failing
the run it was supposed to be clearing the way for, plus the run after
it.

Why:
Dropping the nonce check would give up the readiness guarantee, and
polling health alone can pass while the SIWE mount is still coming up.
Splitting the two keeps both: health is free, so poll it, and the nonce
probe only has to answer once health is up. A small bounded number of
attempts covers a mount that lags health slightly, without the
unbounded spend.

What:
- Poll /api/health at 1s intervals until it returns 200.
- Then attempt the nonce probe at most 3 times, 2s apart. Normal cost
  is 1 nonce, worst case 3, down from up to 30.
- Split the error message so "health never came up" and "health is up
  but SIWE is not serving" are distinguishable, and mention the rate
  limit as a possible cause of the latter.

Measured against a stub backend: exactly 1 nonce request on a healthy
stack, capped at 3 when the nonce endpoint returns 429, and the 201
requirement to pass is unchanged.

This file was not part of the harness change and is strictly speaking
out of its scope. It is included because the branch exists to reason
about the nonce budget and this was the largest uncounted draw on it.

References:
- PR #125 (test(e2e): pre-authenticated session harness)
- code-review-e2e-session-harness.md, Notes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test(e2e): review follow-ups for the session harness
@fionnachan
fionnachan merged commit 77fa8a5 into main Sep 3, 2026
4 of 5 checks passed
@fionnachan
fionnachan deleted the e2e/session-harness branch September 3, 2026 16:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants