[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>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user