mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
f9947b745e
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.
236 lines
7.9 KiB
TypeScript
236 lines
7.9 KiB
TypeScript
/**
|
|
* 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<string, unknown> = {}, authToken?: string) {
|
|
const headers = new Map<string, string>();
|
|
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);
|
|
});
|
|
});
|