CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
Lint (eslint --max-warnings 0): - index.ts: disable no-unsafe-argument on the type-only Context mismatch when delegating to the OIDC handler inside the local-session skip wrapper - localAuth.ts: handleLogout is sync (no await) — drop async (require-await) - devBypass.ts: disable detect-possible-timing-attacks on the public well-known dev-placeholder string compare (not a secret comparison) - remove dead code / unused bindings flagged by no-unused-vars: makeTestApp (localSession.test), makeUnauthContext + BrowserContext import (login.spec), unused memberId (admin.test), unused txSelectCount counter (me.test) - localAuthMiddleware.test / me.test: fix unused + reflow-detached eslint-disable directives Format: prettier --write across the 20 Phase-19 files that were never formatted. Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key regex hit on "credential atomically, 409-equivalent"). Neither is a real secret. Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks), PWA 266/266, API 452/452. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
141 lines
5.0 KiB
TypeScript
141 lines
5.0 KiB
TypeScript
/**
|
|
* localSession.ts + bootGuards.ts — unit tests for JWT session cookie helpers
|
|
* and assertLocalSessionSecretSet boot guard.
|
|
*
|
|
* Uses Hono test app for cookie round-trips. All tests run without MariaDB.
|
|
*
|
|
* Test suite (TDD RED → GREEN — Plan 19-01 Task 2):
|
|
* Test 1: issue then verify round-trips userId
|
|
* Test 2: verifyLocalSessionCookie returns null when no local-session cookie present (no throw)
|
|
* Test 3: verifyLocalSessionCookie returns null for a tampered/garbage token (no throw)
|
|
* Test 4: assertLocalSessionSecretSet does NOT exit when DEV_AUTH_BYPASS='true' even if secret unset
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { Hono } from 'hono';
|
|
|
|
// ── Test constants ─────────────────────────────────────────────────────────────
|
|
const TEST_SECRET = 'test-secret-that-is-at-least-32-characters-long-for-jwt';
|
|
const TEST_USER_ID = 42;
|
|
|
|
describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => {
|
|
let originalEnv: NodeJS.ProcessEnv;
|
|
|
|
beforeEach(() => {
|
|
originalEnv = { ...process.env };
|
|
process.env.LOCAL_SESSION_SECRET = TEST_SECRET;
|
|
vi.resetModules(); // ensure fresh imports pick up env changes
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
vi.resetModules();
|
|
});
|
|
|
|
it('Test 1: issue then verify round-trips userId', async () => {
|
|
const { issueLocalSessionCookie, verifyLocalSessionCookie } =
|
|
await import('../../src/auth/localSession.js');
|
|
const app = new Hono();
|
|
|
|
app.post('/issue', async (c) => {
|
|
await issueLocalSessionCookie(c, TEST_USER_ID);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.get('/verify', async (c) => {
|
|
const userId = await verifyLocalSessionCookie(c);
|
|
return c.json({ userId });
|
|
});
|
|
|
|
// Issue cookie
|
|
const issueRes = await app.request('/issue', { method: 'POST' });
|
|
expect(issueRes.status).toBe(200);
|
|
|
|
const setCookieHeader = issueRes.headers.get('set-cookie');
|
|
expect(setCookieHeader).not.toBeNull();
|
|
expect(setCookieHeader).toContain('local-session=');
|
|
|
|
// Extract cookie value and forward it for verify
|
|
const cookieHeader = setCookieHeader?.split(';')[0]; // just name=value
|
|
const verifyRes = await app.request('/verify', {
|
|
headers: { cookie: cookieHeader ?? '' },
|
|
});
|
|
expect(verifyRes.status).toBe(200);
|
|
const body = (await verifyRes.json()) as { userId: number | null };
|
|
expect(body.userId).toBe(TEST_USER_ID);
|
|
});
|
|
|
|
it('Test 2: verifyLocalSessionCookie returns null when no local-session cookie present (no throw)', async () => {
|
|
const { verifyLocalSessionCookie } = await import('../../src/auth/localSession.js');
|
|
const app = new Hono();
|
|
|
|
app.get('/verify', async (c) => {
|
|
const userId = await verifyLocalSessionCookie(c);
|
|
return c.json({ userId });
|
|
});
|
|
|
|
const res = await app.request('/verify');
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { userId: number | null };
|
|
expect(body.userId).toBeNull();
|
|
});
|
|
|
|
it('Test 3: verifyLocalSessionCookie returns null for a tampered/garbage token (no throw)', async () => {
|
|
const { verifyLocalSessionCookie } = await import('../../src/auth/localSession.js');
|
|
const app = new Hono();
|
|
|
|
app.get('/verify', async (c) => {
|
|
const userId = await verifyLocalSessionCookie(c);
|
|
return c.json({ userId });
|
|
});
|
|
|
|
// Send a garbage token — should return null without throwing
|
|
const res = await app.request('/verify', {
|
|
headers: { cookie: 'local-session=garbage.token.value' },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { userId: number | null };
|
|
expect(body.userId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('assertLocalSessionSecretSet (bootGuards)', () => {
|
|
let originalEnv: NodeJS.ProcessEnv;
|
|
let exitSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
originalEnv = { ...process.env };
|
|
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
throw new Error('process.exit called');
|
|
}) as never);
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
exitSpy.mockRestore();
|
|
vi.resetModules();
|
|
});
|
|
|
|
it('Test 4: assertLocalSessionSecretSet does NOT exit when DEV_AUTH_BYPASS=true even if secret unset', async () => {
|
|
process.env.DEV_AUTH_BYPASS = 'true';
|
|
delete process.env.LOCAL_SESSION_SECRET;
|
|
|
|
const { assertLocalSessionSecretSet } = await import('../../src/lib/bootGuards.js');
|
|
|
|
// Must not throw / must not call process.exit
|
|
expect(() => assertLocalSessionSecretSet()).not.toThrow();
|
|
expect(exitSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('assertLocalSessionSecretSet is exported from bootGuards', async () => {
|
|
process.env.LOCAL_SESSION_SECRET = TEST_SECRET;
|
|
delete process.env.DEV_AUTH_BYPASS;
|
|
|
|
const bootGuards = await import('../../src/lib/bootGuards.js');
|
|
|
|
// Must export the function
|
|
expect(typeof bootGuards.assertLocalSessionSecretSet).toBe('function');
|
|
});
|
|
});
|