Add API token allowlist, docs, UI and tests

Introduce API token allowlist support and documentation. Adds a new backend docs page for API tokens and updates TABLEOFCONTENTS. Implements API token constants and a compiled matcher (isEndpointAllowed) with support for single-segment :placeholders and an isWrite flag. Split getCurrentUser into a JWT-only helper and added getCurrentUserAsync to recognize rmab_ API tokens; updated the audiobooks search route to use getCurrentUserAsync. Update API docs UI (EndpointCard and api-docs page) to surface Write badges and disable "Try it" for mutating endpoints, and add a profile warning in ApiTokensSection. Add tests for the allowlist matcher and middleware, and adjust existing route tests/mocks accordingly.
This commit is contained in:
kikootwo
2026-05-16 14:17:49 -04:00
parent e39e44ee44
commit 6ec53ff7e3
11 changed files with 417 additions and 39 deletions
+1
View File
@@ -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)
@@ -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_<token>`
## 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
+3 -1
View File
@@ -131,7 +131,9 @@ export default function ApiDocsPage() {
{/* Footer note */}
<div className="mt-10 text-center">
<p className="text-xs text-gray-400 dark:text-gray-500">
API tokens are restricted to the endpoints listed above.
API tokens are restricted to the endpoints listed above. Endpoints
flagged <span className="font-semibold text-amber-600 dark:text-amber-400">Write</span> mutate
state on behalf of the token owner keep your tokens private.
JWT session authentication has access to all endpoints.
</p>
</div>
+3 -3
View File
@@ -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
+31 -18
View File
@@ -90,6 +90,11 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)
Admin
</span>
)}
{endpoint.isWrite && (
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wider bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
Write
</span>
)}
</div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">
{endpoint.title}
@@ -99,25 +104,33 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)
</p>
</div>
<button
onClick={handleTryIt}
disabled={loading}
className="flex-shrink-0 inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:opacity-50 transition-all active:scale-[0.97]"
>
{loading ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 dark:border-gray-900/30 border-t-white dark:border-t-gray-900" />
Running
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
Try it
</>
<div className="flex-shrink-0 flex flex-col items-end gap-1">
<button
onClick={handleTryIt}
disabled={loading || endpoint.isWrite}
title={endpoint.isWrite ? 'Use curl or your API client — write actions disabled here for safety' : undefined}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all active:scale-[0.97]"
>
{loading ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 dark:border-gray-900/30 border-t-white dark:border-t-gray-900" />
Running
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
Try it
</>
)}
</button>
{endpoint.isWrite && (
<span className="text-[11px] text-gray-500 dark:text-gray-400 max-w-[180px] text-right leading-snug">
Use curl or your API client write actions disabled here for safety.
</span>
)}
</button>
</div>
</div>
{/* Expandable response area */}
@@ -27,6 +27,10 @@ export function ApiTokensSection() {
View API documentation
</Link>
</p>
<p className="mt-2 text-xs text-amber-700 dark:text-amber-400">
API tokens act with your full user-level permissions, including creating audiobook
requests on your behalf. Keep them private.
</p>
</div>
</div>
+65 -5
View File
@@ -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);
});
}
+20 -1
View File
@@ -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<TokenPayload | null> {
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
*/
@@ -36,6 +36,7 @@ vi.mock('@/lib/utils/ignored-audiobooks', () => ({
vi.mock('@/lib/middleware/auth', () => ({
getCurrentUser: currentUserMock,
getCurrentUserAsync: currentUserMock,
}));
describe('Audiobooks browse routes', () => {
+95
View File
@@ -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);
}
});
});
});
+110 -11
View File
@@ -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();
});
});
});