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/
114 lines
3.7 KiB
JavaScript
114 lines
3.7 KiB
JavaScript
/**
|
|
* 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 };
|