Files
sofarr/tests/integration/auth.test.js
Gronod 8c4cc20551
All checks were successful
Build and Push Docker Image / build (push) Successful in 48s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m21s
CI / Security audit (push) Successful in 1m47s
CI / Tests & coverage (push) Successful in 2m1s
Add MIT copyright headers to all source files
2026-05-19 09:07:42 +01:00

301 lines
10 KiB
JavaScript

// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* 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);
});
});