/** * provision-integration.test — uat-provision against a fake that BEHAVES like a * real SmartStack 3.65 app, the configuration the 7th-round report proved the * chain had never met end-to-end: * * - the role catalogue is GLOBAL and the API serves it LOCALIZED (French * display names) while the plan carries the SQL vocabulary (English) — the * join can only succeed through the role_catalog GUIDs; * - roles are filtered per tenant by its ACTIVE APPLICATIONS: with * `X-Tenant-Slug` the listing only shows roles of apps assigned to that * tenant — a fresh B2C tenant answers an EMPTY list until * POST tenants/{id}/applications/bulk runs (§50); * - `/api/administration/roles` and `/api/health` answer 404 — they never * existed in the socle (§§47/52); * - the b2c create answers 201 once, then 409 WITHOUT a tenant id (§50). * * Routing is by exact pathname (never endsWith) and every request lands in a * journal, so the tests assert WHAT was called, in WHAT order, with WHICH * tenant header — the six §§ defects each map to a journal assertion. */ import { describe, it, expect, afterEach } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { executeProvision } from '../execute.js'; import { validate } from '../validate.js'; import { UatProvisionInputSchema } from '../types.js'; import type { ProvisionContext } from '../validate.js'; import { loadUsersFile, usersFileRelPath } from '../../lib/users-file.js'; import type { FetchLike } from '../../lib/http.js'; const tmpDirs: string[] = []; const makeTmp = (): string => { const dir = mkdtempSync(join(tmpdir(), 'uat-prov-int-')); tmpDirs.push(dir); return dir; }; afterEach(() => { while (tmpDirs.length) rmSync(tmpDirs.pop()!, { recursive: true, force: true }); }); // ── The 3.65-shaped fake ───────────────────────────────────────────────────── interface JournalEntry { method: string; path: string; query?: string; tenantHeader?: string; body?: Record; } interface FakeApp { fetchImpl: FetchLike; journal: JournalEntry[]; accounts: Map; tenantApps: Map>; } /** Global catalogue: FRENCH display names (the API localizes, fallback fr), GUID ids. */ const LIVE_ROLES = [ { id: 'g-support', name: 'Administrateur du support', code: null, applicationId: 'a-admin' }, { id: 'g-hr', name: 'Employé RH', code: null, applicationId: 'a-rh' }, { id: 'g-fleet', name: 'Gestionnaire de flotte', code: 'fleet-manager', applicationId: 'a-rh' }, ]; const APPLICATIONS = [ { id: 'a-admin', code: 'administration' }, { id: 'a-rh', code: 'rh' }, ]; function fakeApp(opts: { tenantPreExists?: boolean; bulkConflict?: boolean } = {}): FakeApp { const journal: JournalEntry[] = []; const accounts = new Map([ ['local.admin@smartstack.local', { password: 'AdminPw!1', roles: ['Super administrateur'] }], ]); const tenants = new Map(opts.tenantPreExists ? [['uat', { id: 't-uat' }]] : []); const tenantApps = new Map>(); const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { status }); const fetchImpl: FetchLike = async (url, init) => { const { pathname, searchParams, search } = new URL(url); const method = (init?.method ?? 'GET').toUpperCase(); const headers = (init?.headers ?? {}) as Record; const tenantHeader = headers['X-Tenant-Slug']; const body = init?.body ? (JSON.parse(String(init.body)) as Record) : undefined; journal.push({ method, path: pathname, ...(search ? { query: search } : {}), ...(tenantHeader ? { tenantHeader } : {}), ...(body ? { body } : {}), }); if (pathname === '/api/config/features') return json({ multiTenantEnabled: true, enableB2C: true }); if (pathname === '/api/auth/login' && method === 'POST') { const account = accounts.get(String(body?.email)); if (!account || account.password !== body?.password) return json({ message: 'Identifiants invalides' }, 401); return json({ token: `tok-${body?.email}`, user: { id: `id-${body?.email}`, email: body?.email, roles: account.roles, permissions: [], tenants: [] }, mustChangePassword: false, }); } if (pathname === '/api/administration/permissions/roles') { // THE §50 behaviour: tenant scope = roles of the tenant's ACTIVE apps only. if (!tenantHeader) return json(LIVE_ROLES); const active = tenantApps.get(tenantHeader) ?? new Set(); return json(LIVE_ROLES.filter((r) => active.has(r.applicationId))); } if (pathname === '/api/administration/tenants/b2c' && method === 'POST') { const slug = String(body?.slug); if (tenants.has(slug)) return json({ message: 'This slug is already taken' }, 409); tenants.set(slug, { id: `t-${slug}` }); return json({ id: `t-${slug}`, slug }, 201); } if (pathname === '/api/administration/tenants' && method === 'GET') { // The real endpoint is PAGINATED (PaginatedResult, default pageSize 20) with a // server-side `search` filter over name/slug/description — modelled faithfully // so a caller relying on an unfiltered first page would break here too. const wanted = searchParams.get('search')?.toLowerCase() ?? ''; const all = [...tenants.entries()].map(([slug, t]) => ({ id: t.id, slug })); const items = (wanted ? all.filter((t) => t.slug.toLowerCase().includes(wanted)) : all).slice(0, 20); return json({ items, totalCount: items.length, page: 1, pageSize: 20 }); } if (pathname === '/api/administration/applications') return json(APPLICATIONS); const bulkMatch = pathname.match(/^\/api\/administration\/tenants\/([^/]+)\/applications\/bulk$/); if (bulkMatch && method === 'POST') { const ids = body?.applicationIds; if (!Array.isArray(ids) || ids.length === 0) return json({ message: 'applicationIds required' }, 400); const set = tenantApps.get('uat') ?? new Set(); for (const id of ids) set.add(String(id)); tenantApps.set('uat', set); // bulkConflict: a backend answering 409 on a re-assignment — the apps ARE // assigned (409 means "already there"), the caller must warn and continue. if (opts.bulkConflict) return json({ message: 'already assigned' }, 409); return json({ assigned: ids.length }); } if (pathname === '/api/administration/users' && method === 'POST') { if (!tenantHeader) return json({ message: 'tenant context required' }, 400); const roleIds = body?.roleIds; if (!Array.isArray(roleIds) || roleIds.some((id) => !LIVE_ROLES.some((r) => r.id === id))) { return json({ message: `unknown roleIds: ${JSON.stringify(roleIds)}` }, 400); } const names = (roleIds as string[]).map((id) => LIVE_ROLES.find((r) => r.id === id)!.name); accounts.set(String(body?.email), { password: String(body?.password), roles: names }); return json({ id: `id-${body?.email}` }, 201); } // Everything else — including /api/health and /api/administration/roles — 404, // exactly like the socle (no MapHealthChecks; roles live under permissions/). return json({ message: `no route ${method} ${pathname}` }, 404); }; return { fetchImpl, journal, accounts, tenantApps }; } // ── Context: the PLAN speaks the SQL vocabulary (EN names + GUIDs) ─────────── const PLAN_CATALOG = { SupportAdmin: { id: 'g-support' }, 'HR Employee': { id: 'g-hr' }, 'Gestionnaire de flotte': { id: 'g-fleet', code: 'fleet-manager' }, }; function makeContext(projectRoot: string, roles: string[] = Object.keys(PLAN_CATALOG)): ProvisionContext { return { spec: UatProvisionInputSchema.parse({ projectPath: projectRoot }), projectRoot, application: 'administration', roles, roleCatalog: PLAN_CATALOG, apiUrl: 'http://api.fake', apiUrlSource: 'spec', adminEmail: 'local.admin@smartstack.local', adminPassword: 'AdminPw!1', adminSource: 'spec', planRelPath: 'plan', }; } const deps = (fetchImpl: FetchLike) => ({ fetchImpl, nowIso: () => '2026-08-30T00:00:00.000Z', generatePassword: () => 'Gen!pw12345a9', }); describe('uat-provision against a 3.65-shaped app', () => { it('fresh tenant end-to-end: global role listing, bootstrap BEFORE users, GUID joins, no dead routes', async () => { const root = makeTmp(); const app = fakeApp(); const outcome = await executeProvision(makeContext(root), deps(app.fetchImpl)); expect(outcome.errors).toEqual([]); expect(outcome.success).toBe(true); expect(outcome.report.tenant).toMatchObject({ outcome: 'created', id: 't-uat', applicationsAssigned: 2 }); expect(outcome.report.users.map((u) => u.status)).toEqual(['created', 'created', 'created']); // §§47/52 — the chain never touches the routes that do not exist. const paths = app.journal.map((e) => e.path); expect(paths).not.toContain('/api/health'); expect(paths).not.toContain('/api/administration/roles'); expect(paths).toContain('/api/config/features'); // §§49/50 — the role listing is GLOBAL (no tenant header): tenant-scoped it // would have been EMPTY (fresh tenant) and the run would have died at step 3. const roleCalls = app.journal.filter((e) => e.path === '/api/administration/permissions/roles'); expect(roleCalls.length).toBeGreaterThan(0); expect(roleCalls.every((e) => e.tenantHeader === undefined)).toBe(true); // §50 — the bootstrap runs BEFORE the first user write. const bulkAt = app.journal.findIndex((e) => e.path.endsWith('/applications/bulk')); const firstUserAt = app.journal.findIndex((e) => e.path === '/api/administration/users' && e.method === 'POST'); expect(bulkAt).toBeGreaterThanOrEqual(0); expect(firstUserAt).toBeGreaterThan(bulkAt); expect(app.tenantApps.get('uat')).toEqual(new Set(['a-admin', 'a-rh'])); // §§49/51 — users are created with the EXACT plan GUIDs; a name join was // impossible (plan says "SupportAdmin", the app says "Administrateur du support"). const userBodies = app.journal .filter((e) => e.path === '/api/administration/users' && e.method === 'POST') .map((e) => e.body?.roleIds); expect(userBodies).toEqual([['g-support'], ['g-hr'], ['g-fleet']]); expect(app.journal.filter((e) => e.path === '/api/administration/users').every((e) => e.tenantHeader === 'uat')).toBe(true); // Documented residual (§49): login-verify compares the plan's EN name to the // LOCALIZED effective roles → a warning, never a failure. expect(outcome.warnings.some((w) => w.includes('absent from the user\'s effective roles'))).toBe(true); const file = loadUsersFile(join(root, usersFileRelPath('administration'))); expect(file?.users.map((u) => u.role)).toEqual(Object.keys(PLAN_CATALOG)); }); it('pre-existing tenant: 409 carries no id → resolved by slug, bootstrap still replayed', async () => { const app = fakeApp({ tenantPreExists: true }); const outcome = await executeProvision(makeContext(makeTmp()), deps(app.fetchImpl)); expect(outcome.errors).toEqual([]); expect(outcome.report.tenant).toMatchObject({ outcome: 'exists', id: 't-uat', applicationsAssigned: 2 }); const tenantLookups = app.journal.filter((e) => e.path === '/api/administration/tenants' && e.method === 'GET'); expect(tenantLookups.length).toBeGreaterThan(0); // The lookup rides the server-side search filter — the endpoint pages at 20. expect(tenantLookups.every((e) => e.query === '?search=uat')).toBe(true); expect(app.journal.some((e) => e.path.endsWith('/applications/bulk'))).toBe(true); }); it('a bulk 409 (apps already assigned) warns and the run completes', async () => { const app = fakeApp({ bulkConflict: true }); const outcome = await executeProvision(makeContext(makeTmp()), deps(app.fetchImpl)); expect(outcome.errors).toEqual([]); expect(outcome.success).toBe(true); expect(outcome.warnings.some((w) => w.includes('already assigned'))).toBe(true); expect(outcome.report.users.map((u) => u.status)).toEqual(['created', 'created', 'created']); }); it('a plan id the app no longer knows aborts before ANY write', async () => { const root = makeTmp(); const app = fakeApp(); const ctx = makeContext(root, ['SupportAdmin']); ctx.roleCatalog = { SupportAdmin: { id: 'g-deleted' } }; const outcome = await executeProvision(ctx, deps(app.fetchImpl)); expect(outcome.success).toBe(false); expect(outcome.errors.join(' ')).toContain('no longer knows: SupportAdmin'); const writes = app.journal.filter((e) => e.method === 'POST' && e.path !== '/api/auth/login'); expect(writes).toEqual([]); expect(loadUsersFile(join(root, usersFileRelPath('administration')))).toBeNull(); }); it('validate refuses a plan without role_catalog with the regenerate message', async () => { const root = makeTmp(); const planPath = join(root, 'old.plantest.yml'); writeFileSync( planPath, [ 'schema_version: 1.0.0', 'meta:', ' application: administration', ' path: administration', 'roles:', ' - SupportAdmin', ' - anonymous', 'execution:', ' screenshots:', ' out_dir: .application-test/runs/x/screenshots', 'routes: []', 'endpoints: []', '', ].join('\n'), 'utf-8', ); const v = await validate({ projectPath: root, planFile: planPath }); expect(v.valid).toBe(false); expect(v.errors.join(' ')).toContain('no role_catalog'); expect(v.errors.join(' ')).toContain('Regenerate the plan'); }); });