import { describe, expect, it } from 'vitest' import { buildPublicApiCode, parseExternalApiMarker, renderExternalApiMarker, catalogRowsFor, codeFromPublicPath, MAX_ALLOWED_PAGE_SIZE, operationForVerb, parsePublicApiCode, permissionMatches, publicApiPath, PUBLIC_API_OPERATIONS, SOCLE_RESERVED_CODES, validatePublicApiCode, } from '../external-api-catalog.js' import { canonicaliseRoute } from '../url-conventions.js' describe('external-api-catalog — code grammar', () => { it('reads keep the canonical resource code, writes carry the operation suffix', () => { expect(buildPublicApiCode('crm', 'factures', 'read')).toBe('crm-factures') expect(buildPublicApiCode('crm', 'factures', 'create')).toBe('crm-factures-create') expect(buildPublicApiCode('crm', 'factures', 'update')).toBe('crm-factures-update') expect(buildPublicApiCode('crm', 'factures', 'delete')).toBe('crm-factures-delete') }) it('collapses every operation onto one code in resource granularity', () => { expect(buildPublicApiCode('crm', 'factures', 'create', 'resource')).toBe('crm-factures') expect(buildPublicApiCode('crm', 'factures', 'delete', 'resource')).toBe('crm-factures') }) it('round-trips through parsePublicApiCode even when the section code is hyphenated', () => { expect(parsePublicApiCode('crm-types-clients')).toEqual({ resourceCode: 'crm-types-clients', operation: 'read' }) expect(parsePublicApiCode('crm-types-clients-update')).toEqual({ resourceCode: 'crm-types-clients', operation: 'update', }) }) it('rejects a code that is not kebab-case', () => { expect(parsePublicApiCode('CRM_Factures')).toBeNull() expect(parsePublicApiCode('-leading')).toBeNull() }) }) describe('external-api-catalog — path resolution mirrors ResolveEndpointCode', () => { it('takes the 4th segment, and every sub-route falls back to the same code', () => { expect(codeFromPublicPath('/api/v1/export/crm-factures')).toBe('crm-factures') expect(codeFromPublicPath('/api/v1/export/crm-factures/{id:guid}')).toBe('crm-factures') expect(codeFromPublicPath('/api/v1/export/crm-factures?tenantId=x')).toBe('crm-factures') }) it('returns null off the public prefix', () => { expect(codeFromPublicPath('/api/screens/factures/list')).toBeNull() expect(codeFromPublicPath('/api/configuration/factures')).toBeNull() expect(codeFromPublicPath('/api/v1/export')).toBeNull() }) it('builds the path the third party calls', () => { expect(publicApiPath('crm-factures-create')).toBe('/api/v1/export/crm-factures-create') }) }) describe('external-api-catalog — fail-closed code validation', () => { it('refuses a code the platform already seeds (Code is UNIQUE database-wide)', () => { for (const reserved of SOCLE_RESERVED_CODES) { expect(validatePublicApiCode(reserved)).toMatch(/seeded by the platform/) } }) it('refuses a code longer than the column', () => { const long = `crm-${'a'.repeat(120)}` expect(validatePublicApiCode(long)).toMatch(/exceeds 100 characters/) }) it('refuses a section code ending with a reserved operation suffix', () => { const err = validatePublicApiCode('crm-bons-de-create', 'bons-de-create') expect(err).toMatch(/reserved operation suffix/) }) it('accepts a well-formed code', () => { expect(validatePublicApiCode('crm-factures-create', 'factures')).toBeNull() }) }) describe('external-api-catalog — permissionMatches mirrors the platform PermissionMatcher', () => { it('matches exactly, case-insensitively', () => { expect(permissionMatches('crm.ventes.factures.read', 'crm.ventes.factures.read')).toBe(true) expect(permissionMatches('CRM.Ventes.Factures.Read', 'crm.ventes.factures.read')).toBe(true) }) it('honours the global wildcard', () => { expect(permissionMatches('*', 'anything.at.all')).toBe(true) }) it('honours a prefix wildcard, including the bare prefix', () => { expect(permissionMatches('crm.ventes.factures.*', 'crm.ventes.factures.create')).toBe(true) expect(permissionMatches('crm.ventes.factures.*', 'crm.ventes.factures')).toBe(true) expect(permissionMatches('crm.ventes.factures.*', 'crm.ventes.avoirs.create')).toBe(false) }) it('does NOT let a read grant satisfy a write constant — the 403 this whole design avoids', () => { expect(permissionMatches('crm.ventes.factures.read', 'crm.ventes.factures.create')).toBe(false) }) }) describe('external-api-catalog — catalogue rows', () => { const spec = { applicationCode: 'crm', moduleCode: 'ventes', sectionCode: 'factures', entityName: 'Facture', operations: ['read', 'create', 'update', 'delete'] as const, } it('operation granularity emits one row per operation with EXACT permissions', () => { const rows = catalogRowsFor({ ...spec, operations: [...spec.operations] }) expect(rows.map(r => r.code)).toEqual([ 'crm-factures', 'crm-factures-create', 'crm-factures-update', 'crm-factures-delete', ]) expect(rows.map(r => r.requiredPermission)).toEqual([ 'crm.ventes.factures.read', 'crm.ventes.factures.create', 'crm.ventes.factures.update', 'crm.ventes.factures.delete', ]) expect(rows.every(r => !r.requiredPermission.includes('*'))).toBe(true) expect(rows.map(r => r.accessType)).toEqual(['Read', 'Write', 'Write', 'Write']) }) it('every emitted row satisfies the action constant it guards', () => { const rows = catalogRowsFor({ ...spec, operations: [...spec.operations] }) for (const row of rows) { for (const op of row.operations) { const action = PUBLIC_API_OPERATIONS.find(o => o.operation === op)!.permissionAction const constant = `crm.ventes.factures.${action}` expect(permissionMatches(row.requiredPermission, constant)).toBe(true) } } }) it('a read-only grant on the read row cannot serve a write row — per-operation revocation', () => { const [readRow] = catalogRowsFor({ ...spec, operations: ['read'] }) const writeRows = catalogRowsFor({ ...spec, operations: ['create'] }) expect(permissionMatches(readRow.requiredPermission, writeRows[0].requiredPermission)).toBe(false) }) it('resource granularity collapses to one wildcard row — all-or-nothing', () => { const rows = catalogRowsFor({ ...spec, operations: [...spec.operations], granularity: 'resource' }) expect(rows).toHaveLength(1) expect(rows[0].code).toBe('crm-factures') expect(rows[0].requiredPermission).toBe('crm.ventes.factures.*') expect(rows[0].httpVerbs).toEqual(['GET', 'POST', 'PUT', 'DELETE']) // The reason DEV-XAPI-013 warns: the wildcard also grants an action nobody // has written yet. expect(permissionMatches(rows[0].requiredPermission, 'crm.ventes.factures.approve')).toBe(true) }) it('a read-only resource row stays exact — no gratuitous wildcard', () => { const rows = catalogRowsFor({ ...spec, operations: ['read'], granularity: 'resource' }) expect(rows[0].requiredPermission).toBe('crm.ventes.factures.read') expect(rows[0].accessType).toBe('Read') }) it('clamps the page size to what the response-buffering middleware can afford', () => { const rows = catalogRowsFor({ ...spec, operations: ['read'], maxPageSize: 100_000 }) expect(rows[0].defaultMaxPageSize).toBe(MAX_ALLOWED_PAGE_SIZE) }) it('maps HTTP verbs back to their operation family', () => { expect(operationForVerb('get')).toBe('read') expect(operationForVerb('POST')).toBe('create') expect(operationForVerb('PATCH')).toBeNull() }) }) describe('url-conventions — the public stratum', () => { it('classifies the export prefix as public, not integration', () => { expect(canonicaliseRoute('/api/v1/export/crm-factures')).toEqual({ stratum: 'public', rest: 'crm-factures', }) expect(canonicaliseRoute('/api/v1/export/crm-factures/{id:guid}').stratum).toBe('public') }) it('leaves the two existing strata untouched', () => { expect(canonicaliseRoute('/api/configuration/types-clients').stratum).toBe('integration') expect(canonicaliseRoute('/api/screens/demandes/list').stratum).toBe('screens') expect(canonicaliseRoute('https://example.com/x').stratum).toBe('unknown') }) }) describe('external-api-catalog — the BA authoring marker', () => { it('reads the canonical line, in French or English, with the grain', () => { const doc = ['## Facture', '- **API externe** : read, création — grain ressource', ''].join('\n') expect(parseExternalApiMarker(doc)).toEqual({ operations: ['read', 'create'], granularity: 'resource', unknown: [], }) expect(parseExternalApiMarker('- **API externe** : lecture, modification')).toEqual({ operations: ['read', 'update'], granularity: 'operation', unknown: [], }) }) it('treats an explicit opt-out as "publishes nothing", not as absent', () => { expect(parseExternalApiMarker('- **API externe** : aucune')).toEqual({ operations: [], granularity: 'operation', unknown: [], }) }) it('returns null when the entity never declared anything', () => { expect(parseExternalApiMarker('- **API** : none')).toBeNull() }) it('surfaces an unknown token instead of silently dropping it', () => { const m = parseExternalApiMarker('- **API externe** : read, approuver') expect(m?.operations).toEqual(['read']) expect(m?.unknown).toEqual(['approuver']) }) it('renders the canonical remedy wording, in declaration order', () => { expect(renderExternalApiMarker(['create', 'read'])).toBe('- **API externe** : read, create') expect(renderExternalApiMarker([], 'operation')).toBe('- **API externe** : aucune') expect(renderExternalApiMarker(['read'], 'resource')).toBe('- **API externe** : read — grain ressource') }) it('round-trips render → parse', () => { const line = renderExternalApiMarker(['read', 'create', 'delete'], 'resource') expect(parseExternalApiMarker(line)).toEqual({ operations: ['read', 'create', 'delete'], granularity: 'resource', unknown: [], }) }) })