test: build comprehensive test suite reaching 80%+ coverage threshold
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>
This commit is contained in:
340
dashcaddy-api/__tests__/csrf-protection.test.js
Normal file
340
dashcaddy-api/__tests__/csrf-protection.test.js
Normal file
@@ -0,0 +1,340 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user