/** * execute.test — provisioning against a STATEFUL fake of the SmartStack admin * API: idempotent creates, tenant conflicts + bootstrap, role-id drift gates, * mustChangePassword rotation, stale-credential failures, and the persisted * uat-users.json + gitignore. (The full 3.65 behavioural fake — localized * names, tenant-scoped role filtering — lives in provision-integration.test.) */ import { describe, it, expect, afterEach } from 'vitest'; import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { executeProvision } from '../execute.js'; import { UatProvisionInputSchema } from '../types.js'; import type { ProvisionContext } from '../validate.js'; import { loadUsersFile, saveUsersFile, usersFileRelPath, type UatUsersFile } 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-')); tmpDirs.push(dir); return dir; }; afterEach(() => { while (tmpDirs.length) rmSync(tmpDirs.pop()!, { recursive: true, force: true }); }); interface FakeAccount { password: string; roles: string[]; mustChange: boolean; } interface FakeApiOptions { tenantStatus?: 201 | 409 | 500; roles?: { id: string; name: string }[]; createUserStatus?: 201 | 409 | 400; newUsersMustChange?: boolean; } /** Stateful fake of the admin API: accounts persist across calls. */ function fakeApi(accounts: Map, opts: FakeApiOptions = {}): FetchLike { const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { status }); return async (url, init) => { const body = init?.body ? (JSON.parse(String(init.body)) as Record) : {}; if (url.endsWith('/api/config/features')) return json({ multiTenantEnabled: true }); if (url.endsWith('/api/auth/login')) { 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}`, refreshToken: 'r', user: { id: `id-${body.email}`, email: body.email, roles: account.roles, permissions: [], tenants: [{ id: 't-1', slug: 'uat' }] }, mustChangePassword: account.mustChange, }); } if (url.endsWith('/api/auth/change-password')) { const token = (init?.headers as Record)?.Authorization ?? ''; const email = token.replace('Bearer tok-', ''); const account = accounts.get(email); if (!account || account.password !== body.currentPassword) return json({ message: 'wrong current' }, 400); account.password = String(body.newPassword); account.mustChange = false; return json({ message: 'ok' }); } if (url.endsWith('/api/administration/tenants/b2c')) { const status = opts.tenantStatus ?? 201; return status === 201 ? json({ id: 't-1' }, 201) : json({ message: 'conflict' }, status); } if (url.includes('/applications/bulk')) return json({ assigned: 2 }); if (url.endsWith('/api/administration/applications')) { return json([{ id: 'a-1', code: 'administration' }, { id: 'a-2', code: 'flotte' }]); } if (new URL(url).pathname === '/api/administration/tenants') { // findTenantBySlug rides ?search= (the endpoint is paginated) — match on pathname. return json([{ id: 't-1', slug: 'uat', name: 'UAT Tenant' }]); } if (url.endsWith('/api/administration/permissions/roles')) { return json(opts.roles ?? [{ id: 'r-admin', name: 'admin' }, { id: 'r-viewer', name: 'viewer' }]); } if (url.endsWith('/api/administration/users')) { const status = opts.createUserStatus ?? 201; if (status !== 201) return json({ message: 'nope' }, status); const roleNames = (body.roleIds as string[]).map((id) => id.replace('r-', '')); accounts.set(String(body.email), { password: String(body.password), roles: roleNames, mustChange: opts.newUsersMustChange ?? false, }); return json({ id: `id-${body.email}` }, 201); } return json({ message: `unexpected ${url}` }, 500); }; } function makeContext( projectRoot: string, roles: string[], roleCatalog?: Record, ): ProvisionContext { return { spec: UatProvisionInputSchema.parse({ projectPath: projectRoot }), projectRoot, application: 'administration', roles, // Default catalog: ids matching the fake's live listing (r-). roleCatalog: roleCatalog ?? Object.fromEntries(roles.map((r) => [r, { id: `r-${r}` }])), apiUrl: 'http://api', apiUrlSource: 'spec', adminEmail: 'local.admin@smartstack.local', adminPassword: 'AdminPw!1', adminSource: 'spec', planRelPath: 'plan', }; } const deps = (fetchImpl: FetchLike) => ({ fetchImpl, nowIso: () => '2026-06-12T00:00:00.000Z', generatePassword: () => 'Gen!pw12345a9', }); const adminAccount = (): Map => new Map([['local.admin@smartstack.local', { password: 'AdminPw!1', roles: ['SupportAdmin'], mustChange: false }]]); describe('executeProvision', () => { it('happy path: creates + bootstraps the tenant, creates users, login-verifies, persists the gitignored users file', async () => { const root = makeTmp(); const accounts = adminAccount(); const outcome = await executeProvision(makeContext(root, ['admin', 'viewer']), deps(fakeApi(accounts))); expect(outcome.errors).toEqual([]); expect(outcome.success).toBe(true); expect(outcome.report.tenant.outcome).toBe('created'); expect(outcome.report.tenant.id).toBe('t-1'); expect(outcome.report.tenant.applicationsAssigned).toBe(2); expect(outcome.report.users.map((u) => u.status)).toEqual(['created', 'created']); expect(outcome.report.users.every((u) => u.verified)).toBe(true); const file = loadUsersFile(join(root, usersFileRelPath('administration'))); expect(file?.users.map((u) => u.role)).toEqual(['admin', 'viewer']); expect(file?.users[0].verifiedAt).toBe('2026-06-12T00:00:00.000Z'); expect(existsSync(join(root, '.gitignore'))).toBe(true); expect(readFileSync(join(root, '.gitignore'), 'utf-8')).toContain('uat-users.json'); }); it('treats a tenant slug conflict as exists, resolves its id by slug, and still bootstraps', async () => { const outcome = await executeProvision( makeContext(makeTmp(), ['admin']), deps(fakeApi(adminAccount(), { tenantStatus: 409 })), ); expect(outcome.success).toBe(true); expect(outcome.report.tenant.outcome).toBe('exists'); expect(outcome.report.tenant.id).toBe('t-1'); expect(outcome.report.tenant.applicationsAssigned).toBe(2); }); it('aborts BEFORE any write when a role has no catalog entry (regenerate the plan)', async () => { const root = makeTmp(); const outcome = await executeProvision( makeContext(root, ['admin', 'ghost'], { admin: { id: 'r-admin' } }), deps(fakeApi(adminAccount())), ); expect(outcome.success).toBe(false); expect(outcome.errors.join(' ')).toContain("absent from the plan's role_catalog: ghost"); expect(outcome.errors.join(' ')).toContain('Regenerate the plan'); // Nothing was provisioned — the gate fires before the tenant/users writes. expect(outcome.report.users).toEqual([]); expect(loadUsersFile(join(root, usersFileRelPath('administration')))).toBeNull(); }); it('aborts when a catalog id is unknown to the live app (plan predates a role change)', async () => { const outcome = await executeProvision( makeContext(makeTmp(), ['admin'], { admin: { id: 'r-gone' } }), deps(fakeApi(adminAccount())), ); expect(outcome.success).toBe(false); expect(outcome.errors.join(' ')).toContain('role id(s) the app no longer knows: admin'); expect(outcome.report.users).toEqual([]); }); it('rotates the password through change-password when the backend flags mustChangePassword', async () => { const root = makeTmp(); const accounts = adminAccount(); const outcome = await executeProvision( makeContext(root, ['viewer']), deps(fakeApi(accounts, { newUsersMustChange: true })), ); expect(outcome.errors).toEqual([]); expect(outcome.report.users[0]).toMatchObject({ status: 'created', verified: true, passwordRotated: true }); const file = loadUsersFile(join(root, usersFileRelPath('administration'))); // Rotation now uses the injected generator (a fresh strong password), not a predictable suffix. expect(file?.users[0].password).toBe('Gen!pw12345a9'); expect(accounts.get('uat.viewer@uat.local')?.password).toBe('Gen!pw12345a9'); }); it('keeps and re-verifies users from an existing file (idempotent re-run)', async () => { const root = makeTmp(); const accounts = adminAccount(); accounts.set('uat.admin@uat.local', { password: 'KnownPw!1', roles: ['admin'], mustChange: false }); const existing: UatUsersFile = { version: '1', application: 'administration', apiUrl: 'http://api', tenant: { name: 'UAT Tenant', slug: 'uat' }, createdAt: '2026-06-01T00:00:00.000Z', users: [{ role: 'admin', email: 'uat.admin@uat.local', password: 'KnownPw!1' }], }; saveUsersFile(join(root, usersFileRelPath('administration')), existing); const outcome = await executeProvision(makeContext(root, ['admin']), deps(fakeApi(accounts))); expect(outcome.success).toBe(true); expect(outcome.report.users[0].status).toBe('kept'); const file = loadUsersFile(join(root, usersFileRelPath('administration'))); expect(file?.createdAt).toBe('2026-06-01T00:00:00.000Z'); // original creation stamp kept }); it('fails actionably when a stored credential no longer logs in', async () => { const root = makeTmp(); const accounts = adminAccount(); accounts.set('uat.admin@uat.local', { password: 'ServerChanged!', roles: ['admin'], mustChange: false }); saveUsersFile(join(root, usersFileRelPath('administration')), { version: '1', application: 'administration', apiUrl: 'http://api', tenant: { name: 'UAT Tenant', slug: 'uat' }, createdAt: 'x', users: [{ role: 'admin', email: 'uat.admin@uat.local', password: 'OldPw!1' }], }); const outcome = await executeProvision(makeContext(root, ['admin']), deps(fakeApi(accounts))); expect(outcome.success).toBe(false); expect(outcome.errors.join(' ')).toContain('no longer logs in'); }); it('fails fast and clear when the API is down or the admin login is wrong', async () => { const down: FetchLike = async () => { throw new Error('refused'); }; const downOutcome = await executeProvision(makeContext(makeTmp(), ['admin']), deps(down)); expect(downOutcome.success).toBe(false); expect(downOutcome.errors[0]).toContain('ss dev up'); const badAdmin = makeContext(makeTmp(), ['admin']); badAdmin.adminPassword = 'wrong'; const badOutcome = await executeProvision(badAdmin, deps(fakeApi(adminAccount()))); expect(badOutcome.success).toBe(false); expect(badOutcome.errors[0]).toContain('Security.InitialAdmin'); }); });