Fix identified issues with server consolidation
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
// Phase 9 §9.4 — vitest scaffold + auth-boundary tests.
|
||||
//
|
||||
// Covers shared/auth/middleware.js. Mocks the Postgres pool with a thin
|
||||
// in-memory fake — no real DB required.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { authenticate, requirePermission } from './middleware.js';
|
||||
|
||||
const SECRET = 'test-secret-please-do-not-use-in-prod';
|
||||
|
||||
function makeFakePool(users, permissions = {}) {
|
||||
const calls = { count: 0 };
|
||||
return {
|
||||
calls,
|
||||
query: vi.fn(async (sql, params) => {
|
||||
calls.count += 1;
|
||||
if (sql.includes('FROM users WHERE id')) {
|
||||
const user = users[params[0]];
|
||||
return { rows: user ? [user] : [] };
|
||||
}
|
||||
if (sql.includes('FROM permissions')) {
|
||||
return { rows: (permissions[params[0]] || []).map((code) => ({ code })) };
|
||||
}
|
||||
return { rows: [] };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeReq(authHeader) {
|
||||
return { headers: authHeader ? { authorization: authHeader } : {} };
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const res = {};
|
||||
res.status = vi.fn(() => res);
|
||||
res.json = vi.fn(() => res);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('authenticate middleware', () => {
|
||||
let activeUser;
|
||||
let inactiveUser;
|
||||
let validToken;
|
||||
|
||||
beforeEach(() => {
|
||||
activeUser = { id: 1, username: 'alice', email: 'a@x', is_admin: false, is_active: true };
|
||||
inactiveUser = { id: 2, username: 'bob', email: 'b@x', is_admin: false, is_active: false };
|
||||
validToken = jwt.sign({ userId: 1 }, SECRET, { expiresIn: '1h' });
|
||||
});
|
||||
|
||||
it('returns 401 when no Authorization header is present', async () => {
|
||||
const pool = makeFakePool({ 1: activeUser });
|
||||
const mw = authenticate({ pool, secret: SECRET });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
await mw(makeReq(), res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when Authorization is not Bearer', async () => {
|
||||
const pool = makeFakePool({ 1: activeUser });
|
||||
const mw = authenticate({ pool, secret: SECRET });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
await mw(makeReq('Basic abc'), res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when token is malformed', async () => {
|
||||
const pool = makeFakePool({ 1: activeUser });
|
||||
const mw = authenticate({ pool, secret: SECRET });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
await mw(makeReq('Bearer not-a-jwt'), res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 403 when the user is inactive', async () => {
|
||||
const inactiveToken = jwt.sign({ userId: 2 }, SECRET, { expiresIn: '1h' });
|
||||
const pool = makeFakePool({ 2: inactiveUser });
|
||||
const mw = authenticate({ pool, secret: SECRET });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
await mw(makeReq(`Bearer ${inactiveToken}`), res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls next() and populates req.user for a valid token + active user', async () => {
|
||||
const pool = makeFakePool({ 1: activeUser }, { 1: ['products:read'] });
|
||||
const mw = authenticate({ pool, secret: SECRET });
|
||||
const req = makeReq(`Bearer ${validToken}`);
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
await mw(req, res, next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(req.user).toBeDefined();
|
||||
expect(req.user.id).toBe(1);
|
||||
expect(req.user.permissions).toEqual(['products:read']);
|
||||
});
|
||||
|
||||
it('caches the user lookup — same token within TTL → one DB hit', async () => {
|
||||
const pool = makeFakePool({ 1: activeUser }, { 1: [] });
|
||||
const mw = authenticate({ pool, secret: SECRET });
|
||||
const next = vi.fn();
|
||||
await mw(makeReq(`Bearer ${validToken}`), makeRes(), next);
|
||||
await mw(makeReq(`Bearer ${validToken}`), makeRes(), next);
|
||||
// Two queries on first hit (user + permissions), zero on the second
|
||||
expect(pool.calls.count).toBe(2);
|
||||
expect(next).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('refetches after TTL expiry', async () => {
|
||||
vi.useFakeTimers();
|
||||
const pool = makeFakePool({ 1: activeUser }, { 1: [] });
|
||||
const mw = authenticate({ pool, secret: SECRET });
|
||||
const next = vi.fn();
|
||||
await mw(makeReq(`Bearer ${validToken}`), makeRes(), next);
|
||||
expect(pool.calls.count).toBe(2);
|
||||
vi.advanceTimersByTime(61_000);
|
||||
await mw(makeReq(`Bearer ${validToken}`), makeRes(), next);
|
||||
expect(pool.calls.count).toBe(4);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('requirePermission middleware', () => {
|
||||
it('returns 401 when req.user is missing', () => {
|
||||
const mw = requirePermission('products:write');
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
mw({}, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls next() for admin users regardless of code', () => {
|
||||
const mw = requirePermission('products:write');
|
||||
const next = vi.fn();
|
||||
mw({ user: { is_admin: true, permissions: [] } }, makeRes(), next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls next() when user has the required permission', () => {
|
||||
const mw = requirePermission('products:write');
|
||||
const next = vi.fn();
|
||||
mw({ user: { is_admin: false, permissions: ['products:write'] } }, makeRes(), next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns 403 when user lacks the required permission', () => {
|
||||
const mw = requirePermission('products:write');
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
mw({ user: { is_admin: false, permissions: ['products:read'] } }, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// Phase 9 §9.4 — vitest scaffold + auth-boundary tests.
|
||||
//
|
||||
// Covers the security-critical surface in shared/auth/verify.js. Five cases
|
||||
// per the original Phase 2 testing-scaffold spec.
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { extractBearerToken, verifyToken, TokenError } from './verify.js';
|
||||
|
||||
const SECRET = 'test-secret-please-do-not-use-in-prod';
|
||||
const WRONG_SECRET = 'a-different-secret';
|
||||
|
||||
let validToken;
|
||||
let expiredToken;
|
||||
let wrongSigToken;
|
||||
|
||||
beforeAll(() => {
|
||||
validToken = jwt.sign({ userId: 42, username: 'alice' }, SECRET, { expiresIn: '1h' });
|
||||
expiredToken = jwt.sign({ userId: 42, username: 'alice' }, SECRET, { expiresIn: '-1s' });
|
||||
wrongSigToken = jwt.sign({ userId: 42, username: 'alice' }, WRONG_SECRET, { expiresIn: '1h' });
|
||||
});
|
||||
|
||||
describe('extractBearerToken', () => {
|
||||
it('returns token from a well-formed Bearer header', () => {
|
||||
expect(extractBearerToken('Bearer abc.def.ghi')).toBe('abc.def.ghi');
|
||||
});
|
||||
|
||||
it('throws TokenError(missing) when no header is provided', () => {
|
||||
expect(() => extractBearerToken(undefined)).toThrow(TokenError);
|
||||
try { extractBearerToken(undefined); } catch (err) { expect(err.code).toBe('missing'); }
|
||||
});
|
||||
|
||||
it('throws TokenError(malformed) when header is not Bearer-prefixed', () => {
|
||||
expect(() => extractBearerToken('Basic abc')).toThrow(TokenError);
|
||||
try { extractBearerToken('Basic abc'); } catch (err) { expect(err.code).toBe('malformed'); }
|
||||
});
|
||||
|
||||
it('throws TokenError(malformed) when Bearer header has empty token', () => {
|
||||
expect(() => extractBearerToken('Bearer ')).toThrow(TokenError);
|
||||
try { extractBearerToken('Bearer '); } catch (err) { expect(err.code).toBe('malformed'); }
|
||||
});
|
||||
|
||||
it('throws TokenError(missing) when header is not a string', () => {
|
||||
expect(() => extractBearerToken(null)).toThrow(TokenError);
|
||||
expect(() => extractBearerToken(42)).toThrow(TokenError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyToken', () => {
|
||||
it('returns decoded payload for a valid token', () => {
|
||||
const decoded = verifyToken(validToken, SECRET);
|
||||
expect(decoded.userId).toBe(42);
|
||||
expect(decoded.username).toBe('alice');
|
||||
});
|
||||
|
||||
it('throws TokenError(expired) for an expired token', () => {
|
||||
expect(() => verifyToken(expiredToken, SECRET)).toThrow(TokenError);
|
||||
try { verifyToken(expiredToken, SECRET); } catch (err) { expect(err.code).toBe('expired'); }
|
||||
});
|
||||
|
||||
it('throws TokenError(invalid) for a wrong-signature token', () => {
|
||||
expect(() => verifyToken(wrongSigToken, SECRET)).toThrow(TokenError);
|
||||
try { verifyToken(wrongSigToken, SECRET); } catch (err) { expect(err.code).toBe('invalid'); }
|
||||
});
|
||||
|
||||
it('throws TokenError(invalid) for malformed JWT', () => {
|
||||
expect(() => verifyToken('not-a-jwt', SECRET)).toThrow(TokenError);
|
||||
try { verifyToken('not-a-jwt', SECRET); } catch (err) { expect(err.code).toBe('invalid'); }
|
||||
});
|
||||
|
||||
it('throws TokenError(misconfigured) when secret is missing', () => {
|
||||
expect(() => verifyToken(validToken, undefined)).toThrow(TokenError);
|
||||
try { verifyToken(validToken, undefined); } catch (err) { expect(err.code).toBe('misconfigured'); }
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user