singularity-forge/src/tests/web-auth-token.test.ts
Tom Boucher f4db25b9b8 fix(web): persist auth token in sessionStorage to survive page refreshes (#1877)
Next.js 16 auto-detects web/proxy.ts as middleware, gating all /api/*
routes behind bearer token validation. The token was only cached in
memory (lost on page refresh) and extracted from the URL hash fragment
(cleared after first extraction). This caused 401 errors on page
refresh and broke the sendBeacon shutdown call which cannot set
custom headers.

Changes:
- Persist the auth token to sessionStorage after extracting from the
  URL fragment so it survives page refreshes within the same tab
- Fall back to sessionStorage when the URL hash is absent (refresh,
  bookmark without hash)
- Pass the auth token as a _token query parameter in the sendBeacon
  shutdown call since sendBeacon cannot set Authorization headers
- Add regression tests for token persistence, sessionStorage fallback,
  and sendBeacon authentication

Fixes #1851

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:25:27 -06:00

87 lines
4.2 KiB
TypeScript

/**
* Tests for the web auth token flow (web/lib/auth.ts).
*
* The auth module runs in the browser, so we verify the source code contains
* the expected patterns for token extraction, persistence, and transmission.
*/
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const projectRoot = process.cwd()
// ─── Source contract tests ──────────────────────────────────────────────────
const authSource = readFileSync(join(projectRoot, 'web', 'lib', 'auth.ts'), 'utf-8')
test('auth.ts persists token to sessionStorage on extraction', () => {
assert.match(authSource, /sessionStorage\.setItem/, 'should persist token to sessionStorage after extracting from hash')
})
test('auth.ts falls back to sessionStorage when hash is absent', () => {
assert.match(authSource, /sessionStorage\.getItem/, 'should read from sessionStorage when URL hash is empty')
})
test('auth.ts defines a sessionStorage key constant', () => {
assert.match(authSource, /SESSION_STORAGE_KEY/, 'should use a named constant for the sessionStorage key')
})
test('auth.ts clears the URL fragment after token extraction', () => {
assert.match(authSource, /replaceState/, 'should clear the hash from the address bar')
})
test('auth.ts wraps sessionStorage calls in try/catch for private browsing', () => {
// sessionStorage can throw in private browsing when quota is exceeded
const setItemIndex = authSource.indexOf('sessionStorage.setItem')
const getItemIndex = authSource.indexOf('sessionStorage.getItem')
assert.ok(setItemIndex > -1)
assert.ok(getItemIndex > -1)
// Both sessionStorage accesses should be inside try blocks
const beforeSetItem = authSource.slice(Math.max(0, setItemIndex - 200), setItemIndex)
const beforeGetItem = authSource.slice(Math.max(0, getItemIndex - 200), getItemIndex)
assert.match(beforeSetItem, /try\s*\{/, 'sessionStorage.setItem should be inside a try block')
assert.match(beforeGetItem, /try\s*\{/, 'sessionStorage.getItem should be inside a try block')
})
// ─── sendBeacon auth token tests ────────────────────────────────────────────
const appShellSource = readFileSync(join(projectRoot, 'web', 'components', 'gsd', 'app-shell.tsx'), 'utf-8')
test('app-shell.tsx sendBeacon includes auth token as query parameter', () => {
// sendBeacon cannot set custom headers, so the token must be passed
// as a _token query parameter for the proxy to accept the request.
assert.match(appShellSource, /_token=/, 'sendBeacon URL should include _token query parameter')
})
test('app-shell.tsx sendBeacon does not send bare unauthenticated URL', () => {
// Every sendBeacon to /api/ should include the auth token
const beaconCalls = appShellSource.match(/sendBeacon\([^)]+\)/g) || []
for (const call of beaconCalls) {
if (call.includes('/api/')) {
// The URL should be constructed with the token, not a bare string literal
assert.ok(
!call.includes('"/api/shutdown"') && !call.includes("'/api/shutdown'"),
`sendBeacon call should not use a bare /api/ URL without auth: ${call}`
)
}
}
})
// ─── proxy.ts contract tests ────────────────────────────────────────────────
const proxySource = readFileSync(join(projectRoot, 'web', 'proxy.ts'), 'utf-8')
test('proxy.ts accepts _token query parameter as fallback authentication', () => {
assert.match(proxySource, /_token/, 'proxy should support _token query parameter for SSE/sendBeacon')
})
test('proxy.ts validates bearer token from Authorization header', () => {
assert.match(proxySource, /Bearer/, 'proxy should check Authorization: Bearer header')
})
test('proxy.ts skips auth when GSD_WEB_AUTH_TOKEN is not set', () => {
assert.match(proxySource, /GSD_WEB_AUTH_TOKEN/, 'proxy should read GSD_WEB_AUTH_TOKEN from env')
assert.match(proxySource, /NextResponse\.next\(\)/, 'proxy should pass through when no token is configured')
})