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"));