Add 22 test files (~700 tests) covering security-critical modules, core infrastructure, API routes, and error handling. Final coverage: 86.73% statements / 80.57% branches / 85.57% functions / 87.42% lines, all above the 80% threshold enforced by jest.config.js. Highlights: - Unit tests for crypto-utils, credential-manager, auth-manager, csrf, input-validator, state-manager, health-checker, backup-manager, update-manager, resource-monitor, app-templates, platform-paths, port-lock-manager, errors, error-handler, pagination, url-resolver - Route tests for health, services, and containers (supertest + mocked deps) - Shared test-utils helper for mock factories and Express app builder - npm scripts for CI: test:ci, test:unit, test:routes, test:security, test:changed, test:debug - jest.config.js: expand coverage targets, add 80% threshold gate - routes/services.js: import ValidationError and NotFoundError from errors - .gitignore: exclude coverage/, *.bak, *.log Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
341 lines
11 KiB
JavaScript
341 lines
11 KiB
JavaScript
const crypto = require('crypto');
|
|
|
|
// Mock crypto-utils to provide a predictable signing key
|
|
const mockFixedKey = Buffer.alloc(32, 'test-key-material');
|
|
jest.mock('../crypto-utils', () => ({
|
|
loadOrCreateKey: jest.fn(() => mockFixedKey),
|
|
}));
|
|
|
|
const {
|
|
CSRF_TOKEN_LENGTH,
|
|
CSRF_COOKIE_NAME,
|
|
CSRF_HEADER_NAME,
|
|
generateToken,
|
|
signToken,
|
|
parseCookie,
|
|
csrfCookieMiddleware,
|
|
csrfValidationMiddleware,
|
|
renewCSRFToken
|
|
} = require('../csrf-protection');
|
|
const { createMockReqRes } = require('./helpers/test-utils');
|
|
|
|
describe('CSRF Protection', () => {
|
|
|
|
describe('generateToken', () => {
|
|
it('returns a base64url-encoded string', () => {
|
|
const token = generateToken();
|
|
expect(typeof token).toBe('string');
|
|
expect(token.length).toBeGreaterThan(0);
|
|
// base64url chars only
|
|
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
});
|
|
|
|
it('returns different values on each call', () => {
|
|
const t1 = generateToken();
|
|
const t2 = generateToken();
|
|
expect(t1).not.toBe(t2);
|
|
});
|
|
|
|
it('has appropriate length for 32 bytes of randomness', () => {
|
|
const token = generateToken();
|
|
// 32 bytes = 43 base64url chars (no padding)
|
|
expect(token.length).toBe(43);
|
|
});
|
|
});
|
|
|
|
describe('signToken', () => {
|
|
it('returns a base64url-encoded HMAC signature', () => {
|
|
const sig = signToken('test-nonce');
|
|
expect(typeof sig).toBe('string');
|
|
expect(sig).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
});
|
|
|
|
it('same nonce produces same signature (deterministic)', () => {
|
|
const sig1 = signToken('my-nonce');
|
|
const sig2 = signToken('my-nonce');
|
|
expect(sig1).toBe(sig2);
|
|
});
|
|
|
|
it('different nonces produce different signatures', () => {
|
|
const sig1 = signToken('nonce-a');
|
|
const sig2 = signToken('nonce-b');
|
|
expect(sig1).not.toBe(sig2);
|
|
});
|
|
});
|
|
|
|
describe('parseCookie', () => {
|
|
it('parses single cookie', () => {
|
|
expect(parseCookie('name=value')).toEqual({ name: 'value' });
|
|
});
|
|
|
|
it('parses multiple cookies', () => {
|
|
const result = parseCookie('a=1; b=2; c=3');
|
|
expect(result).toEqual({ a: '1', b: '2', c: '3' });
|
|
});
|
|
|
|
it('handles cookies with = in value', () => {
|
|
const result = parseCookie('token=abc=def=ghi');
|
|
expect(result.token).toBe('abc=def=ghi');
|
|
});
|
|
|
|
it('returns empty object for null/undefined/empty input', () => {
|
|
expect(parseCookie(null)).toEqual({});
|
|
expect(parseCookie(undefined)).toEqual({});
|
|
expect(parseCookie('')).toEqual({});
|
|
});
|
|
|
|
it('trims outer whitespace of each cookie pair', () => {
|
|
const result = parseCookie(' name=value ');
|
|
expect(result['name']).toBe('value');
|
|
});
|
|
});
|
|
|
|
describe('csrfCookieMiddleware', () => {
|
|
it('generates new nonce and sets cookie when no existing cookie', () => {
|
|
const { req, res, next } = createMockReqRes();
|
|
req.headers.cookie = '';
|
|
|
|
csrfCookieMiddleware(req, res, next);
|
|
|
|
expect(req.csrfNonce).toBeDefined();
|
|
expect(req.csrfToken).toBeDefined();
|
|
expect(res.cookie).toHaveBeenCalledWith(
|
|
CSRF_COOKIE_NAME,
|
|
req.csrfNonce,
|
|
expect.objectContaining({
|
|
httpOnly: false,
|
|
sameSite: 'strict',
|
|
path: '/',
|
|
})
|
|
);
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
|
|
it('reuses existing nonce from cookie (no new Set-Cookie)', () => {
|
|
const { req, res, next } = createMockReqRes();
|
|
const existingNonce = 'existing-nonce-value';
|
|
req.headers.cookie = `${CSRF_COOKIE_NAME}=${existingNonce}`;
|
|
|
|
csrfCookieMiddleware(req, res, next);
|
|
|
|
expect(req.csrfNonce).toBe(existingNonce);
|
|
expect(res.cookie).not.toHaveBeenCalled(); // No new cookie set
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
|
|
it('sets req.csrfToken as HMAC signature of nonce', () => {
|
|
const { req, res, next } = createMockReqRes();
|
|
req.headers.cookie = `${CSRF_COOKIE_NAME}=my-nonce`;
|
|
|
|
csrfCookieMiddleware(req, res, next);
|
|
|
|
const expectedSig = signToken('my-nonce');
|
|
expect(req.csrfToken).toBe(expectedSig);
|
|
});
|
|
});
|
|
|
|
describe('csrfValidationMiddleware', () => {
|
|
it('skips validation for GET requests', () => {
|
|
const { req, res, next } = createMockReqRes({ method: 'GET' });
|
|
csrfValidationMiddleware(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
expect(res.status).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips validation for HEAD requests', () => {
|
|
const { req, res, next } = createMockReqRes({ method: 'HEAD' });
|
|
csrfValidationMiddleware(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips validation for OPTIONS requests', () => {
|
|
const { req, res, next } = createMockReqRes({ method: 'OPTIONS' });
|
|
csrfValidationMiddleware(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips validation in test environment', () => {
|
|
const origEnv = process.env.NODE_ENV;
|
|
process.env.NODE_ENV = 'test';
|
|
const { req, res, next } = createMockReqRes({ method: 'POST', path: '/api/services' });
|
|
|
|
csrfValidationMiddleware(req, res, next);
|
|
|
|
expect(next).toHaveBeenCalled();
|
|
process.env.NODE_ENV = origEnv;
|
|
});
|
|
|
|
it('skips validation for excluded paths', () => {
|
|
const origEnv = process.env.NODE_ENV;
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
const excludedPaths = ['/api/totp/verify', '/api/totp/setup', '/health', '/api/health'];
|
|
for (const excludedPath of excludedPaths) {
|
|
const { req, res, next } = createMockReqRes({ method: 'POST', path: excludedPath });
|
|
csrfValidationMiddleware(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
}
|
|
|
|
process.env.NODE_ENV = origEnv;
|
|
});
|
|
|
|
it('skips validation for auth gate paths', () => {
|
|
const origEnv = process.env.NODE_ENV;
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
const { req, res, next } = createMockReqRes({
|
|
method: 'POST', path: '/api/auth/gate/plex'
|
|
});
|
|
csrfValidationMiddleware(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
|
|
process.env.NODE_ENV = origEnv;
|
|
});
|
|
|
|
it('skips validation when x-api-key header present', () => {
|
|
const origEnv = process.env.NODE_ENV;
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
const { req, res, next } = createMockReqRes({
|
|
method: 'POST', path: '/api/services',
|
|
headers: { 'x-api-key': 'dk_abc_123' }
|
|
});
|
|
csrfValidationMiddleware(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
|
|
process.env.NODE_ENV = origEnv;
|
|
});
|
|
|
|
it('skips validation when Authorization Bearer header present', () => {
|
|
const origEnv = process.env.NODE_ENV;
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
const { req, res, next } = createMockReqRes({
|
|
method: 'POST', path: '/api/services',
|
|
headers: { authorization: 'Bearer some-jwt-token' }
|
|
});
|
|
csrfValidationMiddleware(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
|
|
process.env.NODE_ENV = origEnv;
|
|
});
|
|
|
|
it('returns 403 when CSRF cookie missing', () => {
|
|
const origEnv = process.env.NODE_ENV;
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
const { req, res, next } = createMockReqRes({
|
|
method: 'POST', path: '/api/services',
|
|
headers: { cookie: '' }
|
|
});
|
|
csrfValidationMiddleware(req, res, next);
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ error: expect.stringContaining('DC-100') })
|
|
);
|
|
|
|
process.env.NODE_ENV = origEnv;
|
|
});
|
|
|
|
it('returns 403 when CSRF header missing', () => {
|
|
const origEnv = process.env.NODE_ENV;
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
const nonce = generateToken();
|
|
const { req, res, next } = createMockReqRes({
|
|
method: 'POST', path: '/api/services',
|
|
headers: { cookie: `${CSRF_COOKIE_NAME}=${nonce}` }
|
|
});
|
|
csrfValidationMiddleware(req, res, next);
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ error: expect.stringContaining('DC-100') })
|
|
);
|
|
|
|
process.env.NODE_ENV = origEnv;
|
|
});
|
|
|
|
it('returns 403 when signature is invalid', () => {
|
|
const origEnv = process.env.NODE_ENV;
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
const nonce = generateToken();
|
|
const { req, res, next } = createMockReqRes({
|
|
method: 'POST', path: '/api/services',
|
|
headers: {
|
|
cookie: `${CSRF_COOKIE_NAME}=${nonce}`,
|
|
'x-csrf-token': 'totally-wrong-signature'
|
|
}
|
|
});
|
|
csrfValidationMiddleware(req, res, next);
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ error: expect.stringContaining('DC-101') })
|
|
);
|
|
|
|
process.env.NODE_ENV = origEnv;
|
|
});
|
|
|
|
it('passes when cookie nonce and header signature match', () => {
|
|
const origEnv = process.env.NODE_ENV;
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
const nonce = generateToken();
|
|
const signature = signToken(nonce);
|
|
const { req, res, next } = createMockReqRes({
|
|
method: 'POST', path: '/api/services',
|
|
headers: {
|
|
cookie: `${CSRF_COOKIE_NAME}=${nonce}`,
|
|
'x-csrf-token': signature
|
|
}
|
|
});
|
|
csrfValidationMiddleware(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
expect(res.status).not.toHaveBeenCalled();
|
|
|
|
process.env.NODE_ENV = origEnv;
|
|
});
|
|
|
|
it('normalizes /api/v1/ prefix for exclusion matching', () => {
|
|
const origEnv = process.env.NODE_ENV;
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
const { req, res, next } = createMockReqRes({
|
|
method: 'POST', path: '/api/v1/totp/verify'
|
|
});
|
|
csrfValidationMiddleware(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
|
|
process.env.NODE_ENV = origEnv;
|
|
});
|
|
});
|
|
|
|
describe('renewCSRFToken', () => {
|
|
it('generates new nonce and sets cookie', () => {
|
|
const { res } = createMockReqRes();
|
|
const token = renewCSRFToken(res, true);
|
|
|
|
expect(typeof token).toBe('string');
|
|
expect(res.cookie).toHaveBeenCalledWith(
|
|
CSRF_COOKIE_NAME,
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
httpOnly: false,
|
|
secure: true,
|
|
sameSite: 'strict',
|
|
path: '/',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('returns signed token', () => {
|
|
const { res } = createMockReqRes();
|
|
const token = renewCSRFToken(res, false);
|
|
// Get the nonce that was set in the cookie
|
|
const setCookieNonce = res.cookie.mock.calls[0][1];
|
|
const expectedSig = signToken(setCookieNonce);
|
|
expect(token).toBe(expectedSig);
|
|
});
|
|
});
|
|
});
|