Skip to content

test(e2e): cover OTP expiry without 10-minute wait#122

Merged
aspiers merged 1 commit intomainfrom
test/otp-expiry-e2e
Apr 30, 2026
Merged

test(e2e): cover OTP expiry without 10-minute wait#122
aspiers merged 1 commit intomainfrom
test/otp-expiry-e2e

Conversation

@aspiers
Copy link
Copy Markdown
Contributor

@aspiers aspiers commented Apr 29, 2026

Summary

  • New @email @otp-expiry scenario in passwordless-authentication.feature that drives the full OAuth login through to the OTP form, fast-forwards the OTP past its 10-minute expiry, asserts the helpful OTP expired error, then verifies the resend path completes the flow normally.
  • New auth-service hook POST /_internal/test/expire-otp (gated by EPDS_TEST_HOOKS=1, authenticated via EPDS_INTERNAL_SECRET) that backdates the better-auth verification.expiresAt row so the test runs in seconds rather than minutes.
  • Defence-in-depth: the router throws at startup when EPDS_TEST_HOOKS=1 is set together with NODE_ENV=production, so a misconfigured production deploy fails fast rather than silently exposing the endpoint.

EPDS_TEST_HOOKS=1 is enabled in docker-compose.yml. For Railway, enable it on pr-base only — production should leave it unset.

Test plan

  • pnpm format:check clean
  • pnpm lint clean
  • pnpm typecheck clean
  • pnpm test — 799 unit tests pass (8 new for the test-hooks router: prod block, missing/wrong secret, unknown type, missing email, happy path, no-match, lowercase normalisation)
  • Local e2e: pnpm test:e2e:headless --tags @otp-expiry against the docker-compose stack

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Resend-after-expiry now completes sign-in: auth flow lifetime extended so resending an expired OTP no longer triggers an “Authentication session expired” failure.
  • Tests

    • Added e2e coverage for OTP-expiry and resend recovery.
    • Added integration tests validating secret-gated internal test endpoints.
  • Chores

    • Documented test-only env flags and test-runner secret in templates.
    • Centralized internal-secret verification utility for shared use.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 29, 2026

🦋 Changeset detected

Latest commit: dacf1d2

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 29, 2026

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

Project Deployment Actions Updated (UTC)
epds-demo Ready Ready Preview, Comment Apr 30, 2026 1:48pm

Request Review

@railway-app
Copy link
Copy Markdown

railway-app Bot commented Apr 29, 2026

🚅 Deployed to the ePDS-pr-122 environment in ePDS

Service Status Web Updated (UTC)
@certified-app/auth-service ✅ Success (View Logs) Web Apr 30, 2026 at 1:49 pm
@certified-app/pds-core ✅ Success (View Logs) Web Apr 29, 2026 at 9:59 pm
@certified-app/demo untrusted ✅ Success (View Logs) Web Apr 29, 2026 at 9:31 pm
@certified-app/demo ✅ Success (View Logs) Web Apr 29, 2026 at 9:30 pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 29, 2026

📝 Walkthrough

Walkthrough

Adds test-only internal HTTP endpoints to auth-service gated by an internal secret, centralizes timing-safe internal-secret verification in the shared package, and extends e2e tests and CI to conditionally run OTP-expiry scenarios using those hooks.

Changes

Cohort / File(s) Summary
Environment templates
\.env.example, packages/auth-service/.env.example, e2e/.env.example
Documented EPDS_TEST_HOOKS and E2E_INTERNAL_SECRET variables and guidance for when to enable them.
Docker / CI
docker-compose.yml, .github/workflows/e2e-tests.yml
auth container now exposes EPDS_TEST_HOOKS (default 1); e2e workflow passes E2E_INTERNAL_SECRET from secrets to the runner.
E2E test infra & scenarios
e2e/cucumber.mjs, e2e/support/env.ts, e2e/step-definitions/auth.steps.ts, features/passwordless-authentication.feature
Added gating for @otp-expiry scenarios based on E2E_INTERNAL_SECRET; introduced step plumbing to call test hooks, wait/extract OTPs, simulate expiry, and a new @otp-expiry scenario.
Auth service test hooks
packages/auth-service/src/routes/test-hooks.ts, packages/auth-service/src/index.ts, packages/auth-service/src/__tests__/test-hooks.test.ts
New exported createTestHooksRouter exposing POST /_internal/test/expire-otp and POST /_internal/test/expire-auth-flow, gated by x-internal-secret and EPDS_TEST_HOOKS; mounted conditionally in service; tests validate gating, input, and DB updates.
Auth-flow TTL extraction
packages/auth-service/src/lib/auth-flow.ts, packages/auth-service/src/routes/login-page.ts, packages/auth-service/src/routes/recovery.ts, packages/auth-service/src/__tests__/login-page.test.ts
Introduced AUTH_FLOW_COOKIE and AUTH_FLOW_TTL_MS (60min) and migrated routes/tests to use them instead of 10-minute inline values.
Shared crypto & exports
packages/shared/src/crypto.ts, packages/shared/src/index.ts, packages/shared/src/__tests__/crypto.test.ts, packages/pds-core/src/index.ts
Added exported verifyInternalSecret (timing-safe SHA-256 + timingSafeEqual) with unit tests; replaced local implementations with shared import.

Sequence Diagram

sequenceDiagram
    actor E2ETest as E2E Test Runner
    participant AuthService as Auth Service
    participant DB as SQLite DB
    participant Client as Browser/Client

    E2ETest->>AuthService: POST /_internal/test/expire-otp (x-internal-secret)
    activate AuthService
    AuthService->>AuthService: verifyInternalSecret(header)
    AuthService->>DB: UPDATE verification SET expiresAt = now - 60min WHERE identifier = ...
    DB-->>AuthService: { updated: N }
    AuthService-->>E2ETest: 200 { updated: N }
    deactivate AuthService

    E2ETest->>Client: simulate user (submit aged OTP)
    Client->>AuthService: POST /auth/verify-otp
    AuthService->>DB: SELECT verification row
    DB-->>AuthService: row is expired
    AuthService-->>Client: 400 OTP expired (UI error shown)

    E2ETest->>Client: Click resend OTP
    Client->>AuthService: POST /auth/resend-otp
    AuthService->>DB: INSERT new OTP verification row
    DB-->>AuthService: new OTP created
    AuthService-->>Client: 200 OTP sent

    E2ETest->>Client: retrieve OTP from mailbox, submit
    Client->>AuthService: POST /auth/verify-otp
    AuthService->>DB: validate OTP (valid)
    DB-->>AuthService: valid
    AuthService-->>Client: 200 Auth complete
    Client-->>E2ETest: Access token received
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

Suggested reviewers

  • Kzoeps
  • s-adamantine

Poem

🐰 I nibbled code and found a hook,

backdating OTPs with one quick look.
Secrets snug, tests now play fair,
resends work — I hop with flair! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main objective: adding test coverage for OTP expiry without requiring a 10-minute wait, which is achieved through the test-hooks mechanism introduced in this changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/otp-expiry-e2e

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@coveralls-official
Copy link
Copy Markdown

coveralls-official Bot commented Apr 29, 2026

Coverage Report for CI Build 25169036076

Coverage increased (+0.8%) to 49.371%

Details

  • Coverage increased (+0.8%) from the base build.
  • Patch coverage: 6 uncovered changes across 2 files (48 of 54 lines covered, 88.89%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
packages/auth-service/src/routes/test-hooks.ts 46 42 91.3%
packages/auth-service/src/index.ts 2 0 0.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 2669
Covered Lines: 1328
Line Coverage: 49.76%
Relevant Branches: 1623
Covered Branches: 791
Branch Coverage: 48.74%
Branches in Coverage %: Yes
Coverage Strength: 4.99 hits per line

💛 - Coveralls

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 packages/auth-service/src/index.ts:89-96 — The new POST /_internal/test/expire-otp route is mounted after the global csrfProtection middleware in packages/auth-service/src/index.ts (line 59 vs line 93), so the e2e step's fetch() — which sends only Content-Type and x-internal-secret, no epds_csrf cookie or x-csrf-token — will be rejected with a blanket 403 CSRF validation failed before reaching the handler. The @otp-expiry scenario will fail at runtime on every deployment with EPDS_TEST_HOOKS=1. Fix: mount createTestHooksRouter before csrfProtection (mirroring how /api/auth/* is registered first), or exempt /_internal/test/* from the CSRF check.

    Extended reasoning...

    The bug

    csrfProtection (packages/auth-service/src/middleware/csrf.ts:28-37) rejects all POST requests with a blanket 403 'CSRF validation failed' whenever either the epds_csrf cookie or a matching x-csrf-token header (or csrf body field) is missing. It has no path-based exemption.

    The new test-hooks router is registered after this middleware:

    // packages/auth-service/src/index.ts
    app.use(csrfProtection(config.csrfSecret))           // line 59
    // ... lots of route mounts ...
    if (process.env.EPDS_TEST_HOOKS === '1') {
      app.use(createTestHooksRouter(config.dbLocation))  // line 93
    }

    Better-auth's /api/auth/* endpoints escape this only because app.all('/api/auth/*', toNodeHandler(...)) is registered at line 32, before csrfProtection. The new /_internal/test/* router does not get that treatment.

    The triggering call site

    e2e/step-definitions/auth.steps.ts (the When the OTP for the test email is forced to expire on the server step):

    const res = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-internal-secret': testEnv.internalSecret,
      },
      body: JSON.stringify({ email: this.testEmail, type: 'sign-in' }),
    })

    This is a Node-side fetch() call with no preceding GET to the auth service. There is therefore no epds_csrf cookie and no x-csrf-token header — and crucially, even if Playwright's browser context held the cookie, Node's fetch() does not share that cookie jar.

    Step-by-step proof

    1. @otp-expiry scenario boots, completes OAuth → email submit → OTP email arrives.
    2. Step 'the OTP for the test email is forced to expire on the server' calls fetch(POST authUrl + '/_internal/test/expire-otp') with only Content-Type + x-internal-secret.
    3. Express dispatches the request through middlewares in registration order. cookieParser runs (no epds_csrf cookie). express.json() parses the body (no csrf field).
    4. csrfProtection runs at index.ts:59. Because req.method === 'POST' and req.cookies[CSRF_COOKIE] is undefined, the check at csrf.ts:34 (if (!cookieToken || !submittedToken)) short-circuits with res.status(403).json({ error: 'CSRF validation failed' }) and returnnext() is never called.
    5. The test-hooks router never runs. verifyInternalSecret is never invoked.
    6. The step's if (!res.ok) branch fires and throws: expire-otp hook failed: 403 ... CSRF validation failed.
    7. The scenario fails. Subsequent assertions (OTP expired error → resend → fresh OTP) never execute.

    Why existing code doesn't prevent this

    The unit suite (test-hooks.test.ts) constructs a bare express() and mounts only the test-hooks router — csrfProtection is never registered, so the unit tests pass without exercising the global middleware stack. This is why pnpm test reports green and the bug ships.

    The PR description's last checkbox — Local e2e: pnpm test:e2e:headless --tags @otp-expiry — is unchecked, which is consistent with the integrated path never having been run end-to-end.

    Fixes

    Two clean options:

    1. Mount the test-hooks router before csrfProtection, mirroring better-auth:
      if (process.env.EPDS_TEST_HOOKS === '1') {
        app.use(createTestHooksRouter(config.dbLocation))
      }
      // ...
      app.use(csrfProtection(config.csrfSecret))
    2. Add an explicit path bypass at the top of csrfProtection:
      if (req.path.startsWith('/_internal/test/')) return next()

    Option 1 is consistent with how /api/auth/* already escapes CSRF and keeps csrfProtection simple. Authentication is already enforced by x-internal-secret (timing-safe) and by the EPDS_TEST_HOOKS mount gate, so CSRF protection on these routes adds nothing — the endpoint is not browser-reachable anyway.

Comment thread .env.example Outdated
Comment thread packages/auth-service/src/routes/test-hooks.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/auth-service/src/__tests__/test-hooks.test.ts`:
- Around line 151-162: The cleanup try/catch in the afterEach block that calls
fs.unlinkSync(dbPath) (and the similar block later) silently swallows all
errors; change the empty catch to catch (err) and if err.code === 'ENOENT'
ignore it, otherwise rethrow the error (or at minimum log it via the test logger
at debug level) so real teardown failures aren’t hidden; update both occurrences
(the afterEach cleanup and the later identical block) to follow this pattern and
reference the fs.unlinkSync(dbPath) calls and their surrounding
afterEach/cleanup blocks when making the change.
- Around line 52-68: postHook currently starts an ephemeral server with
app.listen(0) and calls server.unref() but never closes it, and it also swallows
JSON parse errors; fix by storing the server returned by app.listen in a
variable inside postHook and ensuring you call server.close() (awaiting a
Promise that resolves/rejects on close) after the fetch completes (or in a
finally block), modify the JSON parsing from (await res.json().catch(() =>
({}))) to explicitly try { await res.json() } catch (err) { /* log or rethrow
*/; json = {} } so errors are not silently ignored, and update the afterEach
file-deletion catch to handle specific error codes (e.g., ignore ENOENT) or log
other errors instead of swallowing them; reference postHook, postExpire,
postExpireAuthFlow and the afterEach cleanup to locate the changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4ca4e038-cb41-4481-abb5-2053da7b55f1

📥 Commits

Reviewing files that changed from the base of the PR and between b8a5296 and b4b737b.

📒 Files selected for processing (16)
  • .env.example
  • .github/workflows/e2e-tests.yml
  • docker-compose.yml
  • e2e/.env.example
  • e2e/cucumber.mjs
  • e2e/step-definitions/auth.steps.ts
  • e2e/support/env.ts
  • features/passwordless-authentication.feature
  • packages/auth-service/.env.example
  • packages/auth-service/src/__tests__/test-hooks.test.ts
  • packages/auth-service/src/index.ts
  • packages/auth-service/src/routes/test-hooks.ts
  • packages/pds-core/src/index.ts
  • packages/shared/src/__tests__/crypto.test.ts
  • packages/shared/src/crypto.ts
  • packages/shared/src/index.ts

Comment thread packages/auth-service/src/__tests__/test-hooks.test.ts Outdated
Comment thread packages/auth-service/src/__tests__/test-hooks.test.ts
@blacksmith-sh

This comment has been minimized.

A user who takes >10 minutes to enter their OTP and clicks Resend would
land on /auth/complete with "Authentication session expired", because
the auth_flow row, the epds_auth_flow cookie, and the better-auth OTP
verification row all shared the same 10-minute TTL. Resend successfully
issued a new OTP, but by then the auth_flow + cookie had already been
purged so /auth/complete had no flow_id to thread.

Fix: decouple auth_flow lifetime from OTP lifetime. The auth_flow row
and cookie now live for 60 minutes (lifted to lib/auth-flow.ts so
login-page.ts and recovery.ts share a single constant), while OTP
expiry stays at 10 min inside better-auth. A 10-min OTP timeout +
Resend now keeps the same OAuth ticket alive end-to-end.

Test infra:
- e2e scenario @otp-expiry in passwordless-authentication.feature
  reproduces the user-reported flow without a wall-clock wait.
- Test-only auth-service hooks POST /_internal/test/expire-otp and
  POST /_internal/test/expire-auth-flow (gated by EPDS_TEST_HOOKS=1,
  blocked under NODE_ENV=production, authenticated via
  EPDS_INTERNAL_SECRET) backdate the corresponding rows so the
  scenario runs in seconds.
- Lifted verifyInternalSecret into @certified-app/shared, removing
  the duplicate copy in pds-core/src/index.ts.
- Mounted the test-hooks router before csrfProtection because it is
  called by a non-browser test runner that authenticates via
  x-internal-secret rather than CSRF tokens.
- Added .github/workflows/e2e-tests.yml wiring for E2E_INTERNAL_SECRET.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aspiers aspiers force-pushed the test/otp-expiry-e2e branch from 18bb640 to dacf1d2 Compare April 30, 2026 13:47
@railway-app railway-app Bot temporarily deployed to ePDS / ePDS-pr-122 April 30, 2026 13:47 Destroyed
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
e2e/step-definitions/auth.steps.ts (1)

368-387: ⚡ Quick win

Update the OTP-expiry block comment to match current behavior.

This comment says the scenario expires auth_flow and cookie too, but the actual step intentionally expires only the OTP row. Keeping this stale rationale risks future regressions during maintenance.

Proposed comment cleanup
-// Simulates the user taking longer than 10 minutes between requesting
-// the OTP and entering it. To reproduce the real-world failure mode
-// faithfully (auth-service issue: even after Resend, /auth/complete
-// returns "Authentication session expired") we have to age out THREE
-// things in lockstep, all of which expire after 10 minutes in
-// production:
-//
-//   1. The better-auth verification row (the OTP itself) — backdated
-//      via POST /_internal/test/expire-otp.
-//   2. The auth_flow row in the auth-service SQLite — backdated via
-//      POST /_internal/test/expire-auth-flow.
-//   3. The epds_auth_flow cookie in the browser — Playwright doesn't
-//      let us forge an expiry timestamp on an existing cookie, so we
-//      delete it outright. Functionally equivalent for the bug we're
-//      reproducing: the browser presents no auth_flow cookie to
-//      /auth/complete.
+// Simulates the user taking longer than 10 minutes between requesting
+// the OTP and entering it by backdating only the better-auth verification
+// row via POST /_internal/test/expire-otp.
+//
+// We intentionally keep auth_flow row + epds_auth_flow cookie alive
+// (their TTL is longer) so resend can complete the original OAuth flow.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/step-definitions/auth.steps.ts` around lines 368 - 387, The block comment
above the OTP-expiry test section is stale: update it to reflect that the test
only backdates the better-auth verification row via POST
/_internal/test/expire-otp (it does NOT backdate the auth_flow row or delete the
epds_auth_flow cookie); remove or edit the lines claiming the test also expires
the auth_flow SQLite row and the browser cookie, and clarify that only the OTP
row is aged out to reproduce the current behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@e2e/step-definitions/auth.steps.ts`:
- Around line 368-387: The block comment above the OTP-expiry test section is
stale: update it to reflect that the test only backdates the better-auth
verification row via POST /_internal/test/expire-otp (it does NOT backdate the
auth_flow row or delete the epds_auth_flow cookie); remove or edit the lines
claiming the test also expires the auth_flow SQLite row and the browser cookie,
and clarify that only the OTP row is aged out to reproduce the current behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9d2f193b-307b-4680-9cea-9b45ba036be2

📥 Commits

Reviewing files that changed from the base of the PR and between b4b737b and dacf1d2.

📒 Files selected for processing (21)
  • .changeset/auth-flow-ttl-decoupled-from-otp-ttl.md
  • .env.example
  • .github/workflows/e2e-tests.yml
  • docker-compose.yml
  • e2e/.env.example
  • e2e/cucumber.mjs
  • e2e/step-definitions/auth.steps.ts
  • e2e/support/env.ts
  • features/passwordless-authentication.feature
  • packages/auth-service/.env.example
  • packages/auth-service/src/__tests__/login-page.test.ts
  • packages/auth-service/src/__tests__/test-hooks.test.ts
  • packages/auth-service/src/index.ts
  • packages/auth-service/src/lib/auth-flow.ts
  • packages/auth-service/src/routes/login-page.ts
  • packages/auth-service/src/routes/recovery.ts
  • packages/auth-service/src/routes/test-hooks.ts
  • packages/pds-core/src/index.ts
  • packages/shared/src/__tests__/crypto.test.ts
  • packages/shared/src/crypto.ts
  • packages/shared/src/index.ts
✅ Files skipped from review due to trivial changes (9)
  • packages/shared/src/index.ts
  • packages/auth-service/src/tests/login-page.test.ts
  • .changeset/auth-flow-ttl-decoupled-from-otp-ttl.md
  • e2e/.env.example
  • packages/auth-service/src/routes/login-page.ts
  • .env.example
  • packages/pds-core/src/index.ts
  • packages/auth-service/.env.example
  • packages/auth-service/src/tests/test-hooks.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • e2e/support/env.ts
  • e2e/cucumber.mjs
  • .github/workflows/e2e-tests.yml
  • packages/shared/src/tests/crypto.test.ts

@aspiers aspiers merged commit 6e6c3dc into main Apr 30, 2026
15 checks passed
@aspiers aspiers deleted the test/otp-expiry-e2e branch April 30, 2026 14:30
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.

1 participant