From 9f222ac78aee0135dffa690c3a4ea13583fbde1a Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:45:23 +0100 Subject: [PATCH 1/9] test(fortress): attest synthetic test bundle SHA-256 --- scripts/verify-fortress-artifact.mjs | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 scripts/verify-fortress-artifact.mjs diff --git a/scripts/verify-fortress-artifact.mjs b/scripts/verify-fortress-artifact.mjs new file mode 100644 index 0000000..19659d3 --- /dev/null +++ b/scripts/verify-fortress-artifact.mjs @@ -0,0 +1,31 @@ +import {readFileSync,readdirSync,lstatSync,appendFileSync} from 'node:fs'; +import {resolve,relative,join} from 'node:path'; +import {createHash} from 'node:crypto'; +const check=(yes,why)=>{if(!yes)throw new Error('FORTRESS TEST: '+why)}; +const root=resolve(process.cwd()); +check(process.argv[2] && /^[0-9a-f]{40}$/.test(process.env.BUILD_SHA||''),'receipt and source SHA required'); +const receipt=JSON.parse(readFileSync(process.argv[2],'utf8')); +check(typeof receipt.site==='string','site missing'); +const site=resolve(receipt.site); +check(/^\.drive-test-profile-[^/]+\/site$/.test(relative(root,site).replaceAll('\\','/')),'unexpected site folder'); +check(lstatSync(site).isDirectory() && !lstatSync(site).isSymbolicLink(),'site not directory'); +const v=JSON.parse(readFileSync(join(site,'version.json'),'utf8')); +const m=JSON.parse(readFileSync(join(site,'fixture-manifest.json'),'utf8')); +const sha=process.env.BUILD_SHA; +check(receipt.sourceSha===sha && v.sha===sha && m.sourceSha===sha,'source SHA mismatch'); +check(receipt.googleEnabled===Boolean(process.env.FORTRESS_TEST_GOOGLE_CONFIG),'configuration mismatch'); +check(v.googleEnabled===receipt.googleEnabled && m.googleEnabled===receipt.googleEnabled,'Google config mismatch'); +for(const obj of [v,m])check(obj.environment==='isolated-test' && obj.fixtureOnly===true,'not synthetic TEST'); +check(m.raceOrigin==='https://rc-drive-test-ipoower.pages.dev' && m.vaultOrigin==='https://cv-drive-test-ipoower.pages.dev','origin mismatch'); +check(m.files && typeof m.files==='object' && !Array.isArray(m.files),'no manifest'); +const files=[]; +function walk(dir,prefix=''){for(const e of readdirSync(dir,{withFileTypes:true})){const n=prefix+e.name,full=join(dir,e.name),s=lstatSync(full);check(!s.isSymbolicLink(),'symlink: '+n);if(s.isDirectory())walk(full,n+'/');else{check(s.isFile(),'nonfile: '+n);files.push(n)}}} +walk(site); +const actual=files.filter(n=>n!=='fixture-manifest.json').sort(); +check(JSON.stringify(actual)===JSON.stringify(Object.keys(m.files).sort()),'manifest entries differ'); +for(const name of actual){check(createHash('sha256').update(readFileSync(join(site,name))).digest('hex')===m.files[name],'file SHA mismatch: '+name);check(!/(^|\/)(encrypted|private|personal|\.env|\.git|node_modules)(\/|$)/i.test(name),'private path: '+name)} +for(const name of ['index.html','drive-config.json','version.json'])check(actual.includes(name),'missing: '+name); +check(actual.length>=6,'implausibly small build'); +check(typeof process.env.GITHUB_OUTPUT==='string','CI output missing'); +appendFileSync(process.env.GITHUB_OUTPUT,'site_dir='+site+'\n'); +console.log('FORTRESS TEST: verified '+actual.length+' synthetic files, origin, version and SHA-256.'); From 37f67a2e83bc0989f8372638fe0076b79e9b23be Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:45:25 +0100 Subject: [PATCH 2/9] ci(fortress): build PR-only test bundle with strict provenance --- .github/workflows/fortress-test-bundle.yml | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/fortress-test-bundle.yml diff --git a/.github/workflows/fortress-test-bundle.yml b/.github/workflows/fortress-test-bundle.yml new file mode 100644 index 0000000..38a5b0c --- /dev/null +++ b/.github/workflows/fortress-test-bundle.yml @@ -0,0 +1,46 @@ +name: FORTRESS TEST - attested synthetic bundle +on: + pull_request: + types: [opened, reopened, synchronize] +permissions: + contents: read +concurrency: + group: fortress-bundle-${{ github.event.pull_request.head.sha }} + cancel-in-progress: true +jobs: + bundle: + if: github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'test/fortress-drive-execution-') + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout exact PR head, never merge ref + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - name: Configure Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 + with: + node-version: '22' + cache: npm + - name: Dependencies from lockfile + run: npm ci --no-audit --no-fund + - id: verify + name: Build fake-only TEST and verify every SHA-256 + env: + BUILD_SHA: ${{ github.event.pull_request.head.sha }} + FORTRESS_TEST_GOOGLE_CONFIG: ${{ vars.FORTRESS_TEST_GOOGLE_CONFIG }} + GOOGLE_DRIVE_CONFIG: ${{ vars.FORTRESS_TEST_GOOGLE_CONFIG }} + run: | + node scripts/build-drive-test.mjs > "$RUNNER_TEMP/fortress-build-receipt.json" + node scripts/verify-fortress-artifact.mjs "$RUNNER_TEMP/fortress-build-receipt.json" + - name: Upload verified synthetic TEST bundle only + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 + with: + name: fortress-control-vault-test-${{ github.event.pull_request.head.sha }} + path: ${{ steps.verify.outputs.site_dir }}/ + if-no-files-found: error + include-hidden-files: true + retention-days: 14 + +# No deploy, secrets, production environments, real data, OAuth or real Drive calls. From 461a56707b6a0a7dd0de71d5d701b5179e1d2c97 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:49:36 +0100 Subject: [PATCH 3/9] fix(ci): preserve unique pipeline invariant; fold FORTRESS artifact into pipeline --- .github/workflows/fortress-test-bundle.yml | 46 ---------------------- 1 file changed, 46 deletions(-) delete mode 100644 .github/workflows/fortress-test-bundle.yml diff --git a/.github/workflows/fortress-test-bundle.yml b/.github/workflows/fortress-test-bundle.yml deleted file mode 100644 index 38a5b0c..0000000 --- a/.github/workflows/fortress-test-bundle.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: FORTRESS TEST - attested synthetic bundle -on: - pull_request: - types: [opened, reopened, synchronize] -permissions: - contents: read -concurrency: - group: fortress-bundle-${{ github.event.pull_request.head.sha }} - cancel-in-progress: true -jobs: - bundle: - if: github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'test/fortress-drive-execution-') - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - name: Checkout exact PR head, never merge ref - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - - name: Configure Node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 - with: - node-version: '22' - cache: npm - - name: Dependencies from lockfile - run: npm ci --no-audit --no-fund - - id: verify - name: Build fake-only TEST and verify every SHA-256 - env: - BUILD_SHA: ${{ github.event.pull_request.head.sha }} - FORTRESS_TEST_GOOGLE_CONFIG: ${{ vars.FORTRESS_TEST_GOOGLE_CONFIG }} - GOOGLE_DRIVE_CONFIG: ${{ vars.FORTRESS_TEST_GOOGLE_CONFIG }} - run: | - node scripts/build-drive-test.mjs > "$RUNNER_TEMP/fortress-build-receipt.json" - node scripts/verify-fortress-artifact.mjs "$RUNNER_TEMP/fortress-build-receipt.json" - - name: Upload verified synthetic TEST bundle only - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 - with: - name: fortress-control-vault-test-${{ github.event.pull_request.head.sha }} - path: ${{ steps.verify.outputs.site_dir }}/ - if-no-files-found: error - include-hidden-files: true - retention-days: 14 - -# No deploy, secrets, production environments, real data, OAuth or real Drive calls. From d58b1d33bde08e2c5140e4e2a3b32e76b8da1a19 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:49:55 +0100 Subject: [PATCH 4/9] ci(fortress): add verified fake TEST bundle to unique protected pipeline --- .github/workflows/pipeline.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 4e9a81b..62c5484 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -111,6 +111,27 @@ jobs: run: npm run test:e2e env: CI: '1' + # FORTRESS : paquet synthétique atteste pour la seule PR d'exécution TEST. + # Aucun secret, client personnel, upload Cloudflare ou requête Google ici. + - id: fortress_attest + name: FORTRESS - attester un bundle TEST synthétique + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'test/fortress-drive-execution-20261026' + env: + BUILD_SHA: ${{ github.event.pull_request.head.sha }} + FORTRESS_TEST_GOOGLE_CONFIG: ${{ vars.FORTRESS_TEST_GOOGLE_CONFIG }} + GOOGLE_DRIVE_CONFIG: ${{ vars.FORTRESS_TEST_GOOGLE_CONFIG }} + run: | + node scripts/build-drive-test.mjs > "$RUNNER_TEMP/fortress-build-receipt.json" + node scripts/verify-fortress-artifact.mjs "$RUNNER_TEMP/fortress-build-receipt.json" + - name: FORTRESS - conserver les octets TEST attestes (sans deployer) + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'test/fortress-drive-execution-20261026' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: fortress-control-vault-test-${{ github.event.pull_request.head.sha }} + path: ${{ steps.fortress_attest.outputs.site_dir }}/ + include-hidden-files: true + if-no-files-found: error + retention-days: 14 - name: Captures d'écran des parcours (données fictives uniquement) if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 — SHA vérifié le 23/10/2026 From bb29de62429feb9cdc8d81559493fbf59953919d Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:45:33 +0100 Subject: [PATCH 5/9] test(fortress): fail closed unless configured Google TEST is enabled --- scripts/verify-fortress-artifact.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/verify-fortress-artifact.mjs b/scripts/verify-fortress-artifact.mjs index 19659d3..e3102d7 100644 --- a/scripts/verify-fortress-artifact.mjs +++ b/scripts/verify-fortress-artifact.mjs @@ -14,6 +14,8 @@ const m=JSON.parse(readFileSync(join(site,'fixture-manifest.json'),'utf8')); const sha=process.env.BUILD_SHA; check(receipt.sourceSha===sha && v.sha===sha && m.sourceSha===sha,'source SHA mismatch'); check(receipt.googleEnabled===Boolean(process.env.FORTRESS_TEST_GOOGLE_CONFIG),'configuration mismatch'); +// The owner configured TEST OAuth: fail closed instead of certifying Google-off artifacts. +check(receipt.googleEnabled===true,'Google TEST config missing or disabled: refused'); check(v.googleEnabled===receipt.googleEnabled && m.googleEnabled===receipt.googleEnabled,'Google config mismatch'); for(const obj of [v,m])check(obj.environment==='isolated-test' && obj.fixtureOnly===true,'not synthetic TEST'); check(m.raceOrigin==='https://rc-drive-test-ipoower.pages.dev' && m.vaultOrigin==='https://cv-drive-test-ipoower.pages.dev','origin mismatch'); From e809e1c2c3b350006532ad3ee1480f1b080242a3 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:01:58 +0100 Subject: [PATCH 6/9] ci(test): verify Google-enabled synthetic artifact in parallel, preserving unique pipeline --- .github/workflows/pipeline.yml | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 62c5484..10bd665 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -31,6 +31,43 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} 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' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout exact PR head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - name: Configure Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 + with: + node-version: '22' + cache: npm + - name: Install pinned dependencies + run: npm ci --no-audit --no-fund + - id: fortress + name: Build and attest Google-enabled fake-only TEST + env: + BUILD_SHA: ${{ github.event.pull_request.head.sha }} + FORTRESS_TEST_GOOGLE_CONFIG: ${{ vars.FORTRESS_TEST_GOOGLE_CONFIG }} + GOOGLE_DRIVE_CONFIG: ${{ vars.FORTRESS_TEST_GOOGLE_CONFIG }} + run: | + node scripts/build-drive-test.mjs > "$RUNNER_TEMP/fortress-build-receipt.json" + node scripts/verify-fortress-artifact.mjs "$RUNNER_TEMP/fortress-build-receipt.json" + - name: Archive attested synthetic TEST only + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: fortress-control-vault-test-${{ github.event.pull_request.head.sha }} + path: ${{ steps.fortress.outputs.site_dir }}/ + include-hidden-files: true + if-no-files-found: error + retention-days: 14 codeql: name: CodeQL JavaScript/TypeScript runs-on: ubuntu-24.04 From 956005e478ec085d457b6781e230fb6851c91541 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:05:15 +0100 Subject: [PATCH 7/9] ci(fortress): single attested TEST artifact job; prevent duplicate upload conflict --- .github/workflows/pipeline.yml | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 10bd665..e2c7e52 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -148,27 +148,6 @@ jobs: run: npm run test:e2e env: CI: '1' - # FORTRESS : paquet synthétique atteste pour la seule PR d'exécution TEST. - # Aucun secret, client personnel, upload Cloudflare ou requête Google ici. - - id: fortress_attest - name: FORTRESS - attester un bundle TEST synthétique - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'test/fortress-drive-execution-20261026' - env: - BUILD_SHA: ${{ github.event.pull_request.head.sha }} - FORTRESS_TEST_GOOGLE_CONFIG: ${{ vars.FORTRESS_TEST_GOOGLE_CONFIG }} - GOOGLE_DRIVE_CONFIG: ${{ vars.FORTRESS_TEST_GOOGLE_CONFIG }} - run: | - node scripts/build-drive-test.mjs > "$RUNNER_TEMP/fortress-build-receipt.json" - node scripts/verify-fortress-artifact.mjs "$RUNNER_TEMP/fortress-build-receipt.json" - - name: FORTRESS - conserver les octets TEST attestes (sans deployer) - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'test/fortress-drive-execution-20261026' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: fortress-control-vault-test-${{ github.event.pull_request.head.sha }} - path: ${{ steps.fortress_attest.outputs.site_dir }}/ - include-hidden-files: true - if-no-files-found: error - retention-days: 14 - name: Captures d'écran des parcours (données fictives uniquement) if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 — SHA vérifié le 23/10/2026 From 28767b29a53436ef7609dac92aa482410f1af728 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:53:45 +0100 Subject: [PATCH 8/9] fix(test): keep lab app navigation within isolated origins --- scripts/build-drive-test.mjs | 2 +- src/main.ts | 4 ++-- tests/unit/drive-test-profile.test.ts | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/build-drive-test.mjs b/scripts/build-drive-test.mjs index 18736a5..c516143 100644 --- a/scripts/build-drive-test.mjs +++ b/scripts/build-drive-test.mjs @@ -15,7 +15,7 @@ let cfg = null; try { if (text) cfg = GoogleDriveSession.config(JSON.parse(text), VAULT); } catch { throw new Error('Configuration publique TEST invalide.'); } if (cfg && (cfg.raceOrigin !== RACE || cfg.vaultOrigin !== VAULT)) throw new Error('Origines TEST exactes requises.'); const base = fs.mkdtempSync(path.join(ROOT, '.drive-test-profile-')), site = path.join(base, 'site'); -try { execFileSync(process.execPath, ['node_modules/vite/bin/vite.js', 'build'], { cwd: ROOT, env: { ...process.env, VAULT_OUT: site, BUILD_SHA: sha, VAULT_DEDICATED_BUILD: '1', GOOGLE_DRIVE_CONFIG: text }, stdio: 'pipe', timeout: 120000 }); } +try { execFileSync(process.execPath, ['node_modules/vite/bin/vite.js', 'build', '--mode', 'fortress-test'], { cwd: ROOT, env: { ...process.env, VAULT_OUT: site, BUILD_SHA: sha, VAULT_DEDICATED_BUILD: '1', GOOGLE_DRIVE_CONFIG: text }, stdio: 'pipe', timeout: 120000 }); } catch { throw new Error('Build TEST refusé ; aucune sortie brute affichée.'); } const p = name => path.join(site, name); let html = fs.readFileSync(p('index.html'), 'utf8'); diff --git a/src/main.ts b/src/main.ts index 3b12111..df43cfc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -79,9 +79,9 @@ const shell = h( h('nav', { class: 'app-links', 'aria-label': 'Mes applications' }, h('strong', null, 'Mes applications'), h('div', null, - h('a', { href: 'https://race-control-ipoower.pages.dev/', target: '_blank', rel: 'noopener noreferrer', 'aria-label': 'Race Control (nouvel onglet)' }, 'Race Control ↗'), + h('a', { href: import.meta.env.MODE === 'fortress-test' ? 'https://rc-drive-test-ipoower.pages.dev/' : 'https://race-control-ipoower.pages.dev/', target: '_blank', rel: 'noopener noreferrer', 'aria-label': 'Race Control (nouvel onglet)' }, 'Race Control ↗'), h('span', { 'aria-current': 'page' }, 'Control Vault'), - h('a', { href: 'https://ipoower.github.io/Reconversion-Control/', target: '_blank', rel: 'noopener noreferrer', 'aria-label': 'Reconversion Control (nouvel onglet)' }, 'Reconversion Control ↗'), + import.meta.env.MODE !== 'fortress-test' && h('a', { href: 'https://ipoower.github.io/Reconversion-Control/', target: '_blank', rel: 'noopener noreferrer', 'aria-label': 'Reconversion Control (nouvel onglet)' }, 'Reconversion Control ↗'), ), ), ), diff --git a/tests/unit/drive-test-profile.test.ts b/tests/unit/drive-test-profile.test.ts index b10d27e..71a2761 100644 --- a/tests/unit/drive-test-profile.test.ts +++ b/tests/unit/drive-test-profile.test.ts @@ -12,6 +12,10 @@ it('publie uniquement un profil TEST identifié, vérifiable et fermé sans clie const manifest = JSON.parse(readFileSync(join(data.site, 'fixture-manifest.json'), 'utf8')); expect(manifest.fixtureOnly).toBe(true); expect(manifest.sourceSha).toBe('a'.repeat(40)); expect(manifest.googleEnabled).toBe(false); expect(readFileSync(join(data.site, 'index.html'), 'utf8')).toContain('TEST — DONNÉES FICTIVES UNIQUEMENT'); + const scripts = Object.keys(manifest.files).filter(name => name.endsWith('.js')); + const code = scripts.map(name => readFileSync(join(data.site, name), 'utf8')).join('\n'); + expect(code).not.toMatch(/https:\/\/(?:race-control-ipoower\.pages\.dev|ipoower\.github\.io\/Reconversion-Control)\//); + expect(code).toContain('https://rc-drive-test-ipoower.pages.dev/'); expect(readFileSync(join(data.site, 'drive-config.json'), 'utf8')).toBe('null'); for (const [name, hash] of Object.entries(manifest.files)) expect(createHash('sha256').update(readFileSync(join(data.site, name))).digest('hex')).toBe(hash); expect(run(data.site)).toEqual([]); From ac7e7af06c9bca68066717a7b45c18ced1dd91af Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:20:40 +0100 Subject: [PATCH 9/9] fix(fortress-test): import independently red-green-tested CV SW v4 and Cloudflare scope regressions [TEST ONLY] --- public/sw.js | 8 ++++- tests/unit/service-worker.test.ts | 59 ++++++++++++++++++++++++------- 2 files changed, 54 insertions(+), 13 deletions(-) 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); + } + }); + });