Files
familysync/apps/api/tests/routes/localAuth.test.ts
T
Lucas Berger db66295920 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
2026-06-17 16:49:51 -04:00

314 lines
12 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 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<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 = 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);
});
});