diff --git a/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts b/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts index 336ccfd..e173451 100644 --- a/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts +++ b/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts @@ -6,6 +6,7 @@ 'use client'; import { useState } from 'react'; +import { fetchWithAuth } from '@/lib/utils/api'; import type { PathsSettings, TestResult } from '../../lib/types'; interface UsePathsSettingsProps { @@ -34,7 +35,7 @@ export function usePathsSettings({ paths, onChange, onValidationChange }: UsePat setTestResult(null); try { - const response = await fetch('/api/setup/test-paths', { + const response = await fetchWithAuth('/api/setup/test-paths', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/src/app/api/setup/test-abs/route.ts b/src/app/api/setup/test-abs/route.ts index 74fabea..be8f034 100644 --- a/src/app/api/setup/test-abs/route.ts +++ b/src/app/api/setup/test-abs/route.ts @@ -4,10 +4,10 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { requireSetupIncomplete } from '@/lib/middleware/auth'; +import { requireSetupIncompleteOrAdmin } from '@/lib/middleware/auth'; export async function POST(request: NextRequest) { - return requireSetupIncomplete(request, async (req) => { + return requireSetupIncompleteOrAdmin(request, async (req) => { try { const { serverUrl, apiToken } = await req.json(); diff --git a/src/app/api/setup/test-oidc/route.ts b/src/app/api/setup/test-oidc/route.ts index 3894cf1..4ae2afe 100644 --- a/src/app/api/setup/test-oidc/route.ts +++ b/src/app/api/setup/test-oidc/route.ts @@ -5,13 +5,13 @@ import { NextRequest, NextResponse } from 'next/server'; import { Issuer } from 'openid-client'; -import { requireSetupIncomplete } from '@/lib/middleware/auth'; +import { requireSetupIncompleteOrAdmin } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.Setup.TestOIDC'); export async function POST(request: NextRequest) { - return requireSetupIncomplete(request, async (req) => { + return requireSetupIncompleteOrAdmin(request, async (req) => { try { const body = await req.json(); const { issuerUrl, clientId, clientSecret } = body; diff --git a/src/app/api/setup/test-paths/route.ts b/src/app/api/setup/test-paths/route.ts index a7bee2b..2745249 100644 --- a/src/app/api/setup/test-paths/route.ts +++ b/src/app/api/setup/test-paths/route.ts @@ -6,7 +6,7 @@ import { NextRequest, NextResponse } from 'next/server'; import fs from 'fs/promises'; import path from 'path'; -import { requireSetupIncomplete } from '@/lib/middleware/auth'; +import { requireSetupIncompleteOrAdmin } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util'; @@ -46,7 +46,7 @@ async function testPath(dirPath: string): Promise { } export async function POST(request: NextRequest) { - return requireSetupIncomplete(request, async (req) => { + return requireSetupIncompleteOrAdmin(request, async (req) => { try { const { downloadDir, mediaDir, audiobookPathTemplate } = await req.json(); diff --git a/src/lib/middleware/auth.ts b/src/lib/middleware/auth.ts index 2eb8bcd..ce44d33 100644 --- a/src/lib/middleware/auth.ts +++ b/src/lib/middleware/auth.ts @@ -253,3 +253,35 @@ export async function requireSetupIncomplete( return handler(request); } + +/** + * Middleware: Require setup incomplete OR authenticated admin + * For endpoints shared between the setup wizard and admin settings. + * Allows access during setup (no auth needed) or after setup (admin auth required). + */ +export async function requireSetupIncompleteOrAdmin( + request: NextRequest, + handler: (request: NextRequest) => Promise +): Promise { + let setupComplete = false; + + try { + const config = await prisma.configuration.findUnique({ + where: { key: 'setup_completed' }, + }); + setupComplete = config?.value === 'true'; + } catch { + // If database is not ready, setup is definitely not complete — allow through + return handler(request); + } + + if (!setupComplete) { + // Setup in progress — allow unauthenticated access (setup wizard) + return handler(request); + } + + // Setup is complete — require admin authentication + return requireAuth(request, (authenticatedReq) => + requireAdmin(authenticatedReq, () => handler(request)) + ); +} diff --git a/tests/api/setup-guard.routes.test.ts b/tests/api/setup-guard.routes.test.ts index 88bfe9f..90474dd 100644 --- a/tests/api/setup-guard.routes.test.ts +++ b/tests/api/setup-guard.routes.test.ts @@ -2,15 +2,23 @@ * Component: Setup Route Guard Tests * Documentation: documentation/testing.md * - * Verifies that all setup API endpoints return 403 after setup is complete. + * 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', () => ({ @@ -70,20 +78,28 @@ vi.mock('bcrypt', () => ({ 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 = {}) { +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 - blocks access after setup is complete', () => { +describe('Setup route guard - setup-only endpoints (requireSetupIncomplete)', () => { beforeEach(() => { vi.clearAllMocks(); mockSetupComplete(); @@ -126,25 +142,50 @@ describe('Setup route guard - blocks access after setup is complete', () => { expect(payload.error).toBe('Forbidden'); }); - it('POST /api/setup/test-paths returns 403 when setup is already complete', async () => { + 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(403); - expect(payload.error).toBe('Forbidden'); + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); }); - it('POST /api/setup/test-abs returns 403 when setup is already complete', async () => { + 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(403); - expect(payload.error).toBe('Forbidden'); + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); }); - it('POST /api/setup/test-oidc returns 403 when setup is already complete', async () => { + 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', @@ -153,31 +194,42 @@ describe('Setup route guard - blocks access after setup is complete', () => { })); 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('allows requests through when setup is not yet complete', async () => { - // Override: setup not complete - prismaMock.configuration.findUnique.mockResolvedValue(null); + 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-download-client/route'); - const response = await POST(makeRequest({ type: 'qbittorrent', url: 'http://qbt' })); - const payload = await response.json(); + 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 403), even if the actual test fails + // Should reach the handler (not 401 or 403) + expect(response.status).not.toBe(401); expect(response.status).not.toBe(403); }); - it('allows requests through when database is not ready', async () => { - // Override: database error - prismaMock.configuration.findUnique.mockRejectedValue(new Error('DB not ready')); + it('allows unauthenticated access during setup for shared endpoints', 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' })); - const payload = await response.json(); + const { POST } = await import('@/app/api/setup/test-paths/route'); + const response = await POST(makeRequest({ downloadDir: '/downloads', mediaDir: '/media' })); - // Should reach the handler (not 403) — DB not ready means setup hasn't happened + // Should reach the handler (not 401 or 403) — setup in progress + expect(response.status).not.toBe(401); expect(response.status).not.toBe(403); }); });