From 28d2d5c8261f640e09b77e9cf73c49fef7e8a195 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Wed, 7 Oct 2026 16:50:44 +0200 Subject: [PATCH] fix: missing integration page routes --- docs/api.md | 4 +- web/src/components/EndpointCard.vue | 4 +- web/src/components/SessionOnlyGroup.vue | 2 +- web/src/data/__tests__/apiCatalogue.spec.ts | 60 ++++++-- web/src/data/apiCatalogue.ts | 136 +++++++++++++++++- web/src/i18n/messages.ts | 7 +- .../views/__tests__/IntegrationView.spec.ts | 16 ++- 7 files changed, 205 insertions(+), 24 deletions(-) diff --git a/docs/api.md b/docs/api.md index e54be74..669f906 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,11 +19,11 @@ for example `https://cms.example/v1/article/`. or revoked key answers 401, even on public routes. - Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header. -## Routes a key can call (20) +## Routes a key can call (24) | Group | Routes | | --- | --- | | Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} | -| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage | +| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin) | | Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me | | Settings | GET /v1/settings | diff --git a/web/src/components/EndpointCard.vue b/web/src/components/EndpointCard.vue index 331c274..d9e0a6f 100644 --- a/web/src/components/EndpointCard.vue +++ b/web/src/components/EndpointCard.vue @@ -85,7 +85,9 @@ const tabItems = [ color="neutral" variant="outline" :class=" - endpoint.access === 'Owner only' ? CHIP_CLASS.strong : CHIP_CLASS.neutral + endpoint.access === 'Owner only' || endpoint.access === 'Owner or admin' + ? CHIP_CLASS.strong + : CHIP_CLASS.neutral " /> {{ t( - 'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session."', + 'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."', ) }} diff --git a/web/src/data/__tests__/apiCatalogue.spec.ts b/web/src/data/__tests__/apiCatalogue.spec.ts index 4be56c1..e94b4e7 100644 --- a/web/src/data/__tests__/apiCatalogue.spec.ts +++ b/web/src/data/__tests__/apiCatalogue.spec.ts @@ -34,6 +34,10 @@ const EXPECTED_GROUPS: Record = { '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 = { '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}', diff --git a/web/src/data/apiCatalogue.ts b/web/src/data/apiCatalogue.ts index 4f9ffb4..68afc8d 100644 --- a/web/src/data/apiCatalogue.ts +++ b/web/src/data/apiCatalogue.ts @@ -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: [ diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts index 2b48c38..f01e4d1 100644 --- a/web/src/i18n/messages.ts +++ b/web/src/i18n/messages.ts @@ -905,13 +905,16 @@ export const messages = { 'On this page': 'Sur cette page', 'Jump to section': 'Aller à la section', 'Jump to': 'Aller à', - 'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session."': - 'Ces routes sont prévues pour une session de navigateur connectée. Avec une clé d’API, verify, DELETE /auth/register, /api-key/* et /system/* répondent 403 « This action needs a real session. »', + 'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."': + 'Ces routes sont prévues pour une session de navigateur connectée. Avec une clé d’API, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* et /system/* répondent 403 « This action needs a real session. »', 'Session only': 'Session uniquement', 'Public auth flow': 'Parcours d’authentification public', Public: 'Public', 'Verified user': 'Utilisateur vérifié', 'Owner only': 'Propriétaire uniquement', + 'Owner or admin': 'Propriétaire ou administrateur', + 'Settings · owner only': 'Paramètres · propriétaire uniquement', + 'Activity log': 'Journal d’activité', Authentication: 'Authentification', Notes: 'Notes', 'Users and invites': 'Utilisateurs et invitations', diff --git a/web/src/views/__tests__/IntegrationView.spec.ts b/web/src/views/__tests__/IntegrationView.spec.ts index 1cc26c0..71993fe 100644 --- a/web/src/views/__tests__/IntegrationView.spec.ts +++ b/web/src/views/__tests__/IntegrationView.spec.ts @@ -170,14 +170,14 @@ describe('Default, every card collapsed', () => { expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST') }) - it('groups the 20 cards as 7, 6, 6 and 1', async () => { + it('groups the 24 cards as 7, 10, 6 and 1', async () => { const wrapper = await mountIntegration() expect( ['articles', 'media', 'users', 'settings'].map( (id) => section(wrapper, id).findAllComponents(EndpointCard).length, ), - ).toEqual([7, 6, 6, 1]) + ).toEqual([7, 10, 6, 1]) expect(section(wrapper, 'articles').text()).toContain('7 routes') expect(section(wrapper, 'settings').text()).toContain('1 route') }) @@ -361,16 +361,16 @@ describe('Authentication', () => { }) describe('Session only', () => { - it('renders the banner, 13 rows without expand controls and the 403 chips', async () => { + it('renders the banner, 17 rows without expand controls and the 403 chips', async () => { const wrapper = await mountIntegration() const session = section(wrapper, 'session') - expect(session.text()).toContain('13 routes') + expect(session.text()).toContain('17 routes') expect(session.text()).toContain( - 'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session."', + 'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."', ) const rows = session.findAll('[data-session-row]') - expect(rows).toHaveLength(13) + expect(rows).toHaveLength(17) expect(session.findAll('[data-session-row] button')).toHaveLength(0) expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0) @@ -382,6 +382,10 @@ describe('Session only', () => { '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}',