erplibre/test/test_check_hidden_models.py
Mathieu Benoit 2ba64132ca [FIX] migration: la sonde de visibilité déclare ce qu'elle n'a pas prouvé
Elle éprouvait 45 modèles sur 125 portant une règle globale, contre
quatre utilisateurs internes — et les quatre étaient administrateurs. Un
modèle que seuls les administrateurs peuvent lire passait donc au vert :
c'est exactement la forme du bug DMS qui a motivé cet outil, vue d'un
autre angle.

Elle ne peut pas créer un témoin ordinaire — ce serait une écriture.
Elle peut dire qu'elle n'en avait pas, annoncer sa couverture au lieu du
seul nombre éprouvé, et déclarer qu'un masquage codé en Python lui
échappe puisqu'elle part d'ir_rule. Un vert qui prouve moins qu'il n'en
a l'air est pire qu'un rouge.

--- EN ---

It tested 45 models out of the 125 carrying a global rule, against four
internal users — and all four were administrators. A model only admins
can read therefore passed green: exactly the shape of the DMS bug that
motivated this tool, seen from another angle.

It cannot create an ordinary witness — that would be a write. It can say
it had none, announce its coverage rather than only the number checked,
and declare that masking coded in Python escapes it since it starts from
ir_rule. A green that proves less than it looks is worse than a red.

Assisted-by: Claude Opus 5
2026-08-25 03:28:39 -04:00

335 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le test qui aurait vu venir la disparition des documents DMS.
Une propriété porte tout : le comptage se fait avec de VRAIS utilisateurs
internes, jamais avec le super-utilisateur. Les règles ne s'appliquent pas
à lui ; un comptage fait en son nom déclarerait saine une base que
personne ne peut lire — exactement l'erreur qui a laissé passer le cas
DMS pendant six paliers.
"""
import ast
import io
import os
import sys
import unittest
from contextlib import redirect_stdout
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.odoo.migration import check_hidden_models as check # noqa: E402
from script.todo import todo_i18n # noqa: E402
class TestTheGeneratedScript(unittest.TestCase):
def test_it_is_valid_python(self):
ast.parse(check.build_script())
def test_the_superuser_is_excluded(self):
# Sans cette exclusion, tout paraît visible et le test ne sert à
# rien : c'est le cœur du sujet.
code = check.build_script()
self.assertIn('("id", "!=", 1)', code)
def test_only_internal_users_count(self):
# Un portail ne voit presque rien par construction : l'inclure
# ferait crier au loup sur des modèles parfaitement sains.
self.assertIn('("share", "=", False)', check.build_script())
def test_only_active_users_count(self):
self.assertIn('("active", "=", True)', check.build_script())
def test_it_looks_at_global_rules_only(self):
# Une règle non globale ne s'applique qu'à certains groupes :
# qu'elle masque tout pour eux est normal.
code = check.build_script()
self.assertIn('("global", "=", True)', code)
self.assertIn('("active", "=", True)', code)
def test_empty_models_are_skipped(self):
# Un modèle sans ligne n'a rien à cacher ; le signaler noierait la
# vraie trouvaille.
self.assertIn("if not total:", check.build_script())
def test_it_stops_at_the_first_user_who_sees_something(self):
# Sans court-circuit, le coût est modèles × utilisateurs sur une
# base où presque tout est visible.
self.assertIn("break", check.build_script())
def test_the_user_limit_is_honoured(self):
code = check.build_script(limite=7)
self.assertIn("LIMITE = 7", code)
self.assertIn("limit=LIMITE", code)
def test_technical_models_are_excluded_by_name(self):
code = check.build_script()
for nom in ("ir.rule", "ir.model.access"):
self.assertIn(nom, code)
def test_the_exclusion_list_never_swallows_business_models(self):
# Une exclusion trop large rendrait le test muet sans le dire.
for nom in check.ATTENDUS:
self.assertTrue(
nom.startswith(("ir.", "bus.", "res.users.log", "mail.")),
f"exclusion suspecte : {nom}",
)
def test_it_reuses_the_shared_sentinels(self):
from script.odoo.migration import database_cleanup
self.assertEqual(check.DEBUT, database_cleanup.START)
self.assertEqual(check.FIN, database_cleanup.END)
class TestTheReport(unittest.TestCase):
def test_a_clean_database_says_so(self):
texte = "\n".join(
check.render({"models": [], "checked": 85, "users": ["a", "b"]})
)
self.assertIn(
todo_i18n.t("Every one of them is visible to someone."), texte
)
def test_a_finding_names_the_model_and_the_volume(self):
rapport = {
"models": [
{"model": "dms.file", "rows": 69},
{"model": "dms.directory", "rows": 16},
],
"checked": 85,
"users": ["a"],
}
texte = "\n".join(check.render(rapport))
self.assertIn("dms.file", texte)
self.assertIn("69", texte)
self.assertIn(
todo_i18n.t("The data is there; a global rule hides all of it."),
texte,
)
def test_the_biggest_loss_comes_first(self):
rapport = {
"models": [
{"model": "aaa.petit", "rows": 3},
{"model": "zzz.gros", "rows": 900},
],
"checked": 2,
"users": ["a"],
}
texte = "\n".join(check.render(rapport))
self.assertLess(texte.index("zzz.gros"), texte.index("aaa.petit"))
def test_no_internal_user_is_flagged_not_called_clean(self):
# Sans utilisateur, on ne SAIT pas. Dire « tout va bien » serait
# un mensonge tranquille.
texte = "\n".join(check.render({"no_user": True}))
self.assertIn(
todo_i18n.t("No internal user to test visibility with."), texte
)
self.assertNotIn(
todo_i18n.t("Every one of them is visible to someone."), texte
)
def test_every_translation_key_exists(self):
with io.open(check.__file__, encoding="utf-8") as handle:
src = handle.read()
for node in ast.walk(ast.parse(src)):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "t"
and node.args
and isinstance(node.args[0], ast.Constant)
):
cle = node.args[0].value
self.assertTrue(
cle in todo_i18n.TRANSLATIONS,
f"clé sans traduction : {cle!r}",
)
class TestTheExitCodes(unittest.TestCase):
def setUp(self):
from script.odoo.migration import database_cleanup
self.cleanup = database_cleanup
self.vraie = database_cleanup.require_matching_version
self.vrai_shell = database_cleanup.run_shell
database_cleanup.require_matching_version = lambda base: None
def tearDown(self):
self.cleanup.require_matching_version = self.vraie
self.cleanup.run_shell = self.vrai_shell
def lance(self, rapport):
self.cleanup.run_shell = lambda *a, **k: rapport
tampon = io.StringIO()
with redirect_stdout(tampon):
code = check.main(["-d", "db"])
return code, tampon.getvalue()
def test_nothing_hidden_exits_zero(self):
code, _ = self.lance({"models": [], "checked": 3, "users": ["a"]})
self.assertEqual(code, 0)
def test_something_hidden_exits_one(self):
code, _ = self.lance(
{
"models": [{"model": "dms.file", "rows": 69}],
"checked": 3,
"users": ["a"],
}
)
self.assertEqual(code, 1)
def test_a_shell_error_exits_two(self):
# 2 dit « l'outil a échoué », pas « rien trouvé » : les confondre
# ferait conclure qu'une migration est saine sans l'avoir vérifiée.
code, _ = self.lance({"error": "boom"})
self.assertEqual(code, 2)
def test_a_version_mismatch_stops_before_opening_the_database(self):
self.cleanup.require_matching_version = lambda base: "18.0 vs 12.0"
appels = []
self.cleanup.run_shell = lambda *a, **k: appels.append(a) or {}
with redirect_stdout(io.StringIO()):
code = check.main(["-d", "db"])
self.assertEqual(code, 2)
self.assertEqual(appels, [])
class TestTheWiring(unittest.TestCase):
RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def source(self):
with io.open(
os.path.join(self.RACINE, "script", "todo", "todo_upgrade.py"),
encoding="utf-8",
) as handle:
return handle.read()
def test_the_detector_runs_at_every_bump(self):
src = self.source()
self.assertIn("check_hidden_models.py", src)
def test_the_dms_repair_runs_at_the_13_bump_only(self):
# MuK devient OCA DMS à ce palier-là et à aucun autre. Le lancer
# partout coûterait un démarrage d'Odoo par palier pour rien.
src = self.source()
self.assertIn(
"if next_version == 13:", src, "le garde de palier a disparu"
)
debut = src.index("if next_version == 13:")
fin = src.index("dms_access_repair.py")
self.assertLess(debut, fin)
self.assertLess(fin - debut, 700, "le garde de palier s'est éloigné")
def test_the_repair_is_applied_not_only_reported(self):
# Sans --apply il n'écrit rien : câblé sans, il ne réparerait
# jamais et la migration resterait cassée en silence.
src = self.source()
debut = src.index("dms_access_repair.py")
self.assertIn("--apply", src[debut : debut + 200])
class TestItDeclaresWhatItCouldNotProve(unittest.TestCase):
"""Un vert qui prouve moins qu'il n'en a l'air est pire qu'un rouge.
Mesuré sur une base réelle : l'outil éprouvait 45 modèles sur 125
portant une règle globale, contre 4 utilisateurs internes — et les
QUATRE étaient administrateurs. Un modèle que seuls les
administrateurs peuvent lire passait donc au vert. C'est exactement
la forme du bug DMS qui a motivé l'outil, vue d'un autre angle.
L'outil ne peut pas créer un témoin ordinaire — ce serait une
écriture. Il peut dire qu'il n'en avait pas.
"""
def rapport(self, **extra):
base = {
"checked": 45,
"with_rule": 125,
"users": ["a", "b", "c", "d"],
"ordinary": ["b"],
"models": [],
}
base.update(extra)
return base
def tete(self, **extra):
return "\n".join(check.render(self.rapport(**extra)))
def test_it_says_how_many_it_could_not_check(self):
# « 45 éprouvés » sans dire « sur 125 » laisse croire à un examen
# complet.
texte = self.tete()
self.assertIn("45", texte)
self.assertIn("125", texte)
def test_without_the_coverage_it_says_only_what_it_checked(self):
# Un rapport d'une version antérieure n'a pas le renseignement.
# Inventer « sur 0 » serait pire que se taire — et « sur None »
# pire encore : c'est ce que produit un f-string sans garde.
texte = self.tete(with_rule=None)
self.assertIn("45", texte)
self.assertNotIn("125", texte)
self.assertNotIn("None", texte)
self.assertNotIn(check.t("out of"), texte.split("\n")[0])
def test_it_warns_when_every_witness_is_an_administrator(self):
texte = self.tete(ordinary=[])
self.assertIn(
check.t("Every witness is an administrator: a model only"), texte
)
def test_it_stays_quiet_when_one_witness_is_ordinary(self):
self.assertNotIn(
check.t("Every witness is an administrator: a model only"),
self.tete(),
)
def test_it_declares_what_it_cannot_see_at_all(self):
# La sonde part d'ir_rule : un masquage écrit en Python lui
# échappe par construction, et le taire ferait prendre son vert
# pour une garantie.
self.assertIn(
check.t(
"Reads ir_rule only — masking coded in Python escapes it."
),
self.tete(),
)
def test_the_warning_comes_before_the_verdict(self):
lignes = check.render(self.rapport(ordinary=[]))
avertissement = next(
i
for i, x in enumerate(lignes)
if "administrator" in x or "administrateur" in x
)
verdict = next(i for i, x in enumerate(lignes) if "✅" in x)
self.assertLess(avertissement, verdict)
class TestTheScriptCollectsWhatTheReportNeeds(unittest.TestCase):
def corps(self):
return check.build_script()
def test_it_counts_the_models_carrying_a_rule(self):
self.assertIn('rapport["with_rule"]', self.corps())
def test_it_separates_the_ordinary_witnesses(self):
corps = self.corps()
self.assertIn('rapport["ordinary"]', corps)
self.assertIn("base.group_system", corps)
def test_the_script_is_valid_python(self):
compile(self.corps(), "script", "exec")
if __name__ == "__main__":
unittest.main()