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/
This commit is contained in:
108
tests/unit/config.test.js
Normal file
108
tests/unit/config.test.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Tests for server/utils/config.js
|
||||
*
|
||||
* Verifies that instance config is parsed correctly from both the modern JSON
|
||||
* array format and the legacy single-instance env var format. This is critical
|
||||
* because misconfigured instances silently return no data rather than crashing.
|
||||
*/
|
||||
|
||||
import { parseInstances, getSonarrInstances, getRadarrInstances } from '../../server/utils/config.js';
|
||||
|
||||
describe('parseInstances', () => {
|
||||
describe('JSON array format', () => {
|
||||
it('parses a valid single-instance JSON array', () => {
|
||||
const json = JSON.stringify([{ name: 'main', url: 'https://sonarr.local', apiKey: 'abc123' }]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].url).toBe('https://sonarr.local');
|
||||
expect(result[0].apiKey).toBe('abc123');
|
||||
});
|
||||
|
||||
it('parses multiple instances', () => {
|
||||
const json = JSON.stringify([
|
||||
{ name: 'main', url: 'https://s1.local', apiKey: 'key1' },
|
||||
{ name: 'backup', url: 'https://s2.local', apiKey: 'key2' }
|
||||
]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].name).toBe('backup');
|
||||
});
|
||||
|
||||
it('adds id from name when present', () => {
|
||||
const json = JSON.stringify([{ name: 'i3omb', url: 'https://s.local', apiKey: 'k' }]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result[0].id).toBe('i3omb');
|
||||
});
|
||||
|
||||
it('generates fallback id when name is absent', () => {
|
||||
const json = JSON.stringify([{ url: 'https://s.local', apiKey: 'k' }]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result[0].id).toBe('instance-1');
|
||||
});
|
||||
|
||||
it('handles multi-line JSON by stripping whitespace', () => {
|
||||
const json = `[
|
||||
{
|
||||
"name": "main",
|
||||
"url": "https://sonarr.local",
|
||||
"apiKey": "abc"
|
||||
}
|
||||
]`;
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty array for empty JSON array', () => {
|
||||
expect(parseInstances('[]', null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to legacy format when JSON is malformed', () => {
|
||||
const result = parseInstances('not-json', 'https://legacy.local', 'legacyKey');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].url).toBe('https://legacy.local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy single-instance format', () => {
|
||||
it('returns single instance from legacy URL + key', () => {
|
||||
const result = parseInstances(null, 'https://sonarr.local', 'legacyapikey');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('default');
|
||||
expect(result[0].name).toBe('Default');
|
||||
expect(result[0].url).toBe('https://sonarr.local');
|
||||
expect(result[0].apiKey).toBe('legacyapikey');
|
||||
});
|
||||
|
||||
it('returns empty array for qBittorrent with no apiKey and no JSON (legacy requires key)', () => {
|
||||
// parseInstances requires legacyKey to be truthy for the legacy path;
|
||||
// qBittorrent uses JSON array format, not the legacy URL+key path.
|
||||
const result = parseInstances(null, 'https://qbt.local', null, 'admin', 'pass123');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when both JSON and legacy URL are missing', () => {
|
||||
expect(parseInstances(null, null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when URL is set but key is missing', () => {
|
||||
expect(parseInstances(null, 'https://sonarr.local', null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('env-based getters', () => {
|
||||
it('getSonarrInstances reads SONARR_INSTANCES from env', () => {
|
||||
process.env.SONARR_INSTANCES = JSON.stringify([{ name: 'test', url: 'https://s.local', apiKey: 'k' }]);
|
||||
const result = getSonarrInstances();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('test');
|
||||
delete process.env.SONARR_INSTANCES;
|
||||
});
|
||||
|
||||
it('getRadarrInstances returns empty array when unconfigured', () => {
|
||||
delete process.env.RADARR_INSTANCES;
|
||||
delete process.env.RADARR_URL;
|
||||
const result = getRadarrInstances();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
111
tests/unit/qbittorrent.test.js
Normal file
111
tests/unit/qbittorrent.test.js
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Tests for server/utils/qbittorrent.js pure utility functions.
|
||||
*
|
||||
* mapTorrentToDownload, formatBytes, formatSpeed, and formatEta are all
|
||||
* pure functions with no I/O — ideal unit test targets. These power the
|
||||
* dashboard card rendering so correctness matters for UX.
|
||||
*/
|
||||
|
||||
import { mapTorrentToDownload, formatBytes, formatSpeed, formatEta } from '../../server/utils/qbittorrent.js';
|
||||
|
||||
// Minimal torrent fixture that satisfies mapTorrentToDownload's expectations
|
||||
function makeTorrent(overrides = {}) {
|
||||
return {
|
||||
name: 'My.Show.S01E01.1080p.mkv',
|
||||
state: 'downloading',
|
||||
size: 1073741824, // 1 GB
|
||||
completed: 536870912, // 512 MB
|
||||
progress: 0.5,
|
||||
dlspeed: 1048576, // 1 MB/s
|
||||
eta: 512, // seconds
|
||||
num_seeds: 10,
|
||||
num_leechs: 3,
|
||||
availability: 1.0,
|
||||
hash: 'aabbccdd',
|
||||
category: 'sonarr',
|
||||
tags: '',
|
||||
content_path: '/downloads/My.Show.S01E01.1080p.mkv',
|
||||
save_path: '/downloads/',
|
||||
instanceName: 'i3omb',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats 0 bytes', () => expect(formatBytes(0)).toBe('0 B'));
|
||||
it('formats bytes', () => expect(formatBytes(512)).toBe('512 B'));
|
||||
it('formats kilobytes', () => expect(formatBytes(1024)).toBe('1 KB'));
|
||||
it('formats megabytes', () => expect(formatBytes(1048576)).toBe('1 MB'));
|
||||
it('formats gigabytes', () => expect(formatBytes(1073741824)).toBe('1 GB'));
|
||||
it('formats fractional GB', () => expect(formatBytes(1610612736)).toBe('1.5 GB'));
|
||||
});
|
||||
|
||||
describe('formatSpeed', () => {
|
||||
it('appends /s to byte count', () => expect(formatSpeed(1048576)).toBe('1 MB/s'));
|
||||
it('handles zero speed', () => expect(formatSpeed(0)).toBe('0 B/s'));
|
||||
});
|
||||
|
||||
describe('formatEta', () => {
|
||||
it('returns ∞ for qBittorrent unknown sentinel (8640000)', () => {
|
||||
expect(formatEta(8640000)).toBe('∞');
|
||||
});
|
||||
it('returns ∞ for negative eta', () => expect(formatEta(-1)).toBe('∞'));
|
||||
it('formats minutes only', () => expect(formatEta(90)).toBe('1m'));
|
||||
it('formats hours and minutes', () => expect(formatEta(3661)).toBe('1h 1m'));
|
||||
it('formats days, hours and minutes', () => expect(formatEta(90061)).toBe('1d 1h 1m'));
|
||||
it('returns 0m for zero seconds', () => expect(formatEta(0)).toBe('0m'));
|
||||
});
|
||||
|
||||
describe('mapTorrentToDownload', () => {
|
||||
it('maps a downloading torrent correctly', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent());
|
||||
expect(result.status).toBe('Downloading');
|
||||
expect(result.progress).toBe('50.0');
|
||||
expect(result.size).toBe('1 GB');
|
||||
expect(result.speed).toBe('1 MB/s');
|
||||
expect(result.eta).toBe('8m');
|
||||
expect(result.seeds).toBe(10);
|
||||
expect(result.peers).toBe(3);
|
||||
expect(result.qbittorrent).toBe(true);
|
||||
expect(result.instanceName).toBe('i3omb');
|
||||
});
|
||||
|
||||
it('maps state: stalledDL → Downloading', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'stalledDL' })).status).toBe('Downloading');
|
||||
});
|
||||
|
||||
it('maps state: uploading → Seeding', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'uploading' })).status).toBe('Seeding');
|
||||
});
|
||||
|
||||
it('maps state: pausedDL → Paused', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'pausedDL' })).status).toBe('Paused');
|
||||
});
|
||||
|
||||
it('maps state: stoppedUP → Stopped', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'stoppedUP' })).status).toBe('Stopped');
|
||||
});
|
||||
|
||||
it('maps state: error → Error', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'error' })).status).toBe('Error');
|
||||
});
|
||||
|
||||
it('passes through unknown state verbatim', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'weirdState' })).status).toBe('weirdState');
|
||||
});
|
||||
|
||||
it('computes 100% progress for completed torrent', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent({ progress: 1.0 }));
|
||||
expect(result.progress).toBe('100.0');
|
||||
});
|
||||
|
||||
it('uses content_path as savePath when present', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent({ content_path: '/dl/file.mkv' }));
|
||||
expect(result.savePath).toBe('/dl/file.mkv');
|
||||
});
|
||||
|
||||
it('falls back to save_path when content_path is absent', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent({ content_path: null, save_path: '/dl/' }));
|
||||
expect(result.savePath).toBe('/dl/');
|
||||
});
|
||||
});
|
||||
140
tests/unit/requireAuth.test.js
Normal file
140
tests/unit/requireAuth.test.js
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Tests for server/middleware/requireAuth.js
|
||||
*
|
||||
* requireAuth guards all authenticated API routes. Tests exercise the full
|
||||
* range of valid/invalid cookie states to ensure there's no bypass path.
|
||||
*/
|
||||
|
||||
import requireAuth from '../../server/middleware/requireAuth.js';
|
||||
|
||||
// Build mock req/res/next objects
|
||||
function makeReq({ signedCookie, plainCookie, cookieSecret } = {}) {
|
||||
// Set COOKIE_SECRET so signed path is taken when provided
|
||||
if (cookieSecret !== undefined) {
|
||||
process.env.COOKIE_SECRET = cookieSecret;
|
||||
} else {
|
||||
delete process.env.COOKIE_SECRET;
|
||||
}
|
||||
|
||||
return {
|
||||
signedCookies: { emby_user: signedCookie },
|
||||
cookies: { emby_user: plainCookie }
|
||||
};
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(body) { this.body = body; return this; }
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.COOKIE_SECRET;
|
||||
});
|
||||
|
||||
describe('requireAuth middleware', () => {
|
||||
describe('valid sessions', () => {
|
||||
it('calls next() with a valid signed cookie', () => {
|
||||
const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: true });
|
||||
const req = makeReq({ signedCookie: payload, cookieSecret: 'secret' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(req.user).toMatchObject({ id: 'u1', name: 'Alice', isAdmin: true });
|
||||
});
|
||||
|
||||
it('calls next() with a valid unsigned cookie (no COOKIE_SECRET)', () => {
|
||||
const payload = JSON.stringify({ id: 'u2', name: 'Bob', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(req.user.id).toBe('u2');
|
||||
});
|
||||
|
||||
it('coerces non-boolean isAdmin to boolean', () => {
|
||||
const payload = JSON.stringify({ id: 'u3', name: 'Charlie', isAdmin: 1 });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.user.isAdmin).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing or invalid cookies', () => {
|
||||
it('returns 401 when no cookie is present', () => {
|
||||
const req = makeReq({});
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when signed cookie value is false (tampered)', () => {
|
||||
// cookie-parser sets signed cookie to false when signature is invalid
|
||||
const req = makeReq({ signedCookie: false, cookieSecret: 'secret' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 for malformed JSON in cookie', () => {
|
||||
const req = makeReq({ plainCookie: 'not-json' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body.error).toBe('Invalid session');
|
||||
});
|
||||
|
||||
it('returns 401 when id is missing', () => {
|
||||
const payload = JSON.stringify({ name: 'Alice', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
requireAuth(req, makeRes(), vi.fn());
|
||||
// no next called — handled in the assertion below
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
requireAuth(req, res, next);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when name is missing', () => {
|
||||
const payload = JSON.stringify({ id: 'u1', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
requireAuth(req, res, vi.fn());
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 when id is empty string', () => {
|
||||
const payload = JSON.stringify({ id: '', name: 'Alice', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
requireAuth(req, res, vi.fn());
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
121
tests/unit/sanitizeError.test.js
Normal file
121
tests/unit/sanitizeError.test.js
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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:');
|
||||
});
|
||||
});
|
||||
});
|
||||
84
tests/unit/tokenStore.test.js
Normal file
84
tests/unit/tokenStore.test.js
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Tests for server/utils/tokenStore.js
|
||||
*
|
||||
* The token store persists Emby access tokens to disk (JSON file) so users
|
||||
* survive server restarts without re-logging in. Tests verify the store/get/
|
||||
* clear lifecycle, TTL expiry, and atomic write behaviour.
|
||||
*
|
||||
* Each test imports a FRESH module instance (vi.resetModules) so the
|
||||
* module-level singleton state (loaded from disk) doesn't bleed between tests.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Each test gets its own isolated temp dir
|
||||
let tmpDir;
|
||||
let tokenStore;
|
||||
|
||||
async function freshStore(dir) {
|
||||
vi.resetModules();
|
||||
process.env.DATA_DIR = dir;
|
||||
const mod = await import('../../server/utils/tokenStore.js');
|
||||
return mod;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sofarr-ts-'));
|
||||
tokenStore = await freshStore(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('tokenStore', () => {
|
||||
it('stores and retrieves a token', () => {
|
||||
tokenStore.storeToken('user1', 'access-token-abc');
|
||||
const result = tokenStore.getToken('user1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.accessToken).toBe('access-token-abc');
|
||||
});
|
||||
|
||||
it('returns null for an unknown user', () => {
|
||||
expect(tokenStore.getToken('nobody')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears a stored token', () => {
|
||||
tokenStore.storeToken('user1', 'token-xyz');
|
||||
tokenStore.clearToken('user1');
|
||||
expect(tokenStore.getToken('user1')).toBeNull();
|
||||
});
|
||||
|
||||
it('clearToken is a no-op for unknown user', () => {
|
||||
expect(() => tokenStore.clearToken('ghost')).not.toThrow();
|
||||
});
|
||||
|
||||
it('overwrites existing token on re-store', () => {
|
||||
tokenStore.storeToken('user1', 'old-token');
|
||||
tokenStore.storeToken('user1', 'new-token');
|
||||
expect(tokenStore.getToken('user1').accessToken).toBe('new-token');
|
||||
});
|
||||
|
||||
it('persists to disk (tokens.json exists after store)', () => {
|
||||
tokenStore.storeToken('u1', 'tok');
|
||||
const storePath = path.join(tmpDir, 'tokens.json');
|
||||
expect(fs.existsSync(storePath)).toBe(true);
|
||||
const data = JSON.parse(fs.readFileSync(storePath, 'utf8'));
|
||||
expect(data.u1.accessToken).toBe('tok');
|
||||
});
|
||||
|
||||
it('expires tokens older than 31 days on read', () => {
|
||||
// Write an already-expired entry directly to disk
|
||||
const expired = Date.now() - (32 * 24 * 60 * 60 * 1000);
|
||||
const storePath = path.join(tmpDir, 'tokens.json');
|
||||
fs.writeFileSync(storePath, JSON.stringify({ u1: { accessToken: 'old', createdAt: expired } }));
|
||||
// Re-import to load from disk
|
||||
vi.resetModules();
|
||||
return import('../../server/utils/tokenStore.js').then(mod => {
|
||||
expect(mod.getToken('u1')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
84
tests/unit/verifyCsrf.test.js
Normal file
84
tests/unit/verifyCsrf.test.js
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Tests for server/middleware/verifyCsrf.js
|
||||
*
|
||||
* CSRF protection via the double-submit cookie pattern. These tests verify
|
||||
* that the timing-safe comparison works correctly and that safe HTTP methods
|
||||
* are correctly exempted.
|
||||
*/
|
||||
|
||||
import verifyCsrf from '../../server/middleware/verifyCsrf.js';
|
||||
|
||||
function makeReq(method, cookieToken, headerToken) {
|
||||
return {
|
||||
method,
|
||||
cookies: { csrf_token: cookieToken },
|
||||
headers: { 'x-csrf-token': headerToken }
|
||||
};
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(body) { this.body = body; return this; }
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('verifyCsrf middleware', () => {
|
||||
describe('safe methods are exempted', () => {
|
||||
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
|
||||
it(`allows ${method} with no CSRF token`, () => {
|
||||
const next = vi.fn();
|
||||
verifyCsrf(makeReq(method, undefined, undefined), makeRes(), next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('mutating methods require valid token', () => {
|
||||
const TOKEN = 'a'.repeat(64); // 64 hex chars = 32 bytes
|
||||
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
it(`allows ${method} with matching tokens`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, TOKEN, TOKEN), res, next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(res.statusCode).toBeNull();
|
||||
});
|
||||
|
||||
it(`blocks ${method} with mismatched tokens`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, TOKEN, TOKEN.replace('a', 'b')), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`blocks ${method} with missing cookie token`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, undefined, TOKEN), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body.error).toBe('CSRF token missing');
|
||||
});
|
||||
|
||||
it(`blocks ${method} with missing header token`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, TOKEN, undefined), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
}
|
||||
|
||||
it('blocks when tokens have different lengths (timing-safe path)', () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq('POST', 'short', 'much-longer-token-here'), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body.error).toBe('CSRF token invalid');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user