feat: séparer les portails production par entité
All checks were successful
Validation applicative / Tests de contrat (push) Successful in 9s

This commit is contained in:
Manus
2026-10-24 09:49:04 +00:00
parent 04ab72b817
commit abfd216924
4 changed files with 210 additions and 606 deletions

View File

@@ -1,7 +1,7 @@
/*
* Contrat de non-régression du portail de production.
* Il vérifie la séparation stricte des environnements, la publication
* dynamique des applications et les exceptions explicitement demandées.
* Contrat de non-régression des quatre portails production.
* Un seul service statique répond sur quatre domaines, mais le domaine courant
* décide strictement du groupe d'applications et du dashboard de production.
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -10,82 +10,127 @@ const vm = require('node:vm');
const root = path.join(__dirname, '..');
const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8');
const attributions = fs.readFileSync(path.join(root, 'images', 'ATTRIBUTIONS.md'), 'utf8');
const compose = fs.readFileSync(path.join(root, 'docker-compose.yml'), 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)?.[1];
assert.ok(script, 'le script de configuration des portails doit être présent');
assert.equal(/^(<<<<<<<|=======|>>>>>>>)/m.test(html), false, 'aucun marqueur de conflit Git ne doit être livré');
assert.equal((html.match(/var DASHBOARD_APPS_URL/g) || []).length, 1, 'une seule URL API applications doit être déclarée');
assert.equal((html.match(/var DASHBOARD_STATUS_URL/g) || []).length, 1, 'une seule URL API statuts doit être déclarée');
assert.match(html, /var ENV = 'prod';/, 'la version production doit déclarer explicitement son environnement');
assert.match(html, /app\.urls\[ENV\]/, 'les liens doivent être sélectionnés depuis l’environnement déclaré');
assert.match(html, /app\.category === 'INFRA'/, 'les tuiles INFRA doivent rester dans leur onglet dédié');
assert.match(html, /var STATUS_REFRESH_MS = 30000;/, 'le délai de grisage doit être explicite');
assert.match(html, /setInterval\(fetchAndApplyStatuses, STATUS_REFRESH_MS\)/, 'le grisage doit être réactualisé périodiquement');
assert.match(html, /PORTAL_CONFIGS/, 'les domaines des portails doivent être configurés explicitement');
assert.match(html, /STATUS_REFRESH_MS = 30000/, 'le délai de grisage doit être explicite');
assert.match(html, /setInterval\(fetchAndApplyStatuses, STATUS_REFRESH_MS\)/, 'les statuts doivent être réactualisés périodiquement');
for (const imageName of ['budget-si.jpg', 'studio-portee.png']) {
function loadPortal(hostname) {
const redirects = [];
const elements = new Map();
const document = {
title: '',
documentElement: { style: { setProperty() {} } },
addEventListener() {},
getElementById(id) {
if (!elements.has(id)) {
elements.set(id, {
innerHTML: '',
textContent: '',
className: '',
firstChild: { nodeValue: '' },
classList: { toggle() {} },
setAttribute() {}
});
}
return elements.get(id);
},
querySelectorAll() { return []; }
};
const window = {
location: {
hostname,
replace(target) { redirects.push(target); }
}
};
const sandbox = {
AbortController,
Map,
Set,
URL,
console,
document,
window,
setInterval() {},
setTimeout() { return 0; },
clearTimeout() {}
};
vm.createContext(sandbox);
vm.runInContext(script, sandbox);
return { sandbox, redirects };
}
const hosts = {
itinova: 'portail-itinova.santinova-soft.org',
soins: 'portail-soins-et-sante.santinova-soft.org',
santinova: 'portail-santinova.santinova-soft.org',
santelis: 'portail-santelis.santinova-soft.org'
};
const apiApps = 'https://dashboard.santinova-soft.org/api/public/apps';
for (const [group, host] of Object.entries(hosts)) {
const { sandbox } = loadPortal(host);
assert.equal(sandbox.PORTAL.env, 'prod', `${host} doit utiliser la production`);
assert.equal(sandbox.PORTAL.dashboardAppsUrl, apiApps, `${host} doit appeler exclusivement le dashboard production`);
assert.equal(sandbox.PORTAL.dashboardStatusUrl, apiApps.replace('/apps', '/status'), `${host} doit appeler le statut production`);
assert.equal(sandbox.PORTAL.group, group === 'soins' ? 'SOINS_ET_SANTE' : group.toUpperCase(), `${host} doit sélectionner le bon groupe`);
assert.match(
compose,
new RegExp(`traefik\\.http\\.routers\\.portail-[a-z-]+-production\\.rule=Host\\(\\\`${host.replace(/\./g, '\\.')}\\\`\\)`),
`${host} doit avoir un routeur Traefik dédié`
);
}
assert.match(
compose,
/traefik\.http\.routers\.portail-legacy-production\.rule=Host\(`portail\.santinova-soft\.org`\)/,
'l’ancienne URL production doit garder un routeur de redirection'
);
assert.equal((compose.match(/tls\.certresolver=letsencrypt/g) || []).length, 5, 'chaque routeur production doit demander un certificat TLS');
const deployedApps = [
{ id: 'itinova-contacts', category: 'ITINOVA', urls: { prod: 'https://contacts.santinova-soft.org' } },
{ id: 'itinova-budget-si', category: 'SANTINOVA', urls: { prod: 'https://budget-si.santinova-soft.org' } },
{ id: 'manus-dashboard', category: 'INFRA', urls: { prod: 'https://dashboard.santinova-soft.org' } },
{ id: 'santelis-gestion', category: 'SANTINOVA', urls: { prod: 'https://gestion-santelis.santinova-soft.org' } }
];
const expectedVisible = {
[hosts.itinova]: ['itinova-contacts'],
[hosts.soins]: [],
[hosts.santinova]: ['itinova-budget-si', 'manus-dashboard'],
[hosts.santelis]: []
};
for (const [host, ids] of Object.entries(expectedVisible)) {
const { sandbox } = loadPortal(host);
const visible = deployedApps.filter(sandbox.isAppInCurrentPortal).map(app => app.id);
assert.deepEqual(visible, ids, `${host} ne doit afficher que les applications de son portail`);
}
const santelisProductionPortal = loadPortal(hosts.santelis).sandbox;
assert.deepEqual(
Array.from(santelisProductionPortal.getPortalOnlyApps(), app => app.id),
['santelis-gestion'],
'SANTELIS Gestion doit être fournie par le portail production sans être ajoutée au dashboard'
);
assert.equal(
santelisProductionPortal.getPortalOnlyApps()[0].urls.prod,
'https://gestion-santelis.santinova-soft.org',
'la tuile SANTELIS doit cibler son URL de production validée'
);
for (const imageName of ['budget-si.jpg', 'dashboard.png', 'portail.png', 'santelis-gestion.jpg', 'studio-portee.png']) {
const imagePath = path.join(root, 'images', imageName);
assert.equal(fs.existsSync(imagePath), true, `l’asset ${imageName} doit être publié`);
assert.ok(fs.statSync(imagePath).size > 2048, `l’asset ${imageName} ne doit pas être vide`);
assert.match(attributions, new RegExp(imageName.replace('.', '\\.')), `l’attribution de ${imageName} doit être documentée`);
}
const script = html.match(/<script>([\s\S]*?)<\/script>/)?.[1];
assert.ok(script, 'le script dynamique du portail doit être présent');
const elements = new Map();
function element(id) {
if (!elements.has(id)) {
elements.set(id, {
innerHTML: '',
textContent: '',
classList: { toggle() {} },
setAttribute() {}
});
}
return elements.get(id);
}
const sandbox = {
AbortController,
Map,
Set,
URL,
console,
document: {
addEventListener() {},
getElementById: element,
querySelectorAll() { return []; }
},
setInterval() {},
setTimeout() { return 0; },
clearTimeout() {}
};
vm.createContext(sandbox);
vm.runInContext(script, sandbox);
sandbox.renderApps([
{
id: 'itinova-budget-si',
name: 'Gestion budget informatique',
category: 'SANTINOVA',
image: 'images/budget-si.jpg',
urls: { prod: 'https://budget-si.santinova-soft.org' },
status: 'online',
containerRunning: true
},
{
id: 'santelis-gestion',
name: 'SANTELIS Gestion',
category: 'SANTINOVA',
image: 'images/santelis-gestion.png',
urls: { prod: 'https://gestion-santelis.santinova-soft.org' },
status: 'online',
containerRunning: true
}
]);
const santinovaMarkup = element('grid-santinova').innerHTML;
assert.match(santinovaMarkup, /Gestion budget informatique/, 'Budget informatique doit être rendu dans le portail production');
assert.doesNotMatch(santinovaMarkup, /SANTELIS Gestion/, 'SANTELIS Gestion ne doit pas être rendu dans le portail production');
assert.equal(element('count-santinova').textContent, 1, 'le compteur Santinova ne doit retenir que les applications visibles');
assert.deepEqual(
loadPortal('portail.santinova-soft.org').redirects,
['https://portail-santinova.santinova-soft.org/'],
'l’ancienne URL production doit rediriger vers le portail Santinova'
);
console.log('OK portal-contract');