Files
oto-enterprise-os-dtp/05_deliverables_mvp/rbac/tests/test_rbac.py
T
Claude Code DTP Worker cc3e5aef0e [DTP-Worker] Sprint 2 · ERPNext RBAC 50 rôles (schéma + data + spec + gate)
Livrable Sprint 2 (roadmap §S2 l.38 « RBAC 50 rôles configuration »,
GAP_ANALYSIS §3.4). Seul deliverable S2 100% autorable en-repo — les clones
Frontend/CRM dépendent des layouts LIVE (VPS).

- rbac/rbac.schema.json — contrat JSON-Schema draft-07 (sous-ensemble validateur
  maison, zéro pip) : 50 rôles, DocPerm par DocType, scope User Permission.
- rbac/rbac_50_roles.json — 50 rôles × 5 portails métier + console plateforme,
  mappés aux entités CLAUDE.md, ciblant des DocTypes ERPNext v15 natifs.
- rbac/RBAC_50_ROLES_SPEC.md — design RBAC 3 niveaux + séparation des pouvoirs +
  procédure d'application VPS (fixtures bench, hors périmètre worker).
- rbac/tests/test_rbac.py — 10 tests unittest (réutilise le validateur
  Publiciste, pas de doublon) : 50 rôles exacts, unicité, 5 portails, anti-
  élévation de privilège. Oracle jsonschema si présent.
- ci.yml — job rbac-tests ajouté au gate (Gitea Actions uniquement).
- GAP_ANALYSIS §3.4 + daily report 2026-07-30 (session 4) mis à jour.

Anti-invention #6 : aucun plafond monétaire inventé ; DocTypes non natifs
marqués custom → à confirmer VPS. Gate local vert (guard/json/docs + 10 tests
RBAC + 23 tests Publiciste régression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 02:05:54 +00:00

117 lines
5.0 KiB
Python

"""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)