Add requireSetupIncompleteOrAdmin and adjust routes

Introduce a new middleware requireSetupIncompleteOrAdmin that allows unauthenticated access while the setup wizard is in progress but enforces admin authentication once setup is complete. Replace requireSetupIncomplete with the new guard in test-paths, test-abs and test-oidc API routes. Update the front-end hook to use fetchWithAuth for authenticated requests. Revise setup-guard tests to cover the new semantics: shared endpoints now return 401 when setup is complete and no auth is provided, return 403 for authenticated non-admin users, and allow admin access or unauthenticated access during setup/DB-unready conditions; also add jwt verification and user lookup mocks to the tests.
This commit is contained in:
kikootwo
2026-02-09 21:45:37 -05:00
parent 7e53f037af
commit f9947b745e
6 changed files with 116 additions and 31 deletions
@@ -6,6 +6,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { fetchWithAuth } from '@/lib/utils/api';
import type { PathsSettings, TestResult } from '../../lib/types'; import type { PathsSettings, TestResult } from '../../lib/types';
interface UsePathsSettingsProps { interface UsePathsSettingsProps {
@@ -34,7 +35,7 @@ export function usePathsSettings({ paths, onChange, onValidationChange }: UsePat
setTestResult(null); setTestResult(null);
try { try {
const response = await fetch('/api/setup/test-paths', { const response = await fetchWithAuth('/api/setup/test-paths', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
+2 -2
View File
@@ -4,10 +4,10 @@
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { requireSetupIncomplete } from '@/lib/middleware/auth'; import { requireSetupIncompleteOrAdmin } from '@/lib/middleware/auth';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => { return requireSetupIncompleteOrAdmin(request, async (req) => {
try { try {
const { serverUrl, apiToken } = await req.json(); const { serverUrl, apiToken } = await req.json();
+2 -2
View File
@@ -5,13 +5,13 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { Issuer } from 'openid-client'; import { Issuer } from 'openid-client';
import { requireSetupIncomplete } from '@/lib/middleware/auth'; import { requireSetupIncompleteOrAdmin } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestOIDC'); const logger = RMABLogger.create('API.Setup.TestOIDC');
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => { return requireSetupIncompleteOrAdmin(request, async (req) => {
try { try {
const body = await req.json(); const body = await req.json();
const { issuerUrl, clientId, clientSecret } = body; const { issuerUrl, clientId, clientSecret } = body;
+2 -2
View File
@@ -6,7 +6,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import { requireSetupIncomplete } from '@/lib/middleware/auth'; import { requireSetupIncompleteOrAdmin } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util'; import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util';
@@ -46,7 +46,7 @@ async function testPath(dirPath: string): Promise<boolean> {
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => { return requireSetupIncompleteOrAdmin(request, async (req) => {
try { try {
const { downloadDir, mediaDir, audiobookPathTemplate } = await req.json(); const { downloadDir, mediaDir, audiobookPathTemplate } = await req.json();
+32
View File
@@ -253,3 +253,35 @@ export async function requireSetupIncomplete(
return handler(request); 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<NextResponse>
): Promise<NextResponse> {
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))
);
}
+76 -24
View File
@@ -2,15 +2,23 @@
* Component: Setup Route Guard Tests * Component: Setup Route Guard Tests
* Documentation: documentation/testing.md * 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'; import { beforeEach, describe, expect, it, vi } from 'vitest';
const verifyAccessTokenMock = vi.hoisted(() => vi.fn());
const prismaMock = vi.hoisted(() => ({ const prismaMock = vi.hoisted(() => ({
configuration: { configuration: {
findUnique: vi.fn(), findUnique: vi.fn(),
}, },
user: {
findUnique: vi.fn(),
},
})); }));
vi.mock('@/lib/db', () => ({ vi.mock('@/lib/db', () => ({
@@ -70,20 +78,28 @@ vi.mock('bcrypt', () => ({
vi.mock('@/lib/utils/jwt', () => ({ vi.mock('@/lib/utils/jwt', () => ({
generateAccessToken: vi.fn(() => 'token'), generateAccessToken: vi.fn(() => 'token'),
generateRefreshToken: vi.fn(() => 'token'), generateRefreshToken: vi.fn(() => 'token'),
verifyAccessToken: verifyAccessTokenMock,
})); }));
function mockSetupComplete() { function mockSetupComplete() {
prismaMock.configuration.findUnique.mockResolvedValue({ key: 'setup_completed', value: 'true' }); prismaMock.configuration.findUnique.mockResolvedValue({ key: 'setup_completed', value: 'true' });
} }
function makeRequest(body: Record<string, unknown> = {}) { function makeRequest(body: Record<string, unknown> = {}, authToken?: string) {
const headers = new Map<string, string>();
if (authToken) {
headers.set('authorization', `Bearer ${authToken}`);
}
return { return {
json: vi.fn().mockResolvedValue(body), json: vi.fn().mockResolvedValue(body),
nextUrl: { pathname: '/api/setup/test' }, nextUrl: { pathname: '/api/setup/test' },
headers: {
get: (key: string) => headers.get(key) ?? null,
},
} as any; } as any;
} }
describe('Setup route guard - blocks access after setup is complete', () => { describe('Setup route guard - setup-only endpoints (requireSetupIncomplete)', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockSetupComplete(); mockSetupComplete();
@@ -126,25 +142,50 @@ describe('Setup route guard - blocks access after setup is complete', () => {
expect(payload.error).toBe('Forbidden'); 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 { POST } = await import('@/app/api/setup/test-paths/route');
const response = await POST(makeRequest({ downloadDir: '/downloads', mediaDir: '/media' })); const response = await POST(makeRequest({ downloadDir: '/downloads', mediaDir: '/media' }));
const payload = await response.json(); const payload = await response.json();
expect(response.status).toBe(403); expect(response.status).toBe(401);
expect(payload.error).toBe('Forbidden'); 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 { POST } = await import('@/app/api/setup/test-abs/route');
const response = await POST(makeRequest({ serverUrl: 'http://abs', apiToken: 'token' })); const response = await POST(makeRequest({ serverUrl: 'http://abs', apiToken: 'token' }));
const payload = await response.json(); const payload = await response.json();
expect(response.status).toBe(403); expect(response.status).toBe(401);
expect(payload.error).toBe('Forbidden'); 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 { POST } = await import('@/app/api/setup/test-oidc/route');
const response = await POST(makeRequest({ const response = await POST(makeRequest({
issuerUrl: 'http://issuer', issuerUrl: 'http://issuer',
@@ -153,31 +194,42 @@ describe('Setup route guard - blocks access after setup is complete', () => {
})); }));
const payload = await response.json(); 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(response.status).toBe(403);
expect(payload.error).toBe('Forbidden'); expect(payload.error).toBe('Forbidden');
}); });
it('allows requests through when setup is not yet complete', async () => { it('POST /api/setup/test-paths allows admin access after setup is complete', async () => {
// Override: setup not complete verifyAccessTokenMock.mockReturnValue({ sub: 'admin-1', role: 'admin' });
prismaMock.configuration.findUnique.mockResolvedValue(null); prismaMock.user.findUnique.mockResolvedValue({ id: 'admin-1', role: 'admin' });
const { POST } = await import('@/app/api/setup/test-download-client/route'); const { POST } = await import('@/app/api/setup/test-paths/route');
const response = await POST(makeRequest({ type: 'qbittorrent', url: 'http://qbt' })); const response = await POST(makeRequest({ downloadDir: '/downloads', mediaDir: '/media' }, 'admin-token'));
const payload = await response.json();
// 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); expect(response.status).not.toBe(403);
}); });
it('allows requests through when database is not ready', async () => { it('allows unauthenticated access during setup for shared endpoints', async () => {
// Override: database error prismaMock.configuration.findUnique.mockResolvedValue(null);
prismaMock.configuration.findUnique.mockRejectedValue(new Error('DB not ready'));
const { POST } = await import('@/app/api/setup/test-download-client/route'); const { POST } = await import('@/app/api/setup/test-paths/route');
const response = await POST(makeRequest({ type: 'qbittorrent', url: 'http://qbt' })); const response = await POST(makeRequest({ downloadDir: '/downloads', mediaDir: '/media' }));
const payload = await response.json();
// 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); expect(response.status).not.toBe(403);
}); });
}); });