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)
252 lines
7.6 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|