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
This commit is contained in:
@@ -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