[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:
+18
-1
@@ -90,10 +90,27 @@ jobs:
|
||||
- name: Tests générateur Faisabilité
|
||||
run: python3 -m unittest discover -s tests -v
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Générateur de fixtures ERPNext (Sprint 2 · ERPNext Backend) : le contrat
|
||||
# rbac_50_roles.json → fixtures Frappe `Role` + `Custom DocPerm`. Valide le
|
||||
# bundle contre fixtures.schema.json + invariants (50 rôles, séparation des
|
||||
# pouvoirs, round-trip fidèle au contrat). stdlib pur, aucune install pip.
|
||||
# --------------------------------------------------------------------------
|
||||
rbac-fixtures-tests:
|
||||
name: RBAC · fixtures ERPNext (Role + Custom DocPerm)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: 05_deliverables_mvp/rbac/fixtures_gen
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Tests générateur de fixtures RBAC
|
||||
run: python3 -m unittest discover -s tests -v
|
||||
|
||||
gate:
|
||||
name: Gate qualité (agrégat)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests]
|
||||
needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests]
|
||||
steps:
|
||||
- name: Résultat
|
||||
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Daily Report · 2026-07-30 · Claude Code DTP Worker (session 6)
|
||||
|
||||
**Session** : `20260730_025643`
|
||||
|
||||
## Tâche exécutée
|
||||
**Sprint 2 · Livrable ERPNext Backend « Générateur de fixtures RBAC → Frappe »**
|
||||
(roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` §Sprint 2 « RBAC 50 rôles » ·
|
||||
`RBAC_50_ROLES_SPEC.md` §7 qui l'annonce comme prochain incrément · suggéré par
|
||||
les daily reports sessions 4-5).
|
||||
|
||||
## Contexte / analyse
|
||||
- Relu `CLAUDE.md`, `ROADMAP_8_WEEKS_OR_LESS.md`, `GAP_ANALYSIS_SPRINT1.md`,
|
||||
daily reports sessions 4-5, `rbac_50_roles.json` + `rbac.schema.json` +
|
||||
`RBAC_50_ROLES_SPEC.md`, validateur maison Publiciste, patrons générateur
|
||||
Faisabilité + CI.
|
||||
- État Sprint 2 côté repo : RBAC contrat (s4), Publiciste parser+gen (s3),
|
||||
Faisabilité générateur 4 volets (s5) livrés. Frontend/CRM dépendent des
|
||||
layouts LIVE (VPS · hors périmètre).
|
||||
- **Priorité évidente et déjà annoncée** (SPEC §7, reports s4/s5) : le
|
||||
**générateur de fixtures ERPNext** `rbac_50_roles.json → Role + Custom
|
||||
DocPerm`. Sur le chemin critique de l'application RBAC (S4), 100 % autorable
|
||||
en-repo (zéro API, zéro VPS, zéro pip), branché sur le même patron que le
|
||||
Publiciste/Faisabilité.
|
||||
|
||||
## Réalisé — module `05_deliverables_mvp/rbac/fixtures_gen/`
|
||||
- `fixturelib/frappe.py` — modèle **natif Frappe/ERPNext v15** : ordre des 15
|
||||
flags DocPerm, mapping **1:1** des 12 verbes RBAC → flags, `if_owner` dérivé de
|
||||
`scope_donnees == "own"`, constructeurs `role_fixture()` / `docperm_fixture()`.
|
||||
Aucun chiffre métier (contrainte #1 : ERPNext natif, aucun modèle externe).
|
||||
- `fixturelib/builder.py` — `build_bundle()` **déterministe** (tri stable rôles
|
||||
par nom Frappe, DocPerms par (role, doctype, permlevel)), collecte des DocTypes
|
||||
`custom` à confirmer, manifeste de traçabilité.
|
||||
- `rbac_fixtures_gen.py` — CLI `build` (écrit `role.json` / `custom_docperm.json`
|
||||
/ `MANIFEST.json`) et `validate`. La génération **refuse d'écrire** si un
|
||||
invariant casse (schéma, 50 rôles, unicité, séparation des pouvoirs).
|
||||
- `fixtures.schema.json` — contrat de sortie draft-07 (sous-ensemble supporté par
|
||||
le **validateur maison Publiciste**, zéro pip).
|
||||
- `tests/test_fixtures_gen.py` — **11 tests `unittest` (stdlib pur)** : schéma
|
||||
(maison + oracle `jsonschema` si présent), 50 rôles uniques, flags non pilotés
|
||||
à 0, `if_owner`⇔scope, séparation des pouvoirs, **round-trip fidèle au
|
||||
contrat** (aucune permission inventée ni perdue), déterminisme bit-à-bit.
|
||||
- **CI** : job `rbac-fixtures-tests` ajouté au **gate** de `.gitea/workflows/ci.yml`
|
||||
(Gitea Actions uniquement · #2).
|
||||
- `README.md` + `.gitignore` (output `out/` non commité, re-généré à la demande).
|
||||
- `RBAC_50_ROLES_SPEC.md §7` mis à jour (générateur livré).
|
||||
|
||||
## Anti-invention (#6) appliqué
|
||||
- 100 % des données proviennent du contrat `rbac_50_roles.json` déjà validé ;
|
||||
seuls ajouts = flags DocPerm natifs à **0** (défaut sûr) + méta de traçabilité.
|
||||
- Verbes non pilotés (`select`/`amend`/`import`) forcés à 0 et testés.
|
||||
- Verbe hors contrat → `ValueError` explicite (garde-fou anti-contrat corrompu).
|
||||
- DocTypes DTP `custom` **jamais supposés existants** : listés dans le manifeste
|
||||
« à créer + confirmer VPS » avant import (SPEC §6/§7).
|
||||
- Séparation des pouvoirs : `set_user_permissions` réservé au RBAC Admin,
|
||||
vérifié par test **et** par le CLI (échec si un autre rôle le porte).
|
||||
|
||||
## Vérifications effectuées (en-repo, sans toucher au VPS)
|
||||
- **11/11 tests verts** (schéma maison + oracle `jsonschema` → concordance
|
||||
draft-07 confirmée). Génération réelle : 50 rôles, **116 Custom DocPerm** sur
|
||||
45 DocTypes, 4 DocTypes `custom` signalés.
|
||||
- **Gate CI local vert (exit 0)** : `guard_constraints.sh`, `validate_json.sh`,
|
||||
`check_docs.sh` (0 lien cassé), YAML `ci.yml` valide.
|
||||
- **Régression** : 23/23 Publiciste + 10/10 RBAC + 16/16 Faisabilité toujours
|
||||
verts (réutilisation du validateur sans effet de bord). **Total 60 tests.**
|
||||
|
||||
## Non fait (hors périmètre worker · VPS)
|
||||
- Application des fixtures (`bench migrate` / `import-fixtures`) sur
|
||||
`erpnext-backend-1` → agent ERPNext Backend.
|
||||
- Création des DocTypes DTP `custom` + mapping `scope_donnees` → `User Permission`
|
||||
par utilisateur → VPS.
|
||||
- `permlevel > 0` (segmentation par niveau de champ) : non spécifié par le
|
||||
contrat RBAC → non couvert (anti-invention #6).
|
||||
|
||||
## Prochaine tâche suggérée
|
||||
- ERPNext S2 : générateur des fixtures `User Permission` **par entité** (WAF/WA
|
||||
SRL/AC…) depuis `scope_donnees` (même patron, autorable en-repo partiellement).
|
||||
- Ou Faisabilité S3 : `40_llm_outputs/` + rapports bancables FR/EN/ES.
|
||||
- Ou Frontend/CRM S2 : dépend des layouts LIVE (VPS) → hors périmètre worker.
|
||||
|
||||
---
|
||||
|
||||
**Auto-score 4Big du livrable Générateur de fixtures RBAC : 96/100.** Réserve
|
||||
−4 : application `bench` + DocTypes `custom` + `User Permission` = côté VPS
|
||||
(agent ERPNext, hors périmètre) ; `permlevel > 0` non couvert (anti-invention
|
||||
#6). Validé statiquement en-repo (11 tests verts + round-trip fidèle au contrat
|
||||
+ gate CI vert).
|
||||
@@ -124,15 +124,19 @@ du code sans vérifier l'existence courante »).
|
||||
Ce worker n'écrit **jamais** sur le VPS. L'agent ERPNext Backend appliquera la
|
||||
cible côté serveur (`erpnext-backend-1`) :
|
||||
|
||||
1. Générer les fixtures `Role` + `Custom DocPerm` depuis `rbac_50_roles.json`.
|
||||
2. `bench --site frontend migrate` pour installer les rôles.
|
||||
3. Créer les DocTypes DTP `custom` manquants (§6) **après** confirmation.
|
||||
1. Générer les fixtures `Role` + `Custom DocPerm` depuis `rbac_50_roles.json`
|
||||
— **livré** : [`fixtures_gen/`](fixtures_gen/README.md) (`python3 rbac_fixtures_gen.py build`).
|
||||
2. Créer les DocTypes DTP `custom` manquants (§6) **après** confirmation
|
||||
(listés dans `MANIFEST.custom_doctypes_a_confirmer`).
|
||||
3. Déposer `role.json` + `custom_docperm.json` dans `fixtures/` de l'app OTO
|
||||
(`hooks.py`), puis `bench --site frontend migrate`.
|
||||
4. Mapper les `scope_donnees` en `User Permission` par utilisateur.
|
||||
5. Vérification HTTP post-déploiement (workflow #3) + audit QA 4Big.
|
||||
|
||||
> Un **générateur de fixtures** `rbac_50_roles.json → fixtures/` est le prochain
|
||||
> incrément (branchable sur le même patron que le Publiciste) ; il reste autorable
|
||||
> en-repo sans toucher au VPS.
|
||||
> ✅ Le **générateur de fixtures** `rbac_50_roles.json → fixtures/` est **livré**
|
||||
> en-repo ([`fixtures_gen/`](fixtures_gen/README.md), même patron que le
|
||||
> Publiciste, zéro pip / zéro VPS). Sortie déterministe validée par 11 tests +
|
||||
> job CI `rbac-fixtures-tests`. L'application `bench` reste côté serveur.
|
||||
|
||||
## 8. Vérification (en-repo, sans toucher au VPS)
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Artefacts de génération locale (jamais commités — produits à la demande par
|
||||
# `python3 rbac_fixtures_gen.py build`, re-générables en CI).
|
||||
__pycache__/
|
||||
*.pyc
|
||||
out/
|
||||
@@ -0,0 +1,75 @@
|
||||
# Générateur de fixtures ERPNext · RBAC 50 rôles → Frappe
|
||||
|
||||
**Sprint 2 · agent ERPNext Backend.** Transforme le contrat
|
||||
[`../rbac_50_roles.json`](../rbac_50_roles.json) (validé par
|
||||
[`../rbac.schema.json`](../rbac.schema.json)) en **fixtures Frappe/ERPNext v15
|
||||
natives**, prêtes à appliquer sur le VPS par `bench`. Réalise le « prochain
|
||||
incrément » annoncé au §7 de [`../RBAC_50_ROLES_SPEC.md`](../RBAC_50_ROLES_SPEC.md).
|
||||
|
||||
> Ce worker **n'écrit jamais sur le VPS** (contrainte #8). Il produit les
|
||||
> fichiers en-repo ; l'application réelle (`bench migrate`) reste côté serveur.
|
||||
|
||||
## Ce qui est généré (`out/`, non commité)
|
||||
|
||||
| Fichier | DocType Frappe | Rôle |
|
||||
|---|---|---|
|
||||
| `role.json` | `Role` | 50 rôles desk préfixés `OTO ` (anti-collision natif). |
|
||||
| `custom_docperm.json` | `Custom DocPerm` | Permissions par DocType, **sans** modifier le DocType (réversible, ne casse pas les updates d'app). |
|
||||
| `MANIFEST.json` | — | Traçabilité (comptes, version source) + **DocTypes DTP `custom` à créer/confirmer VPS**. |
|
||||
|
||||
## Utilisation
|
||||
|
||||
```bash
|
||||
# Génère out/role.json, out/custom_docperm.json, out/MANIFEST.json
|
||||
python3 rbac_fixtures_gen.py build # [-o DOSSIER]
|
||||
|
||||
# Valide le bundle (schéma + invariants) sans rien écrire
|
||||
python3 rbac_fixtures_gen.py validate
|
||||
|
||||
# Tests (stdlib pur, zéro pip)
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
## Mapping RBAC → Frappe (aucune invention · #6)
|
||||
|
||||
- Chaque `permissions_cibles[].actions` du contrat lève **1:1** le flag DocPerm
|
||||
natif correspondant (`read`/`write`/`create`/`delete`/`submit`/`cancel`/
|
||||
`report`/`export`/`print`/`email`/`share`/`set_user_permissions`). Les flags
|
||||
non pilotés (`select`/`amend`/`import`) restent à **0** (défaut sûr).
|
||||
- `scope_donnees == "own"` → `if_owner = 1` (restriction Frappe native). Les
|
||||
portées `equipe`/`entite`/`groupe` relèvent des **User Permission** par
|
||||
utilisateur → appliquées sur le VPS (SPEC §7), **hors périmètre worker**.
|
||||
- `permlevel = 0` (le contrat ne segmente pas par niveau de champ).
|
||||
- Sortie **déterministe** (tri stable, aucun horodatage) → diffable, re-générable
|
||||
bit-à-bit en CI.
|
||||
|
||||
## Séparation des pouvoirs (défense en profondeur)
|
||||
|
||||
Le flag `set_user_permissions` n'est émis que pour le rôle **RBAC Admin**
|
||||
(`famille = plateforme`, `scope = groupe`) — vérifié par test et par le CLI
|
||||
(`validate` échoue si un autre rôle le porte). Aucune élévation de privilège
|
||||
possible via les fixtures.
|
||||
|
||||
## Application sur VPS (agent ERPNext Backend · hors périmètre worker)
|
||||
|
||||
1. Créer d'abord les DocTypes DTP `custom` listés dans `MANIFEST.custom_doctypes_a_confirmer`
|
||||
(`Faisabilité`, `Publiciste Log`, `CONFOTUR Application`, `API Access`) **après confirmation d'existence**.
|
||||
2. Déposer `role.json` + `custom_docperm.json` dans `fixtures/` de l'app OTO, référencés dans `hooks.py`.
|
||||
3. `bench --site frontend migrate` (ou `bench import-fixtures`).
|
||||
4. Mapper `scope_donnees` → `User Permission` par utilisateur.
|
||||
5. Vérification HTTP post-déploiement (workflow #3) + audit QA 4Big.
|
||||
|
||||
## Vérification en-repo
|
||||
|
||||
- `python3 -m unittest discover -s tests -v` → **11/11 verts** (schéma maison +
|
||||
oracle `jsonschema` si présent ; round-trip fidèle au contrat, déterminisme,
|
||||
séparation des pouvoirs).
|
||||
- Job CI dédié `rbac-fixtures-tests` ajouté au **gate** (`.gitea/workflows/ci.yml`,
|
||||
Gitea Actions uniquement · #2).
|
||||
|
||||
## Auto-score 4Big du livrable : **96/100**
|
||||
|
||||
_Réserve −4_ : application des fixtures + création des DocTypes `custom` +
|
||||
mapping `User Permission` = côté VPS (agent ERPNext, hors périmètre worker) ;
|
||||
`permlevel > 0` non couvert (le contrat RBAC ne le spécifie pas — anti-invention
|
||||
#6). Validé statiquement en-repo (11 tests verts + schéma conforme + gate CI).
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Bibliothèque de génération de fixtures ERPNext depuis le contrat RBAC.
|
||||
|
||||
Modules :
|
||||
- frappe : modèle natif Frappe (Role / Custom DocPerm) + mapping des verbes.
|
||||
- builder : transforme rbac_50_roles.json → bundle de fixtures déterministe.
|
||||
"""
|
||||
|
||||
from . import builder, frappe
|
||||
|
||||
__all__ = ["builder", "frappe"]
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Construction des fixtures ERPNext depuis `rbac_50_roles.json`.
|
||||
|
||||
Entrée : le contrat RBAC 50 rôles (déjà validé par `rbac.schema.json` dans le
|
||||
job CI `rbac-tests`). Sortie : un « bundle » déterministe contenant les deux
|
||||
fichiers de fixtures Frappe (`role`, `custom_docperm`) + un manifeste de
|
||||
traçabilité (contrainte #6 : tout provient du contrat, rien n'est inventé).
|
||||
|
||||
Déterminisme : tri stable (rôles par nom Frappe, DocPerms par (role, doctype,
|
||||
permlevel)) → la sortie est reproductible bit-à-bit à contrat constant, donc
|
||||
diffable et re-générable en CI sans horodatage volatil.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import frappe
|
||||
|
||||
|
||||
def _sorted_roles(contract: dict) -> list[dict]:
|
||||
return sorted(contract["roles"], key=lambda r: r["erpnext_role_name"])
|
||||
|
||||
|
||||
def build_bundle(contract: dict) -> dict[str, Any]:
|
||||
"""Transforme le contrat RBAC en bundle de fixtures Frappe déterministe."""
|
||||
roles = _sorted_roles(contract)
|
||||
|
||||
# --- 1. Fixtures Role (unicité garantie par le contrat, re-vérifiée) ------
|
||||
role_fixtures: list[dict] = []
|
||||
seen_roles: set[str] = set()
|
||||
for r in roles:
|
||||
name = r["erpnext_role_name"]
|
||||
if name in seen_roles:
|
||||
raise ValueError(f"Rôle Frappe dupliqué dans le contrat : {name!r}")
|
||||
seen_roles.add(name)
|
||||
role_fixtures.append(frappe.role_fixture(name))
|
||||
|
||||
# --- 2. Fixtures Custom DocPerm ------------------------------------------
|
||||
docperms: list[dict] = []
|
||||
custom_doctypes: set[str] = set()
|
||||
for r in roles:
|
||||
role_name = r["erpnext_role_name"]
|
||||
if_owner = r["scope_donnees"] == frappe.SCOPE_IF_OWNER
|
||||
# Tri des DocTypes cibles pour un ordre stable indépendant du contrat.
|
||||
for perm in sorted(r["permissions_cibles"], key=lambda p: p["doctype"]):
|
||||
doctype = perm["doctype"]
|
||||
if perm.get("custom") is True:
|
||||
custom_doctypes.add(doctype)
|
||||
docperms.append(
|
||||
frappe.docperm_fixture(
|
||||
doctype,
|
||||
role_name,
|
||||
perm["actions"],
|
||||
if_owner=if_owner,
|
||||
)
|
||||
)
|
||||
|
||||
docperms.sort(key=lambda d: (d["role"], d["parent"], d["permlevel"]))
|
||||
|
||||
# --- 3. Manifeste de traçabilité -----------------------------------------
|
||||
manifest = {
|
||||
"generated_from": "rbac_50_roles.json",
|
||||
"source_version": contract["version"],
|
||||
"cible_rbac_roles": contract["cible_rbac_roles"],
|
||||
"counts": {
|
||||
"role": len(role_fixtures),
|
||||
"custom_docperm": len(docperms),
|
||||
"doctypes_uniques": len({d["parent"] for d in docperms}),
|
||||
},
|
||||
# DocTypes DTP non natifs : à CRÉER + confirmer sur le VPS avant import
|
||||
# des DocPerms qui les ciblent (SPEC §6/§7). Jamais supposés existants.
|
||||
"custom_doctypes_a_confirmer": sorted(custom_doctypes),
|
||||
}
|
||||
|
||||
return {
|
||||
"manifest": manifest,
|
||||
"role": role_fixtures,
|
||||
"custom_docperm": docperms,
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Modèle Frappe/ERPNext v15 : mapping RBAC → fixtures `Role` + `Custom DocPerm`.
|
||||
|
||||
Sépare la CONNAISSANCE FRAPPE (noms de champs, ordre des colonnes de permission,
|
||||
valeurs par défaut) de la logique de construction (`builder.py`). Aucun chiffre
|
||||
métier ici — uniquement la structure native ERPNext (contrainte #1 « ERPNext
|
||||
natif = priorité absolue » : on n'invente aucun modèle de permission externe).
|
||||
|
||||
Références Frappe :
|
||||
- DocType `Role` → un rôle desk (préfixe « OTO » déjà porté par le
|
||||
contrat `rbac_50_roles.json`, anti-collision natif).
|
||||
- DocType `Custom DocPerm`→ personnalise les permissions d'un DocType existant
|
||||
SANS modifier le DocType lui-même (idéal pour appliquer par fixtures +
|
||||
`bench migrate`, réversible, ne casse pas les mises à jour d'app).
|
||||
|
||||
Ce module ne touche JAMAIS le VPS : il produit des dicts sérialisables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Colonnes de permission d'un DocPerm Frappe, dans l'ordre natif du DocType.
|
||||
# Toutes défaut 0 ; on ne lève à 1 que les verbes déclarés dans le contrat RBAC.
|
||||
# --------------------------------------------------------------------------- #
|
||||
DOCPERM_FLAGS: tuple[str, ...] = (
|
||||
"select",
|
||||
"read",
|
||||
"write",
|
||||
"create",
|
||||
"delete",
|
||||
"submit",
|
||||
"cancel",
|
||||
"amend",
|
||||
"report",
|
||||
"export",
|
||||
"import",
|
||||
"print",
|
||||
"email",
|
||||
"share",
|
||||
"set_user_permissions",
|
||||
)
|
||||
|
||||
# Verbes que le contrat RBAC (`permissions_cibles[].actions`) peut émettre.
|
||||
# TOUS correspondent 1:1 à un flag DocPerm natif ci-dessus → aucune traduction
|
||||
# arbitraire, aucune invention. Un verbe hors de cet ensemble = erreur de contrat.
|
||||
RBAC_ACTIONS: frozenset[str] = frozenset(
|
||||
{
|
||||
"read",
|
||||
"write",
|
||||
"create",
|
||||
"delete",
|
||||
"submit",
|
||||
"cancel",
|
||||
"report",
|
||||
"export",
|
||||
"print",
|
||||
"email",
|
||||
"share",
|
||||
"set_user_permissions",
|
||||
}
|
||||
)
|
||||
|
||||
# Verbes DocPerm jamais pilotés par le contrat RBAC (restent à 0) — documentés
|
||||
# pour prouver qu'aucun flag n'est activé « en douce ».
|
||||
NON_PILOTES: frozenset[str] = frozenset(set(DOCPERM_FLAGS) - RBAC_ACTIONS)
|
||||
|
||||
# permlevel par défaut (0 = champs de base). Le contrat RBAC ne segmente pas par
|
||||
# niveau de champ → on reste au niveau 0 (pas d'invention de permlevel).
|
||||
DEFAULT_PERMLEVEL = 0
|
||||
|
||||
# `scope_donnees == "own"` → restriction Frappe native `if_owner` au niveau
|
||||
# DocPerm. Les autres portées (equipe/entite/groupe) relèvent des `User
|
||||
# Permission` par utilisateur (hors périmètre worker · appliqué VPS §7 du SPEC).
|
||||
SCOPE_IF_OWNER = "own"
|
||||
|
||||
|
||||
def role_fixture(erpnext_role_name: str) -> dict[str, Any]:
|
||||
"""Construit un enregistrement de fixture `Role` Frappe minimal mais valide.
|
||||
|
||||
`bench import-fixtures` complète les autres champs par leurs défauts DocType.
|
||||
On force `desk_access=1` (rôles back-office ERPNext) et `disabled=0`.
|
||||
"""
|
||||
return {
|
||||
"doctype": "Role",
|
||||
"name": erpnext_role_name,
|
||||
"role_name": erpnext_role_name,
|
||||
"desk_access": 1,
|
||||
"disabled": 0,
|
||||
"two_factor_auth": 0,
|
||||
"restrict_to_domain": None,
|
||||
}
|
||||
|
||||
|
||||
def docperm_fixture(
|
||||
doctype: str,
|
||||
role_name: str,
|
||||
actions: list[str],
|
||||
*,
|
||||
if_owner: bool,
|
||||
permlevel: int = DEFAULT_PERMLEVEL,
|
||||
) -> dict[str, Any]:
|
||||
"""Construit un enregistrement `Custom DocPerm` pour (DocType, Role).
|
||||
|
||||
Les flags absents de `actions` restent à 0 (défaut sûr). `name` est
|
||||
volontairement omis → Frappe l'auto-nomme à l'import (hash déterministe côté
|
||||
serveur), ce qui évite d'inventer un identifiant ici.
|
||||
"""
|
||||
unknown = set(actions) - RBAC_ACTIONS
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"Verbe(s) RBAC inconnu(s) pour {doctype}/{role_name} : {sorted(unknown)}"
|
||||
)
|
||||
rec: dict[str, Any] = {
|
||||
"doctype": "Custom DocPerm",
|
||||
"parent": doctype,
|
||||
"parenttype": "DocType",
|
||||
"parentfield": "permissions",
|
||||
"role": role_name,
|
||||
"permlevel": permlevel,
|
||||
"if_owner": 1 if if_owner else 0,
|
||||
}
|
||||
wanted = set(actions)
|
||||
for flag in DOCPERM_FLAGS:
|
||||
rec[flag] = 1 if flag in wanted else 0
|
||||
return rec
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://oto-enterprise-os-dtp/rbac/fixtures.schema.json",
|
||||
"title": "Bundle de fixtures ERPNext généré depuis rbac_50_roles.json",
|
||||
"description": "Contrat de sortie du générateur rbac_fixtures_gen.py. Validé par le validateur maison Publiciste (zéro pip) dans le job CI rbac-fixtures-tests.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["manifest", "role", "custom_docperm"],
|
||||
"properties": {
|
||||
"manifest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"generated_from",
|
||||
"source_version",
|
||||
"cible_rbac_roles",
|
||||
"counts",
|
||||
"custom_doctypes_a_confirmer"
|
||||
],
|
||||
"properties": {
|
||||
"generated_from": { "type": "string", "const": "rbac_50_roles.json" },
|
||||
"source_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
|
||||
"cible_rbac_roles": { "type": "integer", "const": 50 },
|
||||
"counts": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["role", "custom_docperm", "doctypes_uniques"],
|
||||
"properties": {
|
||||
"role": { "type": "integer", "minimum": 50, "maximum": 50 },
|
||||
"custom_docperm": { "type": "integer", "minimum": 1 },
|
||||
"doctypes_uniques": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"custom_doctypes_a_confirmer": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"type": "array",
|
||||
"minItems": 50,
|
||||
"maxItems": 50,
|
||||
"items": { "$ref": "#/definitions/role" }
|
||||
},
|
||||
"custom_docperm": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/definitions/custom_docperm" }
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"flag": { "type": "integer", "enum": [0, 1] },
|
||||
"role": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["doctype", "name", "role_name", "desk_access", "disabled"],
|
||||
"properties": {
|
||||
"doctype": { "type": "string", "const": "Role" },
|
||||
"name": { "type": "string", "pattern": "^OTO " },
|
||||
"role_name": { "type": "string", "pattern": "^OTO " },
|
||||
"desk_access": { "$ref": "#/definitions/flag" },
|
||||
"disabled": { "$ref": "#/definitions/flag" },
|
||||
"two_factor_auth": { "$ref": "#/definitions/flag" },
|
||||
"restrict_to_domain": { "type": ["string", "null"] }
|
||||
}
|
||||
},
|
||||
"custom_docperm": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"doctype",
|
||||
"parent",
|
||||
"parenttype",
|
||||
"parentfield",
|
||||
"role",
|
||||
"permlevel",
|
||||
"if_owner",
|
||||
"select",
|
||||
"read",
|
||||
"write",
|
||||
"create",
|
||||
"delete",
|
||||
"submit",
|
||||
"cancel",
|
||||
"amend",
|
||||
"report",
|
||||
"export",
|
||||
"import",
|
||||
"print",
|
||||
"email",
|
||||
"share",
|
||||
"set_user_permissions"
|
||||
],
|
||||
"properties": {
|
||||
"doctype": { "type": "string", "const": "Custom DocPerm" },
|
||||
"parent": { "type": "string", "minLength": 1 },
|
||||
"parenttype": { "type": "string", "const": "DocType" },
|
||||
"parentfield": { "type": "string", "const": "permissions" },
|
||||
"role": { "type": "string", "pattern": "^OTO " },
|
||||
"permlevel": { "type": "integer", "minimum": 0 },
|
||||
"if_owner": { "$ref": "#/definitions/flag" },
|
||||
"select": { "$ref": "#/definitions/flag" },
|
||||
"read": { "$ref": "#/definitions/flag" },
|
||||
"write": { "$ref": "#/definitions/flag" },
|
||||
"create": { "$ref": "#/definitions/flag" },
|
||||
"delete": { "$ref": "#/definitions/flag" },
|
||||
"submit": { "$ref": "#/definitions/flag" },
|
||||
"cancel": { "$ref": "#/definitions/flag" },
|
||||
"amend": { "$ref": "#/definitions/flag" },
|
||||
"report": { "$ref": "#/definitions/flag" },
|
||||
"export": { "$ref": "#/definitions/flag" },
|
||||
"import": { "$ref": "#/definitions/flag" },
|
||||
"print": { "$ref": "#/definitions/flag" },
|
||||
"email": { "$ref": "#/definitions/flag" },
|
||||
"share": { "$ref": "#/definitions/flag" },
|
||||
"set_user_permissions": { "$ref": "#/definitions/flag" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Générateur de fixtures ERPNext · RBAC 50 rôles → Frappe fixtures (Sprint 2).
|
||||
|
||||
Cible de portage VPS : agent ERPNext Backend (SPEC §7). Ce worker n'écrit
|
||||
JAMAIS sur le VPS — il produit en-repo les fichiers de fixtures que l'agent
|
||||
appliquera côté serveur via `bench --site frontend migrate` / `import-fixtures`.
|
||||
|
||||
Transforme `rbac_50_roles.json` (contrat validé par `rbac.schema.json`) en :
|
||||
- role.json → fixtures DocType `Role` (50 rôles préfixés « OTO »)
|
||||
- custom_docperm.json → fixtures `Custom DocPerm` (permissions par DocType)
|
||||
- MANIFEST.json → traçabilité + DocTypes DTP `custom` à confirmer VPS
|
||||
|
||||
Sous-commandes :
|
||||
build [-o OUT] → écrit role.json / custom_docperm.json / MANIFEST.json
|
||||
validate [-o OUT] → (re)génère en mémoire, valide vs fixtures.schema.json,
|
||||
vérifie les invariants (50 rôles, unicité, séparation
|
||||
des pouvoirs) — sort en erreur si un invariant casse.
|
||||
|
||||
Sortie déterministe (tri stable, aucun horodatage) → diffable + re-générable.
|
||||
Anti-invention (#6) : 100 % des données proviennent du contrat ; les seuls
|
||||
ajouts sont des flags DocPerm natifs à 0 (défaut sûr) et des méta de traçabilité.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_RBAC_DIR = os.path.normpath(os.path.join(_HERE, "..")) # rbac/
|
||||
_DELIVERABLES = os.path.normpath(os.path.join(_RBAC_DIR, "..")) # 05_deliverables_mvp/
|
||||
|
||||
sys.path.insert(0, _HERE)
|
||||
# Réutilisation (workflow #5 : zéro duplication) du validateur maison Publiciste.
|
||||
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
|
||||
|
||||
from fixturelib import builder # noqa: E402
|
||||
from lib import validator as maison # type: ignore # noqa: E402
|
||||
|
||||
_CONTRACT_PATH = os.path.join(_RBAC_DIR, "rbac_50_roles.json")
|
||||
_SCHEMA_PATH = os.path.join(_HERE, "fixtures.schema.json")
|
||||
_DEFAULT_OUT = os.path.join(_HERE, "out")
|
||||
|
||||
|
||||
def _eprint(*args) -> None:
|
||||
print(*args, file=sys.stderr)
|
||||
|
||||
|
||||
def _load(path: str) -> dict:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _write_json(path: str, data) -> None:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
|
||||
|
||||
def _build_bundle() -> dict:
|
||||
return builder.build_bundle(_load(_CONTRACT_PATH))
|
||||
|
||||
|
||||
def _validate_bundle(bundle: dict) -> list[str]:
|
||||
"""Valide le bundle contre fixtures.schema.json + invariants métier RBAC."""
|
||||
schema = _load(_SCHEMA_PATH)
|
||||
errors = list(maison.validate(bundle, schema))
|
||||
|
||||
manifest = bundle["manifest"]
|
||||
roles = bundle["role"]
|
||||
docperms = bundle["custom_docperm"]
|
||||
|
||||
# Invariant 1 · cible non-négociable « RBAC 50 rôles ».
|
||||
if manifest["cible_rbac_roles"] != len(roles):
|
||||
errors.append(
|
||||
f"cible_rbac_roles={manifest['cible_rbac_roles']} ≠ role fixtures={len(roles)}"
|
||||
)
|
||||
|
||||
# Invariant 2 · unicité des noms de rôle Frappe (anti-collision).
|
||||
names = [r["role_name"] for r in roles]
|
||||
if len(names) != len(set(names)):
|
||||
errors.append("Noms de rôle Frappe dupliqués dans les fixtures Role.")
|
||||
|
||||
# Invariant 3 · unicité (role, DocType, permlevel) côté DocPerm.
|
||||
keys = [(d["role"], d["parent"], d["permlevel"]) for d in docperms]
|
||||
if len(keys) != len(set(keys)):
|
||||
errors.append("DocPerm dupliqué pour un même (role, DocType, permlevel).")
|
||||
|
||||
# Invariant 4 · séparation des pouvoirs (#6) : `set_user_permissions`
|
||||
# réservé au seul RBAC Admin (aucune élévation de privilège fixée en dur).
|
||||
contract = _load(_CONTRACT_PATH)
|
||||
admin_roles = {
|
||||
r["erpnext_role_name"]
|
||||
for r in contract["roles"]
|
||||
if r.get("famille") == "plateforme" and r.get("scope_donnees") == "groupe"
|
||||
}
|
||||
for d in docperms:
|
||||
if d["set_user_permissions"] == 1 and d["role"] not in admin_roles:
|
||||
errors.append(
|
||||
f"Élévation de privilège : {d['role']} porte set_user_permissions "
|
||||
f"sur {d['parent']} (réservé au RBAC Admin)."
|
||||
)
|
||||
|
||||
# Invariant 5 · cohérence de comptage du manifeste.
|
||||
if manifest["counts"]["role"] != len(roles):
|
||||
errors.append("counts.role incohérent avec le nombre de fixtures Role.")
|
||||
if manifest["counts"]["custom_docperm"] != len(docperms):
|
||||
errors.append("counts.custom_docperm incohérent avec les DocPerm.")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def cmd_build(args: argparse.Namespace) -> int:
|
||||
bundle = _build_bundle()
|
||||
errors = _validate_bundle(bundle)
|
||||
if errors:
|
||||
_eprint("❌ Bundle invalide — génération refusée (anti-régression) :")
|
||||
for e in errors:
|
||||
_eprint(f" - {e}")
|
||||
return 1
|
||||
|
||||
out = os.path.abspath(args.out)
|
||||
os.makedirs(out, exist_ok=True)
|
||||
_write_json(os.path.join(out, "role.json"), bundle["role"])
|
||||
_write_json(os.path.join(out, "custom_docperm.json"), bundle["custom_docperm"])
|
||||
_write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])
|
||||
|
||||
m = bundle["manifest"]
|
||||
print(f"✅ Fixtures générées dans {out}")
|
||||
print(f" role.json : {m['counts']['role']} rôles")
|
||||
print(
|
||||
f" custom_docperm.json : {m['counts']['custom_docperm']} DocPerm "
|
||||
f"sur {m['counts']['doctypes_uniques']} DocTypes"
|
||||
)
|
||||
if m["custom_doctypes_a_confirmer"]:
|
||||
print(
|
||||
" ⚠ DocTypes DTP `custom` à créer + confirmer VPS avant import : "
|
||||
+ ", ".join(m["custom_doctypes_a_confirmer"])
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_validate(args: argparse.Namespace) -> int:
|
||||
bundle = _build_bundle()
|
||||
errors = _validate_bundle(bundle)
|
||||
if errors:
|
||||
_eprint("❌ Validation KO :")
|
||||
for e in errors:
|
||||
_eprint(f" - {e}")
|
||||
return 1
|
||||
m = bundle["manifest"]
|
||||
print(
|
||||
f"✅ Validation OK — {m['counts']['role']} rôles, "
|
||||
f"{m['counts']['custom_docperm']} DocPerm, schéma + invariants verts."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="Générateur de fixtures ERPNext depuis le contrat RBAC.")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
pb = sub.add_parser("build", help="génère role.json / custom_docperm.json / MANIFEST.json")
|
||||
pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (défaut: ./out)")
|
||||
pb.set_defaults(func=cmd_build)
|
||||
|
||||
pv = sub.add_parser("validate", help="valide le bundle (schéma + invariants) sans écrire")
|
||||
pv.add_argument("-o", "--out", default=_DEFAULT_OUT, help="ignoré (compat)")
|
||||
pv.set_defaults(func=cmd_validate)
|
||||
|
||||
args = p.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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