From 6169777f452459ef1e4240d157190ee61a005c7e Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:41:10 +0100 Subject: [PATCH 1/2] =?UTF-8?q?test(fortress):=20Refuser=20le=20HTML=20red?= =?UTF-8?q?irig=C3=A9=20et=20=C3=A9prouver=20la=20premi=C3=A8re=20ouvertur?= =?UTF-8?q?e=20hors=20ligne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pipeline.yml | 2 +- public/sw.js | 18 +++++++++-- tests/e2e/service-worker.spec.ts | 52 +++++++++++++++++++++++++++++++ tests/unit/service-worker.test.ts | 28 ++++++++++++++--- 4 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/service-worker.spec.ts diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index e2c7e52..732362c 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -33,7 +33,7 @@ concurrency: jobs: fortress-test-bundle: name: FORTRESS TEST - isolated public config bundle - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'test/fortress-drive-execution-20261026' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && (github.head_ref == 'test/fortress-drive-execution-20261026' || startsWith(github.head_ref, 'test/fortress-final-acceptance-')) runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: diff --git a/public/sw.js b/public/sw.js index a290f1b..c58cb4c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,6 +9,20 @@ const CACHE_PREFIX = 'cv-shell-'; const VERSION = `${CACHE_PREFIX}v4`; const APP_PATH = new URL(self.registration.scope).pathname; const cached = (request) => caches.open(VERSION).then((cache) => cache.match(request)); +// L'URL finale est la preuve de provenance, après toutes les redirections. +// Filtrer aussi les anciennes entrées évite de servir un cache déjà contaminé. +function validShell(response) { + if (!response?.ok || !/^text\/html(?:\s*;|$)/i.test(response.headers.get('content-type') || '') || !response.url) return false; + try { + const final = new URL(response.url); + return final.origin === self.location.origin && [APP_PATH, APP_PATH + 'index', APP_PATH + 'index.html'].includes(final.pathname); + } catch { return false; } +} +async function cachedShell() { + const response = await cached('./index.html'); + if (!validShell(response)) throw new Error('Aucun shell hors ligne vérifié'); + return response; +} async function remember(request, response) { try { const cache = await caches.open(VERSION); @@ -46,12 +60,12 @@ self.addEventListener('fetch', (event) => { event.respondWith( fetch(req) .then((res) => { - if (res.ok && res.headers.get('content-type')?.includes('text/html')) { + if (validShell(res)) { return remember('./index.html', res); } return res; }) - .catch(() => cached('./index.html')), + .catch(() => cachedShell()), ); return; } diff --git a/tests/e2e/service-worker.spec.ts b/tests/e2e/service-worker.spec.ts new file mode 100644 index 0000000..1d696bf --- /dev/null +++ b/tests/e2e/service-worker.spec.ts @@ -0,0 +1,52 @@ +// Vrai réseau local et vrai Service Worker ; aucun compte Google ni donnée personnelle. +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { resolve, extname } from 'node:path'; +import { expect, test } from '@playwright/test'; + +test('première visite : le build complet redémarre hors ligne sans second chargement en ligne', async ({ page }) => { + const root = resolve('dist'); + const types: Record = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' }; + const server = createServer(async (req, res) => { + try { + const name = new URL(req.url || '/', 'http://localhost').pathname; + const file = resolve(root, '.' + (name === '/' ? '/index.html' : name)); + if (!file.startsWith(root + '/')) throw Error('path'); + const bytes = await readFile(file); + res.writeHead(200, { 'Content-Type': types[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }); + res.end(bytes); + } catch { res.writeHead(404); res.end('Absent'); } + }); + await new Promise(done => server.listen(0, '127.0.0.1', done)); + const address = server.address(); + if (!address || typeof address === 'string') throw Error('local server'); + const origin = `http://127.0.0.1:${address.port}`; + let closed = false; + try { + await page.goto(origin + '/'); + await expect(page.locator('#hero-title')).toBeVisible(); + // L'application réserve son auto-enregistrement à HTTPS. Loopback est un + // contexte sûr : enregistrer explicitement le même fichier sans le modifier. + await page.evaluate(async () => { + await navigator.serviceWorker.register('./sw.js'); + await navigator.serviceWorker.ready; + if (!navigator.serviceWorker.controller) await new Promise(done => navigator.serviceWorker.addEventListener('controllerchange', () => done(), { once: true })); + }); + const keys = await page.evaluate(async () => { + const cache = await caches.open('cv-shell-v4'); + return (await cache.keys()).map(k => new URL(k.url).pathname); + }); + expect(keys.some(k => /\/assets\/.*\.js$/.test(k)), 'JS du build disponible dès la première installation').toBe(true); + // Couper le SERVEUR teste une vraie indisponibilité TCP, sans dépendre + // de l'émulation setOffline de Playwright/WebKit. + await new Promise(done => { server.close(() => done()); server.closeAllConnections(); }); + closed = true; + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(page.locator('#hero-title')).toBeVisible(); + await expect(page.getByText('Démonstration : données fictives')).toBeVisible(); + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(page.locator('#hero-title')).toBeVisible(); + } finally { + if (!closed) await new Promise(done => { server.close(() => done()); server.closeAllConnections(); }); + } +}); diff --git a/tests/unit/service-worker.test.ts b/tests/unit/service-worker.test.ts index 18fdfdc..81fd71a 100644 --- a/tests/unit/service-worker.test.ts +++ b/tests/unit/service-worker.test.ts @@ -11,7 +11,7 @@ type TestEvent = { request?: { method: string; mode: string; url: string }; }; -function harness(initial: string[], options: { fetch?: () => Promise; hit?: boolean; failPut?: boolean; origin?: string; scope?: string } = {}) { +function harness(initial: string[], options: { fetch?: () => Promise; hit?: boolean; failPut?: boolean; origin?: string; scope?: string; cachedUrl?: string } = {}) { const names = new Set(initial); const deleted: string[] = []; const listeners = new Map void>(); @@ -37,7 +37,7 @@ function harness(initial: string[], options: { fetch?: () => Promise; h names.add(key); return { addAll: async (_urls: string[]) => {}, put: async (request: unknown) => { if (options.failPut) throw new Error('Quota fictif'); written.push({ cache: key, request }); }, - match: async (request: string) => { matched.push(key + ':' + request); return options.hit === false ? undefined : { offlineShell: true }; } }; + match: async (request: string) => { matched.push(key + ':' + request); return options.hit === false ? undefined : { offlineShell: true, ok: true, url: options.cachedUrl ?? self.registration.scope, headers: { get: () => 'text/html' } }; } }; }, match: async (key: string) => { matched.push(key); @@ -87,7 +87,7 @@ describe('Service Worker : isolation interprojets', () => { respondWith: (promise) => { response = promise; }, }); expect(response).toBeDefined(); - await expect(response).resolves.toEqual({ offlineShell: true }); + await expect(response).resolves.toMatchObject({ offlineShell: true, url: 'https://ipoower.github.io/Control-Vault/' }); expect(h.matched).toEqual(['cv-shell-v4:./index.html']); expect(h.deleted).toEqual([]); }); @@ -106,7 +106,7 @@ describe('Service Worker : isolation interprojets', () => { expect(responded).toBe(false); }); - const response = (ok = true, type = 'text/html') => ({ ok, headers: { get: () => type }, clone() { return this; } }); + const response = (ok = true, type = 'text/html', url = 'https://ipoower.github.io/Control-Vault/') => ({ ok, url, headers: { get: () => type }, clone() { return this; } }); const navigation = { method: 'GET', mode: 'navigate', url: 'https://ipoower.github.io/Control-Vault/' }; async function request(h: ReturnType, request = navigation) { let pending: Promise | undefined; @@ -132,6 +132,26 @@ describe('Service Worker : isolation interprojets', () => { expect(await request(h)).toBe(res); expect(h.matched).toEqual([]); }); + it('refuse de persister les redirections HTML externes, secondaires ou sans URL finale', async () => { + for (const url of ['https://outside.invalid/login', 'https://ipoower.github.io/Control-Vault/signin.html', '']) { + const h = harness([], { fetch: async () => response(true, 'text/html', url) }); + await request(h); + expect(h.written, 'réponse non autorisée : ' + url).toEqual([]); + } + }); + it('refuse un ancien cache HTML provenant d’une redirection secondaire ou externe', async () => { + for (const cachedUrl of ['https://outside.invalid/login', 'https://ipoower.github.io/Control-Vault/signin.html', '']) { + const h = harness(['cv-shell-v4'], { cachedUrl }); + await expect(request(h)).rejects.toThrow('Aucun shell hors ligne vérifié'); + } + }); + it('actualise aussi le shell légitime sur l’origine Cloudflare TEST', async () => { + const origin = 'https://cv-drive-test-ipoower.pages.dev'; + const res = response(true, 'text/html; charset=utf-8', origin + '/index.html'); + const h = harness([], { origin, scope: origin + '/', fetch: async () => res }); + expect(await request(h, { ...navigation, url: origin + '/' })).toBe(res); + expect(h.written).toEqual([{ cache: 'cv-shell-v4', request: './index.html' }]); + }); it('met en cache les assets du seul chemin applicatif et uniquement après réponse valide', async () => { const res = response(true, 'text/javascript'), h = harness(['twrc-shell-v17'], { fetch: async () => res, hit: false }); const asset = { ...navigation, mode: 'cors', url: 'https://ipoower.github.io/Control-Vault/assets/fiction.js' }; From f0b93ab1588f322b1ccb6a7ad074ad1c755cb73c Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:50:36 +0100 Subject: [PATCH 2/2] fix(test-pwa): precache exact build assets before first offline reopening --- public/sw.js | 5 ++++- vite.config.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/public/sw.js b/public/sw.js index c58cb4c..d224b13 100644 --- a/public/sw.js +++ b/public/sw.js @@ -7,6 +7,9 @@ const CACHE_PREFIX = 'cv-shell-'; // Renouveler le namespace protège contre un ancien shell déjà remplacé par une page annexe. // La migration efface uniquement les anciens caches cv-shell-, jamais les autres applications. const VERSION = `${CACHE_PREFIX}v4`; +// Vite remplit cette liste avec les noms réellement produits (JS/CSS/polices). +// Sans eux le HTML seul ne permet pas un premier redémarrage hors ligne. +const BUILD_ASSETS = /* @build-assets */ []; const APP_PATH = new URL(self.registration.scope).pathname; const cached = (request) => caches.open(VERSION).then((cache) => cache.match(request)); // L'URL finale est la preuve de provenance, après toutes les redirections. @@ -34,7 +37,7 @@ async function remember(request, response) { } self.addEventListener('install', (event) => { - event.waitUntil(caches.open(VERSION).then((c) => c.addAll(['./', './index.html', './manifest.webmanifest', './icons/icon.svg']))); + event.waitUntil(caches.open(VERSION).then((c) => c.addAll(['./', './index.html', './manifest.webmanifest', './icons/icon.svg', ...BUILD_ASSETS]))); self.skipWaiting(); }); diff --git a/vite.config.ts b/vite.config.ts index 1fe2905..0837c45 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { writeFileSync, readFileSync } from 'node:fs'; +import { writeFileSync, readFileSync, readdirSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { resolve } from 'node:path'; import { defineConfig, type Plugin } from 'vite'; @@ -17,6 +17,14 @@ function buildStamp(): Plugin { name: 'control-vault-version', apply: 'build', closeBundle() { + const assets = readdirSync(resolve(out, 'assets'), { withFileTypes: true }).map(entry => { + if (!entry.isFile() || !/\.(?:js|css|woff2|png|svg)$/.test(entry.name)) throw new Error('Asset de build inattendu'); + return './assets/' + entry.name; + }).sort(); + const worker = resolve(out, 'sw.js'), source = readFileSync(worker, 'utf8'); + const marker = '/* @build-assets */ []'; + if (!source.includes(marker) || !assets.some(name => name.endsWith('.js'))) throw new Error('Précache incomplet'); + writeFileSync(worker, source.replace(marker, JSON.stringify(assets))); const sha = /^[0-9a-f]{40}$/.test(process.env.BUILD_SHA ?? '') ? process.env.BUILD_SHA : 'local'; const bytes = JSON.stringify(config); writeFileSync(resolve(out, 'drive-config.json'), bytes);