Files
sofarr/tests/unit/config.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

109 lines
4.1 KiB
JavaScript

/**
* 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([]);
});
});
});