/** * Component: Setup Route Guard Tests * Documentation: documentation/testing.md * * Verifies that setup API endpoints are properly guarded after setup is complete. * - Setup-only endpoints (complete, test-download-client, test-plex, test-prowlarr) * return 403 unconditionally after setup. * - Shared endpoints (test-paths, test-abs, test-oidc) require admin auth after setup. */ import { beforeEach, describe, expect, it, vi } from 'vitest'; const verifyAccessTokenMock = vi.hoisted(() => vi.fn()); const prismaMock = vi.hoisted(() => ({ configuration: { findUnique: vi.fn(), }, user: { findUnique: vi.fn(), }, })); vi.mock('@/lib/db', () => ({ prisma: prismaMock, })); // Mock all external dependencies that setup routes import vi.mock('@/lib/integrations/plex.service', () => ({ getPlexService: () => ({ testConnection: vi.fn(), getLibraries: vi.fn(), }), })); vi.mock('@/lib/integrations/prowlarr.service', () => ({ ProwlarrService: class { constructor() {} getIndexers = vi.fn(); }, })); vi.mock('openid-client', () => ({ Issuer: { discover: vi.fn() }, })); vi.mock('fs/promises', () => ({ default: { access: vi.fn(), mkdir: vi.fn(), writeFile: vi.fn(), unlink: vi.fn(), }, access: vi.fn(), mkdir: vi.fn(), writeFile: vi.fn(), unlink: vi.fn(), constants: { R_OK: 4 }, })); vi.mock('@/lib/services/config.service', () => ({ getConfigService: () => ({ get: vi.fn() }), })); vi.mock('@/lib/services/download-client-manager.service', () => ({ getDownloadClientManager: () => ({ testConnection: vi.fn() }), })); vi.mock('@/lib/services/encryption.service', () => ({ getEncryptionService: () => ({ encrypt: vi.fn((v: string) => `enc-${v}`) }), })); vi.mock('bcrypt', () => ({ default: { hash: vi.fn() }, hash: vi.fn(), })); vi.mock('@/lib/utils/jwt', () => ({ generateAccessToken: vi.fn(() => 'token'), generateRefreshToken: vi.fn(() => 'token'), verifyAccessToken: verifyAccessTokenMock, })); function mockSetupComplete() { prismaMock.configuration.findUnique.mockResolvedValue({ key: 'setup_completed', value: 'true' }); } function makeRequest(body: Record = {}, authToken?: string) { const headers = new Map(); if (authToken) { headers.set('authorization', `Bearer ${authToken}`); } return { json: vi.fn().mockResolvedValue(body), nextUrl: { pathname: '/api/setup/test' }, headers: { get: (key: string) => headers.get(key) ?? null, }, } as any; } describe('Setup route guard - setup-only endpoints (requireSetupIncomplete)', () => { beforeEach(() => { vi.clearAllMocks(); mockSetupComplete(); }); it('POST /api/setup/complete returns 403 when setup is already complete', async () => { const { POST } = await import('@/app/api/setup/complete/route'); const response = await POST(makeRequest({ backendMode: 'plex' })); const payload = await response.json(); expect(response.status).toBe(403); expect(payload.error).toBe('Forbidden'); expect(payload.message).toMatch(/Setup has already been completed/); }); it('POST /api/setup/test-download-client returns 403 when setup is already complete', async () => { const { POST } = await import('@/app/api/setup/test-download-client/route'); const response = await POST(makeRequest({ type: 'qbittorrent', url: 'http://qbt' })); const payload = await response.json(); expect(response.status).toBe(403); expect(payload.error).toBe('Forbidden'); }); it('POST /api/setup/test-plex returns 403 when setup is already complete', async () => { const { POST } = await import('@/app/api/setup/test-plex/route'); const response = await POST(makeRequest({ url: 'http://plex', token: 'token' })); const payload = await response.json(); expect(response.status).toBe(403); expect(payload.error).toBe('Forbidden'); }); it('POST /api/setup/test-prowlarr returns 403 when setup is already complete', async () => { const { POST } = await import('@/app/api/setup/test-prowlarr/route'); const response = await POST(makeRequest({ url: 'http://prowlarr', apiKey: 'key' })); const payload = await response.json(); expect(response.status).toBe(403); expect(payload.error).toBe('Forbidden'); }); it('allows requests through when setup is not yet complete', async () => { prismaMock.configuration.findUnique.mockResolvedValue(null); const { POST } = await import('@/app/api/setup/test-download-client/route'); const response = await POST(makeRequest({ type: 'qbittorrent', url: 'http://qbt' })); expect(response.status).not.toBe(403); }); it('allows requests through when database is not ready', async () => { prismaMock.configuration.findUnique.mockRejectedValue(new Error('DB not ready')); const { POST } = await import('@/app/api/setup/test-download-client/route'); const response = await POST(makeRequest({ type: 'qbittorrent', url: 'http://qbt' })); expect(response.status).not.toBe(403); }); }); describe('Setup route guard - shared endpoints (requireSetupIncompleteOrAdmin)', () => { beforeEach(() => { vi.clearAllMocks(); mockSetupComplete(); }); it('POST /api/setup/test-paths returns 401 when setup is complete and no auth', async () => { const { POST } = await import('@/app/api/setup/test-paths/route'); const response = await POST(makeRequest({ downloadDir: '/downloads', mediaDir: '/media' })); const payload = await response.json(); expect(response.status).toBe(401); expect(payload.error).toBe('Unauthorized'); }); it('POST /api/setup/test-abs returns 401 when setup is complete and no auth', async () => { const { POST } = await import('@/app/api/setup/test-abs/route'); const response = await POST(makeRequest({ serverUrl: 'http://abs', apiToken: 'token' })); const payload = await response.json(); expect(response.status).toBe(401); expect(payload.error).toBe('Unauthorized'); }); it('POST /api/setup/test-oidc returns 401 when setup is complete and no auth', async () => { const { POST } = await import('@/app/api/setup/test-oidc/route'); const response = await POST(makeRequest({ issuerUrl: 'http://issuer', clientId: 'client', clientSecret: 'secret', })); const payload = await response.json(); expect(response.status).toBe(401); expect(payload.error).toBe('Unauthorized'); }); it('POST /api/setup/test-paths returns 403 when setup is complete and user is not admin', async () => { verifyAccessTokenMock.mockReturnValue({ sub: 'user-1', role: 'user' }); prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'user' }); const { POST } = await import('@/app/api/setup/test-paths/route'); const response = await POST(makeRequest({ downloadDir: '/downloads', mediaDir: '/media' }, 'valid-token')); const payload = await response.json(); expect(response.status).toBe(403); expect(payload.error).toBe('Forbidden'); }); it('POST /api/setup/test-paths allows admin access after setup is complete', async () => { verifyAccessTokenMock.mockReturnValue({ sub: 'admin-1', role: 'admin' }); prismaMock.user.findUnique.mockResolvedValue({ id: 'admin-1', role: 'admin' }); const { POST } = await import('@/app/api/setup/test-paths/route'); const response = await POST(makeRequest({ downloadDir: '/downloads', mediaDir: '/media' }, 'admin-token')); // Should reach the handler (not 401 or 403) expect(response.status).not.toBe(401); expect(response.status).not.toBe(403); }); it('allows unauthenticated access during setup for shared endpoints', async () => { prismaMock.configuration.findUnique.mockResolvedValue(null); const { POST } = await import('@/app/api/setup/test-paths/route'); const response = await POST(makeRequest({ downloadDir: '/downloads', mediaDir: '/media' })); // Should reach the handler (not 401 or 403) — setup in progress expect(response.status).not.toBe(401); expect(response.status).not.toBe(403); }); });