import { describe, expect, it } from 'vitest' import { ADMIN_PATH_CANDIDATES, discoverAdminPath, execute, resolveEndpointIds, type FetchLike } from '../execute.js' import { validate } from '../validate.js' import { ProvisionExternalAppInputSchema } from '../types.js' const ENDPOINTS = [ { id: 'e1', code: 'crm-factures', isActive: true }, { id: 'e2', code: 'crm-factures-create', isActive: true }, { id: 'e3', code: 'crm-avoirs', isActive: false }, ] interface Call { url: string method: string body?: unknown } /** A fake platform: only the third admin path answers, like a real deployment. */ function fakeApi(opts: { adminPath?: string; failGrant?: string; failCreate?: boolean } = {}) { const calls: Call[] = [] const adminPath = opts.adminPath ?? '/api/platform/api/accounts' const fetchImpl: FetchLike = async (url, init) => { calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined }) const ok = (body: unknown) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }) const fail = (status: number) => ({ ok: false, status, text: async () => 'nope' }) if (url.endsWith(`${adminPath}/api-endpoints`)) return ok(ENDPOINTS) if (url.includes('/api-endpoints')) return fail(404) if (init.method === 'POST' && url.endsWith(adminPath)) { return opts.failCreate ? fail(400) : ok({ id: 'app-1', clientId: 'cid-1', clientSecret: 'shhh' }) } if (init.method === 'PUT' && url.includes('/api-access/')) { return opts.failGrant && url.endsWith(opts.failGrant) ? fail(403) : ok({ isEnabled: true }) } return fail(500) } return { fetchImpl, calls } } const spec = (over: Record = {}) => ProvisionExternalAppInputSchema.parse({ baseUrl: 'http://localhost:5000', adminToken: 'admin-token', name: 'Partenaire test', codes: ['crm-factures', 'crm-factures-create'], ...over, }) describe('provision-external-app — finding the admin surface', () => { it('probes the candidates because the admin route is resolved from the database', async () => { const { fetchImpl, calls } = fakeApi() const found = await discoverAdminPath(spec(), fetchImpl) expect(found).toMatchObject({ path: '/api/platform/api/accounts' }) expect(calls.map(c => c.url.replace('http://localhost:5000', ''))).toEqual( ADMIN_PATH_CANDIDATES.map(p => `${p}/api-endpoints`), ) }) it('reports every path it tried instead of a bare 404', async () => { const { fetchImpl } = fakeApi({ adminPath: '/nowhere' }) const found = await discoverAdminPath(spec(), fetchImpl) expect('error' in found && found.error).toMatch(/Tried: .*HTTP 404/) }) it('honours an explicit adminPath without probing', async () => { const { fetchImpl, calls } = fakeApi({ adminPath: '/custom' }) await discoverAdminPath(spec({ adminPath: '/custom' }), fetchImpl) expect(calls).toHaveLength(1) }) }) describe('provision-external-app — resolving the codes', () => { it('maps codes to endpoint ids', () => { expect(resolveEndpointIds(ENDPOINTS, ['crm-factures'])).toEqual({ grants: [{ code: 'crm-factures', id: 'e1' }] }) }) it('explains an unknown code by the most likely cause — the seed never ran', () => { const r = resolveEndpointIds(ENDPOINTS, ['crm-inconnu']) expect('error' in r && r.error).toMatch(/seed provider has not run/) }) it('refuses an inactive endpoint rather than granting something dead', () => { const r = resolveEndpointIds(ENDPOINTS, ['crm-avoirs']) expect('error' in r && r.error).toMatch(/inactive: crm-avoirs/) }) }) describe('provision-external-app — the flow', () => { it('creates the application then enables ONE grant per code', async () => { const { fetchImpl, calls } = fakeApi() const { report, errors } = await execute(spec(), fetchImpl) expect(errors).toEqual([]) expect(report).toMatchObject({ applicationId: 'app-1', clientId: 'cid-1', granted: ['crm-factures', 'crm-factures-create'] }) const grants = calls.filter(c => c.method === 'PUT') expect(grants).toHaveLength(2) expect(grants[0].body).toMatchObject({ isEnabled: true }) }) it('hands the secret back once and says it is never stored', async () => { const { fetchImpl } = fakeApi() const { report, warnings } = await execute(spec(), fetchImpl) expect(report?.clientSecret).toBe('shhh') expect(warnings.join('\n')).toMatch(/shown ONCE and is not stored/) }) it('does not swallow a failed grant — the app exists but cannot call that endpoint', async () => { const { fetchImpl } = fakeApi({ failGrant: '/api-access/e2' }) const { report, warnings } = await execute(spec(), fetchImpl) expect(report?.granted).toEqual(['crm-factures']) expect(warnings.join('\n')).toMatch(/Grant on "crm-factures-create" failed/) }) it('surfaces a refused creation with its status', async () => { const { fetchImpl } = fakeApi({ failCreate: true }) const { errors } = await execute(spec(), fetchImpl) expect(errors.join('\n')).toMatch(/Creating the external application failed: HTTP 400/) }) it('creates nothing in dryRun, but still proves the codes resolve', async () => { const { fetchImpl, calls } = fakeApi() const { report } = await execute(spec({ dryRun: true }), fetchImpl) expect(report).toMatchObject({ dryRun: true, applicationId: null, granted: ['crm-factures', 'crm-factures-create'] }) expect(calls.filter(c => c.method !== 'GET')).toEqual([]) }) }) describe('provision-external-app — guards', () => { it('refuses to send an admin token over plain http to a remote host', () => { const r = validate({ baseUrl: 'http://partner.example.com', adminToken: 't', name: 'x', codes: ['c'] }) expect(r.errors.join()).toMatch(/plain http to a non-local host/) }) it('allows localhost over http', () => { const r = validate({ baseUrl: 'http://localhost:5000', adminToken: 't', name: 'x', codes: ['c'] }) expect(r.valid).toBe(true) }) it('warns about an open IP allow-list and an open tenant whitelist', () => { const r = validate({ baseUrl: 'https://api.example.com', adminToken: 't', name: 'x', codes: ['c'] }) expect(r.warnings.join('\n')).toMatch(/reachable from any origin/) expect(r.warnings.join('\n')).toMatch(/No tenant whitelist/) }) it('refuses a token expiry outside the platform bounds', () => { const r = validate({ baseUrl: 'https://x.example.com', adminToken: 't', name: 'x', codes: ['c'], tokenExpirationMinutes: 5000 }) expect(r.valid).toBe(false) }) })