tubearr/src/__tests__/auth-model.test.ts
John Lightner 4606dce553 feat: Tubearr — full project state through M006/S01
Migrated git root from W:/programming/Projects/ to W:/programming/Projects/Tubearr/.
Previous history preserved in Tubearr-full-backup.bundle at parent directory.

Completed milestones: M001 through M005
Active: M006/S02 (Add Channel UX)
2026-03-24 20:20:10 -05:00

252 lines
7.6 KiB
TypeScript

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<typeof schema>;
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);
});
});
});