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