import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdtempSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { type FastifyInstance } from 'fastify'; import { initDatabaseAsync, closeDatabase } from '../db/index'; import { runMigrations } from '../db/migrate'; import { buildServer } from '../server/index'; import { systemConfig } from '../db/schema/index'; import { eq } from 'drizzle-orm'; import { type LibSQLDatabase } from 'drizzle-orm/libsql'; import type * as schema from '../db/schema/index'; /** * Auth model tests: verify dual-mode authentication. * * The Tubearr auth model (matching Sonarr/Radarr): * - Same-origin browser requests (Origin/Referer matching server host) are trusted * - External requests require a valid API key via header or query param * - API key management endpoints allow reading and regenerating the key */ describe('Auth model — dual-mode authentication', () => { let server: FastifyInstance; let db: LibSQLDatabase; let apiKey: string; let tmpDir: string; beforeAll(async () => { tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-auth-')); const dbPath = join(tmpDir, 'test.db'); db = await initDatabaseAsync(dbPath); await runMigrations(dbPath); server = await buildServer({ db }); await server.ready(); // Read the generated API key from the database const rows = await db .select() .from(systemConfig) .where(eq(systemConfig.key, 'api_key')) .limit(1); apiKey = rows[0]?.value ?? ''; expect(apiKey).toBeTruthy(); }); afterAll(async () => { await server.close(); closeDatabase(); try { if (tmpDir && existsSync(tmpDir)) { rmSync(tmpDir, { recursive: true, force: true }); } } catch { // Temp dir cleanup is best-effort on Windows } }); // ── Same-origin bypass ── describe('Same-origin bypass (trusted browser requests)', () => { it('allows request with matching Origin header — no API key needed', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/system/status', headers: { origin: 'http://localhost:3000', }, }); expect(res.statusCode).toBe(200); expect(res.json()).toHaveProperty('appName', 'Tubearr'); }); it('allows request with matching Referer header — no API key needed', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/system/status', headers: { referer: 'http://localhost:8989/settings', }, }); expect(res.statusCode).toBe(200); expect(res.json()).toHaveProperty('appName', 'Tubearr'); }); it('rejects cross-origin request (different hostname) without API key', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/system/status', headers: { origin: 'http://evil.example.com:8989', }, }); expect(res.statusCode).toBe(401); expect(res.json().message).toContain('API key'); }); }); // ── External API key authentication ── describe('External API key authentication', () => { it('rejects external request without API key (no Origin/Referer)', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/system/status', }); expect(res.statusCode).toBe(401); const body = res.json(); expect(body.error).toBe('Unauthorized'); expect(body.message).toContain('API key'); }); it('allows external request with valid API key via X-Api-Key header', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/system/status', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); expect(res.json()).toHaveProperty('appName', 'Tubearr'); }); it('allows external request with valid API key via apikey query param', async () => { const res = await server.inject({ method: 'GET', url: `/api/v1/system/status?apikey=${apiKey}`, }); expect(res.statusCode).toBe(200); expect(res.json()).toHaveProperty('appName', 'Tubearr'); }); it('rejects external request with invalid API key', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/system/status', headers: { 'x-api-key': 'totally-wrong-key' }, }); expect(res.statusCode).toBe(401); expect(res.json().message).toBe('Invalid API key'); }); }); // ── API key management endpoints ── describe('GET /api/v1/system/apikey', () => { it('returns the current API key for same-origin requests', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/system/apikey', headers: { origin: 'http://localhost:8989', }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body).toHaveProperty('apiKey'); expect(body.apiKey).toBe(apiKey); }); it('returns the current API key for API-key-authenticated requests', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/system/apikey', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); expect(res.json().apiKey).toBe(apiKey); }); it('rejects unauthenticated external requests', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/system/apikey', }); expect(res.statusCode).toBe(401); }); }); describe('POST /api/v1/system/apikey/regenerate', () => { it('regenerates the API key and returns the new one', async () => { const oldKey = apiKey; // Regenerate using same-origin auth const res = await server.inject({ method: 'POST', url: '/api/v1/system/apikey/regenerate', headers: { origin: 'http://localhost:8989', }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body).toHaveProperty('apiKey'); expect(body.apiKey).not.toBe(oldKey); expect(body.apiKey).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ ); // Update our local reference for subsequent tests apiKey = body.apiKey; }); it('old API key no longer works for external requests after regeneration', async () => { // The previous test regenerated the key, so the original key should be invalid const res = await server.inject({ method: 'GET', url: '/api/v1/system/status', headers: { 'x-api-key': 'the-original-key-is-gone' }, }); expect(res.statusCode).toBe(401); }); it('new API key works for external requests after regeneration', async () => { // Read the current key from the DB to be sure we have the right one const rows = await db .select() .from(systemConfig) .where(eq(systemConfig.key, 'api_key')) .limit(1); const currentKey = rows[0]?.value ?? ''; const res = await server.inject({ method: 'GET', url: '/api/v1/system/status', headers: { 'x-api-key': currentKey }, }); expect(res.statusCode).toBe(200); }); it('rejects unauthenticated external regeneration requests', async () => { const res = await server.inject({ method: 'POST', url: '/api/v1/system/apikey/regenerate', }); expect(res.statusCode).toBe(401); }); }); });