[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:
@@ -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)
|
||||
Reference in New Issue
Block a user