/** * plan-actions.test — the pure provisioning planner: per-role decisions, * email derivation, catalog-based role-id resolution, password rotation. */ import { describe, it, expect } from 'vitest'; import { emailForRole, planRoleActions, resolveRoleIds } from '../plan-actions.js'; import type { UatUsersFile } from '../../lib/users-file.js'; describe('emailForRole', () => { it('slugs the role into the local part', () => { expect(emailForRole('admin', 'uat.local')).toBe('uat.admin@uat.local'); expect(emailForRole('Super Admin', 'uat.local')).toBe('uat.super-admin@uat.local'); expect(emailForRole('***', 'uat.local')).toBe('uat.role@uat.local'); }); it('an accented name and its pre-§48 mojibake spelling slug to the SAME address', () => { // é (correct) and U+FFFD (corrupted) both fall in [^a-z0-9] → identical emails, // so the encoding fix never forks the address space between runs. expect(emailForRole('Employé RH', 'uat.local')).toBe(emailForRole('Employ� RH', 'uat.local')); }); }); describe('planRoleActions', () => { const existing: UatUsersFile = { version: '1', application: 'app', apiUrl: 'http://x', tenant: { name: 'UAT', slug: 'uat' }, createdAt: 'now', users: [{ role: 'admin', email: 'uat.admin@uat.local', password: 'StoredPw!1' }], }; it('verifies stored entries (keeping their password) and creates the rest', () => { const actions = planRoleActions({ roles: ['admin', 'viewer'], existing, emailDomain: 'uat.local', newPasswords: { viewer: 'NewPw!2', admin: 'unused' }, }); expect(actions).toEqual([ { role: 'admin', email: 'uat.admin@uat.local', action: 'verify', password: 'StoredPw!1' }, { role: 'viewer', email: 'uat.viewer@uat.local', action: 'create', password: 'NewPw!2' }, ]); }); it('reuses (by DERIVED email) an entry stored under a corrupted spelling of the same role', () => { const corrupted: UatUsersFile = { ...existing, users: [{ role: 'Employ� RH', email: 'uat.employ-rh@uat.local', password: 'StoredPw!9' }], }; const actions = planRoleActions({ roles: ['Employé RH'], existing: corrupted, emailDomain: 'uat.local', newPasswords: { 'Employé RH': 'unused' }, }); // Same derived address → verify with the STORED password instead of a doomed // create → 409 → "unknown password" dead end. expect(actions).toEqual([ { role: 'Employé RH', email: 'uat.employ-rh@uat.local', action: 'verify', password: 'StoredPw!9' }, ]); }); it('creates everything when no file exists, and throws on a missing generated password', () => { const actions = planRoleActions({ roles: ['viewer'], existing: null, emailDomain: 'uat.local', newPasswords: { viewer: 'Pw!3' }, }); expect(actions[0].action).toBe('create'); expect(() => planRoleActions({ roles: ['ghost'], existing: null, emailDomain: 'uat.local', newPasswords: {} }), ).toThrow(/missing generated password/); }); }); describe('resolveRoleIds', () => { const CATALOG = { 'SupportAdmin': { id: 'g-support', code: 'support-admin' }, 'HR Employee': { id: 'g-hr' }, 'Stale Role': { id: 'g-gone' }, } as const; // The live listing serves LOCALIZED names — only the ids are joinable. const LIVE = [ { id: 'g-support', name: 'Administrateur du support' }, { id: 'g-hr', name: 'Employé RH' }, ]; it('joins plan roles to live roles by catalog GUID — names never compared', () => { const { resolved, missingFromCatalog, driftedIds } = resolveRoleIds( ['SupportAdmin', 'HR Employee'], CATALOG, LIVE, ); expect(resolved).toEqual({ SupportAdmin: 'g-support', 'HR Employee': 'g-hr' }); expect(missingFromCatalog).toEqual([]); expect(driftedIds).toEqual([]); }); it('separates catalog gaps (regenerate) from live drift (plan predates a role change)', () => { const { resolved, missingFromCatalog, driftedIds } = resolveRoleIds( ['SupportAdmin', 'ghost', 'Stale Role'], CATALOG, LIVE, ); expect(resolved).toEqual({ SupportAdmin: 'g-support' }); expect(missingFromCatalog).toEqual(['ghost']); expect(driftedIds).toEqual(['Stale Role']); }); });