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/
85 lines
2.7 KiB
JavaScript
85 lines
2.7 KiB
JavaScript
/**
|
|
* 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();
|
|
});
|
|
});
|
|
});
|