[FIX] anonymisation : tirer les nombres dans l'étendue mesurée
`resource.calendar.attendance.hour_from` est un `float` qui porte une HEURE DE LA JOURNÉE : un tirage uniforme sur 0 à 1000 le sort de 0..23 et l'affichage de la fiche lève « ValueError: hour must be in 0..23 ». La borne vit dans `time(int(integral), ...)` de resource/models/utils.py, et aucune contrainte PostgreSQL ne la déclare : le schéma ne dit pas le SENS de la donnée. « 0 à 1000 » est donc faux pour tout nombre borné par un usage : heures, pourcentages, taux. On respecte désormais la seule borne que les DONNÉES déclarent, leur propre étendue — min et max mesurés par table, et 0 à 1000 quand elle est inconnue. Vérifié : les tirages tiennent dans l'étendue mesurée, et la fiche s'affiche sans lever. --- EN --- `resource.calendar.attendance.hour_from` is a `float` holding an HOUR OF DAY: a uniform draw over 0 to 1000 takes it out of 0..23 and displaying the record raises "ValueError: hour must be in 0..23". The bound lives in `time(int(integral), ...)` in resource/models/utils.py, and no PostgreSQL constraint declares it: the schema does not say what the data MEANS. So "0 to 1000" is wrong for any number bounded by usage: hours, percentages, rates. We now respect the only bound the DATA declares, its own range — min and max measured per table, and 0 to 1000 when it is unknown. Checked: draws stay inside the measured range, and the record displays without raising. Assisted-by: Claude Opus 5 (cherry picked from commit 8e0a19823714b1d18f05613d498f8136b682c047)
This commit is contained in:
parent
f9edaa4e59
commit
8cc5566743
2 changed files with 265 additions and 32 deletions
|
|
@ -310,12 +310,40 @@ def expression_texte(champ, mots):
|
||||||
|
|
||||||
|
|
||||||
def expression_nombre(champ):
|
def expression_nombre(champ):
|
||||||
"""Le SQL qui remplace un nombre : au hasard, entre 0 et 1000."""
|
"""Le SQL qui remplace un nombre, DANS l'étendue de la colonne.
|
||||||
|
|
||||||
|
0 à 1000 était l'intention, et c'est faux pour tout nombre qui porte
|
||||||
|
un sens borné. Mesuré : `resource.calendar.attendance.hour_from` est
|
||||||
|
un `float` qui vaut une heure de la journée — 8,00 à 13,00 dans la
|
||||||
|
base d'origine. Un tirage à 957 fait lever Odoo :
|
||||||
|
|
||||||
|
time(int(integral), ...) → ValueError: hour must be in 0..23
|
||||||
|
resource/models/utils.py:45
|
||||||
|
|
||||||
|
Aucune contrainte PostgreSQL ne dit cela : la borne vit dans le code.
|
||||||
|
La seule que les DONNÉES déclarent est leur propre étendue, et c'est
|
||||||
|
celle qu'on respecte. Elle protège aussi les pourcentages, les taux et
|
||||||
|
les quantités, sans qu'il faille les nommer un par un.
|
||||||
|
|
||||||
|
Sans étendue connue — colonne vide, ou sonde impossible — on retombe
|
||||||
|
sur 0 à 1000.
|
||||||
|
"""
|
||||||
nom = ident(champ["name"])
|
nom = ident(champ["name"])
|
||||||
if champ["ttype"] == "integer":
|
bas, haut = champ.get("borne_min"), champ.get("borne_max")
|
||||||
tirage = "floor(random() * 1001)::integer"
|
entier = champ["ttype"] == "integer"
|
||||||
|
if bas is None or haut is None:
|
||||||
|
tirage = (
|
||||||
|
"floor(random() * 1001)::integer"
|
||||||
|
if entier
|
||||||
|
else "round((random() * 1000)::numeric, 2)"
|
||||||
|
)
|
||||||
|
elif entier:
|
||||||
|
# +1 pour que la borne haute soit atteignable ; si bas == haut,
|
||||||
|
# le tirage rend cette valeur, ce qui est sans risque : une
|
||||||
|
# colonne constante ne porte aucune information à masquer.
|
||||||
|
tirage = f"floor({bas} + random() * ({haut} - {bas} + 1))::integer"
|
||||||
else:
|
else:
|
||||||
tirage = "round((random() * 1000)::numeric, 2)"
|
tirage = f"round(({bas} + random() * ({haut} - {bas}))::numeric, 2)"
|
||||||
return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END"
|
return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -448,29 +476,36 @@ def plan(
|
||||||
return etapes
|
return etapes
|
||||||
|
|
||||||
|
|
||||||
def colonnes_structurees(database, etapes, config_path=None):
|
def sonder_colonnes(database, etapes, config_path=None):
|
||||||
"""Les colonnes texte dont TOUT le contenu est un chemin d'identifiants.
|
"""Regarder ce que les colonnes CONTIENNENT. Rendre (écartées, bornes).
|
||||||
|
|
||||||
Le filet général, là où `CHAMPS_STRUCTURES` ne nomme que le connu :
|
Deux mesures, une seule requête par table — sur une liste noire de 410
|
||||||
un module maison peut poser son propre champ de chemin, et il ne
|
modèles, la différence est de 410 allers-retours au lieu de 1 300.
|
||||||
portera pas ce nom-là. On regarde donc ce que la colonne CONTIENT.
|
|
||||||
|
|
||||||
Une colonne est écartée seulement si elle est NON VIDE et que toutes
|
ÉCARTÉES — les colonnes texte dont TOUT le contenu est un chemin
|
||||||
ses valeurs sont des chemins. Un seul contre-exemple suffit à la
|
d'identifiants. Le filet général, là où `CHAMPS_STRUCTURES` ne nomme
|
||||||
garder : mieux vaut anonymiser une colonne douteuse que taire une
|
que le connu : un module maison peut poser le sien sans lui donner ce
|
||||||
donnée personnelle.
|
nom. Une seule valeur non conforme suffit à garder la colonne — mieux
|
||||||
|
vaut anonymiser une colonne douteuse que taire une donnée personnelle.
|
||||||
|
|
||||||
Une requête par TABLE, pas par colonne : sur une liste noire de 410
|
BORNES — l'étendue réelle de chaque colonne numérique. C'est la seule
|
||||||
modèles, la différence est de 410 allers-retours au lieu de 863.
|
borne que les données déclarent, et elle vaut pour toutes celles que
|
||||||
|
le code d'Odoo impose sans que PostgreSQL en sache rien : heures de la
|
||||||
|
journée, pourcentages, taux.
|
||||||
"""
|
"""
|
||||||
ecartees = {}
|
ecartees, bornes = {}, {}
|
||||||
for etape in etapes:
|
for etape in etapes:
|
||||||
textes = [
|
textes = [
|
||||||
champ
|
champ
|
||||||
for champ in etape["fields"]
|
for champ in etape["fields"]
|
||||||
if champ["ttype"] in TYPES_TEXTE and champ["pg_type"] != "jsonb"
|
if champ["ttype"] in TYPES_TEXTE and champ["pg_type"] != "jsonb"
|
||||||
]
|
]
|
||||||
if not textes:
|
nombres = [
|
||||||
|
champ
|
||||||
|
for champ in etape["fields"]
|
||||||
|
if champ["ttype"] in TYPES_NOMBRE and champ["pg_type"] != "jsonb"
|
||||||
|
]
|
||||||
|
if not textes and not nombres:
|
||||||
continue
|
continue
|
||||||
morceaux = []
|
morceaux = []
|
||||||
for champ in textes:
|
for champ in textes:
|
||||||
|
|
@ -479,6 +514,12 @@ def colonnes_structurees(database, etapes, config_path=None):
|
||||||
f"count(*) FILTER (WHERE {nom} IS NOT NULL AND {nom} <> '')"
|
f"count(*) FILTER (WHERE {nom} IS NOT NULL AND {nom} <> '')"
|
||||||
f" || ':' || count(*) FILTER (WHERE {nom} ~ {litteral(MOTIF_CHEMIN)})"
|
f" || ':' || count(*) FILTER (WHERE {nom} ~ {litteral(MOTIF_CHEMIN)})"
|
||||||
)
|
)
|
||||||
|
for champ in nombres:
|
||||||
|
nom = ident(champ["name"])
|
||||||
|
morceaux.append(
|
||||||
|
f"coalesce(min({nom})::text, '') || ':'"
|
||||||
|
f" || coalesce(max({nom})::text, '')"
|
||||||
|
)
|
||||||
sql = (
|
sql = (
|
||||||
"SELECT "
|
"SELECT "
|
||||||
+ " || '\x1f' || ".join(morceaux)
|
+ " || '\x1f' || ".join(morceaux)
|
||||||
|
|
@ -490,24 +531,55 @@ def colonnes_structurees(database, etapes, config_path=None):
|
||||||
).strip()
|
).strip()
|
||||||
except Exception: # noqa: BLE001 - une table illisible ne bloque pas
|
except Exception: # noqa: BLE001 - une table illisible ne bloque pas
|
||||||
continue
|
continue
|
||||||
for champ, mesure in zip(textes, brut.split(SEP)):
|
mesures = brut.split(SEP)
|
||||||
|
if len(mesures) != len(textes) + len(nombres):
|
||||||
|
continue
|
||||||
|
for champ, mesure in zip(textes, mesures):
|
||||||
try:
|
try:
|
||||||
remplies, chemins = (int(x) for x in mesure.split(":"))
|
remplies, chemins = (int(x) for x in mesure.split(":"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
if remplies and remplies == chemins:
|
if remplies and remplies == chemins:
|
||||||
ecartees.setdefault(etape["model"], []).append(champ["name"])
|
ecartees.setdefault(etape["model"], []).append(champ["name"])
|
||||||
return ecartees
|
for champ, mesure in zip(nombres, mesures[len(textes) :]):
|
||||||
|
bas, _, haut = mesure.partition(":")
|
||||||
|
if nombre_valide(bas) and nombre_valide(haut):
|
||||||
|
bornes.setdefault(etape["model"], {})[champ["name"]] = (
|
||||||
|
bas,
|
||||||
|
haut,
|
||||||
|
)
|
||||||
|
return ecartees, bornes
|
||||||
|
|
||||||
|
|
||||||
def sans_structurees(etapes, ecartees, mots):
|
def nombre_valide(texte):
|
||||||
"""Refaire le plan sans les colonnes que la sonde a écartées."""
|
"""Un littéral numérique, et rien d'autre.
|
||||||
if not ecartees:
|
|
||||||
return etapes
|
Ces valeurs viennent de la base mais retournent dans du SQL : on ne
|
||||||
|
les recopie qu'après avoir vérifié qu'elles sont bien des nombres.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
float(texte)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def appliquer_sondes(etapes, ecartees, bornes, mots):
|
||||||
|
"""Refaire le plan sans les écartées, et avec les bornes mesurées."""
|
||||||
propre = []
|
propre = []
|
||||||
for etape in etapes:
|
for etape in etapes:
|
||||||
exclues = set(ecartees.get(etape["model"], ()))
|
exclues = set(ecartees.get(etape["model"], ()))
|
||||||
gardes = [c for c in etape["fields"] if c["name"] not in exclues]
|
mesures = bornes.get(etape["model"], {})
|
||||||
|
gardes = []
|
||||||
|
for champ in etape["fields"]:
|
||||||
|
if champ["name"] in exclues:
|
||||||
|
continue
|
||||||
|
borne = mesures.get(champ["name"])
|
||||||
|
gardes.append(
|
||||||
|
{**champ, "borne_min": borne[0], "borne_max": borne[1]}
|
||||||
|
if borne
|
||||||
|
else champ
|
||||||
|
)
|
||||||
# Pas de garde sur une liste vide : `sql_pour_table` rend None, et
|
# Pas de garde sur une liste vide : `sql_pour_table` rend None, et
|
||||||
# le `if sql` ci-dessous l'écarte. Deux vérifications pour la même
|
# le `if sql` ci-dessous l'écarte. Deux vérifications pour la même
|
||||||
# chose se contredisent un jour.
|
# chose se contredisent un jour.
|
||||||
|
|
@ -697,8 +769,8 @@ def main(argv=None):
|
||||||
)
|
)
|
||||||
# La sonde AVANT le rendu : la marche à blanc doit montrer ce que
|
# La sonde AVANT le rendu : la marche à blanc doit montrer ce que
|
||||||
# `--apply` ferait, pas une approximation plus large.
|
# `--apply` ferait, pas une approximation plus large.
|
||||||
ecartees = colonnes_structurees(args.database, etapes, args.config)
|
ecartees, bornes = sonder_colonnes(args.database, etapes, args.config)
|
||||||
etapes = sans_structurees(etapes, ecartees, mots)
|
etapes = appliquer_sondes(etapes, ecartees, bornes, mots)
|
||||||
if ecartees:
|
if ecartees:
|
||||||
combien = sum(len(v) for v in ecartees.values())
|
combien = sum(len(v) for v in ecartees.values())
|
||||||
print(
|
print(
|
||||||
|
|
|
||||||
|
|
@ -677,7 +677,7 @@ class TestStructuredCharFieldsSurvive(unittest.TestCase):
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
anon.colonnes_structurees("base", etapes)
|
anon.sonder_colonnes("base", etapes)
|
||||||
finally:
|
finally:
|
||||||
anon.lib_analyse.run_psql = vrai
|
anon.lib_analyse.run_psql = vrai
|
||||||
self.assertEqual(len(appels), 1)
|
self.assertEqual(len(appels), 1)
|
||||||
|
|
@ -698,14 +698,16 @@ class TestStructuredCharFieldsSurvive(unittest.TestCase):
|
||||||
{"model": "m", "fields": [self._champ("chemin")], "sql": "x"}
|
{"model": "m", "fields": [self._champ("chemin")], "sql": "x"}
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
ecartees = anon.colonnes_structurees("base", etapes)
|
ecartees, _ = anon.sonder_colonnes("base", etapes)
|
||||||
finally:
|
finally:
|
||||||
anon.lib_analyse.run_psql = vrai
|
anon.lib_analyse.run_psql = vrai
|
||||||
self.assertEqual(ecartees, {})
|
self.assertEqual(ecartees, {})
|
||||||
|
|
||||||
def test_an_all_paths_column_is_dropped_from_the_plan(self):
|
def test_an_all_paths_column_is_dropped_from_the_plan(self):
|
||||||
def espion(database, sql, config_path=None):
|
def espion(database, sql, config_path=None):
|
||||||
return "10:10"
|
# Une mesure PAR COLONNE : la sonde compte ce qu'elle
|
||||||
|
# reçoit et renonce si le compte ne tombe pas juste.
|
||||||
|
return "10:10\x1f10:10"
|
||||||
|
|
||||||
vrai = anon.lib_analyse.run_psql
|
vrai = anon.lib_analyse.run_psql
|
||||||
anon.lib_analyse.run_psql = espion
|
anon.lib_analyse.run_psql = espion
|
||||||
|
|
@ -717,13 +719,13 @@ class TestStructuredCharFieldsSurvive(unittest.TestCase):
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
ecartees = anon.colonnes_structurees("base", etapes)
|
ecartees, _ = anon.sonder_colonnes("base", etapes)
|
||||||
finally:
|
finally:
|
||||||
anon.lib_analyse.run_psql = vrai
|
anon.lib_analyse.run_psql = vrai
|
||||||
# Les deux colonnes rendent le même compte ici : c'est le principe
|
# Les deux colonnes rendent le même compte ici : c'est le principe
|
||||||
# qu'on vérifie, pas la ligne exacte.
|
# qu'on vérifie, pas la ligne exacte.
|
||||||
self.assertIn("m", ecartees)
|
self.assertIn("m", ecartees)
|
||||||
propre = anon.sans_structurees(etapes, {"m": ["chemin"]}, None)
|
propre = anon.appliquer_sondes(etapes, {"m": ["chemin"]}, {}, None)
|
||||||
self.assertEqual([c["name"] for c in propre[0]["fields"]], ["nom"])
|
self.assertEqual([c["name"] for c in propre[0]["fields"]], ["nom"])
|
||||||
self.assertNotIn('"chemin"', propre[0]["sql"])
|
self.assertNotIn('"chemin"', propre[0]["sql"])
|
||||||
|
|
||||||
|
|
@ -732,5 +734,164 @@ class TestStructuredCharFieldsSurvive(unittest.TestCase):
|
||||||
{"model": "m", "fields": [self._champ("chemin")], "sql": "x"}
|
{"model": "m", "fields": [self._champ("chemin")], "sql": "x"}
|
||||||
]
|
]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
anon.sans_structurees(etapes, {"m": ["chemin"]}, None), []
|
anon.appliquer_sondes(etapes, {"m": ["chemin"]}, {}, None), []
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestANumberKeepsItsMeaningfulRange(unittest.TestCase):
|
||||||
|
"""`ValueError: hour must be in 0..23`.
|
||||||
|
|
||||||
|
`resource.calendar.attendance.hour_from` est un `float` qui vaut une
|
||||||
|
heure de la journée — 8,00 à 13,00 dans la base d'origine. Un tirage
|
||||||
|
à 957 fait lever Odoo au premier affichage d'un employé :
|
||||||
|
|
||||||
|
time(int(integral), ...) resource/models/utils.py:45
|
||||||
|
|
||||||
|
Aucune contrainte PostgreSQL ne dit cela : la borne vit dans le code.
|
||||||
|
La seule que les DONNÉES déclarent est leur propre étendue, et c'est
|
||||||
|
la seule qu'on puisse respecter sans nommer les champs un par un.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _champ(self, ttype="float", **kw):
|
||||||
|
base = {
|
||||||
|
"model": "resource.calendar.attendance",
|
||||||
|
"name": "hour_from",
|
||||||
|
"ttype": ttype,
|
||||||
|
"pg_type": "numeric",
|
||||||
|
"unique": False,
|
||||||
|
"checked": False,
|
||||||
|
"max_len": None,
|
||||||
|
}
|
||||||
|
base.update(kw)
|
||||||
|
return base
|
||||||
|
|
||||||
|
def test_a_measured_range_bounds_the_draw(self):
|
||||||
|
sql = anon.expression_nombre(
|
||||||
|
self._champ(borne_min="8.0", borne_max="13.0")
|
||||||
|
)
|
||||||
|
self.assertIn("8.0 + random() * (13.0 - 8.0)", sql)
|
||||||
|
self.assertNotIn("1000", sql)
|
||||||
|
|
||||||
|
def test_an_integer_can_reach_its_upper_bound(self):
|
||||||
|
sql = anon.expression_nombre(
|
||||||
|
self._champ(ttype="integer", borne_min="0", borne_max="5")
|
||||||
|
)
|
||||||
|
self.assertIn("(5 - 0 + 1)", sql)
|
||||||
|
|
||||||
|
def test_without_a_range_it_falls_back_on_a_thousand(self):
|
||||||
|
self.assertIn("1000", anon.expression_nombre(self._champ()))
|
||||||
|
self.assertIn(
|
||||||
|
"1001", anon.expression_nombre(self._champ(ttype="integer"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_half_known_range_is_not_used(self):
|
||||||
|
"""Une borne sans l'autre ne borne rien."""
|
||||||
|
for kw in ({"borne_min": "8.0"}, {"borne_max": "13.0"}):
|
||||||
|
self.assertIn("1000", anon.expression_nombre(self._champ(**kw)))
|
||||||
|
|
||||||
|
def test_a_value_that_is_not_a_number_never_reaches_the_sql(self):
|
||||||
|
"""Ces bornes viennent de la base et retournent dans du SQL."""
|
||||||
|
self.assertTrue(anon.nombre_valide("8.0"))
|
||||||
|
self.assertTrue(anon.nombre_valide("-3"))
|
||||||
|
for mauvais in ("8.0); DROP TABLE x; --", "", None, "huit"):
|
||||||
|
self.assertFalse(anon.nombre_valide(mauvais), mauvais)
|
||||||
|
|
||||||
|
def test_the_probe_reads_the_bounds(self):
|
||||||
|
recu = {}
|
||||||
|
|
||||||
|
def espion(database, sql, config_path=None):
|
||||||
|
recu["sql"] = sql
|
||||||
|
return "8.0:13.0"
|
||||||
|
|
||||||
|
vrai = anon.lib_analyse.run_psql
|
||||||
|
anon.lib_analyse.run_psql = espion
|
||||||
|
etapes = [{"model": "m", "fields": [self._champ()], "sql": "x"}]
|
||||||
|
try:
|
||||||
|
_, bornes = anon.sonder_colonnes("base", etapes)
|
||||||
|
finally:
|
||||||
|
anon.lib_analyse.run_psql = vrai
|
||||||
|
self.assertIn("min(", recu["sql"])
|
||||||
|
self.assertIn("max(", recu["sql"])
|
||||||
|
self.assertEqual(bornes["m"]["hour_from"], ("8.0", "13.0"))
|
||||||
|
|
||||||
|
def test_an_empty_column_yields_no_bound(self):
|
||||||
|
def espion(database, sql, config_path=None):
|
||||||
|
return ":"
|
||||||
|
|
||||||
|
vrai = anon.lib_analyse.run_psql
|
||||||
|
anon.lib_analyse.run_psql = espion
|
||||||
|
etapes = [{"model": "m", "fields": [self._champ()], "sql": "x"}]
|
||||||
|
try:
|
||||||
|
_, bornes = anon.sonder_colonnes("base", etapes)
|
||||||
|
finally:
|
||||||
|
anon.lib_analyse.run_psql = vrai
|
||||||
|
self.assertEqual(bornes, {})
|
||||||
|
|
||||||
|
def test_the_bounds_reach_the_generated_sql(self):
|
||||||
|
etapes = [{"model": "m", "fields": [self._champ()], "sql": "x"}]
|
||||||
|
propre = anon.appliquer_sondes(
|
||||||
|
etapes, {}, {"m": {"hour_from": ("8.0", "13.0")}}, None
|
||||||
|
)
|
||||||
|
self.assertIn("8.0 + random()", propre[0]["sql"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheProbeDistrustsWhatItReads(unittest.TestCase):
|
||||||
|
"""Ce que la sonde reçoit repart dans du SQL : elle le vérifie.
|
||||||
|
|
||||||
|
Deux mutations ont survécu au premier tour, et les deux disaient la
|
||||||
|
même chose : j'éprouvais les fonctions de contrôle isolément sans
|
||||||
|
vérifier que la sonde s'en sert. Une garde qu'on n'exerce pas ne
|
||||||
|
garde rien.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _champ(self, nom="hour_from", ttype="float"):
|
||||||
|
return {
|
||||||
|
"model": "m",
|
||||||
|
"name": nom,
|
||||||
|
"ttype": ttype,
|
||||||
|
"pg_type": "numeric",
|
||||||
|
"unique": False,
|
||||||
|
"checked": False,
|
||||||
|
"max_len": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _sonder(self, reponse, champs):
|
||||||
|
def espion(database, sql, config_path=None):
|
||||||
|
return reponse
|
||||||
|
|
||||||
|
vrai = anon.lib_analyse.run_psql
|
||||||
|
anon.lib_analyse.run_psql = espion
|
||||||
|
etapes = [{"model": "m", "fields": champs, "sql": "x"}]
|
||||||
|
try:
|
||||||
|
return anon.sonder_colonnes("base", etapes)
|
||||||
|
finally:
|
||||||
|
anon.lib_analyse.run_psql = vrai
|
||||||
|
|
||||||
|
def test_a_bound_that_is_not_a_number_is_refused(self):
|
||||||
|
"""La base peut rendre autre chose qu'un nombre ; ces valeurs
|
||||||
|
retournent telles quelles dans un littéral SQL."""
|
||||||
|
for reponse in ("abc:def", "8.0); DROP TABLE x; --:13", ":13"):
|
||||||
|
_, bornes = self._sonder(reponse, [self._champ()])
|
||||||
|
self.assertEqual(bornes, {}, reponse)
|
||||||
|
|
||||||
|
def test_a_valid_bound_still_passes(self):
|
||||||
|
_, bornes = self._sonder("8.0:13.0", [self._champ()])
|
||||||
|
self.assertEqual(bornes["m"]["hour_from"], ("8.0", "13.0"))
|
||||||
|
|
||||||
|
def test_a_short_answer_is_refused_whole(self):
|
||||||
|
"""Moins de mesures que de colonnes : `zip` tronquerait en silence
|
||||||
|
et attribuerait la mesure d'une colonne à une autre."""
|
||||||
|
champs = [self._champ("a"), self._champ("b"), self._champ("c")]
|
||||||
|
ecartees, bornes = self._sonder("8.0:13.0", champs)
|
||||||
|
self.assertEqual(bornes, {})
|
||||||
|
self.assertEqual(ecartees, {})
|
||||||
|
|
||||||
|
def test_a_long_answer_is_refused_too(self):
|
||||||
|
champs = [self._champ("a")]
|
||||||
|
_, bornes = self._sonder("8.0:13.0\x1f1.0:2.0", champs)
|
||||||
|
self.assertEqual(bornes, {})
|
||||||
|
|
||||||
|
def test_the_exact_count_is_accepted(self):
|
||||||
|
champs = [self._champ("a"), self._champ("b")]
|
||||||
|
_, bornes = self._sonder("8.0:13.0\x1f1.0:2.0", champs)
|
||||||
|
self.assertEqual(len(bornes["m"]), 2)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue