"""Tests RBAC 50 rôles — stdlib pur (unittest), zéro dépendance pip. Réutilise le validateur JSON-Schema du module Publiciste (Sprint 2) plutôt que d'en dupliquer un (CLAUDE.md · workflow #5 « éliminer le vieux / ne pas accumuler de doublons »). L'oracle `jsonschema` est utilisé s'il est présent. Invariants vérifiés : 1. `rbac_50_roles.json` valide contre `rbac.schema.json`. 2. EXACTEMENT 50 rôles (cible non-négociable CLAUDE.md « RBAC 50 rôles »). 3. `id` et `erpnext_role_name` uniques (pas de collision de rôle Frappe). 4. Les 5 portails métier de la roadmap S4 sont tous couverts par ≥1 rôle. 5. Cohérence interne (portail déclaré ⊆ portails connus, cible=50). 6. Anti-invention (#6) : aucun rôle « own/equipe » n'a le pouvoir `set_user_permissions` (élévation de privilège) — réservé au RBAC Admin. """ import json import os import unittest # --- localisation des fichiers ------------------------------------------------- HERE = os.path.dirname(os.path.abspath(__file__)) RBAC_DIR = os.path.dirname(HERE) DELIVERABLES = os.path.dirname(RBAC_DIR) DATA_PATH = os.path.join(RBAC_DIR, "rbac_50_roles.json") SCHEMA_PATH = os.path.join(RBAC_DIR, "rbac.schema.json") # --- réutilisation du validateur Publiciste (pas de duplication) --------------- import sys sys.path.insert(0, os.path.join(DELIVERABLES, "publiciste")) from lib import validator as maison # noqa: E402 def _load(path): with open(path, encoding="utf-8") as fh: return json.load(fh) class RbacContractTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.data = _load(DATA_PATH) cls.schema = _load(SCHEMA_PATH) cls.roles = cls.data["roles"] # 1 · conformité au schéma (validateur maison) def test_valide_contre_schema_maison(self): errors = maison.validate(self.data, self.schema) self.assertEqual(errors, [], f"Erreurs de schéma (maison) : {errors}") # 1bis · oracle jsonschema si disponible def test_valide_contre_oracle_jsonschema_si_present(self): try: import jsonschema except ImportError: self.skipTest("jsonschema non installé (validateur maison fait foi)") jsonschema.validate(self.data, self.schema) # 2 · cible non-négociable = 50 def test_exactement_50_roles(self): self.assertEqual(self.data["cible_rbac_roles"], 50) self.assertEqual(len(self.roles), 50, "La cible CLAUDE.md « RBAC 50 rôles » exige exactement 50 rôles.") # 3 · unicité des identifiants et des noms de rôle Frappe def test_ids_uniques(self): ids = [r["id"] for r in self.roles] self.assertEqual(len(ids), len(set(ids)), "id de rôle dupliqué.") def test_noms_erpnext_uniques(self): names = [r["erpnext_role_name"] for r in self.roles] self.assertEqual(len(names), len(set(names)), "erpnext_role_name dupliqué (collision Frappe).") def test_noms_erpnext_prefixes_oto(self): for r in self.roles: self.assertTrue( r["erpnext_role_name"].startswith("OTO "), f"{r['id']}: le rôle Frappe doit être préfixé « OTO » pour éviter la collision avec les rôles natifs.", ) # 4 · les 5 portails métier de la roadmap S4 sont tous couverts def test_cinq_portails_business_couverts(self): portails_declares = set(self.data["portails_business"]) self.assertEqual(portails_declares, {"ventes", "construction", "achat", "compta", "direction"}) portails_utilises = {r["portail"] for r in self.roles} for p in portails_declares: self.assertIn(p, portails_utilises, f"Portail métier « {p} » sans aucun rôle rattaché.") # 5 · cohérence portail def test_portail_connu(self): connus = {"ventes", "construction", "achat", "compta", "direction", "plateforme"} for r in self.roles: self.assertIn(r["portail"], connus, f"{r['id']}: portail inconnu {r['portail']!r}") # 6 · anti-élévation de privilège : set_user_permissions réservé au groupe def test_set_user_permissions_reserve_admin(self): for r in self.roles: verbs = {a for p in r["permissions_cibles"] for a in p["actions"]} if "set_user_permissions" in verbs: self.assertEqual( r["scope_donnees"], "groupe", f"{r['id']}: 'set_user_permissions' exige un scope 'groupe' (séparation des pouvoirs).", ) self.assertEqual( r["famille"], "plateforme", f"{r['id']}: seul un rôle 'plateforme' peut porter 'set_user_permissions'.", ) # 7 · chaque rôle a au moins un binding de permission def test_chaque_role_a_des_permissions(self): for r in self.roles: self.assertGreaterEqual(len(r["permissions_cibles"]), 1, f"{r['id']}: aucun binding de permission.") if __name__ == "__main__": unittest.main(verbosity=2)