diff --git a/conftest.py b/conftest.py index e396537..4d7dbe8 100644 --- a/conftest.py +++ b/conftest.py @@ -47,6 +47,27 @@ OUTIL = "security_check.py" +def message(en: str, fr: str) -> str: + """Le message d'une assertion, dans la langue de l'apprenant. + + Quand un test échoue, `dsoxlab check` affiche le **message écrit par + l'auteur** : c'est la seule consigne que l'apprenant reçoit à cet instant. + Tout le reste du catalogue existe en deux versions (`README.md` et + `README.fr.md`) ; ce fichier-ci ne le peut pas, le contrat imposant le nom + `challenge/tests/test_functional.py`. Il suit donc le modèle de + `hints.yaml`, qui porte lui aussi ses deux langues dans un seul fichier. + + Le helper vit ici, et non dans dsoxlab : un test de lab doit rester + jouable par un simple `pytest`, sans l'outil installé. dsoxlab ne + reconnaît que la **forme** de l'appel, et `dsoxlab instructor audit` + compte les messages qui la portent. + + La langue vient de `DSOXLAB_LANG`, comme pour le reste de l'outil, et + l'anglais est le défaut. + """ + return fr if os.environ.get("DSOXLAB_LANG", "en").lower().startswith("fr") else en + + def workdir_lab(fichier_test: str | Path) -> Path: """Le répertoire de travail du lab dont `fichier_test` est le test.""" return Path(fichier_test).resolve().parent.parent / "work" diff --git a/labs/automatisation-code-de-sortie/challenge/tests/test_functional.py b/labs/automatisation-code-de-sortie/challenge/tests/test_functional.py index b0f2873..626603a 100644 --- a/labs/automatisation-code-de-sortie/challenge/tests/test_functional.py +++ b/labs/automatisation-code-de-sortie/challenge/tests/test_functional.py @@ -28,6 +28,7 @@ findings_au_hasard, lancer_outil, lire_resume, + message, tirage, workdir_lab, ) @@ -57,17 +58,23 @@ def _code_attendu(severites: list[str], seuil: str) -> int: def test_un_critique_arrete_le_pipeline(projet: Path, tmp_path: Path) -> None: rapport = _rapport(tmp_path, ["CRITICAL", "HIGH", "LOW"]) execution = lancer_outil(projet, str(rapport), "--fail-on", "CRITICAL") - assert execution.code == 1, ( - "Une vulnérabilité CRITICAL avec `--fail-on CRITICAL` : le code doit " - f"être 1, celui qui arrête le job.\n {execution.resume()}" + assert execution.code == 1, message( + en="A CRITICAL vulnerability with `--fail-on CRITICAL`: the code must be " + f"1, the one that stops the job.\n {execution.resume()}", + fr="Une vulnérabilité CRITICAL avec `--fail-on CRITICAL` : le code doit " + f"être 1, celui qui arrête le job.\n {execution.resume()}", ) - assert lire_resume(execution.sortie) == compter(["CRITICAL", "HIGH", "LOW"]), ( - "Le résumé reste affiché quand le seuil est atteint : c'est ce que " - f"l'équipe lit dans le journal du job pour savoir pourquoi.\n {execution.resume()}" + assert lire_resume(execution.sortie) == compter(["CRITICAL", "HIGH", "LOW"]), message( + en="The summary stays on screen when the threshold is reached: it is what " + f"the team reads in the job's log to know why.\n {execution.resume()}", + fr="Le résumé reste affiché quand le seuil est atteint : c'est ce que " + f"l'équipe lit dans le journal du job pour savoir pourquoi.\n {execution.resume()}", ) - assert "1" in execution.erreurs and "Traceback" not in execution.erreurs, ( - "La sortie d'erreur dit combien de vulnérabilités atteignent le seuil, " - f"en une ligne, sans trace Python.\n {execution.resume()}" + assert "1" in execution.erreurs and "Traceback" not in execution.erreurs, message( + en="The error output says how many vulnerabilities reach the threshold, " + f"in one line, with no Python traceback.\n {execution.resume()}", + fr="La sortie d'erreur dit combien de vulnérabilités atteignent le seuil, " + f"en une ligne, sans trace Python.\n {execution.resume()}", ) @@ -80,26 +87,36 @@ def test_le_seuil_compte_tout_ce_qui_est_au_dessus(projet: Path, tmp_path: Path) for seuil in SEUILS: attendu = _code_attendu(severites, seuil) execution = lancer_outil(projet, str(rapport), "--fail-on", seuil) - assert execution.code == attendu, ( - f"Graine {graine}, `--fail-on {seuil}` : code {execution.code}, attendu {attendu}.\n" + assert execution.code == attendu, message( + en=f"Seed {graine}, `--fail-on {seuil}`: code {execution.code}, expected {attendu}.\n" + f" severities in the report: {compter(severites)}\n\n" + "The threshold is inclusive and counts everything AT LEAST as " + "serious: `--fail-on HIGH` fails on a CRITICAL. UNKNOWN reaches " + "no threshold.\n" + f" {execution.resume()}", + fr=f"Graine {graine}, `--fail-on {seuil}` : code {execution.code}, attendu {attendu}.\n" f" sévérités du rapport : {compter(severites)}\n\n" "Le seuil est inclusif et compte tout ce qui est AU MOINS aussi grave : " "`--fail-on HIGH` échoue sur un CRITICAL. UNKNOWN n'atteint aucun seuil.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) def test_sous_le_seuil_ou_sans_seuil_le_pipeline_continue(projet: Path, tmp_path: Path) -> None: rapport = _rapport(tmp_path, ["MEDIUM", "LOW", "UNKNOWN", "medium"]) avec = lancer_outil(projet, str(rapport), "--fail-on", "high") - assert avec.code == 0, ( - "Rien n'atteint HIGH (le seuil s'écrit aussi en minuscules) : code 0.\n" - f" {avec.resume()}" + assert avec.code == 0, message( + en="Nothing reaches HIGH (the threshold is also accepted in lower case): " + f"code 0.\n {avec.resume()}", + fr="Rien n'atteint HIGH (le seuil s'écrit aussi en minuscules) : code 0.\n" + f" {avec.resume()}", ) sans = lancer_outil(projet, str(_rapport(tmp_path, ["CRITICAL"], "critique.json"))) - assert sans.code == 0, ( - "Sans `--fail-on`, l'outil ne juge pas : il résume et sort en 0, même " - f"sur un CRITICAL.\n {sans.resume()}" + assert sans.code == 0, message( + en="Without `--fail-on`, the tool does not judge: it summarises and exits " + f"with 0, even on a CRITICAL.\n {sans.resume()}", + fr="Sans `--fail-on`, l'outil ne juge pas : il résume et sort en 0, même " + f"sur un CRITICAL.\n {sans.resume()}", ) @@ -107,28 +124,45 @@ def test_une_entree_invalide_reste_un_code_2(projet: Path, tmp_path: Path) -> No """Un seuil inconnu et un rapport absent : 2, jamais 1.""" rapport = _rapport(tmp_path, ["CRITICAL"]) inconnu = lancer_outil(projet, str(rapport), "--fail-on", "SEVERE") - assert inconnu.code == 2 and "critical" in inconnu.erreurs.lower(), ( - "`--fail-on SEVERE` : code 2, et le message liste les seuils acceptés. " + assert inconnu.code == 2 and "critical" in inconnu.erreurs.lower(), message( + en="`--fail-on SEVERE`: code 2, and the message lists the accepted " + "thresholds. Click does that on its own when the option declares its " + "choices.\n" + f" {inconnu.resume()}", + fr="`--fail-on SEVERE` : code 2, et le message liste les seuils acceptés. " "Click le fait seul quand l'option déclare ses choix.\n" - f" {inconnu.resume()}" + f" {inconnu.resume()}", ) absent = lancer_outil(projet, str(tmp_path / "absent.json"), "--fail-on", "CRITICAL") - assert absent.code == 2 and "Traceback" not in absent.erreurs, ( - "Un rapport absent avec `--fail-on` : code 2, l'entrée est inexploitable. " + assert absent.code == 2 and "Traceback" not in absent.erreurs, message( + en="A missing report with `--fail-on`: code 2, the input cannot be used. " + "A code 1 would tell the pipeline « a critical flaw », which is " + f"false.\n {absent.resume()}", + fr="Un rapport absent avec `--fail-on` : code 2, l'entrée est inexploitable. " "Un code 1 dirait au pipeline « une faille critique », ce qui est faux.\n" - f" {absent.resume()}" + f" {absent.resume()}", ) def test_l_aide_est_celle_de_click_et_documente_les_options(projet: Path, tmp_path: Path) -> None: execution = lancer_outil(projet, "--help") - assert execution.code == 0, execution.resume() + assert execution.code == 0, message( + en=execution.resume(), + fr=execution.resume(), + ) aide = execution.sortie - assert aide.startswith("Usage:"), ( - "L'aide doit être celle de Click, qui commence par `Usage:` (argparse " + assert aide.startswith("Usage:"), message( + en="The help must be Click's, which starts with `Usage:` (argparse writes " + "`usage:`). If the tool answers that the click module is missing: " + "`uv add click`.\n" + f" {execution.resume()}", + fr="L'aide doit être celle de Click, qui commence par `Usage:` (argparse " "écrit `usage:`). Si l'outil répond que le module click est " "introuvable : `uv add click`.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) for option in ("--fail-on", "--osv"): - assert option in aide, f"L'aide ne mentionne pas `{option}`.\n {execution.resume()}" + assert option in aide, message( + en=f"The help does not mention `{option}`.\n {execution.resume()}", + fr=f"L'aide ne mentionne pas `{option}`.\n {execution.resume()}", + ) diff --git a/labs/automatisation-dans-la-ci/challenge/tests/test_functional.py b/labs/automatisation-dans-la-ci/challenge/tests/test_functional.py index 50c124f..e0fbc01 100644 --- a/labs/automatisation-dans-la-ci/challenge/tests/test_functional.py +++ b/labs/automatisation-dans-la-ci/challenge/tests/test_functional.py @@ -29,6 +29,7 @@ exiger_workdir, jouer_act, lire_workflows, + message, references_uses, workdir_lab, ) @@ -44,9 +45,14 @@ def projet() -> Path: exiger_workdir(WORKDIR, LAB_ID) if not (WORKDIR / ".github" / "workflows").is_dir(): pytest.fail( - "Aucun workflow dans `.github/workflows/` : GitHub ne cherche les " + message( + en="No workflow in `.github/workflows/`: GitHub only looks for " + "workflows there, with the dot in front of `.github`. Nothing can " + "run as long as it does not exist.", + fr="Aucun workflow dans `.github/workflows/` : GitHub ne cherche les " "workflows que là, avec le point devant `.github`. Rien ne peut " "tourner tant qu'il n'existe pas.", + ), pytrace=False, ) return WORKDIR @@ -70,14 +76,20 @@ def _jouer(projet: Path, severites: list[str] | None = None, outil: Path | None def _job_unique(resultat) -> str: - assert resultat.jobs, ( - "act n'a joué aucun job : soit aucun workflow ne déclare cet événement " - "dans son `on:`, soit le fichier est invalide.\n " + resultat.resume() + assert resultat.jobs, message( + en="act ran no job: either no workflow declares this event in its `on:`, " + "or the file is invalid.\n " + resultat.resume(), + fr="act n'a joué aucun job : soit aucun workflow ne déclare cet événement " + "dans son `on:`, soit le fichier est invalide.\n " + resultat.resume(), ) - assert len(resultat.jobs) == 1, ( - "Un seul job est attendu, qui teste l'outil puis applique le seuil : " + assert len(resultat.jobs) == 1, message( + en="A single job is expected, which tests the tool and then applies the " + "threshold: the order of the steps is what guarantees a broken tool " + "decides nothing.\n " + + resultat.resume(), + fr="Un seul job est attendu, qui teste l'outil puis applique le seuil : " "l'ordre des étapes est la garantie qu'un outil cassé ne décide de rien.\n " - + resultat.resume() + + resultat.resume(), ) return next(iter(resultat.jobs)) @@ -85,16 +97,21 @@ def _job_unique(resultat) -> str: def test_un_rapport_propre_laisse_passer(projet: Path) -> None: res = _jouer(projet, severites=[]) job = _job_unique(res) - assert res.job(job) == "success", ( - "Sur un rapport sans vulnérabilité, la porte doit laisser passer.\n " + res.resume() + assert res.job(job) == "success", message( + en="On a report with no vulnerability, the gate must let it through.\n " + res.resume(), + fr="Sur un rapport sans vulnérabilité, la porte doit laisser passer.\n " + res.resume(), ) - assert any("TOTAL: 0" in ligne for ligne in res.lignes), ( - "Le job est vert, mais l'outil n'a pas résumé le rapport : il doit " - "lire `rapports/trivy.json`, celui que le scanner dépose.\n " + res.resume() + assert any("TOTAL: 0" in ligne for ligne in res.lignes), message( + en="The job is green, but the tool did not summarise the report: it must " + "read `rapports/trivy.json`, the one the scanner writes.\n " + res.resume(), + fr="Le job est vert, mais l'outil n'a pas résumé le rapport : il doit " + "lire `rapports/trivy.json`, celui que le scanner dépose.\n " + res.resume(), ) - assert any(" passed" in ligne for ligne in res.lignes), ( - "Les tests de l'outil (`tests/`) ne tournent pas dans le pipeline. " - "Ils passent avant le seuil : un outil cassé ne doit rien décider.\n " + res.resume() + assert any(" passed" in ligne for ligne in res.lignes), message( + en="The tool's own tests (`tests/`) do not run in the pipeline. They " + "come before the threshold: a broken tool must decide nothing.\n " + res.resume(), + fr="Les tests de l'outil (`tests/`) ne tournent pas dans le pipeline. " + "Ils passent avant le seuil : un outil cassé ne doit rien décider.\n " + res.resume(), ) @@ -102,23 +119,31 @@ def test_un_critique_arrete_le_pipeline(projet: Path) -> None: """Le rapport livré : 2 CRITICAL, sortie réelle de Trivy.""" res = _jouer(projet) job = _job_unique(res) - assert res.job(job) == "failure", ( - "Le rapport livré porte deux vulnérabilités CRITICAL : le job doit " - "échouer. Le seuil est-il passé à l'outil (`--fail-on CRITICAL`) ?\n " + res.resume() + assert res.job(job) == "failure", message( + en="The provided report carries two CRITICAL vulnerabilities: the job " + "must fail. Is the threshold passed to the tool " + "(`--fail-on CRITICAL`)?\n " + res.resume(), + fr="Le rapport livré porte deux vulnérabilités CRITICAL : le job doit " + "échouer. Le seuil est-il passé à l'outil (`--fail-on CRITICAL`) ?\n " + res.resume(), ) - assert any(ligne.startswith("FAILED:") for ligne in res.lignes), ( - "Le job échoue, mais pas sur le seuil : l'outil n'a pas écrit sa ligne " + assert any(ligne.startswith("FAILED:") for ligne in res.lignes), message( + en="The job fails, but not on the threshold: the tool did not write its " + "`FAILED: ...` line. A job red for another reason is not a gate.\n " + + res.resume(), + fr="Le job échoue, mais pas sur le seuil : l'outil n'a pas écrit sa ligne " "`FAILED: ...`. Un job rouge pour une autre raison n'est pas une porte.\n " - + res.resume() + + res.resume(), ) def test_le_seuil_est_critical_et_pas_plus_bas(projet: Path) -> None: res = _jouer(projet, severites=["HIGH", "HIGH", "MEDIUM"]) job = _job_unique(res) - assert res.job(job) == "success", ( - "Un rapport sans CRITICAL (deux HIGH et un MEDIUM) doit passer : la " - "porte demandée bloque au seuil CRITICAL, pas avant.\n " + res.resume() + assert res.job(job) == "success", message( + en="A report with no CRITICAL (two HIGH and one MEDIUM) must pass: the " + "gate asked for blocks at the CRITICAL threshold, not before.\n " + res.resume(), + fr="Un rapport sans CRITICAL (deux HIGH et un MEDIUM) doit passer : la " + "porte demandée bloque au seuil CRITICAL, pas avant.\n " + res.resume(), ) @@ -126,36 +151,48 @@ def test_un_outil_casse_ne_decide_de_rien(projet: Path) -> None: """L'outil remplacé par une version au seuil exclusif : ses tests doivent arrêter le job.""" res = _jouer(projet, severites=[], outil=OUTIL_CASSE) job = _job_unique(res) - assert res.job(job) == "failure", ( - "Avec un outil cassé (seuil exclusif), le pipeline doit s'arrêter sur " + assert res.job(job) == "failure", message( + en="With a broken tool (an exclusive threshold), the pipeline must stop " + "on its tests. The job is green: the tests do not run, or do not run " + "before the threshold.\n " + res.resume(), + fr="Avec un outil cassé (seuil exclusif), le pipeline doit s'arrêter sur " "ses tests. Le job est vert : les tests ne tournent pas, ou pas avant " - "le seuil.\n " + res.resume() + "le seuil.\n " + res.resume(), ) - assert not any(ligne.startswith("TOTAL:") for ligne in res.lignes), ( - "L'outil cassé a quand même résumé le rapport : le seuil s'applique " + assert not any(ligne.startswith("TOTAL:") for ligne in res.lignes), message( + en="The broken tool still summarised the report: the threshold applies " + "before, or in spite of, the tests failing. The tests come first, " + "and a failed step stops the ones after it.\n " + res.resume(), + fr="L'outil cassé a quand même résumé le rapport : le seuil s'applique " "avant ou malgré l'échec des tests. Les tests passent d'abord, et un " - "step en échec arrête les suivants.\n " + res.resume() + "step en échec arrête les suivants.\n " + res.resume(), ) def test_les_pull_requests_passent_la_porte_avec_des_droits_minimaux(projet: Path) -> None: res = _jouer(projet, severites=[], evenement="pull_request") job = _job_unique(res) - assert res.job(job) == "success", ( - "La porte doit aussi tourner sur chaque pull request, avant la fusion.\n " + res.resume() + assert res.job(job) == "success", message( + en="The gate must also run on every pull request, before the merge.\n " + res.resume(), + fr="La porte doit aussi tourner sur chaque pull request, avant la fusion.\n " + res.resume(), ) for chemin, wf in lire_workflows(projet).items(): if not {"push", "pull_request"} & declencheurs(wf): continue droits = wf.get("permissions") - assert droits == {"contents": "read"}, ( - f"{chemin} : `permissions:` vaut {droits!r}. Le job lit le code et " + assert droits == {"contents": "read"}, message( + en=f"{chemin}: `permissions:` is {droits!r}. The job reads the code and " + "nothing else: `contents: read`, at workflow level. A missing " + "block leaves the repository's default rights.", + fr=f"{chemin} : `permissions:` vaut {droits!r}. Le job lit le code et " "rien d'autre : `contents: read`, au niveau du workflow. Un bloc " - "absent laisse les droits par défaut du dépôt." + "absent laisse les droits par défaut du dépôt.", ) non_epinglees = [f"{r.fichier}:{r.ligne} {r.action}@{r.ref}" for r in references_uses(projet) if not r.locale and not r.epinglee_par_sha] - assert not non_epinglees, ( - "Chaque action s'épingle sur le SHA complet de son commit, version en " - "commentaire : " + ", ".join(non_epinglees) + assert not non_epinglees, message( + en="Every action is pinned to the full SHA of its commit, with the " + "version in a comment: " + ", ".join(non_epinglees), + fr="Chaque action s'épingle sur le SHA complet de son commit, version en " + "commentaire : " + ", ".join(non_epinglees), ) diff --git a/labs/automatisation-interroger-osv/challenge/tests/test_functional.py b/labs/automatisation-interroger-osv/challenge/tests/test_functional.py index 36097e3..4c47ae4 100644 --- a/labs/automatisation-interroger-osv/challenge/tests/test_functional.py +++ b/labs/automatisation-interroger-osv/challenge/tests/test_functional.py @@ -27,6 +27,7 @@ exiger_workdir, lancer_outil, lire_resume, + message, workdir_lab, ) @@ -59,11 +60,15 @@ def _osv(projet: Path, rapport: Path, serveur, **env: str): def _exiger_succes(execution) -> None: - assert execution.code == 0, ( - "`--osv` devait réussir (code 0). Si la sortie parle de `requests` " + assert execution.code == 0, message( + en="`--osv` had to succeed (code 0). If the output mentions `requests` " + "not found, the library is not a dependency of the project: " + "`uv add requests` declares it.\n" + f" {execution.resume()}", + fr="`--osv` devait réussir (code 0). Si la sortie parle de `requests` " "introuvable, la bibliothèque n'est pas une dépendance du projet : " "`uv add requests` l'y déclare.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) @@ -73,21 +78,29 @@ def test_chaque_paquet_est_interroge_une_seule_fois(projet: Path, rapport: Path, recus = [] for r in serveur_osv.requetes: corps = r["corps"] - assert r["chemin"] == "/v1/query", ( - f"Requête reçue sur {r['chemin']} : OSV répond sur `/v1/query`, " - "au bout de l'adresse donnée par OSV_API_URL." + assert r["chemin"] == "/v1/query", message( + en=f"Request received on {r['chemin']}: OSV answers on `/v1/query`, at " + "the end of the address given by OSV_API_URL.", + fr=f"Requête reçue sur {r['chemin']} : OSV répond sur `/v1/query`, " + "au bout de l'adresse donnée par OSV_API_URL.", ) paquet = corps.get("package") or {} - assert paquet.get("ecosystem") == "PyPI", ( - f"Corps reçu : {corps}. OSV doit savoir dans quel écosystème " - "chercher : `\"ecosystem\": \"PyPI\"` dans `package`." + assert paquet.get("ecosystem") == "PyPI", message( + en=f"Body received: {corps}. OSV must know which ecosystem to search " + "in: `\"ecosystem\": \"PyPI\"` inside `package`.", + fr=f"Corps reçu : {corps}. OSV doit savoir dans quel écosystème " + "chercher : `\"ecosystem\": \"PyPI\"` dans `package`.", ) recus.append((paquet.get("name"), corps.get("version"))) - assert sorted(recus) == sorted(ATTENDUS), ( - f"Requêtes reçues : {sorted(recus)}\n attendues : {sorted(ATTENDUS)}\n\n" + assert sorted(recus) == sorted(ATTENDUS), message( + en=f"Requests received: {sorted(recus)}\n expected: {sorted(ATTENDUS)}\n\n" + "One request per distinct (package, version) pair of the report: " + "`requests 2.25.0` appears twice there, it is queried once, and " + "`requests 2.31.0` is another pair.", + fr=f"Requêtes reçues : {sorted(recus)}\n attendues : {sorted(ATTENDUS)}\n\n" "Une requête par couple (paquet, version) distinct du rapport : " "`requests 2.25.0` y figure deux fois, il ne s'interroge qu'une fois, " - "et `requests 2.31.0` est un autre couple." + "et `requests 2.31.0` est un autre couple.", ) @@ -105,12 +118,17 @@ def test_les_vulnerabilites_connues_s_affichent_par_paquet(projet: Path, rapport "OSV requests==2.31.0: GHSA-j8r2-6x86-q33q, PYSEC-2023-74", "OSV urllib3==1.26.4: GHSA-v845-jxx5-vc9f", ] - assert lignes == attendu, ( - "Les lignes OSV ne sont pas celles attendues.\n" + assert lignes == attendu, message( + en="The OSV lines are not the expected ones.\n" + f" expected: {attendu}\n obtained: {lignes}\n\n" + "One line per pair, sorted by package then version, in the form " + "`OSV package==version: ID, ID` (sorted identifiers) or `: none`.\n" + f" {execution.resume()}", + fr="Les lignes OSV ne sont pas celles attendues.\n" f" attendu : {attendu}\n obtenu : {lignes}\n\n" "Une ligne par couple, triés par paquet puis version, au format " "`OSV paquet==version: ID, ID` (identifiants triés) ou `: none`.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) @@ -119,32 +137,46 @@ def test_le_resume_reste_et_sans_osv_aucun_appel(projet: Path, rapport: Path, se attendu = compter([f["severity"] for f in FINDINGS]) avec = _osv(projet, rapport, serveur_osv) _exiger_succes(avec) - assert lire_resume(avec.sortie) == attendu, ( - f"Avec `--osv`, le résumé par sévérité doit rester le même.\n attendu : {attendu}\n" - f" {avec.resume()}" + assert lire_resume(avec.sortie) == attendu, message( + en=f"With `--osv`, the summary per severity must stay the same.\n expected: {attendu}\n" + f" {avec.resume()}", + fr=f"Avec `--osv`, le résumé par sévérité doit rester le même.\n attendu : {attendu}\n" + f" {avec.resume()}", ) avant = len(serveur_osv.requetes) sans = lancer_outil(projet, str(rapport), env={"OSV_API_URL": serveur_osv.url}) - assert sans.code == 0 and lire_resume(sans.sortie) == attendu, sans.resume() - assert len(serveur_osv.requetes) == avant, ( - "Sans `--osv`, l'outil ne doit faire aucun appel réseau : dans un " - "pipeline hors ligne, le simple résumé doit continuer de marcher." + assert sans.code == 0 and lire_resume(sans.sortie) == attendu, message( + en=sans.resume(), + fr=sans.resume(), + ) + assert len(serveur_osv.requetes) == avant, message( + en="Without `--osv`, the tool must make no network call at all: in an " + "offline pipeline, the plain summary must keep working.", + fr="Sans `--osv`, l'outil ne doit faire aucun appel réseau : dans un " + "pipeline hors ligne, le simple résumé doit continuer de marcher.", ) def _exiger_refus_propre(execution, indice: str, cas: str) -> None: - assert execution.code == 2, ( - f"{cas} : l'outil doit sortir en 2, comme pour toute entrée inexploitable.\n" - f" {execution.resume()}" + assert execution.code == 2, message( + en=f"{cas}: the tool must exit with 2, as for any unusable input.\n" + f" {execution.resume()}", + fr=f"{cas} : l'outil doit sortir en 2, comme pour toute entrée inexploitable.\n" + f" {execution.resume()}", ) - assert "Traceback" not in execution.erreurs, ( - f"{cas} : une trace Python remonte. Les exceptions de `requests` " + assert "Traceback" not in execution.erreurs, message( + en=f"{cas}: a Python traceback comes through. `requests` exceptions can " + "be caught, and the problem stated in one line.\n" + f" {execution.resume()}", + fr=f"{cas} : une trace Python remonte. Les exceptions de `requests` " "s'attrapent, et le problème se dit en une ligne.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) - assert indice in execution.erreurs, ( - f"{cas} : le message doit dire ce qui s'est passé (il doit contenir « {indice} »).\n" - f" {execution.resume()}" + assert indice in execution.erreurs, message( + en=f"{cas}: the message must say what happened (it must contain « {indice} »).\n" + f" {execution.resume()}", + fr=f"{cas} : le message doit dire ce qui s'est passé (il doit contenir « {indice} »).\n" + f" {execution.resume()}", ) @@ -161,8 +193,11 @@ def test_une_api_muette_ne_bloque_pas_le_pipeline(projet: Path, rapport: Path, s duree = time.monotonic() - debut _exiger_refus_propre(execution, "OSV", "API qui ne répond pas") # uv et Python démarrent en moins de 2 s ; une seule requête attendue. - assert duree < 5, ( - f"L'outil a attendu {duree:.1f} s : le délai maximal OSV_TIMEOUT=1 " + assert duree < 5, message( + en=f"The tool waited {duree:.1f} s: the OSV_TIMEOUT=1 maximum delay is " + "not applied. Without `timeout=`, `requests` waits forever.\n" + f" {execution.resume()}", + fr=f"L'outil a attendu {duree:.1f} s : le délai maximal OSV_TIMEOUT=1 " "n'est pas appliqué. Sans `timeout=`, `requests` attend indéfiniment.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) diff --git a/labs/automatisation-lire-un-rapport/challenge/tests/test_functional.py b/labs/automatisation-lire-un-rapport/challenge/tests/test_functional.py index 5489351..1b1aca8 100644 --- a/labs/automatisation-lire-un-rapport/challenge/tests/test_functional.py +++ b/labs/automatisation-lire-un-rapport/challenge/tests/test_functional.py @@ -35,6 +35,7 @@ findings_au_hasard, lancer_outil, lire_resume, + message, tirage, workdir_lab, ) @@ -50,16 +51,22 @@ def projet() -> Path: def _exiger_resume(execution, attendu: dict[str, int], contexte: str) -> None: - assert execution.code == 0, ( - f"{contexte} : l'outil devait réussir (code 0).\n {execution.resume()}" + assert execution.code == 0, message( + en=f"{contexte}: the tool had to succeed (code 0).\n {execution.resume()}", + fr=f"{contexte} : l'outil devait réussir (code 0).\n {execution.resume()}", ) obtenu = lire_resume(execution.sortie) - assert obtenu == attendu, ( - f"{contexte} : le résumé ne correspond pas au rapport.\n" + assert obtenu == attendu, message( + en=f"{contexte}: the summary does not match the report.\n" + f" expected: {attendu}\n obtained: {obtenu}\n\n" + "One line per severity, in the order CRITICAL, HIGH, MEDIUM, LOW, " + "UNKNOWN, in the form `CRITICAL: 2`, then `TOTAL: n`.\n" + f" {execution.resume()}", + fr=f"{contexte} : le résumé ne correspond pas au rapport.\n" f" attendu : {attendu}\n obtenu : {obtenu}\n\n" "Une ligne par sévérité, dans l'ordre CRITICAL, HIGH, MEDIUM, LOW, " "UNKNOWN, au format `CRITICAL: 2`, puis `TOTAL: n`.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) @@ -102,22 +109,31 @@ def test_un_rapport_vide_rend_des_zeros(projet: Path, tmp_path: Path) -> None: def _exiger_refus_propre(execution, nom: str, cas: str) -> None: - assert execution.code == 2, ( - f"{cas} : l'outil doit sortir en 2, le code des entrées invalides.\n" - f" {execution.resume()}" + assert execution.code == 2, message( + en=f"{cas}: the tool must exit with 2, the code for invalid input.\n" + f" {execution.resume()}", + fr=f"{cas} : l'outil doit sortir en 2, le code des entrées invalides.\n" + f" {execution.resume()}", ) - assert "Traceback" not in execution.erreurs, ( - f"{cas} : l'outil laisse remonter une trace Python. Attrapez l'exception " + assert "Traceback" not in execution.erreurs, message( + en=f"{cas}: the tool lets a Python traceback through. Catch the exception " + "and state the problem in one line, on the error output.\n" + f" {execution.resume()}", + fr=f"{cas} : l'outil laisse remonter une trace Python. Attrapez l'exception " "et dites le problème en une ligne, sur la sortie d'erreur.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) - assert nom in execution.erreurs, ( - f"{cas} : le message d'erreur doit nommer le fichier en cause ({nom}).\n" - f" {execution.resume()}" + assert nom in execution.erreurs, message( + en=f"{cas}: the error message must name the offending file ({nom}).\n" + f" {execution.resume()}", + fr=f"{cas} : le message d'erreur doit nommer le fichier en cause ({nom}).\n" + f" {execution.resume()}", ) - assert not lire_resume(execution.sortie), ( - f"{cas} : aucun résumé ne doit s'afficher pour un rapport illisible.\n" - f" {execution.resume()}" + assert not lire_resume(execution.sortie), message( + en=f"{cas}: no summary must be printed for an unreadable report.\n" + f" {execution.resume()}", + fr=f"{cas} : aucun résumé ne doit s'afficher pour un rapport illisible.\n" + f" {execution.resume()}", ) diff --git a/labs/automatisation-lire-un-sbom/challenge/tests/test_functional.py b/labs/automatisation-lire-un-sbom/challenge/tests/test_functional.py index 3463bfa..8c1de7c 100644 --- a/labs/automatisation-lire-un-sbom/challenge/tests/test_functional.py +++ b/labs/automatisation-lire-un-sbom/challenge/tests/test_functional.py @@ -27,6 +27,7 @@ exiger_workdir, lancer_outil, lire_resume, + message, workdir_lab, ) @@ -68,74 +69,102 @@ def _sbom(projet: Path, serveur, *options: str): def test_chaque_composant_pypi_est_interroge_une_fois(projet: Path, serveur_osv) -> None: execution = _sbom(projet, serveur_osv) - assert execution.code == 0, ( - "Un SBOM CycloneDX avec `--osv` doit être lu. Il se reconnaît à sa clé " - f"`bomFormat`, et ses composants vivent sous `components`.\n {execution.resume()}" + assert execution.code == 0, message( + en="A CycloneDX SBOM with `--osv` must be read. It is recognised by its " + f"`bomFormat` key, and its components live under `components`.\n {execution.resume()}", + fr="Un SBOM CycloneDX avec `--osv` doit être lu. Il se reconnaît à sa clé " + f"`bomFormat`, et ses composants vivent sous `components`.\n {execution.resume()}", ) recus = sorted(((r["corps"].get("package") or {}).get("name"), r["corps"].get("version"), (r["corps"].get("package") or {}).get("ecosystem")) for r in serveur_osv.requetes) attendus = sorted((n, v, "PyPI") for n, v in PYPI) - assert recus == attendus, ( - f"Requêtes reçues : {recus}\n attendues : {attendus}\n\n" + assert recus == attendus, message( + en=f"Requests received: {recus}\n expected: {attendus}\n\n" + "One request per distinct PyPI component: `requests 2.25.0` appears " + "twice in the SBOM. The `purl` states the ecosystem " + "(`pkg:pypi/...`): npm components are not queried like PyPI packages.", + fr=f"Requêtes reçues : {recus}\n attendues : {attendus}\n\n" "Une requête par composant PyPI distinct : `requests 2.25.0` figure deux " "fois dans le SBOM. Le `purl` dit l'écosystème (`pkg:pypi/...`) : les " - "composants npm ne s'interrogent pas comme des paquets PyPI." + "composants npm ne s'interrogent pas comme des paquets PyPI.", ) def test_le_resume_compte_les_vulnerabilites_connues_d_osv(projet: Path, serveur_osv) -> None: serveur_osv.reponses = REPONSES execution = _sbom(projet, serveur_osv) - assert execution.code == 0, execution.resume() + assert execution.code == 0, message( + en=execution.resume(), + fr=execution.resume(), + ) attendu = _attendu(REPONSES) obtenu = lire_resume(execution.sortie) - assert obtenu == attendu, ( - "Le résumé d'un SBOM compte les vulnérabilités qu'OSV connaît, une par " + assert obtenu == attendu, message( + en="An SBOM's summary counts the vulnerabilities OSV knows about, one per " + "advisory, with the severity of the GitHub advisory.\n" + f" expected: {attendu}\n obtained: {obtenu}\n\n" + "GitHub writes MODERATE where the summary says MEDIUM, and an " + f"advisory with no severity (PYSEC) counts as UNKNOWN.\n {execution.resume()}", + fr="Le résumé d'un SBOM compte les vulnérabilités qu'OSV connaît, une par " "avis, avec la sévérité de l'avis GitHub.\n" f" attendu : {attendu}\n obtenu : {obtenu}\n\n" "GitHub écrit MODERATE ce que le résumé appelle MEDIUM, et un avis sans " - f"sévérité (PYSEC) compte en UNKNOWN.\n {execution.resume()}" + f"sévérité (PYSEC) compte en UNKNOWN.\n {execution.resume()}", ) def test_un_sbom_sans_osv_est_une_entree_inexploitable(projet: Path) -> None: execution = lancer_outil(projet, str(SBOM)) - assert execution.code == 2 and "--osv" in execution.erreurs and "Traceback" not in execution.erreurs, ( - "Un SBOM liste des composants, pas des vulnérabilités : sans `--osv`, " + assert execution.code == 2 and "--osv" in execution.erreurs and "Traceback" not in execution.erreurs, message( + en="An SBOM lists components, not vulnerabilities: without `--osv`, the " + "tool has nothing to count. It says so in one line that names " + "`--osv`, with code 2, rather than printing a zeroed summary that " + f"would suggest a healthy project.\n {execution.resume()}", + fr="Un SBOM liste des composants, pas des vulnérabilités : sans `--osv`, " "l'outil n'a rien à compter. Il le dit en une ligne qui nomme `--osv`, " "avec le code 2, au lieu d'afficher un résumé à zéro qui ferait croire " - f"le projet sain.\n {execution.resume()}" + f"le projet sain.\n {execution.resume()}", ) def test_le_seuil_s_applique_a_un_sbom(projet: Path, serveur_osv) -> None: serveur_osv.reponses = REPONSES haut = _sbom(projet, serveur_osv, "--fail-on", "HIGH") - assert haut.code == 1, ( - "Le SBOM porte un HIGH (urllib3) et un CRITICAL (pyyaml) d'après OSV : " - f"`--fail-on HIGH` doit rendre 1.\n {haut.resume()}" + assert haut.code == 1, message( + en="The SBOM carries a HIGH (urllib3) and a CRITICAL (pyyaml) according " + f"to OSV: `--fail-on HIGH` must return 1.\n {haut.resume()}", + fr="Le SBOM porte un HIGH (urllib3) et un CRITICAL (pyyaml) d'après OSV : " + f"`--fail-on HIGH` doit rendre 1.\n {haut.resume()}", ) serveur_osv.reponses = {"requests": {"GHSA-j8r2-6x86-q33q": "MODERATE"}} modere = _sbom(projet, serveur_osv, "--fail-on", "HIGH") - assert modere.code == 0, ( - "Avec un seul avis MODERATE, rien n'atteint HIGH : code 0. MODERATE " - f"est un MEDIUM, pas un HIGH.\n {modere.resume()}" + assert modere.code == 0, message( + en="With a single MODERATE advisory, nothing reaches HIGH: code 0. " + f"MODERATE is a MEDIUM, not a HIGH.\n {modere.resume()}", + fr="Avec un seul avis MODERATE, rien n'atteint HIGH : code 0. MODERATE " + f"est un MEDIUM, pas un HIGH.\n {modere.resume()}", ) def test_les_rapports_restent_lisibles_et_les_composants_npm_ignores(projet: Path, serveur_osv, tmp_path: Path) -> None: """Le rapport Trivy des labs précédents, et un SBOM sans aucun composant PyPI.""" trivy = lancer_outil(projet, str(DONNEES / "trivy-multi.json")) - assert trivy.code == 0 and lire_resume(trivy.sortie).get("TOTAL") == 22, ( - f"Le rapport Trivy doit toujours donner ses 22 failles distinctes.\n {trivy.resume()}" + assert trivy.code == 0 and lire_resume(trivy.sortie).get("TOTAL") == 22, message( + en=f"The Trivy report must still give its 22 distinct flaws.\n {trivy.resume()}", + fr=f"Le rapport Trivy doit toujours donner ses 22 failles distinctes.\n {trivy.resume()}", ) sbom = json.loads(SBOM.read_text(encoding="utf-8")) sbom["components"] = [c for c in sbom["components"] if not str(c.get("purl", "")).startswith("pkg:pypi/")] npm = tmp_path / "npm.cdx.json" npm.write_text(json.dumps(sbom), encoding="utf-8") execution = lancer_outil(projet, str(npm), "--osv", env={"OSV_API_URL": serveur_osv.url}) - assert execution.code == 0 and lire_resume(execution.sortie).get("TOTAL") == 0, execution.resume() - assert not serveur_osv.requetes, ( - "Un SBOM sans composant PyPI ne doit déclencher aucune requête PyPI : " - f"{[r['corps'] for r in serveur_osv.requetes]}" + assert execution.code == 0 and lire_resume(execution.sortie).get("TOTAL") == 0, message( + en=execution.resume(), + fr=execution.resume(), + ) + assert not serveur_osv.requetes, message( + en="An SBOM with no PyPI component must trigger no PyPI request: " + f"{[r['corps'] for r in serveur_osv.requetes]}", + fr="Un SBOM sans composant PyPI ne doit déclencher aucune requête PyPI : " + f"{[r['corps'] for r in serveur_osv.requetes]}", ) diff --git a/labs/automatisation-rapport-trivy/challenge/tests/test_functional.py b/labs/automatisation-rapport-trivy/challenge/tests/test_functional.py index 075fd8d..e14fbaa 100644 --- a/labs/automatisation-rapport-trivy/challenge/tests/test_functional.py +++ b/labs/automatisation-rapport-trivy/challenge/tests/test_functional.py @@ -30,6 +30,7 @@ findings_au_hasard, lancer_outil, lire_resume, + message, tirage, workdir_lab, ) @@ -75,15 +76,20 @@ def trivy_au_hasard(graine: int) -> dict: def _exiger_resume(execution, attendu: dict[str, int], contexte: str) -> None: - assert execution.code == 0, ( - f"{contexte} : l'outil devait réussir (code 0). Un rapport Trivy est un " + assert execution.code == 0, message( + en=f"{contexte}: the tool had to succeed (code 0). A Trivy report is a " + "JSON object, not a list: version 2 refuses it, version 3 must " + f"recognise it.\n {execution.resume()}", + fr=f"{contexte} : l'outil devait réussir (code 0). Un rapport Trivy est un " "objet JSON, pas une liste : la version 2 le refuse, la version 3 doit " - f"le reconnaître.\n {execution.resume()}" + f"le reconnaître.\n {execution.resume()}", ) obtenu = lire_resume(execution.sortie) - assert obtenu == attendu, ( - f"{contexte} : le résumé ne correspond pas au rapport.\n" - f" attendu : {attendu}\n obtenu : {obtenu}\n {execution.resume()}" + assert obtenu == attendu, message( + en=f"{contexte}: the summary does not match the report.\n" + f" expected: {attendu}\n obtained: {obtenu}\n {execution.resume()}", + fr=f"{contexte} : le résumé ne correspond pas au rapport.\n" + f" attendu : {attendu}\n obtenu : {obtenu}\n {execution.resume()}", ) @@ -93,9 +99,14 @@ def test_le_vrai_rapport_trivy_compte_22_failles_distinctes(projet: Path) -> Non execution = lancer_outil(projet, str(chemin)) if execution.code == 0 and lire_resume(execution.sortie).get("TOTAL") == 26: pytest.fail( - "TOTAL vaut 26 : ce sont les occurrences, pas les failles. `requests " + message( + en="TOTAL is 26: those are occurrences, not vulnerabilities. `requests " + "2.25.0` is declared in two files, and its four vulnerabilities " + "appear in two targets; each one counts only once.", + fr="TOTAL vaut 26 : ce sont les occurrences, pas les failles. `requests " "2.25.0` est déclaré dans deux fichiers, et ses quatre vulnérabilités " "apparaissent dans deux cibles ; chacune ne compte qu'une fois.", + ), pytrace=False, ) _exiger_resume(execution, attendu, "rapport réel de Trivy 0.74.0") @@ -140,10 +151,15 @@ def test_osv_interroge_les_paquets_du_rapport_trivy(projet: Path, serveur_osv) - for r in rapport["Results"] for v in r.get("Vulnerabilities") or []}) execution = lancer_outil(projet, str(DONNEES / "trivy-multi.json"), "--osv", env={"OSV_API_URL": serveur_osv.url}) - assert execution.code == 0, execution.resume() + assert execution.code == 0, message( + en=execution.resume(), + fr=execution.resume(), + ) recus = sorted(((r["corps"].get("package") or {}).get("name"), r["corps"].get("version")) for r in serveur_osv.requetes) - assert recus == attendus, ( - f"Avec un rapport Trivy, `--osv` interroge chaque couple (PkgName, " - f"InstalledVersion) une fois.\n attendus : {attendus}\n reçus : {recus}" + assert recus == attendus, message( + en=f"With a Trivy report, `--osv` queries each (PkgName, InstalledVersion) " + f"pair once.\n expected: {attendus}\n received: {recus}", + fr=f"Avec un rapport Trivy, `--osv` interroge chaque couple (PkgName, " + f"InstalledVersion) une fois.\n attendus : {attendus}\n reçus : {recus}", ) diff --git a/labs/automatisation-tester-son-outil/challenge/tests/test_functional.py b/labs/automatisation-tester-son-outil/challenge/tests/test_functional.py index c0b00d0..92b7abe 100644 --- a/labs/automatisation-tester-son-outil/challenge/tests/test_functional.py +++ b/labs/automatisation-tester-son-outil/challenge/tests/test_functional.py @@ -35,7 +35,7 @@ import pytest -from conftest import exiger_workdir, workdir_lab +from conftest import exiger_workdir, message, workdir_lab WORKDIR = workdir_lab(__file__) LAB_ID = "automatisation-tester-son-outil" @@ -63,7 +63,10 @@ def jouer_la_suite(outil: str) -> Passage: exiger_workdir(WORKDIR, LAB_ID) uv = shutil.which("uv") if uv is None: - pytest.fail("uv est introuvable sur le PATH. Lancez `mise install`.", pytrace=False) + pytest.fail(message( + en="uv cannot be found on the PATH. Run `mise install`.", + fr="uv est introuvable sur le PATH. Lancez `mise install`.", + ), pytrace=False) copie = Path(tempfile.mkdtemp(prefix="security-check-")) / "projet" shutil.copytree(WORKDIR, copie, ignore=shutil.ignore_patterns(*EXCLUS)) shutil.copy2(outil, copie / "security_check.py") @@ -85,34 +88,48 @@ def _exiger_reference_verte() -> Passage: ref = jouer_la_suite(str(REFERENCE)) if "Failed to spawn" in ref.sortie and "pytest" in ref.sortie: pytest.fail( - "pytest n'est pas installé dans le projet : `uv add --dev pytest` le " + message( + en="pytest is not installed in the project: `uv add --dev pytest` " + "declares it as a development dependency.\n" + ref.resume(), + fr="pytest n'est pas installé dans le projet : `uv add --dev pytest` le " "déclare comme dépendance de développement.\n" + ref.resume(), + ), pytrace=False, ) - assert ref.code == 0, ( - "Votre suite échoue sur l'outil de RÉFÉRENCE, qui est juste : un test " + assert ref.code == 0, message( + en="Your suite fails against the REFERENCE tool, which is correct: one " + "of your tests expects something other than the behaviour the " + "previous labs asked for.\n" + + ref.resume(), + fr="Votre suite échoue sur l'outil de RÉFÉRENCE, qui est juste : un test " "attend autre chose que le comportement demandé par les labs précédents.\n" - + ref.resume() + + ref.resume(), ) return ref def test_la_suite_passe_sur_l_outil_juste_et_compte_assez_de_tests() -> None: ref = _exiger_reference_verte() - assert ref.reussis >= MINIMUM_DE_TESTS, ( - f"{ref.reussis} test(s) seulement : il en faut au moins {MINIMUM_DE_TESTS}. " + assert ref.reussis >= MINIMUM_DE_TESTS, message( + en=f"Only {ref.reussis} test(s): at least {MINIMUM_DE_TESTS} are needed. " + "The provided example only checks a constant; a useful suite " + "exercises reading, counting, deduplication and the exit codes.\n" + + ref.resume(), + fr=f"{ref.reussis} test(s) seulement : il en faut au moins {MINIMUM_DE_TESTS}. " "L'exemple livré ne vérifie qu'une constante ; une suite utile exerce " "la lecture, le compte, le dédoublonnage et les codes de sortie.\n" - + ref.resume() + + ref.resume(), ) def _exiger_mutant_tue(nom: str, defaut: str, piste: str) -> None: _exiger_reference_verte() passage = jouer_la_suite(str(MUTANTS / f"{nom}.py")) - assert passage.code != 0 and passage.echoues > 0, ( - f"Le mutant « {nom} » survit : votre suite passe sur un outil où {defaut}.\n" - f"Ajoutez un test qui l'attrape : {piste}\n" + passage.resume() + assert passage.code != 0 and passage.echoues > 0, message( + en=f"The « {nom} » mutant survives: your suite passes on a tool where " + f"{defaut}.\nAdd a test that catches it: {piste}\n" + passage.resume(), + fr=f"Le mutant « {nom} » survit : votre suite passe sur un outil où {defaut}.\n" + f"Ajoutez un test qui l'attrape : {piste}\n" + passage.resume(), )