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>
344 lines
13 KiB
TypeScript
344 lines
13 KiB
TypeScript
/**
|
|
* POST /api/auth/local/login + POST/GET /api/auth/local/logout — tests (Plan 19-03, TDD RED → GREEN).
|
|
*
|
|
* Covers:
|
|
* Test 1: valid username+password → 200 { ok:true } + Set-Cookie for local-session
|
|
* Test 2: wrong password → 401 { error: 'Invalid credentials' }
|
|
* Test 3: unknown username → 401 with SAME body as Test 2 (no enumeration / no field discrimination)
|
|
* Test 4: 5 consecutive failures for one username → 6th returns 429
|
|
* Test 5: 10 failures → 423 (lockedOut); a cleared map resets the counter
|
|
* Test 5b (CR-04): a 423 lockout auto-expires after LOCKOUT_TTL_MS (no admin reset needed)
|
|
* Test 6: POST /api/auth/local/logout clears the local-session cookie (expired Set-Cookie)
|
|
* Test 6b: GET /api/auth/local/logout (alias) also clears the local-session cookie
|
|
* Test 7 (no-echo): malformed body (missing password) → 400 { error: 'Invalid request' };
|
|
* body must NOT contain submitted value or Zod 'received' field
|
|
*
|
|
* Architecture:
|
|
* Tests mock DB client and issueLocalSessionCookie/clearLocalSessionCookie.
|
|
* loginAttempts Map is imported directly and cleared between tests.
|
|
* IP is derived from x-forwarded-for header.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock DB client
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type LocalCredRow = { userId: number; passwordHash: string } | undefined;
|
|
let mockCredRow: LocalCredRow;
|
|
|
|
vi.mock('../../src/db/client.js', () => ({
|
|
db: {
|
|
execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]),
|
|
select: vi.fn().mockImplementation(() => ({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi
|
|
.fn()
|
|
.mockImplementation(() => Promise.resolve(mockCredRow ? [mockCredRow] : [])),
|
|
}),
|
|
}),
|
|
})),
|
|
insert: vi.fn().mockReturnValue({
|
|
values: vi.fn().mockReturnValue({
|
|
onDuplicateKeyUpdate: vi.fn().mockResolvedValue(undefined),
|
|
}),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock localSession helpers — track calls and control cookie behavior
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let issueSessionCalled = false;
|
|
let issuedUserId: number | null = null;
|
|
let clearSessionCalled = false;
|
|
|
|
vi.mock('../../src/auth/localSession.js', () => ({
|
|
issueLocalSessionCookie: vi.fn().mockImplementation((_c: unknown, userId: number) => {
|
|
issueSessionCalled = true;
|
|
issuedUserId = userId;
|
|
// Simulate setting a cookie on the context
|
|
return Promise.resolve();
|
|
}),
|
|
clearLocalSessionCookie: vi.fn().mockImplementation((_c: unknown) => {
|
|
clearSessionCalled = true;
|
|
}),
|
|
verifyLocalSessionCookie: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock devAuthBypass and OIDC — standard passthrough for route tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock('../../src/auth/devBypass.js', () => ({
|
|
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
|
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
|
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
|
}));
|
|
|
|
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
|
localAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
|
}));
|
|
|
|
vi.mock('@hono/oidc-auth', () => ({
|
|
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
|
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
|
|
getAuth: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeLoginRequest(body: Record<string, string>, ip = '1.2.3.4'): Request {
|
|
return new Request('http://localhost/api/auth/local/login', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-forwarded-for': ip,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async function getApp() {
|
|
const { app } = await import('../../src/index.js');
|
|
return app;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const originalNodeEnv = process.env.NODE_ENV;
|
|
const originalBypass = process.env.DEV_AUTH_BYPASS;
|
|
const originalSecret = process.env.LOCAL_SESSION_SECRET;
|
|
|
|
beforeEach(async () => {
|
|
// Use dev-bypass mode so no OIDC redirect occurs
|
|
process.env.NODE_ENV = 'test';
|
|
process.env.DEV_AUTH_BYPASS = 'true';
|
|
// Provide a valid LOCAL_SESSION_SECRET for the session helpers
|
|
process.env.LOCAL_SESSION_SECRET = 'test-secret-that-is-at-least-32-chars!!';
|
|
|
|
issueSessionCalled = false;
|
|
issuedUserId = null;
|
|
clearSessionCalled = false;
|
|
mockCredRow = undefined;
|
|
|
|
vi.resetModules();
|
|
|
|
// Clear the rate-limit map between tests
|
|
const { loginAttempts } = await import('../../src/routes/localAuth.js');
|
|
loginAttempts.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.NODE_ENV = originalNodeEnv;
|
|
if (originalBypass === undefined) delete process.env.DEV_AUTH_BYPASS;
|
|
else process.env.DEV_AUTH_BYPASS = originalBypass;
|
|
if (originalSecret === undefined) delete process.env.LOCAL_SESSION_SECRET;
|
|
else process.env.LOCAL_SESSION_SECRET = originalSecret;
|
|
vi.resetModules();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import verifyPassword/hashPassword for test credential setup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function getLocalCredentials() {
|
|
return import('../../src/auth/localCredentials.js');
|
|
}
|
|
|
|
// ===========================================================================
|
|
// POST /api/auth/local/login
|
|
// ===========================================================================
|
|
|
|
describe('POST /api/auth/local/login', () => {
|
|
it('Test 1: valid username+password → 200 { ok:true } and issueLocalSessionCookie called', async () => {
|
|
const { hashPassword } = await getLocalCredentials();
|
|
const hash = await hashPassword('correcthorse');
|
|
mockCredRow = { userId: 5, passwordHash: hash };
|
|
|
|
const app = await getApp();
|
|
const res = await app.fetch(makeLoginRequest({ username: 'alice', password: 'correcthorse' }));
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { ok: boolean };
|
|
expect(body.ok).toBe(true);
|
|
expect(issueSessionCalled).toBe(true);
|
|
expect(issuedUserId).toBe(5);
|
|
});
|
|
|
|
it('Test 2: wrong password → 401 { error: "Invalid credentials" }', async () => {
|
|
const { hashPassword } = await getLocalCredentials();
|
|
const hash = await hashPassword('correcthorse');
|
|
mockCredRow = { userId: 5, passwordHash: hash };
|
|
|
|
const app = await getApp();
|
|
const res = await app.fetch(makeLoginRequest({ username: 'alice', password: 'wrongpassword' }));
|
|
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as { error: string };
|
|
expect(body.error).toBe('Invalid credentials');
|
|
expect(issueSessionCalled).toBe(false);
|
|
});
|
|
|
|
it('Test 3: unknown username → 401 with SAME body as wrong password (no enumeration)', async () => {
|
|
mockCredRow = undefined; // No credential row found
|
|
|
|
const app = await getApp();
|
|
const res = await app.fetch(
|
|
makeLoginRequest({ username: 'unknown-user', password: 'anypassword' }),
|
|
);
|
|
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as { error: string };
|
|
// CRITICAL: same body as wrong-password case (Test 2) — no field discrimination
|
|
expect(body.error).toBe('Invalid credentials');
|
|
expect(issueSessionCalled).toBe(false);
|
|
});
|
|
|
|
it('Test 4: 5 consecutive failures → 6th attempt returns 429', async () => {
|
|
mockCredRow = undefined; // Always unknown — every attempt fails
|
|
|
|
const app = await getApp();
|
|
|
|
// 5 failures to trigger the rate window
|
|
for (let i = 0; i < 5; i++) {
|
|
const res = await app.fetch(
|
|
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'),
|
|
);
|
|
expect(res.status).toBe(401);
|
|
}
|
|
|
|
// 6th attempt from same IP → 429
|
|
const res6 = await app.fetch(
|
|
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'),
|
|
);
|
|
expect(res6.status).toBe(429);
|
|
const body = (await res6.json()) as { error: string };
|
|
expect(body.error).toBe('Too many attempts');
|
|
});
|
|
|
|
it('Test 5: 10 failures → 423 (account locked); cleared map resets counter', async () => {
|
|
mockCredRow = undefined;
|
|
|
|
const app = await getApp();
|
|
|
|
// 10 failures from same IP → lockout
|
|
for (let i = 0; i < 10; i++) {
|
|
await app.fetch(makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'));
|
|
}
|
|
|
|
// 11th attempt → 423 (locked)
|
|
const res11 = await app.fetch(
|
|
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'),
|
|
);
|
|
expect(res11.status).toBe(423);
|
|
const body = (await res11.json()) as { error: string };
|
|
expect(body.error).toBe('Account locked');
|
|
|
|
// Clear the map (simulates admin reset) → counter gone → next attempt is 401 again (not locked)
|
|
// CR-04: the limiter is keyed on the USERNAME ('alice'), not the IP.
|
|
const { loginAttempts } = await import('../../src/routes/localAuth.js');
|
|
loginAttempts.delete('alice');
|
|
|
|
const resAfterReset = await app.fetch(
|
|
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'),
|
|
);
|
|
expect(resAfterReset.status).toBe(401);
|
|
});
|
|
|
|
it('Test 5b (CR-04): a 423 lockout auto-expires after the TTL — no admin reset needed', async () => {
|
|
mockCredRow = undefined;
|
|
|
|
const app = await getApp();
|
|
|
|
// 10 failures → lockout for username 'bob'
|
|
for (let i = 0; i < 10; i++) {
|
|
await app.fetch(makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'));
|
|
}
|
|
|
|
// Confirm locked (423)
|
|
const resLocked = await app.fetch(
|
|
makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'),
|
|
);
|
|
expect(resLocked.status).toBe(423);
|
|
|
|
// Simulate the TTL elapsing by back-dating lockedAt well past LOCKOUT_TTL_MS (15 min).
|
|
const { loginAttempts } = await import('../../src/routes/localAuth.js');
|
|
const entry = loginAttempts.get('bob');
|
|
expect(entry?.lockedOut).toBe(true);
|
|
if (entry) entry.lockedAt = Date.now() - 16 * 60 * 1000;
|
|
|
|
// Next attempt: the lockout has expired → handler drops the entry and processes the
|
|
// login normally, so a wrong password is a fresh 401 (not a 423). CR-04: self-healing.
|
|
const resAfterTtl = await app.fetch(
|
|
makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'),
|
|
);
|
|
expect(resAfterTtl.status).toBe(401);
|
|
});
|
|
|
|
it('Test 7 (no-echo): malformed body (missing password) → 400 { error: "Invalid request" }; body has no echoed value or Zod received field', async () => {
|
|
const app = await getApp();
|
|
// Body with username but missing password (Zod will reject)
|
|
const res = await app.fetch(
|
|
new Request('http://localhost/api/auth/local/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' },
|
|
body: JSON.stringify({ username: 'mysecretusername' }),
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
const bodyText = await res.text();
|
|
const parsed = JSON.parse(bodyText) as { error: string };
|
|
expect(parsed.error).toBe('Invalid request');
|
|
// CRITICAL (no-echo): the response must NOT contain the submitted value or Zod 'received' field
|
|
expect(bodyText).not.toContain('mysecretusername');
|
|
expect(bodyText).not.toContain('received');
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// POST /api/auth/local/logout + GET /api/auth/local/logout
|
|
// ===========================================================================
|
|
|
|
describe('POST /api/auth/local/logout', () => {
|
|
it('Test 6: POST /logout → 200 { ok:true } and clearLocalSessionCookie called', async () => {
|
|
const app = await getApp();
|
|
const res = await app.fetch(
|
|
new Request('http://localhost/api/auth/local/logout', {
|
|
method: 'POST',
|
|
headers: { 'x-forwarded-for': '1.2.3.4' },
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { ok: boolean };
|
|
expect(body.ok).toBe(true);
|
|
expect(clearSessionCalled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/auth/local/logout', () => {
|
|
it('Test 6b: GET /logout (alias) → 200 { ok:true } and clearLocalSessionCookie called', async () => {
|
|
const app = await getApp();
|
|
const res = await app.fetch(
|
|
new Request('http://localhost/api/auth/local/logout', {
|
|
method: 'GET',
|
|
headers: { 'x-forwarded-for': '1.2.3.4' },
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { ok: boolean };
|
|
expect(body.ok).toBe(true);
|
|
expect(clearSessionCalled).toBe(true);
|
|
});
|
|
});
|