Files
sofarr/tests/unit/sanitizeError.test.js
Gronod 5fd55b4e1a
Some checks failed
Build and Push Docker Image / build (push) Successful in 49s
CI / Security audit (push) Successful in 1m23s
CI / Tests & coverage (push) Failing after 2m13s
test: add comprehensive test suite (115 tests, Vitest + supertest + nock)
Framework:
- Vitest v4 as test runner (fast ESM/CJS support, V8 coverage built-in)
- supertest for integration tests against createApp() factory
- nock for HTTP interception (works with CJS require('axios'), unlike vi.mock)

New files:
- vitest.config.js          — test config: node env, isolate, V8 coverage, per-file thresholds
- tests/setup.js             — isolated DATA_DIR per worker, SKIP_RATE_LIMIT, console suppression
- tests/README.md            — approach, structure, design decisions
- server/app.js              — testable Express factory (extracted from index.js side-effects)

Unit tests (91 tests):
- tests/unit/sanitizeError.test.js  — secret redaction: apikey, token, bearer, basic-auth URLs
- tests/unit/config.test.js         — JSON array + legacy single-instance config parsing
- tests/unit/requireAuth.test.js    — valid/invalid/tampered cookies, schema validation
- tests/unit/verifyCsrf.test.js     — double-submit pattern, timing-safe compare, safe methods
- tests/unit/qbittorrent.test.js    — formatBytes, formatEta, mapTorrentToDownload state map
- tests/unit/tokenStore.test.js     — store/get/clear lifecycle, TTL expiry, atomic disk write

Integration tests (24 tests):
- tests/integration/health.test.js  — /health and /ready endpoints
- tests/integration/auth.test.js    — full login/logout/me/csrf flows, input validation,
                                      cookie attributes, no token leakage, Emby mock via nock

Production code changes (minimal, no behaviour change):
- server/routes/auth.js: EMBY_URL captured at request-time (not module load) for testability
- server/routes/auth.js: loginLimiter max → Number.MAX_SAFE_INTEGER when SKIP_RATE_LIMIT set
- server/utils/sanitizeError.js: fix HEADER_PATTERN to redact full line (not just first token)

CI:
- .gitea/workflows/ci.yml: add parallel 'test' job (npm run test:coverage, artifact upload)
- package.json: add test/test:watch/test:coverage/test:ui scripts
- .gitignore: add coverage/
2026-05-17 07:45:33 +01:00

122 lines
4.6 KiB
JavaScript

/**
* Tests for server/utils/sanitizeError.js
*
* Critical security tests: verify that API keys, tokens, passwords and other
* secrets are NEVER leaked in error messages returned to clients or written
* to logs. Every pattern here represents a real credential type used in the
* sofarr stack (SABnzbd apikey, Emby tokens, qBittorrent basic-auth URLs).
*/
import sanitizeError from '../../server/utils/sanitizeError.js';
describe('sanitizeError', () => {
describe('query-param secrets', () => {
it('redacts ?apikey= values', () => {
const err = new Error('Request failed: https://sabnzbd.local/api?apikey=abc123secret&output=json');
expect(sanitizeError(err)).toContain('[REDACTED]');
expect(sanitizeError(err)).not.toContain('abc123secret');
});
it('redacts &apikey= mid-URL', () => {
const err = new Error('GET https://host/path?mode=queue&apikey=SUPERSECRET&output=json');
expect(sanitizeError(err)).not.toContain('SUPERSECRET');
expect(sanitizeError(err)).toContain('[REDACTED]');
});
it('redacts ?token= values', () => {
const err = new Error('https://api.example.com/data?token=tok_private99');
expect(sanitizeError(err)).not.toContain('tok_private99');
});
it('redacts ?password= values', () => {
const err = new Error('Auth failed: https://service.local?password=hunter2');
expect(sanitizeError(err)).not.toContain('hunter2');
});
it('redacts ?api_key= values', () => {
const err = new Error('https://sonarr.local/api/v3/series?api_key=e583d270f89846478e42');
expect(sanitizeError(err)).not.toContain('e583d270f89846478e42');
});
it('preserves non-secret query params', () => {
const result = sanitizeError(new Error('GET /api?mode=queue&output=json'));
expect(result).toContain('mode=queue');
expect(result).toContain('output=json');
});
});
describe('HTTP auth headers', () => {
it('redacts X-Api-Key header values', () => {
const err = new Error('Request: x-api-key: e583d270f89846478e42dd3cf90bfb00');
expect(sanitizeError(err)).not.toContain('e583d270f89846478e42dd3cf90bfb00');
expect(sanitizeError(err)).toContain('[REDACTED]');
});
it('redacts X-MediaBrowser-Token header values', () => {
const err = new Error('x-mediabrowser-token: b862f3a43f4c417285043a11aa28b1f7');
expect(sanitizeError(err)).not.toContain('b862f3a43f4c417285043a11aa28b1f7');
});
it('redacts Authorization header values', () => {
const err = new Error('authorization: MediaBrowser Token="abc123", DeviceId="xyz"');
expect(sanitizeError(err)).not.toContain('abc123');
});
});
describe('bearer tokens', () => {
it('redacts Bearer token values', () => {
const err = new Error('Error: bearer eyJhbGciOiJIUzI1NiJ9.payload.sig');
expect(sanitizeError(err)).not.toContain('eyJhbGciOiJIUzI1NiJ9');
expect(sanitizeError(err)).toContain('bearer [REDACTED]');
});
it('is case-insensitive for BEARER', () => {
const err = new Error('BEARER TOKEN_VALUE_HERE');
expect(sanitizeError(err)).not.toContain('TOKEN_VALUE_HERE');
});
});
describe('basic-auth URLs', () => {
it('redacts user:pass@ in URLs', () => {
const err = new Error('GET http://admin:b053288369XX!@qbittorrent.local/api');
expect(sanitizeError(err)).not.toContain('b053288369XX!');
expect(sanitizeError(err)).not.toContain('admin:');
expect(sanitizeError(err)).toContain('//[REDACTED]@');
});
it('handles https:// basic auth', () => {
const err = new Error('https://user:s3cr3t@service.local/path');
expect(sanitizeError(err)).not.toContain('s3cr3t');
});
});
describe('edge cases', () => {
it('handles non-Error input (plain string)', () => {
const result = sanitizeError('plain string error');
expect(typeof result).toBe('string');
});
it('handles null gracefully', () => {
expect(() => sanitizeError(null)).not.toThrow();
});
it('handles undefined gracefully', () => {
expect(() => sanitizeError(undefined)).not.toThrow();
});
it('preserves non-sensitive error messages unchanged', () => {
const err = new Error('Connection refused: ECONNREFUSED 127.0.0.1:8080');
const result = sanitizeError(err);
expect(result).toContain('ECONNREFUSED');
expect(result).toContain('127.0.0.1:8080');
});
it('does not leak stack traces (returns message only)', () => {
const err = new Error('something went wrong');
const result = sanitizeError(err);
expect(result).not.toContain('at ');
expect(result).not.toContain('.js:');
});
});
});