diff --git a/public/sw.js b/public/sw.js index a3d98d8..a290f1b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -4,7 +4,9 @@ // CacheStorage est partagé par toutes les applications d'une même origine GitHub Pages. // Ne jamais supprimer les caches Race Control ou les caches tiers. const CACHE_PREFIX = 'cv-shell-'; -const VERSION = `${CACHE_PREFIX}v3`; +// 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`; const APP_PATH = new URL(self.registration.scope).pathname; const cached = (request) => caches.open(VERSION).then((cache) => cache.match(request)); async function remember(request, response) { @@ -37,6 +39,10 @@ self.addEventListener('fetch', (event) => { // Navigation : réseau d'abord (toujours la dernière version), cache en secours hors ligne. if (req.mode === 'navigate') { + // Une page de reprise, une configuration ou tout autre document HTML ne doit + // jamais écraser ./index.html dans CacheStorage. Seuls les alias du shell + // bénéficient du repli hors ligne. + if (![APP_PATH, APP_PATH + 'index', APP_PATH + 'index.html'].includes(url.pathname)) return; event.respondWith( fetch(req) .then((res) => { diff --git a/tests/unit/service-worker.test.ts b/tests/unit/service-worker.test.ts index 8e9734b..18fdfdc 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 } = {}) { +function harness(initial: string[], options: { fetch?: () => Promise; hit?: boolean; failPut?: boolean; origin?: string; scope?: string } = {}) { const names = new Set(initial); const deleted: string[] = []; const listeners = new Map void>(); @@ -20,8 +20,8 @@ function harness(initial: string[], options: { fetch?: () => Promise; h let claimed = false; const self = { - location: { origin: 'https://ipoower.github.io' }, - registration: { scope: 'https://ipoower.github.io/Control-Vault/' }, + location: { origin: options.origin ?? 'https://ipoower.github.io' }, + registration: { scope: options.scope ?? 'https://ipoower.github.io/Control-Vault/' }, addEventListener: (type: string, fn: (event: TestEvent) => void) => listeners.set(type, fn), skipWaiting: () => {}, clients: { claim: async () => { claimed = true; } }, @@ -57,7 +57,7 @@ function harness(initial: string[], options: { fetch?: () => Promise; h describe('Service Worker : isolation interprojets', () => { it('ne supprime que les ANCIENS caches Control Vault et préserve Race Control et les autres applications', async () => { - const h = harness(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2', 'cv-shell-v3', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache']); + const h = harness(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2', 'cv-shell-v3', 'cv-shell-v4', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache']); const activate = h.listeners.get('activate'); expect(activate).toBeTypeOf('function'); let pending: Promise | undefined; @@ -65,8 +65,8 @@ describe('Service Worker : isolation interprojets', () => { expect(pending).toBeDefined(); await pending; - expect(h.deleted.sort()).toEqual(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2']); - expect([...h.names].sort()).toEqual(['cv-shell-v3', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache'].sort()); + expect(h.deleted.sort()).toEqual(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2', 'cv-shell-v3']); + expect([...h.names].sort()).toEqual(['cv-shell-v4', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache'].sort()); expect(h.isClaimed()).toBe(true); }); @@ -80,7 +80,7 @@ describe('Service Worker : isolation interprojets', () => { }); it('utilise encore la page hors ligne en cas d’échec réseau', async () => { - const h = harness(['cv-shell-v3', 'twrc-shell-v17']); + const h = harness(['cv-shell-v4', 'twrc-shell-v17']); let response: Promise | undefined; h.listeners.get('fetch')?.({ request: { method: 'GET', mode: 'navigate', url: 'https://ipoower.github.io/Control-Vault/' }, @@ -88,12 +88,12 @@ describe('Service Worker : isolation interprojets', () => { }); expect(response).toBeDefined(); await expect(response).resolves.toEqual({ offlineShell: true }); - expect(h.matched).toEqual(['cv-shell-v3:./index.html']); + expect(h.matched).toEqual(['cv-shell-v4:./index.html']); expect(h.deleted).toEqual([]); }); it('ignore les requêtes externes et non GET', () => { - const h = harness(['cv-shell-v3', 'twrc-shell-v17']); + const h = harness(['cv-shell-v4', 'twrc-shell-v17']); let responded = false; for (const request of [ { method: 'POST', mode: 'navigate', url: 'https://ipoower.github.io/Control-Vault/' }, @@ -117,7 +117,7 @@ describe('Service Worker : isolation interprojets', () => { it('attend la mise en cache de l’interface réseau dans son seul cache', async () => { const res = response(), h = harness(['twrc-shell-v17'], { fetch: async () => res }); expect(await request(h)).toBe(res); - expect(h.written).toEqual([{ cache: 'cv-shell-v3', request: './index.html' }]); + expect(h.written).toEqual([{ cache: 'cv-shell-v4', request: './index.html' }]); expect(h.deleted).toEqual([]); }); it('ne remplace pas son interface hors ligne par une erreur HTTP ou une réponse JSON', async () => { @@ -136,7 +136,42 @@ describe('Service Worker : isolation interprojets', () => { 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' }; expect(await request(h, asset)).toBe(res); - expect(h.written).toEqual([{ cache: 'cv-shell-v3', request: asset }]); - expect(h.matched.every(k => k.startsWith('cv-shell-v3:'))).toBe(true); + expect(h.written).toEqual([{ cache: 'cv-shell-v4', request: asset }]); + expect(h.matched.every(k => k.startsWith('cv-shell-v4:'))).toBe(true); }); + it('ne remplace jamais le shell par une navigation vers /recovery ou un JSON de configuration', () => { + const h = harness(['cv-shell-v4'], { fetch: async () => response(true, 'text/html') }); + const paths = ['recovery', 'recovery.html', 'drive-config.json', 'fixture-manifest.json']; + for (const path of paths) { + let intercepted = false; + h.listeners.get('fetch')?.({ + request: { method: 'GET', mode: 'navigate', url: 'https://ipoower.github.io/Control-Vault/' + path }, + respondWith: () => { intercepted = true; }, + }); + expect(intercepted, 'le navigateur doit aller directement au réseau pour ' + path).toBe(false); + } + expect(h.written).toEqual([]); + }); + + it('applique aussi les frontières TEST Cloudflare à la racine, sans bloquer le shell', () => { + const origin = 'https://cv-drive-test-ipoower.pages.dev'; + const h = harness(['cv-shell-v3'], { origin, scope: origin + '/' }); + for (const pathname of ['/recovery', '/recovery.html', '/drive-config.json', '/fixture-manifest.json', '/autre-page.html']) { + let intercepted = false; + h.listeners.get('fetch')?.({ + request: { method: 'GET', mode: 'navigate', url: origin + pathname }, + respondWith: () => { intercepted = true; }, + }); + expect(intercepted, 'ne pas intercepter la route secondaire ' + pathname).toBe(false); + } + for (const pathname of ['/', '/index', '/index.html']) { + let intercepted = false; + h.listeners.get('fetch')?.({ + request: { method: 'GET', mode: 'navigate', url: origin + pathname }, + respondWith: () => { intercepted = true; }, + }); + expect(intercepted, 'la route shell doit rester interceptée ' + pathname).toBe(true); + } + }); + });