Files
dashcaddy/dashcaddy-api/__tests__/csrf-protection.test.js
Sami ea5acfa9a2 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>
2026-04-06 21:36:46 -07:00

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);
});
});
});