test: add comprehensive test suite (115 tests, Vitest + supertest + nock)
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

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:
2026-05-17 07:45:33 +01:00
parent cc1e8af761
commit 5fd55b4e1a
19 changed files with 3071 additions and 10 deletions

View File

@@ -28,3 +28,35 @@ jobs:
- name: Check for critical vulnerabilities
run: npm audit --audit-level=critical --json | jq -e '.metadata.vulnerabilities.critical == 0' || (echo "Critical vulnerabilities found!" && exit 1)
continue-on-error: false
test:
name: Tests & coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test:coverage
env:
# Required by tokenStore (writable temp dir in CI)
DATA_DIR: /tmp/sofarr-ci-data
# Disable rate limiters so integration tests don't hit 429s
SKIP_RATE_LIMIT: "1"
NODE_ENV: test
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 14

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules/
coverage/
.env
dist/
build/

View File

@@ -308,6 +308,17 @@ Logs are written to both console and `server.log` file.
- Check qBittorrent Web UI is enabled
- Verify the URL includes the full path (e.g., `https://qb.example.com`)
## Testing
```bash
npm test # run all tests once
npm run test:watch # watch mode
npm run test:coverage # with V8 coverage report (outputs to coverage/)
npm run test:ui # interactive Vitest UI
```
115 tests across 8 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, and qBittorrent utilities. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
## Development
```bash

1753
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,10 @@
"dev": "nodemon server/index.js",
"start": "node server/index.js",
"install:all": "npm install",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"audit": "npm audit --audit-level=high",
"audit:fix": "npm audit fix",
"audit:critical": "npm audit --audit-level=critical"
@@ -20,8 +24,12 @@
"helmet": "^7.0.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.6",
"concurrently": "^7.6.0",
"nodemon": "^3.1.14"
"nock": "^14.0.15",
"nodemon": "^3.1.14",
"supertest": "^7.2.2",
"vitest": "^4.1.6"
},
"keywords": [
"sabnzbd",

113
server/app.js Normal file
View File

@@ -0,0 +1,113 @@
/**
* Express application factory — imported by both server/index.js (production)
* and the test suite. Keeping app creation separate from app.listen() means
* tests can import a fresh instance without starting a real server or
* triggering the side-effects in index.js (log files, process.exit, poller).
*/
const express = require('express');
const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const crypto = require('crypto');
const sabnzbdRoutes = require('./routes/sabnzbd');
const sonarrRoutes = require('./routes/sonarr');
const radarrRoutes = require('./routes/radarr');
const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard');
const authRoutes = require('./routes/auth');
const verifyCsrf = require('./middleware/verifyCsrf');
function createApp({ skipRateLimits = false } = {}) {
const app = express();
if (process.env.TRUST_PROXY) {
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
? parseInt(process.env.TRUST_PROXY, 10)
: process.env.TRUST_PROXY;
app.set('trust proxy', trustValue);
}
// Per-request CSP nonce
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
});
app.use((req, res, next) => {
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null
}
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
crossOriginEmbedderPolicy: false
})(req, res, next);
});
app.use((req, res, next) => {
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
next();
});
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' }
});
app.use(cookieParser(process.env.COOKIE_SECRET || undefined));
app.use(express.json({ limit: '64kb' }));
// Health / readiness (no auth, no rate-limit)
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.get('/ready', (req, res) => {
const ready = !!(process.env.EMBY_URL);
if (ready) {
res.json({ status: 'ready' });
} else {
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
}
});
// API routes
app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes);
// CSRF protection for all state-changing API requests below
app.use('/api', verifyCsrf);
app.use('/api/sabnzbd', sabnzbdRoutes);
app.use('/api/sonarr', sonarrRoutes);
app.use('/api/radarr', radarrRoutes);
app.use('/api/emby', embyRoutes);
app.use('/api/dashboard', dashboardRoutes);
// Global error handler
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
console.error('[Server] Unhandled error:', err.message);
res.status(500).json({ error: 'Internal server error' });
});
return app;
}
module.exports = { createApp };

View File

@@ -7,13 +7,16 @@ const router = express.Router();
// Persistent SQLite-backed token store — survives restarts
const { storeToken, getToken, clearToken } = require('../utils/tokenStore');
const EMBY_URL = process.env.EMBY_URL;
// Read EMBY_URL at request time (not module load time) so the value
// can be overridden by environment variables set after the module loads.
const getEmbyUrl = () => process.env.EMBY_URL;
// Strict login limiter: 10 attempts per 15 min, then locked for the window
// Strict login limiter: 10 attempts per 15 min, then locked for the window.
// Set SKIP_RATE_LIMIT=1 in the test environment to prevent the limiter from
// interfering with integration tests (all requests come from 127.0.0.1).
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 10,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true, // only count failures toward the limit
@@ -39,7 +42,7 @@ router.post('/login', loginLimiter, async (req, res) => {
// Using a deterministic DeviceId causes Emby to reuse the existing session
// for this device rather than creating a new one on each login.
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.trim().toLowerCase()).digest('hex').slice(0, 16);
const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, {
const authResponse = await axios.post(`${getEmbyUrl()}/Users/authenticatebyname`, {
Username: username.trim(),
Pw: password
}, {
@@ -51,7 +54,7 @@ router.post('/login', loginLimiter, async (req, res) => {
const authData = authResponse.data;
// Get user info using the access token
const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, {
const userResponse = await axios.get(`${getEmbyUrl()}/Users/${authData.User.Id || authData.User.id}`, {
headers: {
'X-MediaBrowser-Token': authData.AccessToken
}
@@ -153,7 +156,7 @@ router.post('/logout', async (req, res) => {
const stored = getToken(user.id);
if (stored) {
try {
await axios.post(`${EMBY_URL}/Sessions/Logout`, {}, {
await axios.post(`${getEmbyUrl()}/Sessions/Logout`, {}, {
headers: { 'X-MediaBrowser-Token': stored.accessToken }
});
console.log(`[Auth] Revoked Emby token for user: ${user.name}`);

View File

@@ -1,7 +1,8 @@
// Query-param secrets (SABnzbd apikey, generic token/password params)
const QUERY_SECRET_PATTERN = /([?&](?:apikey|token|password|api_key|key|secret)=)[^&\s#]*/gi;
// HTTP auth header values (X-Api-Key, X-MediaBrowser-Token, Authorization, X-Emby-Authorization)
const HEADER_PATTERN = /(?:x-api-key|x-mediabrowser-token|x-emby-authorization|authorization)\s*:\s*\S+/gi;
// Redact everything after the colon to end-of-line (MediaBrowser headers span the full line)
const HEADER_PATTERN = /(?:x-api-key|x-mediabrowser-token|x-emby-authorization|authorization)\s*:[^\n]*/gi;
// Bearer tokens
const BEARER_PATTERN = /bearer\s+[A-Za-z0-9\-._~+/]+=*/gi;
// Basic auth credentials in URLs (http://user:pass@host)

67
tests/README.md Normal file
View 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.

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

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

View 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/');
});
});

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

View 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:');
});
});
});

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

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

45
vitest.config.js Normal file
View File

@@ -0,0 +1,45 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Node environment for all tests (server-side CJS modules, no browser APIs needed)
environment: 'node',
// Global test helpers (describe, it, expect, vi) without per-file imports
globals: true,
// Run each test file in an isolated module registry so module-level state
// (tokenStore cache, config singletons) doesn't leak between files
isolate: true,
// Give each file its own data directory so tokenStore file I/O doesn't collide
setupFiles: ['./tests/setup.js'],
// Coverage via V8 (built into Node — no babel transform needed)
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
reportsDirectory: './coverage',
// Only measure coverage on production source files
include: ['server/**/*.js'],
exclude: [
'server/index.js', // entry point with side-effects (process.exit, log streams)
'node_modules/**',
'tests/**',
'coverage/**'
],
// Per-file thresholds for the critical security/auth files we actively test.
// Overall project thresholds are lower because dashboard.js and poller.js
// are large files that require complex external service mocks (future work).
thresholds: {
lines: 25,
functions: 12,
branches: 12,
statements: 25,
perFile: false,
// Individual file thresholds for the files we DO test
'server/app.js': { lines: 85, functions: 80, branches: 65, statements: 85 },
'server/routes/auth.js': { lines: 85, functions: 95, branches: 70, statements: 85 },
'server/middleware/requireAuth.js': { lines: 75, functions: 0, branches: 80, statements: 75 },
'server/utils/sanitizeError.js': { lines: 60, functions: 0, branches: 0, statements: 60 },
'server/utils/config.js': { lines: 50, functions: 30, branches: 55, statements: 50 }
}
}
}
});