[DTP-Worker] Sprint 2 · Générateur fixtures ERPNext (RBAC 50 rôles → Role + Custom DocPerm)

Transforme rbac_50_roles.json en fixtures Frappe/ERPNext v15 natives, prêtes à
appliquer via bench (VPS · agent ERPNext). Réalise le prochain incrément annoncé
au §7 de RBAC_50_ROLES_SPEC.md. Zéro pip, zéro VPS, sortie déterministe.

- fixturelib/frappe.py : modèle natif (15 flags DocPerm, mapping 1:1 des verbes
  RBAC, if_owner⇔scope "own"). fixturelib/builder.py : bundle déterministe.
- rbac_fixtures_gen.py : CLI build/validate (refuse d'écrire si invariant KO).
- fixtures.schema.json : contrat de sortie (validateur maison Publiciste réutilisé).
- 11 tests unittest : schéma+oracle, 50 rôles, séparation des pouvoirs,
  round-trip fidèle au contrat, déterminisme. Job CI rbac-fixtures-tests au gate.
- Anti-invention #6 : 100% dérivé du contrat, flags non pilotés à 0, DocTypes
  custom signalés « à confirmer VPS ».

Vérif : 11/11 verts + gate CI local vert (exit 0) + régression 60 tests 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:03:12 +00:00
parent 911f70b9ab
commit d815c7ab63
11 changed files with 869 additions and 7 deletions
@@ -0,0 +1,160 @@
"""Tests du générateur de fixtures ERPNext (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 bundle valide contre fixtures.schema.json (maison + oracle).
2. Exactement 50 fixtures Role, noms Frappe uniques préfixés « OTO ».
3. Chaque DocPerm ne lève que des flags déclarés dans le contrat ; les verbes
non pilotés (select/amend/import) restent à 0 (défaut sûr).
4. `if_owner` ⇔ scope_donnees == "own" (fidélité au contrat).
5. Séparation des pouvoirs : set_user_permissions réservé au RBAC Admin.
6. Round-trip : chaque DocPerm renvoie vers une permission réelle du contrat
(aucune permission inventée, aucune perdue).
7. Déterminisme : deux builds successifs sont bit-à-bit identiques.
8. Les DocTypes `custom` du contrat sont tous signalés dans le manifeste.
"""
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, "..")) # fixtures_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 fixturelib 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, "fixtures.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(
"rbac_fixtures_gen", os.path.join(_GEN, "rbac_fixtures_gen.py")
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
class FixturesGenTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.contract = _load(_CONTRACT)
cls.schema = _load(_SCHEMA)
cls.bundle = builder.build_bundle(cls.contract)
# 1 · conformité au schéma (validateur maison)
def test_bundle_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_bundle_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 · exactement 50 rôles, noms uniques préfixés OTO
def test_50_roles_uniques(self):
roles = self.bundle["role"]
self.assertEqual(len(roles), 50)
names = [r["role_name"] for r in roles]
self.assertEqual(len(names), len(set(names)), "Noms de rôle dupliqués")
for r in roles:
self.assertTrue(r["role_name"].startswith("OTO "))
self.assertEqual(r["name"], r["role_name"])
self.assertEqual(r["desk_access"], 1)
# 3 · verbes non pilotés toujours à 0 ; flags levés ⊆ RBAC_ACTIONS
def test_flags_non_pilotes_a_zero(self):
for d in self.bundle["custom_docperm"]:
for flag in frappe.NON_PILOTES:
self.assertEqual(d[flag], 0, f"{flag} levé indûment sur {d['parent']}")
leves = {f for f in frappe.DOCPERM_FLAGS if d[f] == 1}
self.assertTrue(
leves <= frappe.RBAC_ACTIONS,
f"Flag hors contrat levé : {leves - frappe.RBAC_ACTIONS}",
)
# 4 · if_owner ⇔ scope "own"
def test_if_owner_reflete_scope_own(self):
scope_by_role = {r["erpnext_role_name"]: r["scope_donnees"] for r in self.contract["roles"]}
for d in self.bundle["custom_docperm"]:
expected = 1 if scope_by_role[d["role"]] == "own" else 0
self.assertEqual(d["if_owner"], expected, f"if_owner incohérent pour {d['role']}")
# 5 · séparation des pouvoirs
def test_set_user_permissions_reserve_admin(self):
admin = {
r["erpnext_role_name"]
for r in self.contract["roles"]
if r.get("famille") == "plateforme" and r.get("scope_donnees") == "groupe"
}
for d in self.bundle["custom_docperm"]:
if d["set_user_permissions"] == 1:
self.assertIn(d["role"], admin, f"Élévation de privilège : {d['role']}")
# 6 · round-trip : chaque DocPerm ↔ une permission réelle du contrat
def test_roundtrip_fidele_au_contrat(self):
# (role, doctype) → set(actions) attendu depuis le contrat
expected = {}
for r in self.contract["roles"]:
for p in r["permissions_cibles"]:
expected[(r["erpnext_role_name"], p["doctype"])] = set(p["actions"])
seen = set()
for d in self.bundle["custom_docperm"]:
key = (d["role"], d["parent"])
self.assertIn(key, expected, f"DocPerm inventé : {key}")
leves = {f for f in frappe.DOCPERM_FLAGS if d[f] == 1}
self.assertEqual(leves, expected[key], f"Actions divergentes pour {key}")
seen.add(key)
self.assertEqual(seen, set(expected), "Des permissions du contrat sont perdues")
# 7 · déterminisme bit-à-bit
def test_deterministe(self):
a = json.dumps(builder.build_bundle(_load(_CONTRACT)), sort_keys=False)
b = json.dumps(builder.build_bundle(_load(_CONTRACT)), sort_keys=False)
self.assertEqual(a, b, "Génération non déterministe")
# 8 · DocTypes custom tous signalés dans le manifeste
def test_manifeste_signale_doctypes_custom(self):
contract_custom = sorted(
{
p["doctype"]
for r in self.contract["roles"]
for p in r["permissions_cibles"]
if p.get("custom") is True
}
)
self.assertEqual(
self.bundle["manifest"]["custom_doctypes_a_confirmer"], contract_custom
)
# 9 · verbe inconnu → erreur explicite (garde-fou anti-contrat corrompu)
def test_verbe_inconnu_rejete(self):
with self.assertRaises(ValueError):
frappe.docperm_fixture("Lead", "OTO X", ["read", "teleport"], if_owner=False)
# 10 · le CLI validate/build fonctionne 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)