fix: missing integration page routes

This commit is contained in:
fsylvain committed 2026-10-07 16:50:44 +02:00
1 parent 59c72a84ce
commit 28d2d5c826
7 files changed
+205 -24

No files matched your search

+51 -9
View File
@@ -34,6 +34,10 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
'DELETE /v1/media/{id}',
'POST /v1/media/merge',
'GET /v1/media/{id}/usage',
'GET /v1/media/purge-settings',
'PATCH /v1/media/purge-settings',
'GET /v1/media/purge-preview',
'POST /v1/media/purge',
],
'Users and invites': [
'GET /v1/user/',
@@ -61,6 +65,8 @@ const EXPECTED_SESSION: Record<string, string[]> = {
'DELETE /v1/auth/register',
'POST /v1/auth/password-reset',
],
'Settings · owner only': ['PATCH /v1/settings'],
'Activity log': ['GET /v1/activity', 'GET /v1/activity/summary', 'GET /v1/activity/export'],
'API key management': ['GET /v1/api-key', 'POST /v1/api-key', 'DELETE /v1/api-key/{id}'],
}
@@ -133,20 +139,20 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
}
describe('Catalogue routes', () => {
it('holds exactly the 20 key-callable routes in 4 groups, in order', () => {
it('holds exactly the 24 key-callable routes in 4 groups, in order', () => {
expect(
Object.fromEntries(
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
),
).toEqual(EXPECTED_GROUPS)
expect(allEndpoints()).toHaveLength(20)
expect(allEndpoints()).toHaveLength(24)
})
it('holds the 13 session-only rows in 3 sub-groups', () => {
it('holds the 17 session-only rows in 5 sub-groups', () => {
expect(
Object.fromEntries(SESSION_GROUPS.map((group) => [group.title, group.rows.map(label)])),
).toEqual(EXPECTED_SESSION)
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(13)
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(17)
})
it('holds the 6 public auth flow rows', () => {
@@ -168,7 +174,7 @@ describe('Catalogue routes', () => {
),
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
]
expect(described).toHaveLength(4 + 20 + 13 + 6)
expect(described).toHaveLength(4 + 24 + 17 + 6)
const blank = described.filter(([, description]) => description.trim() === '')
expect(blank.map(([name]) => name)).toEqual([])
})
@@ -190,7 +196,9 @@ describe('Catalogue completeness', () => {
const examples = endpointExamples(endpoint, ORIGIN)
expect(['GET', 'POST', 'PATCH', 'DELETE']).toContain(endpoint.method)
expect(endpoint.path.startsWith('/v1/')).toBe(true)
expect(['Public', 'Verified user', 'Owner only']).toContain(endpoint.access)
expect(['Public', 'Verified user', 'Owner or admin', 'Owner only']).toContain(
endpoint.access,
)
expect(examples.fetch).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(
@@ -227,6 +235,30 @@ describe('Catalogue completeness', () => {
})
})
describe('Owner or admin purge routes', () => {
it('gives Owner or admin to the four purge routes only', () => {
expect(
allEndpoints()
.filter((endpoint) => endpoint.access === 'Owner or admin')
.map(label),
).toEqual([
'GET /v1/media/purge-settings',
'PATCH /v1/media/purge-settings',
'GET /v1/media/purge-preview',
'POST /v1/media/purge',
])
})
it('lists the owner or admin 403 on each purge route', () => {
for (const endpoint of allEndpoints().filter((item) => item.access === 'Owner or admin')) {
expect(endpoint.errors).toContainEqual({
status: 403,
caption: 'Only owners and admins can do this.',
})
}
})
})
describe('Response samples use the real domain JSON keys', () => {
it('uses the article keys, without images or status', () => {
const single = parsed(find('GET /v1/article/{id}'))
@@ -286,8 +318,14 @@ describe('Response samples use the real domain JSON keys', () => {
expectOnlyKeys(parsed(find('PATCH /v1/user/me')), ME_KEYS)
})
it('uses version for the settings', () => {
expect(keysOf(parsed(find('GET /v1/settings')))).toEqual(['version'])
it('uses the version and activity retention keys for the settings', () => {
expect(keysOf(parsed(find('GET /v1/settings')))).toEqual([
'activity_last_cleanup_at',
'activity_last_cleanup_removed',
'activity_next_cleanup_at',
'activity_retention_days',
'version',
])
})
it('answers 204 for the three deletes without a JSON body', () => {
@@ -456,7 +494,7 @@ describe('Per-endpoint error captions use the real strings', () => {
describe('Session only rows', () => {
const rows = SESSION_GROUPS.flatMap((group) => group.rows)
it('claims 403 only on verify, DELETE register, api-key and system rows', () => {
it('claims 403 only on verify, DELETE register, settings, activity, api-key and system rows', () => {
expect(rows.filter((row) => row.answers403).map(label)).toEqual([
'GET /v1/system/update',
'POST /v1/system/update/check',
@@ -464,6 +502,10 @@ describe('Session only rows', () => {
'POST /v1/system/update/restart',
'POST /v1/auth/verify',
'DELETE /v1/auth/register',
'PATCH /v1/settings',
'GET /v1/activity',
'GET /v1/activity/summary',
'GET /v1/activity/export',
'GET /v1/api-key',
'POST /v1/api-key',
'DELETE /v1/api-key/{id}',
+133 -3
View File
@@ -1,5 +1,5 @@
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'
export type AccessLevel = 'Public' | 'Verified user' | 'Owner only'
export type AccessLevel = 'Public' | 'Verified user' | 'Owner or admin' | 'Owner only'
export interface ApiField {
name: string
@@ -72,6 +72,7 @@ export const API_KEY_PLACEHOLDER = 'rcms_Kf3x9QwL0aBc…'
export const NO_CONTENT_RESPONSE = '204 No Content'
export const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
export const OWNER_REQUIRED_MESSAGE = 'Only the owner can do this.'
export const OWNER_OR_ADMIN_MESSAGE = 'Only owners and admins can do this.'
export const LOGIN_FAMILY_CAPTION = 'Made for the browser. A key is ignored here, with no 403.'
export const RATE_LIMIT_SENTENCE = 'This route is rate limited. Too many requests answer 429.'
@@ -81,6 +82,10 @@ const VERIFY_REQUIRED = 'You must verify your account to access this resource.'
const ID_FORMAT =
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'
const FORMAT_INVALID = 'The format must be one of: markdown, html, json.'
const PURGE_INTERVAL = 'Enter a whole number of days between 1 and 365.'
const PURGE_CONFLICT =
'The library changed, so nothing was purged. Check the new summary and try again.'
const PURGE_BUSY = 'A purge is already running.'
const TOO_MANY_REQUESTS = 'Too many requests. Please try again later.'
const FORMAT_QUERY: ApiField = {
@@ -177,10 +182,26 @@ const articleErrors = (...own: ApiError[]) => [...own, e(400, FORMAT_INVALID), e
const verified = (errors: ApiError[]) => [...errors, e(403, VERIFY_REQUIRED)]
const ownerOrAdminErrors = (...own: ApiError[]) => [
...own,
e(401, UNAUTHORIZED),
e(403, OWNER_OR_ADMIN_MESSAGE),
]
const articleIdErrors = [e(400, ID_FORMAT), e(404, 'Article not found')]
const none: ApiField[] = []
const PURGE_SETTINGS = {
auto_purge_enabled: true,
auto_purge_interval_days: 30,
last_purge_at: '2026-09-10T07:00:00Z',
last_purge_merged: 2,
last_purge_articles_updated: 5,
enabled_at: '2026-08-11T07:00:00Z',
next_purge_at: '2026-10-09T07:00:00Z',
}
function endpoint(
id: string,
method: HttpMethod,
@@ -479,6 +500,97 @@ export const API_GROUPS: ApiGroup[] = [
json([{ id: 12, title: ARTICLE_TITLE, published: true }]),
{ pathId: 4 },
),
endpoint(
'med-purge-get',
'GET',
'/v1/media/purge-settings',
'Get the automatic duplicate purge settings.',
'Owner or admin',
ownerOrAdminErrors(),
json(PURGE_SETTINGS),
),
endpoint(
'med-purge-set',
'PATCH',
'/v1/media/purge-settings',
'Turn the automatic purge on or off, or change its interval.',
'Owner or admin',
ownerOrAdminErrors(e(400, BODY_MISMATCH), e(400, PURGE_INTERVAL)),
json(PURGE_SETTINGS),
{
bodyKind: 'json',
body: [
{
name: 'auto_purge_enabled',
type: 'boolean',
required: false,
rule: 'Leave out to keep the current value.',
},
{
name: 'auto_purge_interval_days',
type: 'number',
required: false,
rule: '1 to 365. Leave out to keep the current value.',
},
],
bodySample: { auto_purge_enabled: true, auto_purge_interval_days: 30 },
},
),
endpoint(
'med-purge-prev',
'GET',
'/v1/media/purge-preview',
'Preview what a purge would merge. Nothing changes.',
'Owner or admin',
ownerOrAdminErrors(),
json({
groups: 1,
copies: 2,
articles: 5,
list: [
{
keep_id: 4,
filename: 'north-wall.jpg',
url: '/static/uploadedImages/3f9c2b7e-5d1a-4c8e-9b0f-6a2d4e8c1b73.jpg',
used_in: 5,
merged: 2,
},
],
more: 0,
fingerprint: '9c1f5e0a7d3b',
}),
),
endpoint(
'med-purge-run',
'POST',
'/v1/media/purge',
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library.',
'Owner or admin',
ownerOrAdminErrors(
e(400, BODY_MISMATCH),
e(409, PURGE_CONFLICT),
e(409, PURGE_BUSY),
),
json({
groups_total: 1,
groups_purged: 1,
merged: 2,
articles_updated: 5,
failed_groups: 0,
}),
{
bodyKind: 'json',
body: [
{
name: 'fingerprint',
type: 'string',
required: false,
rule: 'The fingerprint of the preview. Without it, the purge runs on the current library.',
},
],
bodySample: { fingerprint: '9c1f5e0a7d3b' },
},
),
],
},
{
@@ -598,7 +710,7 @@ export const API_GROUPS: ApiGroup[] = [
{
key: 'settings',
title: 'Settings',
description: 'Read the site settings.',
description: 'Read the site settings, including the activity log retention.',
endpoints: [
endpoint(
'set-get',
@@ -607,7 +719,13 @@ export const API_GROUPS: ApiGroup[] = [
'Get settings.',
'Verified user',
verified([e(401, UNAUTHORIZED)]),
json({ version: 'v2.2.0-beta' }),
json({
version: 'v3.0.0-beta',
activity_retention_days: 90,
activity_last_cleanup_at: '2026-09-10T03:00:00Z',
activity_last_cleanup_removed: 12,
activity_next_cleanup_at: '2026-09-11T03:00:00Z',
}),
),
],
},
@@ -646,6 +764,18 @@ export const SESSION_GROUPS: SessionGroup[] = [
row('POST', '/v1/auth/password-reset', 'Reset password', false),
],
},
{
title: 'Settings · owner only',
rows: [row('PATCH', '/v1/settings', 'Change the activity log retention')],
},
{
title: 'Activity log',
rows: [
row('GET', '/v1/activity', 'List activity events'),
row('GET', '/v1/activity/summary', 'Activity summary, owner and admin'),
row('GET', '/v1/activity/export', 'Export activity as CSV, owner and admin'),
],
},
{
title: 'API key management',
rows: [