Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 20 additions & 3 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,25 @@ 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.
// 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);
Expand All @@ -20,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();
});

Expand All @@ -46,12 +63,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;
}
Expand Down
52 changes: 52 additions & 0 deletions tests/e2e/service-worker.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { '.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<void>(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<void>(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<void>(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<void>(done => { server.close(() => done()); server.closeAllConnections(); });
}
});
28 changes: 24 additions & 4 deletions tests/unit/service-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type TestEvent = {
request?: { method: string; mode: string; url: string };
};

function harness(initial: string[], options: { fetch?: () => Promise<unknown>; hit?: boolean; failPut?: boolean; origin?: string; scope?: string } = {}) {
function harness(initial: string[], options: { fetch?: () => Promise<unknown>; hit?: boolean; failPut?: boolean; origin?: string; scope?: string; cachedUrl?: string } = {}) {
const names = new Set(initial);
const deleted: string[] = [];
const listeners = new Map<string, (event: TestEvent) => void>();
Expand All @@ -37,7 +37,7 @@ function harness(initial: string[], options: { fetch?: () => Promise<unknown>; 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);
Expand Down Expand Up @@ -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([]);
});
Expand All @@ -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<typeof harness>, request = navigation) {
let pending: Promise<unknown> | undefined;
Expand All @@ -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' };
Expand Down
10 changes: 9 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand Down
Loading