/** * 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); }); });