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/
300 lines
10 KiB
JavaScript
300 lines
10 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|