Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
findings_au_hasard,
lancer_outil,
lire_resume,
message,
tirage,
workdir_lab,
)
Expand Down Expand Up @@ -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()}",
)


Expand All @@ -80,55 +87,82 @@ 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()}",
)


def test_une_entree_invalide_reste_un_code_2(projet: Path, tmp_path: Path) -> None:
"""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()}",
)
113 changes: 75 additions & 38 deletions labs/automatisation-dans-la-ci/challenge/tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
exiger_workdir,
jouer_act,
lire_workflows,
message,
references_uses,
workdir_lab,
)
Expand All @@ -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
Expand All @@ -70,92 +76,123 @@ 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))


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(),
)


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(),
)


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),
)
Loading