Phase 19: Local Auth (No-OIDC Mode) #23
@@ -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<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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user