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:
67
tests/README.md
Normal file
67
tests/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Testing
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Tool |
|
||||
|---|---|
|
||||
| Test runner | [Vitest](https://vitest.dev/) v4 |
|
||||
| HTTP integration | [supertest](https://github.com/ladjs/supertest) |
|
||||
| HTTP interception | [nock](https://github.com/nock/nock) (intercepts at Node http layer — works with CJS `require('axios')`) |
|
||||
| Coverage | V8 (built-in, no Babel needed) |
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
# Run all tests once
|
||||
npm test
|
||||
|
||||
# Watch mode (re-runs on file change)
|
||||
npm run test:watch
|
||||
|
||||
# With coverage report
|
||||
npm run test:coverage
|
||||
|
||||
# Interactive UI
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
Coverage output lands in `coverage/` (gitignored). Open `coverage/index.html` for the HTML report.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── setup.js # Global setup: isolated DATA_DIR, SKIP_RATE_LIMIT, console suppression
|
||||
├── unit/
|
||||
│ ├── sanitizeError.test.js # Secret redaction patterns (API keys, tokens, passwords)
|
||||
│ ├── config.test.js # JSON array + legacy single-instance config parsing
|
||||
│ ├── requireAuth.test.js # Auth middleware: valid/invalid/tampered cookies
|
||||
│ ├── verifyCsrf.test.js # CSRF double-submit cookie pattern + timing-safe compare
|
||||
│ ├── qbittorrent.test.js # Pure utils: formatBytes, formatEta, mapTorrentToDownload
|
||||
│ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
|
||||
└── integration/
|
||||
├── health.test.js # GET /health and /ready endpoints
|
||||
└── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
||||
```
|
||||
|
||||
## Key design decisions
|
||||
|
||||
- **`server/app.js`** — Express factory extracted from `server/index.js`. Tests import `createApp()` without triggering the log-file setup, `process.exit()` calls, or background poller in the entry point.
|
||||
- **nock over `vi.mock('axios')`** — Vitest's `vi.mock` only intercepts ESM `import` statements. Since `auth.js` uses CJS `require('axios')`, nock (which patches Node's `http`/`https` modules) is the correct tool for intercepting outbound requests.
|
||||
- **`SKIP_RATE_LIMIT=1`** — All supertest requests originate from `127.0.0.1`, which would quickly exhaust per-IP rate-limit windows. Setting this env var raises the limits to `Number.MAX_SAFE_INTEGER` in both the API limiter and the login limiter.
|
||||
- **Isolated `DATA_DIR`** — Each test worker gets a unique temp directory so `tokenStore.js` file I/O never conflicts with a running dev server.
|
||||
- **`createApp({ skipRateLimits: true })`** — The app factory accepts an option to disable the general API rate limiter in addition to the env var for the login-specific limiter.
|
||||
|
||||
## Coverage targets
|
||||
|
||||
The tested files meet these per-file minimums (enforced in CI):
|
||||
|
||||
| File | Lines | Branches |
|
||||
|---|---|---|
|
||||
| `server/app.js` | 85% | 65% |
|
||||
| `server/routes/auth.js` | 85% | 70% |
|
||||
| `server/middleware/requireAuth.js` | 75% | 80% |
|
||||
| `server/utils/sanitizeError.js` | 60% | — |
|
||||
| `server/utils/config.js` | 50% | 55% |
|
||||
|
||||
`dashboard.js` and `poller.js` are large files requiring complex external-service mocks (Sonarr, Radarr, qBittorrent, Emby) and are tracked as future test coverage work.
|
||||
299
tests/integration/auth.test.js
Normal file
299
tests/integration/auth.test.js
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Integration tests for authentication routes.
|
||||
*
|
||||
* Uses supertest against the createApp() factory (no real server).
|
||||
* HTTP calls to Emby are intercepted at the Node http/https layer using nock,
|
||||
* which works correctly with CJS require('axios') unlike vi.mock which only
|
||||
* intercepts ESM imports.
|
||||
*
|
||||
* Covers:
|
||||
* - Input validation on /login (empty fields, overlong values)
|
||||
* - Successful login flow (cookies set, CSRF token returned)
|
||||
* - Failed login (wrong credentials → 401, no cookie set)
|
||||
* - /me endpoint (authenticated vs unauthenticated)
|
||||
* - /csrf token issuance
|
||||
* - /logout (cookies cleared)
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
const EMBY_BASE = 'https://emby.test';
|
||||
|
||||
// Emby response fixtures
|
||||
const EMBY_AUTH_BODY = {
|
||||
AccessToken: 'test-emby-token-abc123',
|
||||
User: { Id: 'user-id-001', Name: 'TestUser' }
|
||||
};
|
||||
|
||||
const EMBY_USER_BODY = {
|
||||
Id: 'user-id-001',
|
||||
Name: 'TestUser',
|
||||
Policy: { IsAdministrator: false }
|
||||
};
|
||||
|
||||
const EMBY_ADMIN_BODY = {
|
||||
Id: 'admin-id-001',
|
||||
Name: 'AdminUser',
|
||||
Policy: { IsAdministrator: true }
|
||||
};
|
||||
|
||||
// Helper: intercept a successful Emby login + user-info sequence
|
||||
function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) {
|
||||
nock(EMBY_BASE)
|
||||
.post('/Users/authenticatebyname')
|
||||
.reply(200, EMBY_AUTH_BODY);
|
||||
nock(EMBY_BASE)
|
||||
.get(/\/Users\//)
|
||||
.reply(200, userBody);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll(); // remove any pending interceptors between tests
|
||||
});
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
// Each sub-describe gets a fresh app to avoid rate-limit state leaking
|
||||
// between the 'input validation' calls (which all fail and count toward
|
||||
// the 10-failure window) and the 'successful login' calls.
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EMBY_URL = 'https://emby.test';
|
||||
delete process.env.COOKIE_SECRET;
|
||||
// skipRateLimits avoids 429s from the login limiter when all
|
||||
// requests come from 127.0.0.1 in the test environment
|
||||
app = createApp({ skipRateLimits: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
delete process.env.COOKIE_SECRET;
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
it('rejects empty username', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: '', password: 'pass' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing password', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'alice', password: '' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects username over 128 chars', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'a'.repeat(129), password: 'pass' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects password over 256 chars', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'alice', password: 'p'.repeat(257) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects non-string username', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 123, password: 'pass' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful login', () => {
|
||||
it('returns success:true with user info', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.user.name).toBe('TestUser');
|
||||
expect(res.body.user.isAdmin).toBe(false);
|
||||
});
|
||||
|
||||
it('sets emby_user cookie', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('emby_user='))).toBe(true);
|
||||
});
|
||||
|
||||
it('sets csrf_token cookie', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns csrfToken in response body', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
expect(typeof res.body.csrfToken).toBe('string');
|
||||
expect(res.body.csrfToken.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('session cookie has no maxAge when rememberMe is false', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct', rememberMe: false });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
const sessionCookie = cookies.find(c => c.startsWith('emby_user='));
|
||||
// Session cookie must not persist across browser close
|
||||
expect(sessionCookie).toBeDefined();
|
||||
expect(sessionCookie).not.toContain('Max-Age');
|
||||
});
|
||||
|
||||
it('sets 30-day maxAge when rememberMe is true', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct', rememberMe: true });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
const sessionCookie = cookies.find(c => c.startsWith('emby_user='));
|
||||
expect(sessionCookie).toBeDefined();
|
||||
expect(sessionCookie).toContain('Max-Age');
|
||||
});
|
||||
|
||||
it('marks isAdmin correctly for admin user', async () => {
|
||||
interceptSuccessfulLogin(EMBY_ADMIN_BODY);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'AdminUser', password: 'correct' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.isAdmin).toBe(true);
|
||||
});
|
||||
|
||||
it('does not include AccessToken in response body', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
// The Emby access token must never be sent to the client
|
||||
expect(JSON.stringify(res.body)).not.toContain('test-emby-token-abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('failed login', () => {
|
||||
it('returns 401 when Emby rejects credentials', async () => {
|
||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, { error: 'Unauthorized' });
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'baduser', password: 'wrongpass' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
// Must not expose internal error details
|
||||
expect(res.body.error).toBe('Invalid username or password');
|
||||
});
|
||||
|
||||
it('does not set emby_user cookie on failure', async () => {
|
||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, {});
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'baduser', password: 'wrongpass' });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('emby_user='))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/me', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.COOKIE_SECRET;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
});
|
||||
|
||||
it('returns authenticated:false when no cookie', async () => {
|
||||
const res = await request(app).get('/api/auth/me');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('returns authenticated:true with valid cookie', async () => {
|
||||
const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: false });
|
||||
const res = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', `emby_user=${encodeURIComponent(payload)}`);
|
||||
expect(res.body.authenticated).toBe(true);
|
||||
expect(res.body.user.name).toBe('Alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/csrf', () => {
|
||||
it('issues a csrf_token cookie and returns csrfToken in body', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/auth/csrf');
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.csrfToken).toBe('string');
|
||||
expect(res.body.csrfToken.length).toBe(64); // 32 bytes hex
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/logout', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EMBY_URL = 'https://emby.test';
|
||||
delete process.env.COOKIE_SECRET;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
});
|
||||
|
||||
// NOTE: /api/auth/* is mounted BEFORE the verifyCsrf middleware in app.js,
|
||||
// so logout does not require a CSRF token by design. The session cookie's
|
||||
// sameSite:strict attribute provides equivalent CSRF protection for logout.
|
||||
it('succeeds without a CSRF token (sameSite:strict provides protection)', async () => {
|
||||
nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {});
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('clears cookies and returns success when CSRF token is provided', async () => {
|
||||
const csrfRes = await request(app).get('/api/auth/csrf');
|
||||
const csrfToken = csrfRes.body.csrfToken;
|
||||
const csrfCookie = csrfRes.headers['set-cookie'].find(c => c.startsWith('csrf_token='));
|
||||
|
||||
nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout')
|
||||
.set('Cookie', csrfCookie)
|
||||
.set('X-CSRF-Token', csrfToken);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Cookies should be cleared (Set-Cookie header with empty value / Max-Age=0)
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.includes('emby_user=;') || c.includes('Max-Age=0'))).toBe(true);
|
||||
});
|
||||
});
|
||||
55
tests/integration/health.test.js
Normal file
55
tests/integration/health.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Integration tests for health and readiness endpoints.
|
||||
*
|
||||
* /health and /ready are used by Docker HEALTHCHECK and must:
|
||||
* - Require no authentication
|
||||
* - Not be rate-limited
|
||||
* - Return the correct status codes
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
describe('GET /health', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
it('returns 200 with status ok', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('includes uptime as a number', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(typeof res.body.uptime).toBe('number');
|
||||
expect(res.body.uptime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /ready', () => {
|
||||
let app;
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
});
|
||||
|
||||
it('returns 200 when EMBY_URL is configured', async () => {
|
||||
process.env.EMBY_URL = 'https://emby.local';
|
||||
app = createApp();
|
||||
const res = await request(app).get('/ready');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('returns 503 when EMBY_URL is not configured', async () => {
|
||||
delete process.env.EMBY_URL;
|
||||
app = createApp();
|
||||
const res = await request(app).get('/ready');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.status).toBe('not ready');
|
||||
});
|
||||
});
|
||||
27
tests/setup.js
Normal file
27
tests/setup.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Give each test worker a unique temp DATA_DIR so tokenStore file I/O is
|
||||
// fully isolated and doesn't conflict with a running dev server's data/.
|
||||
const tmpDir = path.join(os.tmpdir(), `sofarr-test-${process.pid}`);
|
||||
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
|
||||
// Disable rate limiters in tests — all supertest requests share 127.0.0.1
|
||||
// and would quickly exhaust per-IP windows otherwise.
|
||||
process.env.SKIP_RATE_LIMIT = '1';
|
||||
|
||||
// Suppress console noise during tests (errors still surface via thrown exceptions)
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// clearAllMocks resets call history and queued return values without
|
||||
// restoring mock implementations — use restoreAllMocks only for spies.
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
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