/** * Component: Auth Middleware Tests * Documentation: documentation/backend/services/auth.md */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { NextResponse } from 'next/server'; import { createPrismaMock } from '../helpers/prisma'; import crypto from 'crypto'; const prismaMock = createPrismaMock(); const verifyAccessTokenMock = vi.fn(); vi.mock('@/lib/db', () => ({ prisma: prismaMock, })); vi.mock('@/lib/utils/jwt', () => ({ verifyAccessToken: verifyAccessTokenMock, })); const makeRequest = (authHeader?: string, pathname = '/api/requests', method = 'GET') => ({ method, nextUrl: { pathname }, headers: { get: (key: string) => { if (key.toLowerCase() === 'authorization') { return authHeader ?? null; } return null; }, }, }); // Helper to create a valid API token hash for testing const createTestApiToken = (token: string) => { return crypto.createHash('sha256').update(token).digest('hex'); }; describe('auth middleware', () => { beforeEach(() => { vi.clearAllMocks(); }); it('rejects requests without a token', async () => { const { requireAuth } = await import('@/lib/middleware/auth'); const response = await requireAuth(makeRequest() as any, vi.fn()); const payload = await response.json(); expect(response.status).toBe(401); expect(payload.error).toBe('Unauthorized'); }); it('rejects invalid tokens', async () => { verifyAccessTokenMock.mockReturnValue(null); const { requireAuth } = await import('@/lib/middleware/auth'); const response = await requireAuth(makeRequest('Bearer badtoken') as any, vi.fn()); const payload = await response.json(); expect(response.status).toBe(401); expect(payload.message).toMatch(/invalid/i); }); it('rejects tokens for missing users', async () => { verifyAccessTokenMock.mockReturnValue({ sub: 'user-1', plexId: 'plex-1', username: 'user', role: 'user', iat: 1, exp: 2, }); prismaMock.user.findUnique.mockResolvedValue(null); const { requireAuth } = await import('@/lib/middleware/auth'); const response = await requireAuth(makeRequest('Bearer token') as any, vi.fn()); const payload = await response.json(); expect(response.status).toBe(401); expect(payload.message).toMatch(/user not found/i); }); it('passes authenticated requests to handler', async () => { verifyAccessTokenMock.mockReturnValue({ sub: 'user-1', plexId: 'plex-1', username: 'user', role: 'user', iat: 1, exp: 2, }); prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1' }); const { requireAuth } = await import('@/lib/middleware/auth'); const handler = vi.fn(async (req: any) => NextResponse.json({ ok: true, userId: req.user?.id }) ); const response = await requireAuth(makeRequest('Bearer token') as any, handler); const payload = await response.json(); expect(handler).toHaveBeenCalled(); expect(payload.userId).toBe('user-1'); }); it('requires admin role', async () => { const { requireAdmin } = await import('@/lib/middleware/auth'); const noUserResponse = await requireAdmin({} as any, vi.fn()); expect(noUserResponse.status).toBe(401); const response = await requireAdmin({ user: { role: 'user' } } as any, vi.fn()); expect(response.status).toBe(403); }); it('allows admin users', async () => { const { requireAdmin } = await import('@/lib/middleware/auth'); const handler = vi.fn(async () => NextResponse.json({ ok: true })); const response = await requireAdmin({ user: { role: 'admin' } } as any, handler); expect(handler).toHaveBeenCalled(); expect(response.status).toBe(200); }); it('requires local admin with setup flag', async () => { prismaMock.user.findUnique.mockResolvedValue({ isSetupAdmin: true, plexId: 'local-admin', }); const { requireLocalAdmin } = await import('@/lib/middleware/auth'); const handler = vi.fn(async () => NextResponse.json({ ok: true })); const response = await requireLocalAdmin( { user: { id: 'user-1', role: 'admin' } } as any, handler ); expect(handler).toHaveBeenCalled(); expect(response.status).toBe(200); }); it('rejects non-local admins', async () => { prismaMock.user.findUnique.mockResolvedValue({ isSetupAdmin: false, plexId: 'plex-user', }); const { requireLocalAdmin } = await import('@/lib/middleware/auth'); const response = await requireLocalAdmin( { user: { id: 'user-1', role: 'admin' } } as any, vi.fn() ); expect(response.status).toBe(403); }); it('checks local admin helper', async () => { prismaMock.user.findUnique.mockResolvedValue({ isSetupAdmin: true, plexId: 'local-admin', }); const { isLocalAdmin } = await import('@/lib/middleware/auth'); const result = await isLocalAdmin('user-1'); expect(result).toBe(true); }); it('rejects JWT tokens for soft-deleted users', async () => { verifyAccessTokenMock.mockReturnValue({ sub: 'user-1', plexId: 'plex-1', username: 'user', role: 'user', iat: 1, exp: 2, }); prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', deletedAt: new Date(), }); const { requireAuth } = await import('@/lib/middleware/auth'); const response = await requireAuth(makeRequest('Bearer token') as any, vi.fn()); const payload = await response.json(); expect(response.status).toBe(401); expect(payload.message).toMatch(/user not found/i); }); describe('API token authentication', () => { const testToken = 'rmab_test1234567890abcdef'; const testTokenHash = createTestApiToken(testToken); it('rejects API tokens for soft-deleted users', async () => { prismaMock.apiToken.findUnique.mockResolvedValue({ id: 'token-1', tokenHash: testTokenHash, role: 'user', expiresAt: null, tokenUser: { id: 'user-1', plexUsername: 'deleteduser', role: 'user', deletedAt: new Date(), }, }); const { requireAuth } = await import('@/lib/middleware/auth'); const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn()); const payload = await response.json(); expect(response.status).toBe(401); expect(payload.message).toMatch(/invalid.*expired/i); }); it('rejects API tokens for missing users', async () => { prismaMock.apiToken.findUnique.mockResolvedValue({ id: 'token-1', tokenHash: testTokenHash, role: 'user', expiresAt: null, tokenUser: null, }); const { requireAuth } = await import('@/lib/middleware/auth'); const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn()); const payload = await response.json(); expect(response.status).toBe(401); expect(payload.message).toMatch(/invalid.*expired/i); }); it('accepts valid API tokens for active users on allowed endpoints', async () => { prismaMock.apiToken.findUnique.mockResolvedValue({ id: 'token-1', tokenHash: testTokenHash, role: 'user', expiresAt: null, tokenUser: { id: 'user-1', plexUsername: 'activeuser', role: 'user', deletedAt: null, }, }); prismaMock.apiToken.update.mockResolvedValue({}); const { requireAuth } = await import('@/lib/middleware/auth'); const handler = vi.fn(async (req: any) => NextResponse.json({ ok: true, userId: req.user?.id }) ); const response = await requireAuth( makeRequest(`Bearer ${testToken}`, '/api/requests', 'GET') as any, handler ); const payload = await response.json(); expect(handler).toHaveBeenCalled(); expect(payload.userId).toBe('user-1'); }); it('blocks API tokens on endpoints not in the allowlist', async () => { prismaMock.apiToken.findUnique.mockResolvedValue({ id: 'token-1', tokenHash: testTokenHash, role: 'admin', expiresAt: null, tokenUser: { id: 'user-1', plexUsername: 'activeuser', role: 'admin', deletedAt: null, }, }); prismaMock.apiToken.update.mockResolvedValue({}); const { requireAuth } = await import('@/lib/middleware/auth'); const handler = vi.fn(); const response = await requireAuth( makeRequest(`Bearer ${testToken}`, '/api/admin/settings', 'GET') as any, handler ); const payload = await response.json(); expect(handler).not.toHaveBeenCalled(); expect(response.status).toBe(403); expect(payload.message).toMatch(/not available via API token/i); }); it('allows API tokens on every permitted endpoint (incl. dynamic ID match)', async () => { const allowedCalls: Array<[string, string]> = [ ['GET', '/api/auth/me'], ['GET', '/api/audiobooks/search'], ['GET', '/api/requests'], ['POST', '/api/requests'], ['GET', '/api/requests/abc-uuid-123'], ['GET', '/api/requests/00000000-0000-0000-0000-000000000000'], ['GET', '/api/admin/metrics'], ['GET', '/api/admin/downloads/active'], ['GET', '/api/admin/requests/recent'], ]; for (const [method, path] of allowedCalls) { vi.clearAllMocks(); prismaMock.apiToken.findUnique.mockResolvedValue({ id: 'token-1', tokenHash: testTokenHash, role: 'admin', expiresAt: null, tokenUser: { id: 'user-1', plexUsername: 'activeuser', role: 'admin', deletedAt: null, }, }); prismaMock.apiToken.update.mockResolvedValue({}); const { requireAuth } = await import('@/lib/middleware/auth'); const handler = vi.fn(async () => NextResponse.json({ ok: true })); const response = await requireAuth( makeRequest(`Bearer ${testToken}`, path, method) as any, handler ); expect(handler, `${method} ${path}`).toHaveBeenCalled(); expect(response.status, `${method} ${path}`).toBe(200); } }); it('blocks API tokens on sibling sub-routes of /api/requests/:id', async () => { const blockedCalls: Array<[string, string]> = [ ['GET', '/api/requests/abc/select-torrent'], ['GET', '/api/requests/abc/download-token'], ['GET', '/api/requests/abc/interactive-search'], ['POST', '/api/requests/abc/manual-search'], ['POST', '/api/requests/abc/select-ebook'], ]; for (const [method, path] of blockedCalls) { vi.clearAllMocks(); prismaMock.apiToken.findUnique.mockResolvedValue({ id: 'token-1', tokenHash: testTokenHash, role: 'admin', expiresAt: null, tokenUser: { id: 'user-1', plexUsername: 'activeuser', role: 'admin', deletedAt: null, }, }); prismaMock.apiToken.update.mockResolvedValue({}); const { requireAuth } = await import('@/lib/middleware/auth'); const handler = vi.fn(); const response = await requireAuth( makeRequest(`Bearer ${testToken}`, path, method) as any, handler ); expect(handler, `${method} ${path} should be blocked`).not.toHaveBeenCalled(); expect(response.status, `${method} ${path}`).toBe(403); } }); it('does not restrict JWT-authenticated users to the allowlist', async () => { verifyAccessTokenMock.mockReturnValue({ sub: 'user-1', plexId: 'plex-1', username: 'user', role: 'admin', iat: 1, exp: 2, }); prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1' }); const { requireAuth } = await import('@/lib/middleware/auth'); const handler = vi.fn(async () => NextResponse.json({ ok: true })); // Use a non-allowlisted endpoint — JWT should still work const response = await requireAuth( makeRequest('Bearer jwttoken', '/api/admin/settings', 'POST') as any, handler ); expect(handler).toHaveBeenCalled(); expect(response.status).toBe(200); }); }); it('returns current user from token', async () => { verifyAccessTokenMock.mockReturnValue({ sub: 'user-1', plexId: 'plex-1', username: 'user', role: 'admin', iat: 1, exp: 2, }); const { getCurrentUser, isAdmin } = await import('@/lib/middleware/auth'); const payload = getCurrentUser(makeRequest('Bearer token') as any); expect(payload?.sub).toBe('user-1'); expect(isAdmin(payload)).toBe(true); }); describe('getCurrentUserAsync', () => { it('returns null when no token is present', async () => { const { getCurrentUserAsync } = await import('@/lib/middleware/auth'); const payload = await getCurrentUserAsync(makeRequest() as any); expect(payload).toBeNull(); }); it('resolves JWT tokens via verifyAccessToken', async () => { verifyAccessTokenMock.mockReturnValue({ sub: 'user-jwt', plexId: 'plex-jwt', username: 'jwtuser', role: 'user', iat: 1, exp: 2, }); const { getCurrentUserAsync } = await import('@/lib/middleware/auth'); const payload = await getCurrentUserAsync(makeRequest('Bearer jwttoken') as any); expect(payload?.sub).toBe('user-jwt'); expect(payload?.role).toBe('user'); }); it('resolves API tokens via apiToken lookup', async () => { const apiTok = 'rmab_async_test_1234567890'; const apiTokHash = crypto.createHash('sha256').update(apiTok).digest('hex'); prismaMock.apiToken.findUnique.mockResolvedValue({ id: 'token-async', tokenHash: apiTokHash, role: 'user', expiresAt: null, tokenUser: { id: 'user-api', plexId: 'plex-api', plexUsername: 'apiuser', role: 'user', deletedAt: null, }, }); prismaMock.apiToken.update.mockResolvedValue({}); const { getCurrentUserAsync } = await import('@/lib/middleware/auth'); const payload = await getCurrentUserAsync(makeRequest(`Bearer ${apiTok}`) as any); expect(payload?.sub).toBe('user-api'); expect(payload?.username).toBe('apiuser'); }); it('returns null for invalid API tokens', async () => { prismaMock.apiToken.findUnique.mockResolvedValue(null); const { getCurrentUserAsync } = await import('@/lib/middleware/auth'); const payload = await getCurrentUserAsync( makeRequest('Bearer rmab_invalid_token_abc') as any ); expect(payload).toBeNull(); }); }); });