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:
113
server/app.js
Normal file
113
server/app.js
Normal 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 };
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user