2026-08-21 05:25:23 -04:00
|
|
|
|
#!/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])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 05:57:06 -04:00
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-21 05:25:23 -04:00
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
unittest.main()
|