mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
6ec53ff7e3
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.
96 lines
3.8 KiB
TypeScript
96 lines
3.8 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
});
|
|
});
|
|
});
|