diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index b74b97a..0e3deb2 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -6,6 +6,7 @@ - **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md) - **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md) - **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md) +- **API tokens (allowlist, write capability, /api-docs)** → [backend/services/api-tokens.md](backend/services/api-tokens.md) - **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md) - **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md) - **Credential recovery (lost CONFIG_ENCRYPTION_KEY, locked-out admin)** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md) diff --git a/documentation/backend/services/api-tokens.md b/documentation/backend/services/api-tokens.md new file mode 100644 index 0000000..2e8283c --- /dev/null +++ b/documentation/backend/services/api-tokens.md @@ -0,0 +1,84 @@ +# API Tokens + +**Status:** ✅ Implemented | Personal long-lived tokens, allowlisted endpoints, write capability per issue #169 + +## Overview +Static `rmab_`-prefixed tokens act with the owner's full user-level permissions on a fixed allowlist of endpoints. JWT sessions are NOT restricted by the allowlist. + +## Key Details +- **Prefix:** `rmab_` (12-char stored display prefix: `rmab_` + 7 hex chars) +- **Storage:** SHA-256 hash in `apiToken.tokenHash`; full token shown ONCE on create +- **Role binding:** Token `role` matches token owner's role at creation time; admin tokens require admin-created +- **Per-user cap:** 25 active (non-expired) tokens (`MAX_TOKENS_PER_USER`) +- **Expiry:** Optional (`never`, `30d`, `90d`, `1y`) +- **Soft-deleted users:** Tokens reject if `tokenUser.deletedAt` is set +- **Identity attribution:** `req.user.id` resolves to `apiToken.userId` (target user), NOT `apiToken.createdById` +- **Header:** `Authorization: Bearer rmab_` + +## Allowed Endpoints +| Method | Path | Title | Write | Admin | +|---|---|---|---|---| +| GET | `/api/auth/me` | Current user | | | +| GET | `/api/audiobooks/search` | Search audiobooks | | | +| GET | `/api/requests` | List requests | | | +| POST | `/api/requests` | Create request | ✓ | | +| GET | `/api/requests/:id` | Get request by ID | | | +| GET | `/api/admin/metrics` | System metrics | | ✓ | +| GET | `/api/admin/downloads/active` | Active downloads | | ✓ | +| GET | `/api/admin/requests/recent` | Recent requests | | ✓ | + +Source of truth: `src/lib/constants/api-tokens.ts` (`API_TOKEN_ALLOWED_ENDPOINTS`, `API_TOKEN_ENDPOINT_DOCS`). + +## Matcher (`isEndpointAllowed`) +- Compiled once at module load. +- `path` entries containing `:name` are converted to anchored regexes where each placeholder matches `[^/]+` (a single segment). +- Sibling sub-routes (e.g. `/api/requests/:id/select-torrent`) are NOT matched by the `/api/requests/:id` entry — they require their own allowlist entry. +- Method comparison is case-insensitive. + +## POST `/api/requests` (Write) +- Body: `{ "audiobook": { "asin", "title", "author", "narrator?", "description?", "coverArtUrl?" } }` +- Internally calls `createRequestForUser(req.user.id, audiobook, { bypassIgnore: true })` — token requests bypass the ignore list, matching UI behavior. +- Optional query param: `?skipAutoSearch=true` defers search-job creation. +- Side effects (identical to UI): duplicate detection, library check, Audnexus enrichment, audiobook upsert, ignore-list check (bypassed), per-user dedup, auto-approve gating, release-date gate, notification queue, search-job queue. +- Auto-approve: follows the token owner's per-user `autoApproveRequests` setting, then global. No bypass. +- Response: `201 { success: true, request }` or named error: `{ error: "AlreadyAvailable" | "BeingProcessed" | "DuplicateRequest" | "Ignored" | "UserNotFound" | "ValidationError", message }` + +## GET `/api/requests/:id` +- Returns full request including `audiobook`, `downloadHistory` (selected), and recent `jobs`. +- Ownership enforced: `requestRecord.userId === req.user.id || role === 'admin'` → otherwise 403. +- Soft-deleted requests (`deletedAt != null`) return 404. + +## GET `/api/audiobooks/search` +- Auth is optional, NOT gated by allowlist (route never calls `requireAuth`). +- Uses `getCurrentUserAsync` to recognize both JWT sessions AND API tokens for per-user enrichment (request status, ignore status). +- Without auth: returns generic results with no user-context annotations. +- With JWT or `rmab_` token: returns results enriched with `isRequested`, `requestStatus`, `requestId`, `isIgnored`, etc. + +## Auth flow +1. Request hits route; `requireAuth` extracts `Authorization: Bearer ...` token. +2. If token starts with `rmab_` → `authenticateApiToken` (SHA-256 lookup, expiry + soft-delete check, fire-and-forget `lastUsedAt` update). +3. If on the allowlist → handler runs with `req.user = { sub, id, plexId, username, role }`. +4. If not on the allowlist → 403 "This endpoint is not available via API token authentication". +5. JWT tokens skip the allowlist entirely. + +## UI surfaces +- `/api-docs` page (`src/app/api-docs/page.tsx`) — auto-renders `API_TOKEN_ENDPOINT_DOCS`. Endpoints with `isWrite: true` show an amber **Write** badge; the "Try it" button is disabled with a "use curl" hint to avoid sending mutating requests from a UI that cannot construct request bodies. +- Profile → API Tokens (`src/components/profile/ApiTokensSection.tsx`) — create/revoke UI. Includes a one-line warning that tokens act with the owner's full permissions. +- Admin → Users → API Tokens — admin can create tokens on behalf of any user. + +## Files +- Constants + matcher: `src/lib/constants/api-tokens.ts` +- Middleware: `src/lib/middleware/auth.ts` (`requireAuth`, `getCurrentUser`, `getCurrentUserAsync`) +- Routes: + - `src/app/api/user/api-tokens/route.ts` (user create/list/revoke) + - `src/app/api/admin/api-tokens/route.ts` (admin) +- UI: `src/app/api-docs/page.tsx`, `src/components/api-docs/EndpointCard.tsx`, `src/components/api-docs/TokenInput.tsx`, `src/components/profile/ApiTokensSection.tsx` + +## Tests +- `tests/constants/api-tokens.test.ts` — matcher: positive matches, negative matches, sub-route exclusion, method case-insensitivity, allowlist/docs parity. +- `tests/middleware/auth.middleware.test.ts` — middleware token auth path, allowlist enforcement (incl. dynamic ID match), sibling-route blocking, `getCurrentUserAsync`. +- `tests/api/requests-id.route.test.ts` — owner GET 200, cross-user GET 403. + +## Related +- [backend/services/auth.md](auth.md) — JWT sessions, role-based access control +- [backend/services/notifications.md](notifications.md) — request notification triggers diff --git a/src/app/api-docs/page.tsx b/src/app/api-docs/page.tsx index 1454c01..f652a92 100644 --- a/src/app/api-docs/page.tsx +++ b/src/app/api-docs/page.tsx @@ -131,7 +131,9 @@ export default function ApiDocsPage() { {/* Footer note */}

- API tokens are restricted to the endpoints listed above. + API tokens are restricted to the endpoints listed above. Endpoints + flagged Write mutate + state on behalf of the token owner — keep your tokens private. JWT session authentication has access to all endpoints.

diff --git a/src/app/api/audiobooks/search/route.ts b/src/app/api/audiobooks/search/route.ts index 7412cb2..d61c70d 100644 --- a/src/app/api/audiobooks/search/route.ts +++ b/src/app/api/audiobooks/search/route.ts @@ -8,7 +8,7 @@ import { getAudibleService } from '@/lib/integrations/audible.service'; import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'; import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service'; -import { getCurrentUser } from '@/lib/middleware/auth'; +import { getCurrentUserAsync } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks'; @@ -37,8 +37,8 @@ export async function GET(request: NextRequest) { const audibleService = getAudibleService(); const results = await audibleService.search(query, page); - // Get current user (optional - for request status enrichment) - const currentUser = getCurrentUser(request); + // Get current user (optional — JWT or API token — for request-status enrichment) + const currentUser = await getCurrentUserAsync(request); const userId = currentUser?.sub || undefined; // Two-pass dedup: local title/narrator/duration matching first, then collapse diff --git a/src/components/api-docs/EndpointCard.tsx b/src/components/api-docs/EndpointCard.tsx index a521dcd..12fae46 100644 --- a/src/components/api-docs/EndpointCard.tsx +++ b/src/components/api-docs/EndpointCard.tsx @@ -90,6 +90,11 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps) Admin )} + {endpoint.isWrite && ( + + Write + + )}

{endpoint.title} @@ -99,25 +104,33 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)

- + {endpoint.isWrite && ( + + Use curl or your API client — write actions disabled here for safety. + )} - + {/* Expandable response area */} diff --git a/src/components/profile/ApiTokensSection.tsx b/src/components/profile/ApiTokensSection.tsx index b3586ee..7f452bf 100644 --- a/src/components/profile/ApiTokensSection.tsx +++ b/src/components/profile/ApiTokensSection.tsx @@ -27,6 +27,10 @@ export function ApiTokensSection() { View API documentation

+

+ API tokens act with your full user-level permissions, including creating audiobook + requests on your behalf. Keep them private. +

diff --git a/src/lib/constants/api-tokens.ts b/src/lib/constants/api-tokens.ts index c6860f5..0e49f93 100644 --- a/src/lib/constants/api-tokens.ts +++ b/src/lib/constants/api-tokens.ts @@ -21,7 +21,11 @@ export const MAX_TOKENS_PER_USER = 25; // Endpoint allowlist — restricts which routes API tokens may access // --------------------------------------------------------------------------- -/** Shape of an allowed endpoint entry */ +/** + * Shape of an allowed endpoint entry. + * `path` may be a literal (e.g. `/api/requests`) or contain `:name` placeholders + * that match a single path segment (e.g. `/api/requests/:id`). + */ export interface AllowedEndpoint { method: string; path: string; @@ -34,6 +38,8 @@ export interface EndpointDoc { title: string; description: string; requiresAdmin: boolean; + /** True for endpoints that mutate state. Surfaced in the /api-docs UI. */ + isWrite?: boolean; } /** @@ -42,7 +48,10 @@ export interface EndpointDoc { */ export const API_TOKEN_ALLOWED_ENDPOINTS: readonly AllowedEndpoint[] = [ { method: 'GET', path: '/api/auth/me' }, + { method: 'GET', path: '/api/audiobooks/search' }, { method: 'GET', path: '/api/requests' }, + { method: 'POST', path: '/api/requests' }, + { method: 'GET', path: '/api/requests/:id' }, { method: 'GET', path: '/api/admin/metrics' }, { method: 'GET', path: '/api/admin/downloads/active' }, { method: 'GET', path: '/api/admin/requests/recent' }, @@ -61,6 +70,14 @@ export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [ 'Returns the authenticated user\'s profile information including username, role, and account details.', requiresAdmin: false, }, + { + method: 'GET', + path: '/api/audiobooks/search', + title: 'Search audiobooks', + description: + 'Search Audible for audiobooks by title or author. Query params: `q` (required), `page` (optional). Returns enriched results including per-user request and library availability status.', + requiresAdmin: false, + }, { method: 'GET', path: '/api/requests', @@ -69,6 +86,23 @@ export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [ 'Returns all audiobook requests visible to the authenticated user. Admins see all requests, users see their own.', requiresAdmin: false, }, + { + method: 'POST', + path: '/api/requests', + title: 'Create request', + description: + 'Create a new audiobook request on behalf of the token owner. Body: `{ "audiobook": { "asin", "title", "author", "narrator?", "description?", "coverArtUrl?" } }`. Follows the user\'s normal auto-approve rules; returns named error codes (`already_available`, `being_processed`, `duplicate`, `ignored`, `user_not_found`) on rejection.', + requiresAdmin: false, + isWrite: true, + }, + { + method: 'GET', + path: '/api/requests/:id', + title: 'Get request by ID', + description: + 'Returns a single audiobook request including audiobook details, download history, and recent job state. Users may only fetch requests they own; admins may fetch any.', + requiresAdmin: false, + }, { method: 'GET', path: '/api/admin/metrics', @@ -95,13 +129,39 @@ export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [ }, ] as const; +/** + * Compiled allowlist used by `isEndpointAllowed`. Patterns with `:name` + * placeholders are compiled to anchored regexes that match a single path + * segment (`[^/]+`); literal paths use string equality. + */ +interface CompiledEndpoint { + method: string; + literal: string | null; + pattern: RegExp | null; +} + +function compileEndpoint(ep: AllowedEndpoint): CompiledEndpoint { + const method = ep.method.toUpperCase(); + if (!ep.path.includes(':')) { + return { method, literal: ep.path, pattern: null }; + } + const escaped = ep.path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regexSource = escaped.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '[^/]+'); + return { method, literal: null, pattern: new RegExp(`^${regexSource}$`) }; +} + +const COMPILED_ENDPOINTS: readonly CompiledEndpoint[] = API_TOKEN_ALLOWED_ENDPOINTS.map(compileEndpoint); + /** * Check whether a given method + path is on the API token allowlist. - * Method comparison is case-insensitive. + * Method comparison is case-insensitive. Supports dynamic single-segment + * placeholders (`:id`) compiled at module load. */ export function isEndpointAllowed(method: string, path: string): boolean { const upperMethod = method.toUpperCase(); - return API_TOKEN_ALLOWED_ENDPOINTS.some( - (ep) => ep.method === upperMethod && ep.path === path - ); + return COMPILED_ENDPOINTS.some((ep) => { + if (ep.method !== upperMethod) return false; + if (ep.literal !== null) return ep.literal === path; + return ep.pattern!.test(path); + }); } diff --git a/src/lib/middleware/auth.ts b/src/lib/middleware/auth.ts index 8ab4410..44f7e51 100644 --- a/src/lib/middleware/auth.ts +++ b/src/lib/middleware/auth.ts @@ -242,7 +242,9 @@ export async function requireAdmin( } /** - * Helper: Get current user from request (for use in API routes) + * Helper: Get current user from request (for use in API routes). + * JWT-only — does NOT recognize API tokens. Use `getCurrentUserAsync` if + * the caller should also accept `rmab_`-prefixed API tokens. */ export function getCurrentUser(request: NextRequest): TokenPayload | null { const token = extractToken(request); @@ -250,6 +252,23 @@ export function getCurrentUser(request: NextRequest): TokenPayload | null { return verifyAccessToken(token); } +/** + * Helper: Get current user from request, recognizing BOTH JWT sessions and + * API tokens (`rmab_` prefix). Returns the same `TokenPayload` shape in both + * cases so callers don't need to branch on auth type. + * + * Use this in routes that are open to optional auth but should still enrich + * responses with per-user context when called by an API token holder. + */ +export async function getCurrentUserAsync(request: NextRequest): Promise { + const token = extractToken(request); + if (!token) return null; + if (token.startsWith(API_TOKEN_PREFIX)) { + return authenticateApiToken(token); + } + return verifyAccessToken(token); +} + /** * Helper: Check if user is admin */ diff --git a/tests/api/audiobooks-browse.routes.test.ts b/tests/api/audiobooks-browse.routes.test.ts index baa0a9f..b162db1 100644 --- a/tests/api/audiobooks-browse.routes.test.ts +++ b/tests/api/audiobooks-browse.routes.test.ts @@ -36,6 +36,7 @@ vi.mock('@/lib/utils/ignored-audiobooks', () => ({ vi.mock('@/lib/middleware/auth', () => ({ getCurrentUser: currentUserMock, + getCurrentUserAsync: currentUserMock, })); describe('Audiobooks browse routes', () => { diff --git a/tests/constants/api-tokens.test.ts b/tests/constants/api-tokens.test.ts new file mode 100644 index 0000000..5f71f4f --- /dev/null +++ b/tests/constants/api-tokens.test.ts @@ -0,0 +1,95 @@ +/** + * Component: API Token Constants Tests + * Documentation: documentation/backend/services/api-tokens.md + */ + +import { describe, expect, it } from 'vitest'; +import { + API_TOKEN_ALLOWED_ENDPOINTS, + API_TOKEN_ENDPOINT_DOCS, + isEndpointAllowed, +} from '@/lib/constants/api-tokens'; + +describe('isEndpointAllowed', () => { + describe('positive matches (every allowlisted endpoint)', () => { + const cases: 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'], + ]; + + it.each(cases)('%s %s is allowed', (method, path) => { + expect(isEndpointAllowed(method, path)).toBe(true); + }); + }); + + describe('negative matches', () => { + it('rejects unrelated paths', () => { + expect(isEndpointAllowed('GET', '/api/admin/settings')).toBe(false); + expect(isEndpointAllowed('GET', '/api/audiobooks/popular')).toBe(false); + }); + + it('rejects wrong HTTP method', () => { + expect(isEndpointAllowed('DELETE', '/api/requests')).toBe(false); + expect(isEndpointAllowed('POST', '/api/requests/abc')).toBe(false); + expect(isEndpointAllowed('PATCH', '/api/requests/abc')).toBe(false); + }); + + it('rejects sibling sub-routes of /api/requests/:id', () => { + // The :id placeholder must match a SINGLE segment — anything deeper is excluded. + expect(isEndpointAllowed('GET', '/api/requests/abc/select-torrent')).toBe(false); + expect(isEndpointAllowed('GET', '/api/requests/abc/download-token')).toBe(false); + expect(isEndpointAllowed('GET', '/api/requests/abc/interactive-search')).toBe(false); + expect(isEndpointAllowed('GET', '/api/requests/abc/manual-search')).toBe(false); + expect(isEndpointAllowed('GET', '/api/requests/abc/select-ebook')).toBe(false); + expect(isEndpointAllowed('POST', '/api/requests/abc/select-torrent')).toBe(false); + }); + + it('rejects partial / extended paths', () => { + expect(isEndpointAllowed('GET', '/api/request')).toBe(false); + expect(isEndpointAllowed('GET', '/api/requests/')).toBe(false); + expect(isEndpointAllowed('GET', '/api/auth/me/extra')).toBe(false); + }); + + it('does not allow empty :id segment to match', () => { + // `/api/requests/:id` requires at least one char in the segment; + // a literal `/api/requests` is matched separately. + expect(isEndpointAllowed('GET', '/api/requests/')).toBe(false); + }); + }); + + describe('method case-insensitivity', () => { + it('accepts lowercase and mixed-case methods', () => { + expect(isEndpointAllowed('get', '/api/requests')).toBe(true); + expect(isEndpointAllowed('Get', '/api/requests')).toBe(true); + expect(isEndpointAllowed('post', '/api/requests')).toBe(true); + expect(isEndpointAllowed('PoSt', '/api/requests')).toBe(true); + }); + }); + + describe('docs / allowlist parity', () => { + it('every documented endpoint is on the allowlist', () => { + for (const doc of API_TOKEN_ENDPOINT_DOCS) { + const found = API_TOKEN_ALLOWED_ENDPOINTS.some( + (ep) => ep.method === doc.method && ep.path === doc.path + ); + expect(found, `${doc.method} ${doc.path} missing from allowlist`).toBe(true); + } + }); + + it('every allowlisted endpoint has a docs entry', () => { + for (const ep of API_TOKEN_ALLOWED_ENDPOINTS) { + const found = API_TOKEN_ENDPOINT_DOCS.some( + (doc) => doc.method === ep.method && doc.path === ep.path + ); + expect(found, `${ep.method} ${ep.path} missing from docs`).toBe(true); + } + }); + }); +}); diff --git a/tests/middleware/auth.middleware.test.ts b/tests/middleware/auth.middleware.test.ts index 7da03f7..8414bd3 100644 --- a/tests/middleware/auth.middleware.test.ts +++ b/tests/middleware/auth.middleware.test.ts @@ -290,16 +290,20 @@ describe('auth middleware', () => { expect(payload.message).toMatch(/not available via API token/i); }); - it('allows API tokens on all 5 permitted endpoints', async () => { - const allowedPaths = [ - '/api/auth/me', - '/api/requests', - '/api/admin/metrics', - '/api/admin/downloads/active', - '/api/admin/requests/recent', + 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 path of allowedPaths) { + for (const [method, path] of allowedCalls) { vi.clearAllMocks(); prismaMock.apiToken.findUnique.mockResolvedValue({ id: 'token-1', @@ -318,12 +322,49 @@ describe('auth middleware', () => { const handler = vi.fn(async () => NextResponse.json({ ok: true })); const response = await requireAuth( - makeRequest(`Bearer ${testToken}`, path, 'GET') as any, + makeRequest(`Bearer ${testToken}`, path, method) as any, handler ); - expect(handler).toHaveBeenCalled(); - expect(response.status).toBe(200); + 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); } }); @@ -366,4 +407,62 @@ describe('auth middleware', () => { 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(); + }); + }); });