From 2c43a0a69f807a4826483fb3074c7b7a2286f609 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 00:46:48 +0100 Subject: [PATCH] =?UTF-8?q?Test=20dans=20les=20navigateurs=20:=20CSP=20v?= =?UTF-8?q?=C3=A9rifi=C3=A9e,=20diagnostic=20des=20blocages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make test-browser vérifie maintenant la politique de sécurité dans Chromium et Firefox : aucune violation pendant toute la partie, et un script en ligne ajouté exprès est bloqué. Un blocage est diagnostiqué (étape, capture, dernière commande du moteur, journal de la partie) au lieu d'attendre sans fin. Firefox sous Playwright refuse toute navigation hors ligne : on y vérifie le cache et le contrôle par le service worker. Co-Authored-By: Claude Opus 5.5 --- docs/deploiement.md | 11 +++- tests/browser.sh | 2 +- tests/browser/play.mjs | 125 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 128 insertions(+), 10 deletions(-) diff --git a/docs/deploiement.md b/docs/deploiement.md index 51f3e6e..ea30cc3 100644 --- a/docs/deploiement.md +++ b/docs/deploiement.md @@ -71,7 +71,11 @@ make deploy-prod`. en-têtes dont la politique de sécurité, et commit de `version.json`. - `make test-browser` (hors de `make test`, plusieurs minutes) joue une partie complète par l'interface dans Chromium et Firefox sans écran, sur - la même image, puis vérifie la reprise, l'aide et le hors ligne. + la même image, puis vérifie la reprise, l'aide, le hors ligne (Chromium + seulement : Firefox sous Playwright refuse toute navigation « hors + ligne », on y vérifie que le cache est complet et que la page rechargée + est servie par le service worker) et la politique de sécurité. Captures + d'écran dans `build/browser-shots/`. **Hors ligne et mises à jour.** Le service worker met tous les fichiers du site en cache à la première visite (environ 9 Mo), puis sert tout depuis ce @@ -93,8 +97,9 @@ politique de sécurité du contenu (`Content-Security-Policy`) : tout vient du site lui-même, aucun script ni style en ligne, aucun `eval`, seule la compilation du WebAssembly est permise (`'wasm-unsafe-eval'`). Elle est vérifiée par `tests/test_site.sh` (en-tête présent) et par -`make test-browser` (aucune violation signalée par Chromium ni Firefox -pendant une partie). +`make test-browser` : aucune violation signalée par Chromium ni Firefox +pendant toute une partie (moteur, Web Worker, service worker, aide), et un +script en ligne ajouté exprès à la page est bien bloqué. ## Vérifier diff --git a/tests/browser.sh b/tests/browser.sh index 35b926f..580baaf 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -42,7 +42,7 @@ for browser in $browsers; do # Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker # demande un contexte sûr, ce qu'est localhost. docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ - -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ + -e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ node play.mjs "http://localhost:$port/" "$browser" /shots || status=1 done exit "$status" diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 34f7f71..75a6b78 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -26,17 +26,51 @@ const context = await browser.newContext({ deviceScaleFactor: 2, locale: "fr-FR", }); +// Pour le diagnostic d'un blocage : la dernière commande envoyée au moteur +// (Web Worker), et si elle a reçu sa réponse ; et, pour vérifier la +// politique de sécurité du contenu, chaque violation signalée par la page. +await context.addInitScript(() => { + window.__engine = { sent: 0, received: 0, last: null }; + const post = Worker.prototype.postMessage; + Worker.prototype.postMessage = function (message, ...rest) { + window.__engine.sent++; + window.__engine.last = message?.args ?? null; + if (!this.__counted) { + this.__counted = true; + this.addEventListener("message", () => window.__engine.received++); + } + return post.call(this, message, ...rest); + }; + window.__csp = []; + document.addEventListener("securitypolicyviolation", (e) => + window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`), + ); +}); const page = await context.newPage(); const problems = []; page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`)); +page.on("crash", () => { + console.error(`[${browserName}] la page a planté`); + process.exit(1); +}); +// Vrai pendant le contrôle final de la politique de sécurité, qui provoque +// exprès une violation. +let cspProbe = false; page.on("console", (message) => { - if (message.type() === "error") { + if (message.type() === "error" && !(cspProbe && /Content.Security.Policy/i.test(message.text()))) { problems.push(`console : ${message.text()}`); } }); const log = (text) => console.log(`[${browserName}] ${text}`); const shot = (name) => page.screenshot({ path: `${shots}/${browserName}-${name}.png` }); +/** Relève les violations de la CSP signalées par la page (avant chaque + * rechargement, qui les efface). */ +const violations = []; +async function collectViolations() { + violations.push(...(await page.evaluate(() => window.__csp))); +} + function check(condition, message) { if (!condition) { throw new Error(message); @@ -79,9 +113,40 @@ async function waitForPlayer() { } await new Promise((resolve) => setTimeout(resolve, 100)); } - throw new Error("rien à faire pour le joueur depuis 60 s"); + await shot("bloque").catch(() => {}); + const screen = await page + .evaluate(() => { + const text = (id) => document.getElementById(id)?.textContent; + const busy = document.body.classList.contains("busy") ? "occupé" : "libre"; + const engine = window.__engine; + return ( + `${busy} ; « ${text("info")} » ; « ${text("prompt")} » ; moteur : ` + + `${engine.sent} commandes, ${engine.received} réponses, dernière ` + + `${JSON.stringify(engine.last)} ; journal ` + + localStorage.getItem("ustagammon.current-game") + ); + }) + .catch((error) => `page illisible (${error.message})`); + throw new Error(`rien à faire pour le joueur depuis 60 s : ${screen}`); } +// Garde-fou : une étape bloquée plus de deux minutes arrête tout, avec une +// capture et l'état de l'écran. +let lastStep = Date.now(); +let stepName = "chargement"; +function step(name) { + lastStep = Date.now(); + stepName = name; +} +const watchdog = setInterval(async () => { + if (Date.now() - lastStep > 120_000) { + clearInterval(watchdog); + console.error(`[${browserName}] bloqué depuis 2 minutes (étape : ${stepName})`); + await shot("bloque").catch(() => {}); + process.exit(1); + } +}, 5_000); + const start = Date.now(); await page.goto(base); const first = await waitForPlayer(); @@ -95,6 +160,10 @@ let computerMs = []; for (let actions = 0; ; actions++) { check(actions < 800, "la partie ne finit pas"); const todo = await waitForPlayer(); + step(`partie, action ${actions}`); + if (process.env.TRACE) { + log(`${todo} : ${await page.textContent("#info")} / ${await page.textContent("#prompt")}`); + } const info = await page.textContent("#info"); const timing = /\((\d+),(\d) s\)/.exec(info ?? ""); if (timing) { @@ -136,6 +205,7 @@ for (let actions = 0; ; actions++) { await shot("3-milieu-de-partie"); const before = await savedJournal(); const pips = await page.textContent("#pips"); + await collectViolations(); await page.reload(); await waitForPlayer(); const after = await savedJournal(); @@ -159,6 +229,7 @@ if (computerMs.length > 0) { } await shot("4-fin-de-partie"); +step("aide"); // Aide : scénario et glossaire lus dans les copies de docs/. await page.click("#help-button"); await page.waitForSelector(".glossary dt"); @@ -169,6 +240,7 @@ log(`aide : ${scenarios} scénarios, ${terms} termes du glossaire`); await shot("5-aide"); await page.click("#help-back"); +step("thème sombre"); // Thème sombre. await page.emulateMedia({ colorScheme: "dark" }); await page.click("#again"); @@ -176,16 +248,19 @@ await waitForPlayer(); await shot("6-sombre"); await page.emulateMedia({ colorScheme: "light" }); +step("mise en cache"); // Hors ligne : une fois le cache du service worker rempli, la page se // recharge sans réseau, et le moteur répond. const cached = await page.evaluate(async () => { - await navigator.serviceWorker.ready; + // Le service worker est enregistré une fois le moteur chargé ; s'il ne + // s'installe pas, l'attente s'arrête au bout d'une minute. for (let i = 0; i < 600; i++) { const names = await caches.keys(); if (names.length === 1) { const cache = await caches.open(names[0]); const keys = await cache.keys(); - if (keys.some((r) => r.url.endsWith("/wasm/engine.data"))) { + const active = (await navigator.serviceWorker.getRegistration())?.active; + if (active && keys.some((r) => r.url.endsWith("/wasm/engine.data"))) { return keys.length; } } @@ -194,8 +269,19 @@ const cached = await page.evaluate(async () => { return 0; }); check(cached > 0, "le service worker n'a pas mis le site en cache"); -await context.setOffline(true); +// Firefox sous Playwright refuse toute navigation une fois le contexte +// « hors ligne », même servie par le service worker (NS_ERROR_OFFLINE) : on +// y vérifie seulement que la page rechargée est contrôlée par le service +// worker, dont le cache est complet. +const offlineMode = browserName === "chromium"; +if (offlineMode) { + await context.setOffline(true); +} +step("rechargement hors ligne"); +await collectViolations(); await page.reload(); +const controlled = await page.evaluate(() => navigator.serviceWorker.controller !== null); +check(controlled, "la page rechargée n'est pas contrôlée par le service worker"); const offline = await waitForPlayer(); if (offline === "roll") { await page.click("#roll"); @@ -204,9 +290,36 @@ if (offline === "roll") { await page.click("#help-button"); await page.waitForSelector(".glossary dt"); await page.click("#help-back"); -log(`hors ligne : page rechargée depuis le cache (${cached} adresses), moteur et aide disponibles`); +log( + offlineMode + ? `hors ligne : page rechargée sans réseau depuis le cache (${cached} adresses), moteur et aide disponibles` + : `cache du service worker rempli (${cached} adresses), page rechargée sous son contrôle ` + + "(le hors ligne lui-même n'est pas simulable dans Firefox sous Playwright)", +); await shot("7-hors-ligne"); +step("CSP"); +// Politique de sécurité du contenu : aucune violation pendant toute la +// partie (moteur WebAssembly, Web Worker, service worker, aide compris) ; et +// elle est bien appliquée : un script en ligne ajouté à la page est bloqué. +await collectViolations(); +check(violations.length === 0, `violations de la CSP : ${violations.join(", ")}`); +cspProbe = true; +const probe = await page.evaluate(async () => { + const script = document.createElement("script"); + script.textContent = "window.__inline = true;"; + document.head.append(script); + await new Promise((resolve) => setTimeout(resolve, 300)); + return { ran: window.__inline === true, violations: window.__csp }; +}); +check(!probe.ran, "CSP non appliquée : un script en ligne a été exécuté"); +check( + probe.violations.some((v) => v.startsWith("script-src")), + "CSP : le script en ligne bloqué n'a pas été signalé", +); +log("CSP : aucune violation pendant la partie, script en ligne bloqué"); + +clearInterval(watchdog); await browser.close(); if (problems.length > 0) { console.error(problems.join("\n"));