From db6629592057436a4b7bcc83c2b2aedcf9e72f36 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 16:49:51 -0400 Subject: [PATCH] test(19-03): add failing tests for POST /api/auth/local/login + logout - RED: 8 tests for login success, wrong-password 401, unknown-username 401 (no enumeration), rate-limit 429, lockout 423, logout cookie clear, no-echo 400 --- apps/api/tests/routes/localAuth.test.ts | 313 ++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 apps/api/tests/routes/localAuth.test.ts diff --git a/apps/api/tests/routes/localAuth.test.ts b/apps/api/tests/routes/localAuth.test.ts new file mode 100644 index 0000000..9e2a33a --- /dev/null +++ b/apps/api/tests/routes/localAuth.test.ts @@ -0,0 +1,313 @@ +/** + * 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 from one IP → 6th returns 429 + * Test 5: 10 failures → 423 (lockedOut); a cleared map resets the counter + * 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(), +})); + +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 = 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 = 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) + const { loginAttempts } = await import('../../src/routes/localAuth.js'); + loginAttempts.delete('10.0.0.2'); + + const resAfterReset = await app.fetch( + makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2') + ); + expect(resAfterReset.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); + }); +});