mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -36,6 +36,7 @@ vi.mock('@/lib/utils/ignored-audiobooks', () => ({
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
getCurrentUser: currentUserMock,
|
||||
getCurrentUserAsync: currentUserMock,
|
||||
}));
|
||||
|
||||
describe('Audiobooks browse routes', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user