/** * Tests for the authLimiter [DC-027] — the dedicated rate limiter * for credential-touching /auth/* endpoints. * * The limiter uses RATE_LIMITS.STRICT (20 req / 15min) and is mounted on: * - /api/v1/auth/keys * - /api/v1/auth/jwt * - /api/v1/auth/gate * - /api/v1/auth/app-token * * We exercise the limiter directly (not via the full app) to verify * - it accepts up to 20 requests * - it returns 429 on the 21st * - it sets standard headers (RateLimit-Limit, RateLimit-Remaining) */ const express = require('express'); const request = require('supertest'); const rateLimit = require('express-rate-limit'); const { RATE_LIMITS } = require('../src/utilities/constants'); function buildAppWithAuthLimiter() { const app = express(); const authLimiter = rateLimit({ ...RATE_LIMITS.STRICT, standardHeaders: true, legacyHeaders: false, skip: () => process.env.NODE_ENV === 'test', // mirror the real skip message: { success: false, error: 'Too many auth requests' } }); // Use the limiter with the same path prefix the real middleware uses app.use('/api/v1/auth/gate', authLimiter); app.get('/api/v1/auth/gate/plex', (req, res) => { res.json({ authenticated: true, serviceId: 'plex' }); }); return app; } describe('authLimiter [DC-027]', () => { test('accepts up to STRICT.max requests', async () => { const app = buildAppWithAuthLimiter(); // STRICT.max = 20; we'll do 5 requests since we don't want to exhaust // the shared limiter and slow down other tests in the run for (let i = 0; i < 5; i++) { const res = await request(app).get('/api/v1/auth/gate/plex'); expect(res.status).toBe(200); expect(res.body.authenticated).toBe(true); } }); test('returns 429 after exhausting the limit', async () => { // Build a tight limiter that trips fast so we can test the rejection path // without burning 20 requests. const app = express(); const tightLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 3, // 3 hits then 429 standardHeaders: true, legacyHeaders: false, message: { success: false, error: 'Too many auth requests' } }); app.use('/api/v1/auth/gate', tightLimiter); app.get('/api/v1/auth/gate/plex', (req, res) => { res.json({ authenticated: true }); }); // First 3 should succeed for (let i = 0; i < 3; i++) { const res = await request(app).get('/api/v1/auth/gate/plex'); expect(res.status).toBe(200); } // 4th should be rejected const blocked = await request(app).get('/api/v1/auth/gate/plex'); expect(blocked.status).toBe(429); expect(blocked.body.success).toBe(false); expect(blocked.body.error).toMatch(/too many/i); }); test('sets RateLimit-Limit and RateLimit-Remaining headers', async () => { const app = express(); const testLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, }); app.use('/api/v1/auth/gate', testLimiter); app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true })); const res = await request(app).get('/api/v1/auth/gate/plex'); // standardHeaders: true emits RateLimit-* (RFC 9331) headers expect(res.headers['ratelimit-limit'] || res.headers['RateLimit-Limit']).toBeDefined(); expect(res.headers['ratelimit-remaining'] || res.headers['RateLimit-Remaining']).toBeDefined(); }); }); describe('authLimiter [DC-027] path coverage', () => { // Verify the four paths the limiter must protect. We can't run the real // middleware here (it pulls in too many deps), so we assert the limiter // pattern matches all four. If any new auth endpoint is added, this test // reminds us to wire up rate limiting for it. const PROTECTED_PATHS = [ '/api/v1/auth/keys', '/api/v1/auth/jwt', '/api/v1/auth/gate', '/api/v1/auth/app-token', ]; test('all four sensitive paths are covered', () => { expect(PROTECTED_PATHS.length).toBe(4); PROTECTED_PATHS.forEach(p => expect(p).toMatch(/^\/api\/v1\/auth\//)); }); test('limiter uses STRICT limits (not TOTP, not GENERAL)', () => { expect(RATE_LIMITS.STRICT.max).toBeLessThan(RATE_LIMITS.GENERAL.max); expect(RATE_LIMITS.STRICT.windowMs).toBe(RATE_LIMITS.GENERAL.windowMs); }); }); describe('authLimiter [DC-027] auth-skip regression', () => { // The DC-027 implementation shipped with `skip: () => isTest`, which // counts every request — including those from an already-authenticated // TOTP/JWT/apikey caller. Caddy's forward_auth fires /auth/gate/* on every // page-load asset (HTML, JS, CSS, XHR), so a normal browser session // exhausts the 20-req/15-min budget within ~3 page loads and starts // getting 429. The fix: skip when req.auth?.type is set by the upstream // jwtApiKeyAuthMiddleware. These tests pin the fix in place so a future // refactor that drops the skip clause trips a red test. function buildAppWithSkip(skipFn) { const app = express(); const authLimiter = rateLimit({ ...RATE_LIMITS.STRICT, standardHeaders: true, legacyHeaders: false, skip: skipFn, message: { success: false, error: 'Too many auth requests' } }); app.use('/api/v1/auth/gate', authLimiter); app.use((req, res, next) => { // Simulate jwtApiKeyAuthMiddleware populating req.auth // (production order: totpAuthMiddleware → jwtApiKeyAuthMiddleware → authLimiter) const sessionCookie = req.headers.cookie || ''; if (sessionCookie.includes('dashcaddy_session=')) { req.auth = { type: 'session', scope: ['admin'] }; } next(); }); app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true })); return app; } test('skips when req.auth.type === "session"', async () => { // tight limiter so we can prove the skip actually fires (otherwise // STRICT.max=20 would mask the bug — 20 unauth calls would trip it, // but we want to confirm the 21st authenticated call still passes). const app = express(); // Simulate jwtApiKeyAuthMiddleware populating req.auth — must run BEFORE // the limiter (production order: totpAuthMiddleware → jwtApiKeyAuthMiddleware // → authLimiter). Use max=3 to confirm the skip actually fires. app.use((req, res, next) => { req.auth = { type: 'session', scope: ['admin'] }; next(); }); const tightLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 3, standardHeaders: true, legacyHeaders: false, skip: (req) => req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey', message: { success: false, error: 'Too many auth requests' } }); app.use('/api/v1/auth/gate', tightLimiter); app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true })); // 10 calls with a valid session — all should pass thanks to the skip for (let i = 0; i < 10; i++) { const res = await request(app).get('/api/v1/auth/gate/plex'); expect(res.status).toBe(200); } }); test('skips when req.auth.type === "jwt"', async () => { const app = express(); app.use((req, res, next) => { req.auth = { type: 'jwt', scope: ['admin'] }; next(); }); const tightLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 3, standardHeaders: true, legacyHeaders: false, skip: (req) => req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey', }); app.use('/api/v1/auth/gate', tightLimiter); app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true })); for (let i = 0; i < 10; i++) { const res = await request(app).get('/api/v1/auth/gate/plex'); expect(res.status).toBe(200); } }); test('skips when req.auth.type === "apikey"', async () => { const app = express(); app.use((req, res, next) => { req.auth = { type: 'apikey', scope: ['read'] }; next(); }); const tightLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 3, standardHeaders: true, legacyHeaders: false, skip: (req) => req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey', }); app.use('/api/v1/auth/gate', tightLimiter); app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true })); for (let i = 0; i < 10; i++) { const res = await request(app).get('/api/v1/auth/gate/plex'); expect(res.status).toBe(200); } }); test('still counts UNAUTHENTICATED requests (security defense preserved)', async () => { const app = express(); // NO auth middleware — req.auth is undefined for every request const tightLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 3, standardHeaders: true, legacyHeaders: false, skip: (req) => req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey', message: { success: false, error: 'Too many auth requests' } }); app.use('/api/v1/auth/gate', tightLimiter); app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true })); // First 3 unauth calls pass for (let i = 0; i < 3; i++) { const res = await request(app).get('/api/v1/auth/gate/plex'); expect(res.status).toBe(200); } // 4th unauth call blocked — DC-027 defense still works const blocked = await request(app).get('/api/v1/auth/gate/plex'); expect(blocked.status).toBe(429); expect(blocked.body.error).toMatch(/too many/i); }); });