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:
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user