/** * 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) => next(), // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); vi.mock('../../src/auth/localAuthMiddleware.js', () => ({ localAuthMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); vi.mock('@hono/oidc-auth', () => ({ oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise) => next(), processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }), getAuth: vi.fn().mockResolvedValue(null), })); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeLoginRequest(body: Record, 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); }); });