[DTP-Worker] Sprint 2 · Générateur plan User Permission (RBAC row-level scope_donnees → Frappe)

Complète le pipeline RBAC (Role + Custom DocPerm déjà livrés) par la dimension
row-level. Mapping natif ERPNext v15 des 4 scope_donnees :
- entite → User Permission allow=Company (28 templates, user=sentinelle)
- own    → if_owner (déjà posé par fixtures_gen)
- groupe → aucune restriction (vue consolidée)
- equipe → pas de dimension native → signalé VPS (jamais mappé, #6)

Module userperm_gen/ : permlib/{frappe,builder}, CLI build/validate (refuse
d'écrire si invariant KO), userperm.schema.json (validateur maison, zéro pip),
12 tests unittest, README. Job CI rbac-userperm-tests ajouté au gate.
Docs SPEC §7 + fixtures_gen README cousues. Régression 72 tests verts, gate OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 03:33:44 +00:00
parent d815c7ab63
commit b1b011ab81
12 changed files with 925 additions and 3 deletions
@@ -0,0 +1,158 @@
"""Tests du générateur de plan User Permission (stdlib `unittest`, zéro pip).
Réutilise le validateur maison Publiciste (workflow #5 · pas de duplication) et,
s'il est installé, l'oracle `jsonschema` en plus. Invariants couverts :
1. Le plan valide contre userperm.schema.json (maison + oracle).
2. Exactement 50 entrées, une par rôle du contrat (couverture bijective).
3. Le mécanisme de chaque entrée = le mapping natif de son scope_donnees.
4. Un template User Permission existe SSI portée `entite` (own/groupe/equipe → null).
5. Fidélité du template : allow=Company, for_value=entite du contrat, user=sentinelle.
6. Anti-invention : aucun utilisateur réel (toujours la sentinelle), aucune
restriction fabriquée hors portée `entite`.
7. Manifeste : Companies + rôles `equipe` à confirmer = exactement ceux du contrat.
8. Déterminisme : deux builds successifs sont bit-à-bit identiques.
9. Garde-fous : scope inconnu et `entite`+périmètre consolidé → ValueError.
10. Le CLI validate fonctionne de bout en bout.
"""
import importlib.util
import json
import os
import sys
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
_GEN = os.path.normpath(os.path.join(_HERE, "..")) # userperm_gen/
_RBAC = os.path.normpath(os.path.join(_GEN, "..")) # rbac/
_DELIVERABLES = os.path.normpath(os.path.join(_RBAC, "..")) # 05_deliverables_mvp/
sys.path.insert(0, _GEN)
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
from permlib import builder, frappe # noqa: E402
from lib import validator as maison # noqa: E402
_CONTRACT = os.path.join(_RBAC, "rbac_50_roles.json")
_SCHEMA = os.path.join(_GEN, "userperm.schema.json")
def _load(path):
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def _load_cli():
spec = importlib.util.spec_from_file_location(
"userperm_gen", os.path.join(_GEN, "userperm_gen.py")
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
class UserPermGenTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.contract = _load(_CONTRACT)
cls.schema = _load(_SCHEMA)
cls.bundle = builder.build_plan(cls.contract)
cls.plan = cls.bundle["plan"]
cls.by_role = {r["erpnext_role_name"]: r for r in cls.contract["roles"]}
# 1 · conformité au schéma (validateur maison)
def test_plan_valide_schema_maison(self):
errors = maison.validate(self.bundle, self.schema)
self.assertEqual(errors, [], f"Erreurs de schéma (maison) : {errors}")
# 1bis · oracle jsonschema si disponible
def test_plan_valide_oracle_jsonschema(self):
try:
import jsonschema # noqa: PLC0415
except ImportError:
self.skipTest("jsonschema non installé — oracle ignoré")
jsonschema.validate(self.bundle, self.schema)
# 2 · couverture bijective des 50 rôles
def test_couverture_50_roles(self):
self.assertEqual(len(self.plan), 50)
roles = [e["erpnext_role_name"] for e in self.plan]
self.assertEqual(len(roles), len(set(roles)), "Rôle dupliqué")
self.assertEqual(set(roles), set(self.by_role), "Plan ≠ contrat")
# 3 · mécanisme = mapping natif du scope
def test_mecanisme_reflete_scope(self):
for e in self.plan:
scope = self.by_role[e["erpnext_role_name"]]["scope_donnees"]
self.assertEqual(e["scope_donnees"], scope)
self.assertEqual(e["mechanism"], frappe.SCOPE_MECHANISM[scope])
# 4 · template SSI portée entite
def test_template_ssi_entite(self):
for e in self.plan:
tmpl = e["user_permission_template"]
if e["scope_donnees"] == "entite":
self.assertIsNotNone(tmpl, f"{e['erpnext_role_name']}: template manquant")
else:
self.assertIsNone(
tmpl, f"{e['erpnext_role_name']}: template fabriqué hors portée entite"
)
# 5 · fidélité du template au contrat
def test_template_fidele_au_contrat(self):
for e in self.plan:
if e["scope_donnees"] != "entite":
continue
tmpl = e["user_permission_template"]
entite = self.by_role[e["erpnext_role_name"]]["entite_principale"]
self.assertEqual(tmpl["doctype"], "User Permission")
self.assertEqual(tmpl["allow"], "Company")
self.assertEqual(tmpl["for_value"], entite)
self.assertEqual(tmpl["apply_to_all_doctypes"], 1)
# 6 · anti-invention : jamais d'utilisateur réel, jamais de Company consolidée
def test_anti_invention_user_et_company(self):
for e in self.plan:
tmpl = e["user_permission_template"]
if tmpl is not None:
self.assertEqual(
tmpl["user"], frappe.USER_PLACEHOLDER,
"Utilisateur inventé dans un template (viole #6)",
)
self.assertNotIn(tmpl["for_value"], frappe.NON_COMPANY_ENTITES)
# 7 · manifeste = exactement le contrat (companies + equipe)
def test_manifeste_fidele(self):
m = self.bundle["manifest"]
companies = sorted(
{r["entite_principale"] for r in self.contract["roles"] if r["scope_donnees"] == "entite"}
)
self.assertEqual(m["companies_a_confirmer"], companies)
equipe = sorted(
r["erpnext_role_name"] for r in self.contract["roles"] if r["scope_donnees"] == "equipe"
)
self.assertEqual(m["roles_scope_equipe_a_confirmer"], equipe)
self.assertEqual(sum(m["counts"]["by_mechanism"].values()), 50)
# 8 · déterminisme bit-à-bit
def test_deterministe(self):
a = json.dumps(builder.build_plan(_load(_CONTRACT)), sort_keys=False)
b = json.dumps(builder.build_plan(_load(_CONTRACT)), sort_keys=False)
self.assertEqual(a, b, "Génération non déterministe")
# 9 · garde-fous
def test_scope_inconnu_rejete(self):
with self.assertRaises(ValueError):
frappe.mechanism_for("planetaire")
def test_entite_consolidee_rejetee(self):
with self.assertRaises(ValueError):
frappe.user_permission_template("Groupe")
# 10 · CLI validate de bout en bout
def test_cli_validate_ok(self):
cli = _load_cli()
self.assertEqual(cli.main(["validate"]), 0)
if __name__ == "__main__":
unittest.main(verbosity=2)