From c70609f71d2712bcb7d7fb409534596232d2c60c Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:20:21 +0200 Subject: [PATCH 01/39] fix(ci): pin verified action commits and Pages packaging --- .github/workflows/pipeline.yml | 28 +++++++---- docs/NOESIS31_CI_PROVENANCE_20261023.md | 49 ++++++++++++++++++ tests/unit/workflow-provenance.test.ts | 66 +++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 docs/NOESIS31_CI_PROVENANCE_20261023.md create mode 100644 tests/unit/workflow-provenance.test.ts diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index fac99ad..1d596ed 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -43,10 +43,10 @@ jobs: env: BUILD_SHA: ${{ github.sha }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 — SHA vérifié le 23/10/2026 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 — SHA vérifié le 23/10/2026 with: node-version: 22 cache: npm @@ -90,14 +90,14 @@ jobs: CI: '1' - name: Captures d'écran des parcours (données fictives uniquement) if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 — SHA vérifié le 23/10/2026 with: name: captures-parcours path: screenshots/ retention-days: 30 - name: Rapport Playwright if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 — SHA vérifié le 23/10/2026 with: name: rapport-playwright path: playwright-report/ @@ -105,17 +105,25 @@ jobs: # L'artefact publié est celui-ci : construit puis testé ci-dessus, pour ce commit. - name: Artefact vérifié (Cloudflare) if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 — SHA vérifié le 23/10/2026 with: name: site-verifie-${{ github.sha }} path: dist-cloudflare/ include-hidden-files: true retention-days: 7 + # Même archive Linux que upload-pages-artifact v3, sans son appel + # transitif mobile à upload-artifact@v4. Les octets de dist/ restent ceux testés. + - name: Archive vérifiée (GitHub Pages) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: tar --dereference --hard-dereference -C dist --exclude=.git --exclude=.github -cvf "$RUNNER_TEMP/artifact.tar" . - name: Artefact vérifié (GitHub Pages) if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 — SHA vérifié le 23/10/2026 with: - path: dist/ + name: github-pages + path: ${{ runner.temp }}/artifact.tar + retention-days: 1 + if-no-files-found: error gate: name: Porte de publication @@ -144,7 +152,7 @@ jobs: cancel-in-progress: false steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 — SHA vérifié le 23/10/2026 - name: Le site sert bien ce commit run: | url="${{ steps.deployment.outputs.page_url }}" @@ -170,13 +178,13 @@ jobs: cancel-in-progress: false steps: - name: Récupération de l'artefact vérifié de ce run - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 — SHA vérifié le 23/10/2026 with: name: site-verifie-${{ github.sha }} path: dist - name: L'artefact correspond au commit run: grep -q "\"sha\":\"${{ github.sha }}\"" dist/version.json - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 — SHA vérifié le 23/10/2026 with: node-version: 22 - name: Projet Cloudflare en envoi direct (créé une seule fois, sans connexion Git) diff --git a/docs/NOESIS31_CI_PROVENANCE_20261023.md b/docs/NOESIS31_CI_PROVENANCE_20261023.md new file mode 100644 index 0000000..5fe785c --- /dev/null +++ b/docs/NOESIS31_CI_PROVENANCE_20261023.md @@ -0,0 +1,49 @@ +# NOESIS 3.1 — références CI immuables — 23 octobre 2026 + +## Problème et périmètre + +Le pipeline utilisait des tags mobiles d'actions, y compris lors des publications. +Un déplacement amont peut ainsi changer le code exécuté sans modification du dépôt. +Ce correctif sensible est préparé pour revue humaine ; il ne doit pas être fusionné +automatiquement. Aucun réglage GitHub/Cloudflare, secret, OAuth ou fichier utilisateur +n'est modifié. Les conditions, permissions, environnements et gates de publication +restent inchangés. + +Les SHA ci-dessous sont ceux résolus dans les logs du [pipeline réussi 39928831251](https://github.com/iPoower/Control-Vault/actions/runs/39928831251), +comparés le 23/10/2026 aux références des dépôts officiels `actions/*`. +Aucune montée de version d'action n'est effectuée. + +| Action | Référence figée | +|---|---| +| actions/checkout v4 | `11d5960a326750d5838078e36cf38b85af677262` | +| actions/setup-node v4 | `49933ea5288caeca8642d1e84afbd3f7d6820020` | +| actions/upload-artifact v4 | `ea165f8d65b6e75b540449e92b4886f43607fa02` | +| actions/download-artifact v4 | `d3f86a106a0bac45b974a628896c90dbdf5c8093` | +| actions/deploy-pages v4 | `d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e` | + +Épingler seulement `upload-pages-artifact` ne suffit pas : son [action composite v3](https://github.com/actions/upload-pages-artifact/blob/56afc609e74202658d3ffba0e8f6dda462b719fa/action.yml) +appelle `upload-artifact@v4`. L'archive Linux est donc préparée directement avec GNU tar, +puis envoyée par l'uploader figé : même nom `github-pages`, fichier `artifact.tar`, +liens déréférencés, fichiers cachés inclus hors métadonnées Git, rétention d'artefact CI +d'un jour. Cette rétention concerne uniquement l'artefact de publication GitHub Actions, +pas des sauvegardes personnelles ; elle conserve la valeur par défaut précédente. + +## Vérification et limites + +Les tests `workflow-provenance.test.ts` refusent les références mobiles et la réintroduction +du composite. Ils exécutent le vrai conditionnement sur des fichiers fictifs, comparent +les octets extraits (HTML, version/configuration, assets et fichier caché) et contrôlent +le déréférencement d'un lien ainsi que l'exclusion de `.git`/`.github`. +Ils échouent avant le correctif. Les invariants existants du pipeline restent requis. + +La PR ne publie rien : les jobs de déploiement exigent toujours un push sur main après +la vérification complète. Une CI de PR verte ne démontre pas à elle seule le déploiement +du nouvel assemblage Pages. La recette de publication restera à effectuer après GO humain. + +Le runner, l'image Playwright et le lancement npx de Wrangler restent des dépendances de +confiance à examiner séparément. Ce correctif ne prétend pas verrouiller toute la chaîne +transitive npm ni empêcher une compromission du compte administrateur. + +Retour arrière : PR relue et CI complète, vers un état connu ; ne pas réintroduire +silencieusement des tags mobiles. Un retour de code ne restaure aucune donnée. +Référence : [GitHub, usage sécurisé des actions](https://docs.github.com/en/actions/reference/security/secure-use), consultée le 23/10/2026. diff --git a/tests/unit/workflow-provenance.test.ts b/tests/unit/workflow-provenance.test.ts new file mode 100644 index 0000000..e38ba47 --- /dev/null +++ b/tests/unit/workflow-provenance.test.ts @@ -0,0 +1,66 @@ +// Regression guards for action provenance and the Pages artifact contract. +// The archive test runs only on disposable files, never app/backup data. +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { parse } from 'yaml'; + +interface Step { name?: string; uses?: string; if?: string; run?: string; env?: Record; with?: Record } +const workflow = parse(readFileSync('.github/workflows/pipeline.yml', 'utf8')) as { jobs: Record }; +const steps = Object.values(workflow.jobs).flatMap(job => job.steps); +const verify = workflow.jobs.verify.steps; + +describe('provenance immuable du pipeline', () => { + it('toutes les actions tierces sont liées à un commit complet, jamais un tag ou une branche', () => { + const references = steps.flatMap(step => step.uses ? [step.uses] : []); + expect(references.length).toBeGreaterThan(0); + for (const reference of references) { + expect(reference).toMatch(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_./-]+)?@[0-9a-f]{40}$/); + } + }); + + it('Pages utilise directement un uploader figé, sans composite à référence transitive mobile', () => { + expect(steps.some(step => (step.uses ?? '').startsWith('actions/upload-pages-artifact@'))).toBe(false); + const archive = verify.find(step => step.name === 'Archive vérifiée (GitHub Pages)'); + const upload = verify.find(step => step.with?.name === 'github-pages'); + expect(archive?.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/main'"); + expect(upload?.if).toBe(archive?.if); + expect(upload?.uses).toMatch(/^actions\/upload-artifact@[0-9a-f]{40}$/); + expect(upload?.with?.path).toBe('${{ runner.temp }}/artifact.tar'); + expect(upload?.with?.['retention-days']).toBe(1); + expect(upload?.with?.['if-no-files-found']).toBe('error'); + }); + + it('le vrai conditionnement garde les octets, fichiers cachés et liens déréférencés, sans métadonnées Git', () => { + const archive = verify.find(step => step.name === 'Archive vérifiée (GitHub Pages)'); + expect(archive?.run).toBeTruthy(); + const root = mkdtempSync(join(tmpdir(), 'cv-pages-fixture-')); + try { + for (const dir of ['dist/assets', 'dist/.git', 'dist/.github', 'runner']) mkdirSync(join(root, dir), { recursive: true }); + const fixture: Record = { + 'index.html': '

fictional-build

', + 'version.json': '{"sha":"fictional-build"}', + 'drive-config.json': 'null', + '.hidden-fixture': 'included', + 'assets/app.js': 'console.log("fixture");', + }; + for (const [file, content] of Object.entries(fixture)) writeFileSync(join(root, 'dist', file), content); + writeFileSync(join(root, 'dist/.git/private'), 'must-not-be-packed'); + writeFileSync(join(root, 'dist/.github/private'), 'must-not-be-packed'); + symlinkSync('assets/app.js', join(root, 'dist/alias.js')); + const packed = spawnSync('sh', ['-ec', archive!.run!], { cwd: root, env: { ...process.env, RUNNER_TEMP: join(root, 'runner') }, encoding: 'utf8' }); + expect(packed.status, packed.stderr).toBe(0); + const tar = join(root, 'runner/artifact.tar'); + const names = execFileSync('tar', ['-tf', tar], { encoding: 'utf8' }).trim().split('\n'); + expect(names.some(name => /\/(?:\.git|\.github)(?:\/|$)/.test(name))).toBe(false); + for (const [file, content] of Object.entries(fixture)) { + expect(execFileSync('tar', ['-xOf', tar, './' + file], { encoding: 'utf8' })).toBe(content); + } + expect(execFileSync('tar', ['-xOf', tar, './alias.js'], { encoding: 'utf8' })).toBe(fixture['assets/app.js']); + const listing = execFileSync('tar', ['-tvf', tar], { encoding: 'utf8' }).split('\n'); + expect(listing.filter(line => line.includes('./alias.js')).every(line => line.startsWith('-'))).toBe(true); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +}); From 2b2dda97790971b5395aafacf36c29f1c303f16c Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:58:15 +0200 Subject: [PATCH 02/39] fix(drive): require confirmed test account and dedicated destination --- docs/DRIVE_ACCOUNT_BOUNDARY_20261023.md | 47 ++++++++++ src/services/connected-drive.ts | 47 ++++++---- src/services/google-drive-session.d.ts | 2 +- src/services/google-drive-session.js | 46 +++++++--- src/services/opaque-drive.ts | 49 ++++++++--- src/views/drive.ts | 10 ++- tests/e2e/connected-drive.spec.ts | 21 +++-- tests/e2e/opaque-drive.spec.ts | 2 +- tests/unit/drive-boundary.test.ts | 111 ++++++++++++++++++++++++ tests/unit/drive-connection.test.ts | 4 +- tests/unit/opaque-drive.test.ts | 8 +- 11 files changed, 296 insertions(+), 51 deletions(-) create mode 100644 docs/DRIVE_ACCOUNT_BOUNDARY_20261023.md create mode 100644 tests/unit/drive-boundary.test.ts diff --git a/docs/DRIVE_ACCOUNT_BOUNDARY_20261023.md b/docs/DRIVE_ACCOUNT_BOUNDARY_20261023.md new file mode 100644 index 0000000..8f81b4f --- /dev/null +++ b/docs/DRIVE_ACCOUNT_BOUNDARY_20261023.md @@ -0,0 +1,47 @@ +# Frontière compte et destination — NOESIS, 23 octobre 2026 + +Statut : préparation sensible, revue humaine obligatoire, aucune fusion ni activation cloud autorisée par cette PR. Cette note ne modifie pas les contrats normatifs V1/A1/A2. + +## Défauts reproduits avant correction + +Deux contre-tests du fournisseur, exécutés sur la version antérieure, échouent : un dossier personnel simplement accessible en écriture est accepté ; un fichier déplacé hors du dossier sélectionné peut encore être téléchargé et annoncé vérifié si ses empreintes concordent. + +## Correction limitée au périmètre + +Le choix du compte reste dans l'interface officielle Google, avec `prompt: select_account`, le seul scope `drive.file` et aucun consentement automatique. Après le retour Google, l'application lit seulement l'identité du compte courant (`about.get`, permissionId/emailAddress), l'affiche en mémoire et exige une confirmation explicite avant Picker, réservation d'ID ou accès aux fichiers. Ni compte, ni adresse, ni jeton ne sont persistés ou consignés. L'application ne peut pas déterminer seule si le compte est personnel : l'utilisateur doit choisir un compte de test dédié. La configuration de production reste absente/désactivée. + +Le dossier est neuf, créé par l'application avec un ID réservé et la marque privée `rc-folder-v1`, puis relu. Sa sélection reste explicite dans Google Picker. Le fournisseur exige son identité, sa propriété, son type, l'absence de corbeille et le droit d'ajout. Aucune modification, suppression, exploration générale ou labellisation d'un dossier existant. Un dossier ancien non marqué est refusé : aucune migration implicite. Le même dossier marqué peut être choisi sur l'autre appareil avec le même projet/client OAuth autorisé. + +Listes et relectures sont limitées au dossier choisi, aux marques `rc-v1`/`rc-key-a1` et aux métadonnées attendues. Le parent du fichier est vérifié avant sa lecture. Une empreinte SHA-256 reste une preuve d'octets, pas une authentification GCM : celle-ci appartient uniquement à Race Control. Un ajout extérieur au dossier ou une suppression par un utilisateur autorisé n'est pas empêché par cette marque. + +Le jeton est exclusivement en mémoire. Expiration, déconnexion et révocation connue invalident la sélection. Les callbacks d'un ancien compte ou d'un ancien Picker ne réactivent pas la nouvelle session. Un changement de destination invalide le bail réseau précédent. Une opération persistée conserve sa liaison opaque compte/client/dossier et ne peut être reprise ailleurs. Le marquage du dossier ne remplace ni l'autorisation Google ni la comparaison de cette liaison. + +La création du dossier réutilise l'ID réservé pendant la session si une réponse est perdue. Après fermeture complète avant confirmation du dossier, il faut retrouver explicitement le dossier existant dans Picker ; ne pas annoncer une reprise persistante de cette préparation de dossier. Les files d'archives conservent déjà durablement leurs octets et identifiants, séparément. + +Race Control suspend son moteur avant un changement de destination, attend la fin de l'ancienne exécution, et ne valide pas son résultat tardif. La file reste conservée ; un paquet éventuellement créé est relu avec son ID initial lors d'une reprise autorisée dans sa destination initiale. Les données métier restent exclusivement dans Race Control. + +Control Vault affiche le compte et le dossier sélectionnés, la date d'opération, la file connue et les états distincts attente/envoi/envoyé/octets vérifiés/blocage/erreur. Les têtes A2 listées restent déclaratives avant authentification dans Race Control. Aucun déchiffrement ou arbitrage métier dans Vault. + +## Reproduction et vérifications + +- Vault : `npm ci`, `npm run typecheck`, `npm test`, `npm run build`, `npm run security`, `CI=1 npm run test:e2e` ; les deux moteurs et les quatre projets de viewport restent requis. +- Contre-tests : `tests/unit/drive-boundary.test.ts` (dossier non dédié, parent divergent, confirmation, changement de compte, callbacks tardifs, création idempotente et révocation). +- Parcours navigateur : `tests/e2e/connected-drive.spec.ts` (Google entièrement intercepté, données fictives, confirmation avant création/Picker, relecture et téléchargement, rejet du clair, oubli du jeton au rechargement). +- Race Control : `node tests/run-ci.js --lane unit`, `BROWSER=chromium node tests/e2e85-drive-runtime.js`, puis `BROWSER=webkit node tests/e2e85-drive-runtime.js` ; suite CI complète avant toute décision de fusion. `tests/test_drive_sync.js` vérifie aussi le changement de destination pendant l'envoi. +- Reproduction statique du fournisseur dans Race Control : `node tools/vendor-vault-drive.js ../Control-Vault`. Les SHA-256 des quatre sources sont inscrits dans l'en-tête généré. Le module GIS est identique entre dépôts, hormis l'export ES de Vault. + +Ces preuves sont unitaires et d'intégration avec réseau Google simulé. Aucun appel Google réel, aucun compte personnel, aucune validation sur un iPhone physique. VALIDATION PHYSIQUE EN ATTENTE. + +## Environnement réel à autoriser séparément + +Deux origines HTTPS de test indépendantes, un client OAuth Web de test commun à ces seules origines, une clé Picker restreinte, un compte Google de test confirmé par l'utilisateur et un dossier neuf sont nécessaires. La création de ressources administratives et leur publication nécessitent un GO ciblé distinct. Le schéma public reste exactement `approved`, `clientId`, `apiKey`, `appId`, `raceOrigin`, `vaultOrigin`. Aucun compte/dossier/jeton/secret ajouté au schéma. Ne jamais configurer un secret client OAuth dans une application statique. + +L'autorisation `approved: true` est réservée au futur environnement de test isolé après validation de ses ressources. Aucun changement des variables ou secrets de production. Aucun accès réel depuis `ipoower.github.io`. Les essais réels doivent démontrer les droits effectifs sur les fichiers des deux applications : Picker seul ne prouve pas ces droits. + +## Risques, dépendances et récupération + +Modification sensible commune aux deux clients : relire les deux PR et la compatibilité du fournisseur reproduit. La PR de sécurité CI/CD Control Vault #16 reste séparée et non fusionnée. Aucun changement de workflow, permission, secret, données existantes, contrat normatif, service worker ou animation. + +Le retour arrière concerne le code uniquement, par PR et après accord. Ne jamais effacer la file, les bases locales, le dossier ou les archives pour faciliter un retour arrière. Une installation ancienne non marquée reste disponible ; son export chiffré et sa restauration isolée doivent être validés avant toute migration personnelle explicite. + +Aucune promesse de synchronisation permanente d'une PWA fermée sur iOS. Reprise seulement application ouverte, autorisée, connectée et coffre déverrouillé ; expiration Google implique une action humaine. Un succès HTTP ne constitue pas une sauvegarde vérifiée, et les octets vérifiés par Vault ne constituent pas une restauration réussie. diff --git a/src/services/connected-drive.ts b/src/services/connected-drive.ts index 16833e6..2a42e34 100644 --- a/src/services/connected-drive.ts +++ b/src/services/connected-drive.ts @@ -9,7 +9,8 @@ import { OpaqueDrive, type RemotePacket, type ByteProof } from './opaque-drive'; interface Job { binding: string; bytes: Uint8Array; fileId: string | null; sha: string } interface Durable { v: 1; job: Job | null; receipts: ByteProof[] } export interface ConnectedState { configured: boolean; prepared: boolean; connected: boolean; selected: boolean; busy: boolean; message: string; - versions: RemotePacket[]; receipts: ByteProof[]; recoveryOrigin: string | null } + versions: RemotePacket[]; receipts: ByteProof[]; recoveryOrigin: string | null; account: string | null; accountConfirmed: boolean; folder: string | null; + phase: 'WAITING' | 'SENDING' | 'UPLOADED' | 'VERIFIED_BYTES' | 'CONFLICT' | 'SYNC_BLOCKED' | 'ERROR'; pending: boolean; lastOperationAt: number | null } export class ConnectedDrive { private provider: OpaqueDrive | null = null; private session: DriveSession | null = null; @@ -20,7 +21,7 @@ export class ConnectedDrive { private listeners = new Set<() => void>(); private epoch = 0; state: ConnectedState = { configured: false, prepared: false, connected: false, selected: false, busy: false, - message: 'Connexion réelle non activée sur cette version.', versions: [], receipts: [], recoveryOrigin: null }; + message: 'Connexion réelle non activée sur cette version.', versions: [], receipts: [], recoveryOrigin: null, account: null, accountConfirmed: false, folder: null, phase: 'WAITING', pending: false, lastOperationAt: null }; subscribe(fn: () => void): () => void { this.listeners.add(fn); return () => this.listeners.delete(fn); } private change(patch: Partial) { this.state = { ...this.state, ...patch }; this.listeners.forEach(fn => fn()); } async init() { @@ -29,6 +30,8 @@ export class ConnectedDrive { this.config = await GoogleDriveSession.load(); if (location.origin !== this.config.vaultOrigin) return; this.provider = new OpaqueDrive([this.config.vaultOrigin]); this.session = GoogleDriveSession.create(this.config, this.provider); + const checkSession = () => { if (this.state.connected && !this.session?.connected) { this.disconnect(); this.change({ phase: 'SYNC_BLOCKED', message: 'Autorisation Google expirée ou révoquée : reconnecte et confirme le compte dédié.' }); } }; + setInterval(checkSession, 30000); window.addEventListener('focus', checkSession); document.addEventListener('visibilitychange', checkSession); this.change({ configured: true, recoveryOrigin: this.config.raceOrigin, message: 'Prêt à connecter Google Drive. Aucun code Race Control ne sera demandé ici.' }); } catch { /* absence de configuration = aucun OAuth ni transfert */ } } @@ -38,26 +41,39 @@ export class ConnectedDrive { try { await work(epoch); } catch (e) { const code = (e as { code?: string }).code; - this.change({ connected: this.session?.connected === true, + if (epoch !== this.epoch) return; + this.change({ connected: this.session?.connected === true, phase: ['auth','binding','integrity','conflict','confirmation','quota','missing','incomplete','unavailable','origin'].includes(code || '') ? 'SYNC_BLOCKED' : 'ERROR', lastOperationAt: Date.now(), message: code === 'auth' ? 'Session Google expirée : reconnecte-toi pour reprendre.' : code === 'binding' ? 'Cet envoi attend le compte et le dossier utilisés à sa préparation.' : code === 'quota' ? 'Espace Google Drive insuffisant. Copie locale conservée.' : 'Opération non confirmée. Aucune copie distante n’a été supprimée ni remplacée.' }); - } finally { this.change({ busy: false }); } + } finally { if (epoch === this.epoch) this.change({ busy: false }); } } - async prepare() { return this.action(async () => { await this.session?.prepare(); this.change({ prepared: true, message: 'Connexion prête. Appuie sur Se connecter à Google.' }); }); } + async prepare() { return this.action(async epoch => { await this.session?.prepare(); if (epoch !== this.epoch) return; this.change({ prepared: true, message: 'Connexion prête. Appuie sur Se connecter à Google.' }); }); } // Conserver le geste utilisateur : ouvrir Google avant tout await. connect() { if (!this.session || this.state.busy) return Promise.resolve(); - this.epoch++; this.selection = null; this.change({ selected: false, versions: [], receipts: [] }); + this.epoch++; this.selection = null; this.change({ selected: false, account: null, accountConfirmed: false, folder: null, versions: [], receipts: [], phase: 'WAITING' }); const promise = this.session.connect(); - return this.action(async epoch => { await promise; this.check(epoch); this.change({ connected: true, message: 'Connecté. Choisis le dossier de Race Control dans Google Drive.' }); }); + return this.action(async epoch => { await promise; this.check(epoch); this.change({ connected: true, account: this.session?.identity?.label || null, accountConfirmed: false, message: 'Compte affiché : confirme explicitement ce compte dédié avant tout accès aux fichiers.' }); }); + } + confirmAccount() { + if (!this.session?.identity || this.state.busy) return; + try { this.session.confirmAccount(this.session.identity.id); this.change({ accountConfirmed: true, message: 'Compte confirmé. Crée un dossier neuf dédié ou retrouve celui créé par cette application dans Picker.' }); } + catch { this.change({ phase: 'SYNC_BLOCKED', message: 'Confirmation du compte refusée. Reconnecte le compte dédié.' }); } + } + newFolder() { + if (!this.session || this.state.busy || !this.state.accountConfirmed) return Promise.resolve(); + this.epoch++; this.selection = null; this.change({ selected: false, folder: null, versions: [], receipts: [] }); + return this.action(async epoch => { await this.session!.newFolder(); this.check(epoch); this.change({ phase: 'WAITING', lastOperationAt: Date.now(), message: 'Dossier neuf dédié créé et relu. Choisis-le maintenant dans Google Picker.' }); }); } chooseFolder() { if (!this.session || this.state.busy) return Promise.resolve(); + if (!this.state.accountConfirmed) return Promise.resolve(); + this.epoch++; this.selection = null; this.change({ selected: false, folder: null, versions: [], receipts: [], phase: 'WAITING' }); const promise = this.session.chooseFolder(); - return this.action(async epoch => { const selection = await promise; this.check(epoch); this.selection = selection; this.change({ selected: true, versions: [], receipts: [], message: 'Dossier sélectionné. Actualise les versions pour les lire.' }); }); + return this.action(async epoch => { const selection = await promise; this.check(epoch); this.selection = selection; this.change({ selected: true, folder: selection.folder, versions: [], receipts: [], lastOperationAt: Date.now(), message: 'Dossier sélectionné. Actualise les versions pour les lire.' }); }); } disconnect() { this.epoch++; this.session?.disconnect(); this.selection = null; - this.change({ connected: false, selected: false, versions: [], receipts: [], message: 'Déconnecté. Les fichiers et les envois en attente sont conservés.' }); + this.change({ busy: false, connected: false, selected: false, account: null, accountConfirmed: false, folder: null, phase: 'WAITING', versions: [], receipts: [], message: 'Déconnecté. Les fichiers et les envois en attente sont conservés.' }); } private check(epoch: number) { if (this.epoch !== epoch || !this.session?.connected) throw Object.assign(new Error('auth'), { code: 'auth' }); } private selected() { if (!this.provider || !this.selection) throw new Error('Dossier requis'); return { provider: this.provider, ...this.selection }; } @@ -71,7 +87,7 @@ export class ConnectedDrive { const saved = await this.durable.read(); this.check(epoch); // Les anciens reçus restent des preuves d'octets datées, pas une preuve // que la copie existe encore ni qu'elle est authentique/lue par RC. - this.change({ versions, receipts: saved?.receipts || [], message: `${versions.length} copies distantes listées. Leur contenu est encore à vérifier.` }); + this.change({ versions, pending: !!saved?.job, receipts: (saved?.receipts || []).filter(p => versions.some(v => v.fileId === p.fileId && v.fileSha256 === p.fileSha256)), lastOperationAt: Date.now(), message: `${versions.length} copies distantes listées. Leur contenu est encore à vérifier.` }); }); } private async resumeInside(epoch: number, packet?: RacePacket) { @@ -85,13 +101,14 @@ export class ConnectedDrive { await this.archive.save(packet); this.check(epoch); saved = { ...saved, job: { binding, bytes: packet.copyBytes(), fileId: null, sha: packet.info.fileSha256 } }; await this.durable.write(saved); } - const job = saved.job; if (!job) { this.change({ message: 'Aucun envoi en attente.' }); return; } + const job = saved.job; this.change({ pending: !!job }); if (!job) { this.change({ message: 'Aucun envoi en attente.' }); return; } const checked = await readRacePacket(job.bytes, job.sha); this.check(epoch); if (!job.fileId) { job.fileId = await provider.reserveId(); this.check(epoch); await this.durable.write(saved); } - const back = await provider.upload(job.fileId, folder, checked); this.check(epoch); + this.change({ phase: 'SENDING' }); + const back = await provider.upload(job.fileId, folder, checked, undefined, (sent, total) => { if (epoch === this.epoch) this.change({ phase: sent === total ? 'UPLOADED' : 'SENDING' }); }); this.check(epoch); saved.receipts = [...saved.receipts.filter(p => p.fileId !== back.proof.fileId), back.proof]; saved.job = null; await this.durable.write(saved); this.check(epoch); - this.change({ receipts: saved.receipts, message: 'Copie envoyée et octets distants relus à l’identique. Authentification et restauration à effectuer dans Race Control.' }); + this.change({ receipts: saved.receipts, pending: false, phase: 'VERIFIED_BYTES', lastOperationAt: Date.now(), message: 'Copie envoyée et octets distants relus à l’identique. Authentification et restauration à effectuer dans Race Control.' }); } async upload(file: File) { return this.action(async epoch => { @@ -104,10 +121,10 @@ export class ConnectedDrive { async download(ref: RemotePacket): Promise { let bytes: Uint8Array | null = null; await this.action(async epoch => { - const { provider } = this.selected(); const back = await provider.download(ref); this.check(epoch); + const { provider, folder } = this.selected(); const back = await provider.download(ref, undefined, folder); this.check(epoch); await this.archive.save(back.packet); this.check(epoch); bytes = back.packet.copyBytes(); - this.change({ receipts: [...this.state.receipts.filter(p => p.fileId !== ref.fileId), back.proof], message: 'Octets vérifiés et copie chiffrée gardée sur cet appareil. Ouvre ce fichier dans Race Control pour le déchiffrer.' }); + this.change({ phase: 'VERIFIED_BYTES', lastOperationAt: Date.now(), receipts: [...this.state.receipts.filter(p => p.fileId !== ref.fileId), back.proof], message: 'Octets vérifiés et copie chiffrée gardée sur cet appareil. Ouvre ce fichier dans Race Control pour le déchiffrer.' }); }); return bytes; } diff --git a/src/services/google-drive-session.d.ts b/src/services/google-drive-session.d.ts index 4e7653e..0496b70 100644 --- a/src/services/google-drive-session.d.ts +++ b/src/services/google-drive-session.d.ts @@ -1,6 +1,6 @@ import type { OpaqueDrive } from './opaque-drive'; export interface DriveConfig { approved: true; clientId: string; apiKey: string; appId: string; raceOrigin: string; vaultOrigin: string } -export interface DriveSession { prepare(): Promise; connect(): Promise; chooseFolder(): Promise<{ folder: string; binding: string }>; disconnect(): void; readonly connected: boolean } +export interface DriveSession { prepare(): Promise; connect(): Promise; confirmAccount(id: string): void; newFolder(): Promise; chooseFolder(): Promise<{ folder: string; binding: string; account: string }>; readonly identity: { id: string; label: string } | null; readonly confirmed: boolean; disconnect(): void; readonly connected: boolean } export const GoogleDriveSession: { config(value: unknown, origin?: string): DriveConfig; load(fetcher?: typeof fetch): Promise; diff --git a/src/services/google-drive-session.js b/src/services/google-drive-session.js index eb2cead..be611d6 100644 --- a/src/services/google-drive-session.js +++ b/src/services/google-drive-session.js @@ -30,7 +30,7 @@ const GoogleDriveSession = (() => { } function create(input, provider, host = globalThis) { const cfg = config(input, host.location.origin); - let token = null, until = 0, epoch = 0, prepared = false, pending = null, picker = null, pickReject = null; + let token = null, until = 0, epoch = 0, prepared = false, pending = null, picker = null, pickReject = null, pickerBusy = false, identity = null, approvedAccount = null, pendingFolder = null; const script = src => new Promise((resolve, reject) => { const el = host.document.createElement('script'); el.src = src; el.async = true; el.referrerPolicy = 'strict-origin-when-cross-origin'; const timer = setTimeout(() => { el.remove(); reject(Object.assign(new Error('network'), { code: 'network' })); }, 20000); @@ -38,7 +38,7 @@ const GoogleDriveSession = (() => { host.document.head.appendChild(el); }); function disconnect() { - token = null; until = 0; epoch++; provider.disconnect(); picker?.setVisible(false); picker = null; + token = null; until = 0; identity = approvedAccount = pendingFolder = null; pickerBusy = false; epoch++; provider.disconnect(); picker?.setVisible(false); picker = null; if (pickReject) { pickReject(Object.assign(new Error('cancelled'), { code: 'cancelled' })); pickReject = null; } if (pending) { pending.reject(Object.assign(new Error('cancelled'), { code: 'cancelled' })); pending = null; } } @@ -57,25 +57,45 @@ const GoogleDriveSession = (() => { return new Promise((resolve, reject) => { const request = { resolve, reject, epoch }; pending = request; const client = host.google.accounts.oauth2.initTokenClient({ client_id: cfg.clientId, scope: SCOPE, include_granted_scopes: false, - callback: response => { + callback: async response => { if (request !== pending || request.epoch !== epoch) return; - pending = null; try { if (response.error || typeof response.access_token !== 'string' || !host.google.accounts.oauth2.hasGrantedAllScopes(response, SCOPE) || response.scope?.split(/\s+/).filter(Boolean).join(' ') !== SCOPE) return fail('auth'); const seconds = Number(response.expires_in); provider.connect(response.access_token, seconds, [SCOPE]); token = response.access_token; until = Date.now() + (seconds - 30) * 1000; - request.resolve(); - } catch { disconnect(); request.reject(Object.assign(new Error('auth'), { code: 'auth' })); } + const selected = await provider.identity(); + if (request !== pending || request.epoch !== epoch) return; + identity = Object.freeze({ ...selected }); pending = null; request.resolve(); + } catch { if (request !== pending || request.epoch !== epoch) return; pending = null; disconnect(); request.reject(Object.assign(new Error('auth'), { code: 'auth' })); } }, error_callback: () => { if (request !== pending || request.epoch !== epoch) return; pending = null; disconnect(); request.reject(Object.assign(new Error('auth'), { code: 'auth' })); } }); try { client.requestAccessToken({ prompt: 'select_account' }); } catch { pending = null; disconnect(); reject(Object.assign(new Error('auth'), { code: 'auth' })); } }); } + function confirmAccount(accountId) { + if (!token || Date.now() >= until || !identity || identity.id !== accountId) return fail('confirmation'); + approvedAccount = accountId; + } + function fileAccess() { + if (!token || Date.now() >= until || provider.connected === false) { disconnect(); return fail('auth'); } + if (!identity || approvedAccount !== identity.id) return fail('confirmation'); + // Invalider toutes les réponses de l'ancienne destination, sans partager le jeton. + provider.connect(token, Math.floor((until - Date.now()) / 1000) + 30, [SCOPE]); + } + async function newFolder() { + fileAccess(); const lease = epoch, account = await provider.account(); + if (lease !== epoch || account !== approvedAccount) return fail('auth'); + if (!pendingFolder) { const reserved = await provider.reserveId(); if (lease !== epoch) return fail('auth'); pendingFolder = reserved; } + const folder = await provider.createFolder(pendingFolder); + if (lease !== epoch || Date.now() >= until) return fail('auth'); + return folder; + } function chooseFolder() { if (pickReject) return Promise.reject(Object.assign(new Error('busy'), { code: 'busy' })); if (!token || Date.now() >= until) { disconnect(); return Promise.reject(Object.assign(new Error('auth'), { code: 'auth' })); } + try { fileAccess(); } catch (e) { return Promise.reject(e); } const lease = epoch; return new Promise((resolve, reject) => { pickReject = reject; @@ -83,23 +103,27 @@ const GoogleDriveSession = (() => { const view = new gp.DocsView(gp.ViewId.FOLDERS).setIncludeFolders(true).setSelectFolderEnabled(true); picker = new gp.PickerBuilder().addView(view).setOAuthToken(token).setDeveloperKey(cfg.apiKey).setAppId(cfg.appId) .setOrigin(host.location.origin).setCallback(async data => { + if (lease !== epoch || pickReject !== reject || pickerBusy) return; if (data.action === gp.Action.CANCEL) { picker = null; pickReject = null; reject(Object.assign(new Error('cancelled'), { code: 'cancelled' })); } if (data.action !== gp.Action.PICKED) return; - picker = null; pickReject = null; + picker = null; pickerBusy = true; try { if (lease !== epoch || Date.now() >= until || data.docs?.length !== 1) return fail('auth'); - const folder = data.docs[0].id; await provider.folder(folder); const account = await provider.account(); + if (lease !== epoch || account !== approvedAccount) return fail('auth'); + const folder = data.docs[0].id; await provider.folder(folder); if (lease !== epoch || Date.now() >= until) return fail('auth'); const binding = [...new Uint8Array(await host.crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify([cfg.clientId, account, folder]))))].map(x => x.toString(16).padStart(2, '0')).join(''); if (lease !== epoch) return fail('auth'); - resolve({ folder, binding }); // identifiants jamais consignés ni placés dans une URL de l'application - } catch (e) { reject(e); } + pickReject = null; pickerBusy = false; resolve({ folder, binding, account: identity.label }); // identifiants jamais consignés ni placés dans une URL de l'application + } catch (e) { if (pickReject === reject) { pickReject = null; pickerBusy = false; } reject(e); } }).build(); picker.setVisible(true); }); } - return Object.freeze({ prepare, connect, chooseFolder, disconnect, get connected() { return !!token && Date.now() < until; } }); + return Object.freeze({ prepare, connect, confirmAccount, newFolder, chooseFolder, disconnect, + get identity() { return identity && { ...identity }; }, get confirmed() { return !!approvedAccount; }, + get connected() { if (token && (Date.now() >= until || provider.connected === false)) disconnect(); return !!token; } }); } return Object.freeze({ SCOPE, config, load, create }); })(); diff --git a/src/services/opaque-drive.ts b/src/services/opaque-drive.ts index 482c01f..b49a0b9 100644 --- a/src/services/opaque-drive.ts +++ b/src/services/opaque-drive.ts @@ -43,6 +43,7 @@ export class OpaqueDrive { this.#token = token; this.#until = this.now() + expiresIn * 1000 - 30000; } disconnect(): void { this.#token = null; this.#until = 0; this.#epoch++; } + get connected(): boolean { if (!this.#token || this.now() >= this.#until) { this.disconnect(); return false; } return true; } private lease(): number { this.guard(); if (!this.#token || this.now() >= this.#until) { this.disconnect(); return fail('auth'); } return this.#epoch; } private check(epoch: number, signal?: AbortSignal | null): void { if (signal?.aborted) return fail('cancelled'); @@ -126,11 +127,31 @@ export class OpaqueDrive { if (!object(value.user) || typeof value.user.permissionId !== 'string') return fail('incomplete'); return id(value.user.permissionId); } + async identity(signal?: AbortSignal): Promise<{ id: string; label: string }> { + const epoch = this.lease(); + const value = await this.json(await this.request(API + '/about?fields=user(permissionId,emailAddress)', { signal }, epoch), signal); + this.check(epoch, signal); + if (!object(value.user) || typeof value.user.permissionId !== 'string' || typeof value.user.emailAddress !== 'string' || + value.user.emailAddress.length > 254 || !/^[^\s@]+@[^\s@]+$/.test(value.user.emailAddress)) return fail('incomplete'); + return Object.freeze({ id: id(value.user.permissionId), label: value.user.emailAddress }); // mémoire/UI seulement + } async folder(folder: string, signal?: AbortSignal): Promise { id(folder); const epoch = this.lease(); - const p = await this.json(await this.request(API + '/files/' + folder + '?fields=id,mimeType,trashed,capabilities(canAddChildren)', { signal }, epoch), signal); + const p = await this.json(await this.request(API + '/files/' + folder + '?fields=id,mimeType,trashed,ownedByMe,appProperties,capabilities(canAddChildren)', { signal }, epoch), signal); this.check(epoch, signal); - if (p.id !== folder || p.trashed === true || p.mimeType !== 'application/vnd.google-apps.folder' || !object(p.capabilities) || p.capabilities.canAddChildren !== true) return fail('conflict'); + if (p.id !== folder || p.trashed !== false || p.ownedByMe !== true || p.mimeType !== 'application/vnd.google-apps.folder' || + !object(p.appProperties) || p.appProperties.cv_contract !== 'rc-folder-v1' || + !object(p.capabilities) || p.capabilities.canAddChildren !== true) return fail('conflict'); + } + async createFolder(fileId: string, signal?: AbortSignal): Promise { + id(fileId); const epoch = this.lease(); + // Réserver l'ID avant l'appel ; une réponse perdue est relue, jamais remplacée. + try { await this.folder(fileId, signal); this.check(epoch, signal); return fileId; } + catch (e) { if (!(e instanceof OpaqueDriveError) || e.code !== 'missing') throw e; } + const metadata = { id: fileId, name: 'Control Vault — sauvegardes chiffrées', mimeType: 'application/vnd.google-apps.folder', + appProperties: { cv_contract: 'rc-folder-v1' } }; + await this.request(API + '/files?fields=id', { method: 'POST', signal, headers: { 'Content-Type': 'application/json; charset=UTF-8' }, body: JSON.stringify(metadata) }, epoch, [409]); + await this.folder(fileId, signal); this.check(epoch, signal); return fileId; } // Descripteur A1 public : jamais un code, une clé ou un profil métier. Son // contrôle GCM et sa dérivation restent exclusivement dans Race Control. @@ -149,15 +170,17 @@ export class OpaqueDrive { Object.keys(p.check).sort().join() !== 'c,i' || !base64(p.check.i, 12) || !base64(p.check.c, 46)) return fail('integrity'); } async profiles(folder: string, signal?: AbortSignal): Promise { - id(folder); const epoch = this.lease(), out: SyncProfile[] = [], seen = new Set(); let next = ''; + id(folder); const epoch = this.lease(); await this.folder(folder, signal); this.check(epoch, signal); + const out: SyncProfile[] = [], seen = new Set(); let next = ''; do { const params = new URLSearchParams({ q: `'${folder}' in parents and trashed = false and appProperties has { key='cv_contract' and value='rc-key-a1' }`, - pageSize: '1000', fields: 'nextPageToken,incompleteSearch,files(id,size,appProperties)' }); + pageSize: '1000', fields: 'nextPageToken,incompleteSearch,files(id,size,trashed,parents,appProperties)' }); if (next) params.set('pageToken', next); const page = await this.json(await this.request(API + '/files?' + params, { signal }, epoch), signal); if (page.incompleteSearch !== false || !Array.isArray(page.files)) return fail('incomplete'); for (const f of page.files) { - if (!object(f) || typeof f.id !== 'string' || !object(f.appProperties) || !hash(f.appProperties.cv_file) || + if (!object(f) || typeof f.id !== 'string' || f.trashed === true || !Array.isArray(f.parents) || !f.parents.includes(folder) || + !object(f.appProperties) || f.appProperties.cv_contract !== 'rc-key-a1' || !hash(f.appProperties.cv_file) || typeof f.size !== 'string' || !/^[1-9][0-9]*$/.test(f.size) || Number(f.size) > 2048) return fail('incomplete'); const fileId = id(f.id), bytes = await this.bytes(await this.request(API + '/files/' + fileId + '?alt=media', { signal }, epoch), 2048, signal); this.profileBytes(bytes); @@ -176,6 +199,7 @@ export class OpaqueDrive { async uploadProfile(fileId: string, folder: string, input: Uint8Array, signal?: AbortSignal): Promise { id(fileId); id(folder); const bytes = input.slice(); this.profileBytes(bytes); const epoch = this.lease(), sha256 = await this.digest(bytes); + await this.folder(folder, signal); this.check(epoch, signal); const verify = async () => { const meta = await this.json(await this.request(API + '/files/' + fileId + '?fields=id,size,trashed,parents,appProperties', { signal }, epoch), signal); if (meta.id !== fileId || meta.trashed === true || !Array.isArray(meta.parents) || !meta.parents.includes(folder) || @@ -185,7 +209,6 @@ export class OpaqueDrive { this.check(epoch, signal); return { fileId, sha256, bytes: back }; }; try { return await verify(); } catch (e) { if (!(e instanceof OpaqueDriveError) || e.code !== 'missing') throw e; } - await this.folder(folder, signal); this.check(epoch, signal); const boundary = 'rc_key_' + sha256; const metadata = { id: fileId, name: 'rc-sync-key-' + sha256 + '.json', mimeType: 'application/json', parents: [folder], appProperties: { cv_contract: 'rc-key-a1', cv_file: sha256 } }; const body = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}\r\n--${boundary}\r\nContent-Type: application/json\r\n\r\n${new TextDecoder().decode(bytes)}\r\n--${boundary}--`; @@ -203,14 +226,16 @@ export class OpaqueDrive { ...(revision ? { revision } : {}) }); } async list(folder: string, signal?: AbortSignal): Promise { - id(folder); const epoch = this.lease(), versions = new Map(), tokens = new Set(); let next = ''; + id(folder); const epoch = this.lease(); await this.folder(folder, signal); this.check(epoch, signal); + const versions = new Map(), tokens = new Set(); let next = ''; do { const p = new URLSearchParams({ q: `'${folder}' in parents and trashed = false and appProperties has { key='cv_contract' and value='rc-v1' }`, - pageSize: '1000', fields: 'nextPageToken,incompleteSearch,files(id,size,trashed,appProperties)' }); + pageSize: '1000', fields: 'nextPageToken,incompleteSearch,files(id,size,trashed,parents,appProperties)' }); if (next) p.set('pageToken', next); const page = await this.json(await this.request(API + '/files?' + p, { signal }, epoch), signal); if (page.incompleteSearch !== false || !Array.isArray(page.files)) return fail('incomplete'); for (const raw of page.files) { + if (!object(raw) || !Array.isArray(raw.parents) || !raw.parents.includes(folder)) return fail('conflict'); const v = this.version(raw), old = versions.get(v.fileId); if (old && JSON.stringify(old) !== JSON.stringify(v)) return fail('conflict'); versions.set(v.fileId, v); @@ -239,7 +264,10 @@ export class OpaqueDrive { this.check(epoch, signal); return { packet, proof: Object.freeze({ ...remote, verifiedAt: this.now(), state: 'verified-bytes' }) }; } - async download(expected: RemotePacket, signal?: AbortSignal) { return this.verified(expected, this.lease(), undefined, signal); } + async download(expected: RemotePacket, signal?: AbortSignal, folder?: string) { + const epoch = this.lease(); if (folder !== undefined) { await this.folder(folder, signal); this.check(epoch, signal); } + return this.verified(expected, epoch, folder, signal); + } async upload(fileId: string, folder: string, input: RacePacket, signal?: AbortSignal, progress: (sent: number, total: number) => void = () => {}) { return this.uploadPacket(fileId, folder, input, undefined, signal, progress); } @@ -253,13 +281,12 @@ export class OpaqueDrive { progress: (sent: number, total: number) => void) { id(fileId); id(folder); const packet = await readRacePacket(input.copyBytes(), input.info.fileSha256), epoch = this.lease(); + await this.folder(folder, signal); this.check(epoch, signal); const expected: RemotePacket = { fileId, fileSha256: packet.info.fileSha256, cipherSha256: packet.info.cipherSha256, size: packet.info.size, state: 'unverified', ...(revision ? { revision } : {}) }; // Réessai après perte de la dernière réponse : relire la copie existante, jamais l'écraser. try { return await this.verified(expected, epoch, folder, signal); } catch (e) { if (!(e instanceof OpaqueDriveError) || e.code !== 'missing') throw e; } - const parent = await this.json(await this.request(API + '/files/' + folder + '?fields=id,mimeType,capabilities(canAddChildren)', { signal }, epoch), signal); - if (parent.id !== folder || parent.mimeType !== 'application/vnd.google-apps.folder' || !object(parent.capabilities) || parent.capabilities.canAddChildren !== true) return fail('conflict'); const meta = { id: fileId, name: 'rc-' + expected.fileSha256 + '.json', mimeType: 'application/json', parents: [folder], appProperties: { cv_contract: 'rc-v1', cv_file: expected.fileSha256, cv_cipher: expected.cipherSha256, ...(revision ? syncRevisionProperties(revision) : {}) } }; diff --git a/src/views/drive.ts b/src/views/drive.ts index ef94a41..ae43365 100644 --- a/src/views/drive.ts +++ b/src/views/drive.ts @@ -16,9 +16,16 @@ export function driveView(): View { h('p', { role: 'status', 'aria-live': 'polite' }, s.message), h('p', { class: 'muted' }, 'Race Control chiffre et déchiffre sur chaque appareil. Control Vault conserve les fichiers opaques : il ne demande jamais ton code et ne peut pas authentifier leur contenu.'), h('p', { class: 'muted' }, 'Choisis le même compte et le même dossier sur le PC et l’iPhone. La synchronisation de Race Control fonctionne aussi lorsque Control Vault est fermé. Les autres écrans restent des démonstrations.'), + s.account ? h('p', null, 'Compte Google sélectionné : ' + s.account) : null, + s.folder ? h('p', { class: 'wrap' }, 'Dossier dédié sélectionné : ' + s.folder) : null, + h('p', null, ({ WAITING: 'En attente', SENDING: 'Envoi en cours', UPLOADED: 'Envoyé — relecture requise', VERIFIED_BYTES: 'Octets distants vérifiés — restauration à valider dans Race Control', CONFLICT: 'Conflit', SYNC_BLOCKED: 'Synchronisation bloquée', ERROR: 'Erreur — action nécessaire' })[s.phase]), + s.lastOperationAt ? h('p', { class: 'muted' }, 'Dernière opération : ' + new Date(s.lastOperationAt).toLocaleString('fr-FR')) : null, + s.pending ? h('p', null, 'Un envoi chiffré est conservé en attente sur cet appareil.') : null, s.configured ? h('div', { class: 'btn-row' }, !s.prepared ? button('Préparer la connexion Google', () => connectedDrive.prepare()) : button(s.connected ? 'Changer de compte Google' : 'Se connecter à Google', () => connectedDrive.connect()), - s.connected ? button('Choisir le dossier Google Drive', () => connectedDrive.chooseFolder()) : null, + s.connected && !s.accountConfirmed ? button('Confirmer ce compte dédié', () => connectedDrive.confirmAccount()) : null, + s.connected && s.accountConfirmed ? button('Créer un dossier neuf dédié', () => connectedDrive.newFolder()) : null, + s.connected && s.accountConfirmed ? button('Choisir le dossier Google Drive', () => connectedDrive.chooseFolder()) : null, s.connected ? h('button', { class: 'btn', type: 'button', onclick: () => connectedDrive.disconnect() }, 'Déconnecter') : null, ) : null, s.selected ? h('div', { class: 'btn-row' }, button('Actualiser les versions', () => connectedDrive.refresh()), @@ -26,6 +33,7 @@ export function driveView(): View { button('Reprendre l’envoi en attente', () => connectedDrive.resume())) : null, )), h('section', { class: 'panel' }, h('div', { class: 'panel-head' }, h('h2', null, 'Versions distantes')), + (() => { const parents = new Set(s.versions.flatMap(v => v.revision?.parents || [])); const heads = s.versions.filter(v => v.revision && !parents.has(v.revision.id)); return heads.length > 1 ? h('p', { role: 'status' }, 'Plusieurs têtes de révision déclarées : conserver les branches et vérifier le conflit dans Race Control. Control Vault ne les authentifie ni ne les fusionne.') : null; })(), h('div', { class: 'panel-body' }, s.versions.length ? s.versions.map(ref => { const proof = s.receipts.find(p => p.fileId === ref.fileId && p.fileSha256 === ref.fileSha256); return h('div', { class: 'row' }, h('div', { class: 'row-main' }, h('p', { class: 'row-title wrap' }, ref.revision ? `Révision ${ref.revision.seq} · ${ref.revision.id.slice(0, 8)}` : 'Copie manuelle'), diff --git a/tests/e2e/connected-drive.spec.ts b/tests/e2e/connected-drive.spec.ts index 95beeaf..50227ef 100644 --- a/tests/e2e/connected-drive.spec.ts +++ b/tests/e2e/connected-drive.spec.ts @@ -6,9 +6,9 @@ const origin = 'https://vault.example.com'; const scope = 'https://www.googleapis.com/auth/drive.file'; const config = { approved: true, clientId: ['123456', 'fake.apps.googleusercontent.com'].join('-'), apiKey: 'AIza' + 'a'.repeat(35), appId: '123456', raceOrigin: 'https://race.example.com', vaultOrigin: origin }; -test('parcours réel : connexion explicite, dossier Picker, upload opaque, relecture et jeton oublié au rechargement', async ({ context, page }) => { +test('application complète, Google simulé : confirmation compte, dossier dédié, upload opaque, relecture et jeton oublié', async ({ context, page }) => { await context.route('**/*', r => r.abort()); - const fixture = await packetFixture(61), files = new Map; bytes: Buffer }>(), pending = new Map>(); let next = 0, posts = 0; + const fixture = await packetFixture(61), files = new Map; bytes: Buffer }>(), pending = new Map>(); let next = 0, posts = 0, folderPosts = 0, folderReady = false; await context.route(origin + '/**', async r => { const path = new URL(r.request().url()).pathname; if (path === '/drive-config.json') return r.fulfill({ contentType: 'application/json', body: JSON.stringify(config) }); @@ -31,9 +31,11 @@ test('parcours réel : connexion explicite, dossier Picker, upload opaque, relec const json = (v: unknown, status = 200) => r.fulfill({ status, headers, contentType: 'application/json', body: JSON.stringify(v) }); if (request.method() === 'OPTIONS') return r.fulfill({ status: 204, headers }); expect(request.headers().authorization).toBe('Bearer fixture-token'); expect(u.searchParams.has('access_token')).toBe(false); - if (key === 'about') return json({ user: { permissionId: 'synthetic_account' } }); - if (key === 'generateIds') return json({ ids: ['version-' + ++next] }); - if (key === 'folder-A') return json({ id: key, mimeType: 'application/vnd.google-apps.folder', capabilities: { canAddChildren: true } }); + if (key === 'about') return json({ user: { permissionId: 'synthetic_account', emailAddress: 'fixture-backup@example.invalid' } }); + if (key === 'generateIds') return json({ ids: [++next === 1 ? 'folder-A' : 'version-' + next] }); + if (key === 'folder-A' && !folderReady) return json({},404); + if (key === 'folder-A') return json({ id: key, mimeType: 'application/vnd.google-apps.folder', trashed: false, ownedByMe: true, appProperties: { cv_contract: 'rc-folder-v1' }, capabilities: { canAddChildren: true } }); + if (request.method() === 'POST' && u.pathname === '/drive/v3/files') { const m=request.postDataJSON(); expect(m.mimeType).toBe('application/vnd.google-apps.folder');expect(m.appProperties).toEqual({cv_contract:'rc-folder-v1'});expect(m.id).toBe('folder-A');folderReady=true;folderPosts++;return json({id:m.id}); } if (request.method() === 'POST') { posts++; const m = request.postDataJSON() as Record; pending.set(String(m.id), m); return r.fulfill({ headers: { ...headers, Location: 'https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&upload_id=' + m.id } }); } if (request.method() === 'PUT') { const id = u.searchParams.get('upload_id')!, bytes = request.postDataBuffer()!; expect(bytes.equals(Buffer.from(fixture.bytes))).toBe(true); files.set(id, { meta: { ...pending.get(id), size: String(bytes.length) }, bytes }); return json({ id }); } if (u.pathname === '/drive/v3/files') return json({ incompleteSearch: false, files: [...files.values()].map(f => f.meta) }); @@ -45,6 +47,15 @@ test('parcours réel : connexion explicite, dossier Picker, upload opaque, relec await expect(page.getByRole('heading', { name: 'Google Drive', exact: true })).toBeVisible(); await page.getByRole('button', { name: 'Préparer la connexion Google' }).click(); await page.getByRole('button', { name: 'Se connecter à Google', exact: true }).click(); + await expect(page.getByText('Compte Google sélectionné : fixture-backup@example.invalid')).toBeVisible(); + await expect(page.getByRole('button', {name:'Choisir le dossier Google Drive'})).toHaveCount(0); + expect(posts).toBe(0);expect(folderPosts).toBe(0); + await page.getByRole('button', {name:'Confirmer ce compte dédié'}).click(); + await page.getByRole('button', {name:'Créer un dossier neuf dédié'}).click(); + await expect(page.getByRole('status').filter({hasText:'Dossier neuf dédié créé'})).toBeVisible(); + expect(folderPosts).toBe(1); + await page.getByRole('button', {name:'Créer un dossier neuf dédié'}).click(); + await expect(page.getByRole('status').filter({hasText:'Dossier neuf dédié créé'})).toBeVisible();expect(folderPosts).toBe(1); await page.getByRole('button', { name: 'Choisir le dossier Google Drive' }).click(); await page.getByLabel('Copie chiffrée Race Control').setInputFiles({ name: 'opaque.json', mimeType: 'application/json', buffer: Buffer.from(fixture.bytes) }); await expect(page.getByRole('status').filter({ hasText: 'Copie envoyée' })).toBeVisible(); diff --git a/tests/e2e/opaque-drive.spec.ts b/tests/e2e/opaque-drive.spec.ts index a2847a3..d1b2099 100644 --- a/tests/e2e/opaque-drive.spec.ts +++ b/tests/e2e/opaque-drive.spec.ts @@ -51,7 +51,7 @@ function remoteFixture() { files.set(id, { meta: { ...meta, size: String(bytes.length), trashed: false }, bytes }); pending.delete(id); await json({ id }); return; } - if (key === folder) { await json({ id: folder, mimeType: 'application/vnd.google-apps.folder', capabilities: { canAddChildren: true } }); return; } + if (key === folder) { await json({ id: folder, mimeType: 'application/vnd.google-apps.folder', trashed: false, ownedByMe: true, appProperties: { cv_contract: 'rc-folder-v1' }, capabilities: { canAddChildren: true } }); return; } if (u.pathname === '/drive/v3/files') { await json({ incompleteSearch: false, files: [...files.values()].map(x => x.meta) }); return; } const entry = files.get(key); if (!entry) { await json({}, 404); return; } diff --git a/tests/unit/drive-boundary.test.ts b/tests/unit/drive-boundary.test.ts new file mode 100644 index 0000000..88192ad --- /dev/null +++ b/tests/unit/drive-boundary.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { OpaqueDrive, DRIVE_FILE_SCOPE } from '../../src/services/opaque-drive'; +import { GoogleDriveSession } from '../../src/services/google-drive-session.js'; +import { webcrypto } from 'node:crypto'; +import { packetFixture } from '../fixtures/race-packet'; + +const origin = 'https://vault.example.com'; +const json = (v: unknown, status = 200) => new Response(JSON.stringify(v), { status, headers: { 'Content-Type': 'application/json' } }); +beforeEach(() => vi.stubGlobal('location', { origin })); +afterEach(() => vi.unstubAllGlobals()); +const client = (fetcher: typeof fetch) => { const p = new OpaqueDrive([origin], fetcher); p.connect('fixture-token', 3600, [DRIVE_FILE_SCOPE]); return p; }; + +describe('compte et destination : frontière réelle du fournisseur, réseau fictif', () => { + it('refuse un dossier personnel accessible en écriture mais non créé comme dossier dédié par cette application', async () => { + const fetcher = vi.fn(async () => json({ id: 'personal-folder', mimeType: 'application/vnd.google-apps.folder', + trashed: false, ownedByMe: true, capabilities: { canAddChildren: true } })); + await expect(client(fetcher).folder('personal-folder')).rejects.toMatchObject({ code: 'conflict' }); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(String(fetcher.mock.calls[0][0])).not.toContain('alt=media'); + }); + + it('refuse un fichier déplacé hors de la destination avant de lire ses octets, même si son hash est correct', async () => { + const fixture = await packetFixture(); let media = 0; + const meta = { id: 'archive-file', parents: ['other-folder'], trashed: false, size: String(fixture.bytes.length), + appProperties: { cv_contract: 'rc-v1', cv_file: fixture.hash, cv_cipher: fixture.envelope.sha256 } }; + const fetcher = vi.fn(async input => { + const u = new URL(String(input)); + if (u.pathname.endsWith('/allowed-folder')) return json({ id: 'allowed-folder', mimeType: 'application/vnd.google-apps.folder', + trashed: false, ownedByMe: true, appProperties: { cv_contract: 'rc-folder-v1' }, capabilities: { canAddChildren: true } }); + if (u.searchParams.get('alt') === 'media') { media++; return new Response(fixture.bytes as BodyInit); } + return json(meta); + }); + const ref = { fileId: meta.id, fileSha256: fixture.hash, cipherSha256: fixture.envelope.sha256, size: fixture.bytes.length, state: 'unverified' as const }; + await expect((client(fetcher).download as any)(ref, undefined, 'allowed-folder')).rejects.toMatchObject({ code: 'integrity' }); + expect(media).toBe(0); + }); +}); + + +const scope = DRIVE_FILE_SCOPE; +const config = { approved: true as const, clientId: ['123456','fake.apps.googleusercontent.com'].join('-'), apiKey: 'AIza'+'a'.repeat(35), appId: '123456', raceOrigin: 'https://race.example.com', vaultOrigin: origin }; +function sessionFixture() { + const clients: any[] = [], callbacks: any[] = []; + class View { setIncludeFolders(){return this;} setSelectFolderEnabled(){return this;} } + class Picker { + addView(){return this;} setOAuthToken(){return this;} setDeveloperKey(){return this;} setAppId(){return this;} setOrigin(){return this;} + setCallback(cb: any){callbacks.push(cb);return this;} build(){return this;} setVisible(){} + } + const provider = { connect: vi.fn(), disconnect: vi.fn(), identity: vi.fn(async()=>({id:'fixture-account',label:'backup-fixture@example.invalid'})), + account: vi.fn(async()=>'fixture-account'), folder: vi.fn(async()=>{}), reserveId: vi.fn(async()=>'reserved-folder'), createFolder: vi.fn(async(id: string)=>id) }; + const host = { location:{origin}, crypto:webcrypto, gapi:{load:(_: string,o: any)=>o.callback()}, google:{accounts:{oauth2:{ + initTokenClient:(o: any)=>{clients.push(o);return {requestAccessToken:(arg: any)=>expect(arg.prompt).toBe('select_account')};},hasGrantedAllScopes:()=>true}}, + picker:{DocsView:View,PickerBuilder:Picker,ViewId:{FOLDERS:'folders'},Action:{PICKED:'picked',CANCEL:'cancel'}}}}; + const session = (GoogleDriveSession.create as any)(config,provider,host); + const connect = async()=>{await session.prepare();const done=session.connect();await clients.at(-1).callback({access_token:'fixture-token',scope,expires_in:3600});await done;}; + return {session,provider,clients,callbacks,connect}; +} + +describe('consentement et réponses tardives GIS/Picker, aucun vrai compte',()=>{ + it('impose la confirmation du compte affiché avant toute sélection ou création de dossier',async()=>{ + const f=sessionFixture();await f.connect();expect(f.session.identity.label).toBe('backup-fixture@example.invalid'); + await expect(f.session.chooseFolder()).rejects.toMatchObject({code:'confirmation'}); + await expect(f.session.newFolder()).rejects.toMatchObject({code:'confirmation'}); + expect(()=>f.session.confirmAccount('other-account')).toThrow('confirmation'); + expect(f.provider.folder).not.toHaveBeenCalled();expect(f.provider.reserveId).not.toHaveBeenCalled(); + f.session.confirmAccount('fixture-account');expect(f.session.confirmed).toBe(true); + const p=f.session.chooseFolder();await f.callbacks.at(-1)({action:'picked',docs:[{id:'dedicated-folder'}]}); + expect((await p).folder).toBe('dedicated-folder');expect(f.provider.folder).toHaveBeenCalledWith('dedicated-folder'); + f.session.disconnect();expect(f.session.identity).toBeNull(); + }); + it('bloque un compte différent avant toute lecture de dossier ou réservation',async()=>{ + const f=sessionFixture();await f.connect();f.session.confirmAccount('fixture-account');f.provider.account.mockResolvedValue('other-account'); + const p=f.session.chooseFolder();const rejected=expect(p).rejects.toMatchObject({code:'auth'}); + await f.callbacks.at(-1)({action:'picked',docs:[{id:'dedicated-folder'}]});await rejected; + await expect(f.session.newFolder()).rejects.toMatchObject({code:'auth'}); + expect(f.provider.folder).not.toHaveBeenCalled();expect(f.provider.reserveId).not.toHaveBeenCalled(); + }); + it('ignore le callback Picker de la session antérieure sans annuler le nouveau choix',async()=>{ + const f=sessionFixture();await f.connect();f.session.confirmAccount('fixture-account'); + const old=f.session.chooseFolder().catch((e: any)=>e.code), stale=f.callbacks.at(-1);f.session.disconnect();expect(await old).toBe('cancelled'); + await f.connect();f.session.confirmAccount('fixture-account');const current=f.session.chooseFolder(); + await stale({action:'picked',docs:[{id:'old-folder'}]});expect(f.provider.folder).not.toHaveBeenCalled(); + await f.callbacks.at(-1)({action:'picked',docs:[{id:'new-folder'}]});expect((await current).folder).toBe('new-folder'); + }); + it('réutilise le même identifiant de dossier après une réponse perdue',async()=>{ + const f=sessionFixture();await f.connect();f.session.confirmAccount('fixture-account'); + f.provider.createFolder.mockRejectedValueOnce(Object.assign(Error('unavailable'),{code:'unavailable'})); + await expect(f.session.newFolder()).rejects.toMatchObject({code:'unavailable'});expect(await f.session.newFolder()).toBe('reserved-folder'); + expect(f.provider.reserveId).toHaveBeenCalledTimes(1);expect(f.provider.createFolder.mock.calls.map(c=>c[0])).toEqual(['reserved-folder','reserved-folder']); + }); +}); + +describe('relecture du dossier réservé et révocation',()=>{ + it('retrouve le dossier créé malgré une réponse POST perdue, sans nouvelle création',async()=>{ + let exists=false, posts=0; + const fetcher=vi.fn(async(_url,init)=>{ + if(init?.method==='POST'){posts++;exists=true;throw Error('response lost');} + return exists?json({id:'reserved-folder',mimeType:'application/vnd.google-apps.folder',trashed:false,ownedByMe:true, + appProperties:{cv_contract:'rc-folder-v1'},capabilities:{canAddChildren:true}}):json({},404); + }); + const provider=client(fetcher); + await expect(provider.createFolder('reserved-folder')).rejects.toMatchObject({code:'unavailable'}); + expect(await provider.createFolder('reserved-folder')).toBe('reserved-folder');expect(posts).toBe(1); + }); + it('ne réactive pas un jeton révoqué pour changer de destination',async()=>{ + const f=sessionFixture();await f.connect();f.session.confirmAccount('fixture-account'); + Object.defineProperty(f.provider,'connected',{get:()=>false}); + await expect(f.session.newFolder()).rejects.toMatchObject({code:'auth'}); + expect(f.session.connected).toBe(false);expect(f.session.identity).toBeNull();expect(f.provider.reserveId).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/drive-connection.test.ts b/tests/unit/drive-connection.test.ts index 87e4648..c0a62f0 100644 --- a/tests/unit/drive-connection.test.ts +++ b/tests/unit/drive-connection.test.ts @@ -31,13 +31,13 @@ describe('connexion réelle et métadonnées publiques A1', () => { if (u.pathname.endsWith('/about')) return json({ user: { permissionId: 'synthetic_account' } }); if (u.pathname.endsWith('/files/generateIds')) return json({ ids: ['reserved_key'] }); if (u.pathname === '/upload/drive/v3/files') { expect(init.method).toBe('POST'); expect(String(init.body)).not.toContain('synthetic-owner-code'); expect(String(init.body)).toContain('reserved_key'); stored = true; return json({ id: 'reserved_key' }); } - if (u.pathname.endsWith('/files/folder_A')) return json({ id: 'folder_A', mimeType: 'application/vnd.google-apps.folder', capabilities: { canAddChildren: true } }); + if (u.pathname.endsWith('/files/folder_A')) return json({ id: 'folder_A', mimeType: 'application/vnd.google-apps.folder', trashed: false, ownedByMe: true, appProperties: { cv_contract: 'rc-folder-v1' }, capabilities: { canAddChildren: true } }); if (u.pathname.endsWith('/files/reserved_key')) { if (!stored) return json({}, 404); if (u.searchParams.get('alt') === 'media') return new Response(bytes as BodyInit); return json({ id: 'reserved_key', size: String(bytes.length), parents: ['folder_A'], appProperties: { cv_contract: 'rc-key-a1', cv_file: hash } }); } - if (u.pathname.endsWith('/files')) return json({ incompleteSearch: false, files: stored ? [{ id: 'reserved_key', size: String(bytes.length), appProperties: { cv_contract: 'rc-key-a1', cv_file: hash } }] : [] }); + if (u.pathname.endsWith('/files')) return json({ incompleteSearch: false, files: stored ? [{ id: 'reserved_key', parents: ['folder_A'], size: String(bytes.length), appProperties: { cv_contract: 'rc-key-a1', cv_file: hash } }] : [] }); throw new Error('Unexpected request'); }) as typeof fetch; const drive = new OpaqueDrive(['https://vault.example.com'], fetcher); drive.connect('synthetic_token', 3600, [DRIVE_FILE_SCOPE]); diff --git a/tests/unit/opaque-drive.test.ts b/tests/unit/opaque-drive.test.ts index 4ae651c..cdcf318 100644 --- a/tests/unit/opaque-drive.test.ts +++ b/tests/unit/opaque-drive.test.ts @@ -35,7 +35,7 @@ function backend() { if (loseReply) throw new TypeError('Panne fictive après écriture'); return json({ id }); } - if (key === folder) return json({ id: folder, mimeType: 'application/vnd.google-apps.folder', capabilities: { canAddChildren: true } }); + if (key === folder) return json({ id: folder, mimeType: 'application/vnd.google-apps.folder', trashed: false, ownedByMe: true, appProperties: { cv_contract: 'rc-folder-v1' }, capabilities: { canAddChildren: true } }); if (u.pathname === '/drive/v3/files') return json({ incompleteSearch: false, files: [...files.values()].map(f => f.meta) }); const file = files.get(key); if (!file) return json({}, 404); if (u.searchParams.get('alt') === 'media') { @@ -290,15 +290,15 @@ describe('fournisseur opaque, réseau entièrement fictif', () => { await expect(drive.reserveId(aborted.signal)).rejects.toMatchObject({ code: 'cancelled' }); }); it('énumère toutes les pages, en gardant les versions non vérifiées', async () => { - const meta = (n: number) => ({ id: 'fixture-' + n, size: String(fixture.bytes.length), appProperties: { cv_contract: 'rc-v1', cv_file: fixture.hash, cv_cipher: fixture.envelope.sha256 } }); + const meta = (n: number) => ({ id: 'fixture-' + n, parents: [folder], size: String(fixture.bytes.length), appProperties: { cv_contract: 'rc-v1', cv_file: fixture.hash, cv_cipher: fixture.envelope.sha256 } }); const calls: string[] = []; - const drive = client(async input => { calls.push(String(input)); return json({ incompleteSearch: false, files: Array.from({ length: 100 }, (_, i) => meta(i + (calls.length - 1) * 100)), ...(calls.length === 1 ? { nextPageToken: 'next-fixture' } : {}) }); }); + const drive = client(async input => { if(new URL(String(input)).pathname.endsWith('/'+folder)) return json({id:folder,mimeType:'application/vnd.google-apps.folder',trashed:false,ownedByMe:true,appProperties:{cv_contract:'rc-folder-v1'},capabilities:{canAddChildren:true}}); calls.push(String(input)); return json({ incompleteSearch: false, files: Array.from({ length: 100 }, (_, i) => meta(i + (calls.length - 1) * 100)), ...(calls.length === 1 ? { nextPageToken: 'next-fixture' } : {}) }); }); const list = await drive.list(folder); expect(list).toHaveLength(200); expect(list.every(v => v.state === 'unverified')).toBe(true); expect(new URL(calls[1]).searchParams.get('pageToken')).toBe('next-fixture'); }); it('recherche incomplète et boucle de pagination ne donnent jamais un catalogue complet', async () => { for (const page of [{ incompleteSearch: true, files: [] }, { incompleteSearch: false, files: [], nextPageToken: 'again' }]) { - const drive = client(async () => json(page)); await expect(drive.list(folder)).rejects.toMatchObject({ code: 'incomplete' }); + const drive = client(async input => new URL(String(input)).pathname.endsWith('/'+folder) ? json({id:folder,mimeType:'application/vnd.google-apps.folder',trashed:false,ownedByMe:true,appProperties:{cv_contract:'rc-folder-v1'},capabilities:{canAddChildren:true}}) : json(page)); await expect(drive.list(folder)).rejects.toMatchObject({ code: 'incomplete' }); } }); }); From b677c7c25bdedf1b1d98befd176aad6c0e753592 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:14:37 +0200 Subject: [PATCH 03/39] fix(drive): distinguish conflict from integrity and access blocks --- src/services/connected-drive.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/connected-drive.ts b/src/services/connected-drive.ts index 2a42e34..09dad43 100644 --- a/src/services/connected-drive.ts +++ b/src/services/connected-drive.ts @@ -42,8 +42,8 @@ export class ConnectedDrive { catch (e) { const code = (e as { code?: string }).code; if (epoch !== this.epoch) return; - this.change({ connected: this.session?.connected === true, phase: ['auth','binding','integrity','conflict','confirmation','quota','missing','incomplete','unavailable','origin'].includes(code || '') ? 'SYNC_BLOCKED' : 'ERROR', lastOperationAt: Date.now(), - message: code === 'auth' ? 'Session Google expirée : reconnecte-toi pour reprendre.' : code === 'binding' ? 'Cet envoi attend le compte et le dossier utilisés à sa préparation.' : code === 'quota' ? 'Espace Google Drive insuffisant. Copie locale conservée.' : 'Opération non confirmée. Aucune copie distante n’a été supprimée ni remplacée.' }); + this.change({ connected: this.session?.connected === true, phase: code === 'conflict' ? 'CONFLICT' : ['auth','binding','integrity','conflict','confirmation','quota','missing','incomplete','unavailable','origin'].includes(code || '') ? 'SYNC_BLOCKED' : 'ERROR', lastOperationAt: Date.now(), + message: code === 'conflict' ? 'Conflit de destination ou de métadonnées : opération bloquée, copies conservées. Vérifie les révisions dans Race Control.' : code === 'integrity' ? 'Intégrité non confirmée : synchronisation bloquée, copie locale conservée.' : code === 'auth' ? 'Session Google expirée : reconnecte-toi pour reprendre.' : code === 'binding' ? 'Cet envoi attend le compte et le dossier utilisés à sa préparation.' : code === 'quota' ? 'Espace Google Drive insuffisant. Copie locale conservée.' : 'Opération non confirmée. Aucune copie distante n’a été supprimée ni remplacée.' }); } finally { if (epoch === this.epoch) this.change({ busy: false }); } } async prepare() { return this.action(async epoch => { await this.session?.prepare(); if (epoch !== this.epoch) return; this.change({ prepared: true, message: 'Connexion prête. Appuie sur Se connecter à Google.' }); }); } From 642da6f24c3416f89caf3d7406629bc5238c79a6 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:25:17 +0200 Subject: [PATCH 04/39] fix(drive): freeze download identity before asynchronous folder checks --- src/services/opaque-drive.ts | 3 +++ tests/unit/drive-boundary.test.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/services/opaque-drive.ts b/src/services/opaque-drive.ts index b49a0b9..74809f0 100644 --- a/src/services/opaque-drive.ts +++ b/src/services/opaque-drive.ts @@ -265,6 +265,9 @@ export class OpaqueDrive { return { packet, proof: Object.freeze({ ...remote, verifiedAt: this.now(), state: 'verified-bytes' }) }; } async download(expected: RemotePacket, signal?: AbortSignal, folder?: string) { + // Le contrôle du dossier est lui-même asynchrone : figer aussi AVANT lui. + try { expected = Object.freeze({ ...expected, ...(expected.revision !== undefined ? { revision: readSyncRevision(expected.revision) } : {}) }); } + catch { return fail('integrity'); } const epoch = this.lease(); if (folder !== undefined) { await this.folder(folder, signal); this.check(epoch, signal); } return this.verified(expected, epoch, folder, signal); } diff --git a/tests/unit/drive-boundary.test.ts b/tests/unit/drive-boundary.test.ts index 88192ad..361c92c 100644 --- a/tests/unit/drive-boundary.test.ts +++ b/tests/unit/drive-boundary.test.ts @@ -109,3 +109,19 @@ describe('relecture du dossier réservé et révocation',()=>{ expect(f.session.connected).toBe(false);expect(f.session.identity).toBeNull();expect(f.provider.reserveId).not.toHaveBeenCalled(); }); }); + +describe('référence du téléchargement figée avant le contrôle du dossier',()=>{ + it('ne suit pas une référence modifiée pendant la relecture asynchrone du dossier',async()=>{ + const fixture=await packetFixture();let release!:()=>void;const held=new Promise(r=>release=r); + const fetcher=vi.fn(async input=>{ + const u=new URL(String(input)),fileId=u.pathname.split('/').at(-1)!; + if(fileId==='dedicated-folder'){await held;return json({id:fileId,mimeType:'application/vnd.google-apps.folder',trashed:false,ownedByMe:true,appProperties:{cv_contract:'rc-folder-v1'},capabilities:{canAddChildren:true}});} + if(u.searchParams.get('alt')==='media')return new Response(fixture.bytes as BodyInit); + return json({id:fileId,parents:['dedicated-folder'],size:String(fixture.bytes.length),appProperties:{cv_contract:'rc-v1',cv_file:fixture.hash,cv_cipher:fixture.envelope.sha256}}); + }); + const ref={fileId:'expected-archive',fileSha256:fixture.hash,cipherSha256:fixture.envelope.sha256,size:fixture.bytes.length,state:'unverified' as const}; + const reading=client(fetcher).download(ref,undefined,'dedicated-folder');ref.fileId='other-archive';release(); + expect((await reading).proof.fileId).toBe('expected-archive'); + expect(fetcher.mock.calls.some(c=>String(c[0]).includes('/other-archive'))).toBe(false); + }); +}); From 3e33edc371e185532e68fe5d70c72d81608b4158 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:12:46 +0200 Subject: [PATCH 05/39] fix(idb): retry failed opens and invalidate closed connections --- src/services/drive-store.js | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/services/drive-store.js b/src/services/drive-store.js index b1e4332..c2cb85a 100644 --- a/src/services/drive-store.js +++ b/src/services/drive-store.js @@ -4,21 +4,37 @@ */ const DriveStore = (() => { function create(name, indexedDB = globalThis.indexedDB) { - let opening; + let opening, active; function open() { if (!opening) opening = new Promise((resolve, reject) => { if (!indexedDB) return reject(new Error('stockage')); const r = indexedDB.open(name, 1); + let invalid = false; + const failure = () => { invalid = true; reject(new Error('stockage')); }; r.onupgradeneeded = () => { r.result.createObjectStore('state'); r.result.createObjectStore('recovery'); }; - r.onerror = () => reject(new Error('stockage')); r.onblocked = () => reject(new Error('stockage')); - r.onsuccess = () => { r.result.onversionchange = () => r.result.close(); resolve(r.result); }; - }); + r.onerror = failure; + // Blocked opens may eventually complete: close a late connection from a rejected attempt. + r.onblocked = failure; + r.onsuccess = () => { + const db = r.result; + if (invalid) { db.close(); return; } + active = db; + const invalidate = () => { if (active === db) { active = null; opening = null; } }; + db.onversionchange = () => { db.close(); invalidate(); }; + db.onclose = invalidate; + resolve(db); + }; + }).catch(error => { opening = null; throw error; }); return opening; } + function transaction(db, store, mode) { + try { return db.transaction(store, mode); } + catch { opening = null; if (active === db) active = null; throw new Error('stockage'); } + } async function read(store, key) { const db = await open(); return new Promise((resolve, reject) => { - const tx = db.transaction(store, 'readonly'), request = tx.objectStore(store).get(key); let value; + const tx = transaction(db, store, 'readonly'), request = tx.objectStore(store).get(key); let value; request.onsuccess = () => { value = request.result; }; tx.oncomplete = () => resolve(value ?? null); tx.onabort = tx.onerror = () => reject(new Error('stockage')); }); @@ -27,7 +43,7 @@ const DriveStore = (() => { const expected = structuredClone(value); const db = await open(); await new Promise((resolve, reject) => { - const tx = db.transaction('state', 'readwrite'); tx.objectStore('state').put(expected, 'current'); + const tx = transaction(db, 'state', 'readwrite'); tx.objectStore('state').put(expected, 'current'); tx.oncomplete = resolve; tx.onerror = tx.onabort = () => reject(new Error('stockage')); }); // Comparer les octets sans les convertir en une immense liste JSON. @@ -46,14 +62,14 @@ const DriveStore = (() => { const copy = bytes.slice(), hash = [...new Uint8Array(await crypto.subtle.digest('SHA-256', copy))].map(x => x.toString(16).padStart(2, '0')).join(''); const db = await open(); await new Promise((resolve, reject) => { - const tx = db.transaction('recovery', 'readwrite'), store = tx.objectStore('recovery'), r = store.get(hash); + const tx = transaction(db, 'recovery', 'readwrite'), store = tx.objectStore('recovery'), r = store.get(hash); r.onsuccess = () => { if (r.result == null) store.add(copy, hash); }; tx.oncomplete = resolve; tx.onerror = tx.onabort = () => reject(new Error('stockage')); }); const back = await read('recovery', hash); if (!(back instanceof Uint8Array) || back.length !== copy.length || back.some((v, i) => v !== copy[i])) throw new Error('stockage'); await new Promise((resolve, reject) => { - const tx = db.transaction('state', 'readwrite'); tx.objectStore('state').put(hash, 'recovery-latest'); + const tx = transaction(db, 'state', 'readwrite'); tx.objectStore('state').put(hash, 'recovery-latest'); tx.oncomplete = resolve; tx.onerror = tx.onabort = () => reject(new Error('stockage')); }); return hash; From f2eff8ce63dc698f6a865fcd3d1e26e450512f59 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:13:59 +0200 Subject: [PATCH 06/39] test(idb): verify blocked, failed and versionchanged reopen without purge --- tests/unit/drive-store-reopen.test.ts | 94 +++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/unit/drive-store-reopen.test.ts diff --git a/tests/unit/drive-store-reopen.test.ts b/tests/unit/drive-store-reopen.test.ts new file mode 100644 index 0000000..32375e7 --- /dev/null +++ b/tests/unit/drive-store-reopen.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { DriveStore } from '../../src/services/drive-store.js'; + +// NOESIS-CV-002: injected provider only; the production DriveStore is imported. +function backend() { + let mode: 'ok' | 'error' | 'blocked' = 'ok'; + let opens = 0; + let deletes = 0; + const connections: any[] = []; + const late: Array<() => void> = []; + const rows: Record> = { state: new Map(), recovery: new Map() }; + function connection() { + let closed = false; + const db: any = { + close() { closed = true; }, createObjectStore() {}, + get closed() { return closed; }, + transaction(store: string) { + if (closed) throw Error('InvalidStateError: closed database'); + const tx: any = { objectStore() { return { + get(key: string) { + const req: any = {}; + queueMicrotask(() => { req.result = rows[store].get(key); req.onsuccess?.(); queueMicrotask(() => tx.oncomplete?.()); }); + return req; + }, + put(value: unknown, key: string) { + rows[store].set(key, structuredClone(value)); + queueMicrotask(() => tx.oncomplete?.()); + } + }; } }; + return tx; + } + }; + connections.push(db); + return db; + } + const indexedDB = { + open() { + opens++; + const req: any = { result: connection() }; + const behavior = mode; mode = 'ok'; + queueMicrotask(() => { + if (behavior === 'error') { req.onerror?.(); return; } + if (behavior === 'blocked') { req.onblocked?.(); late.push(() => req.onsuccess?.()); return; } + req.onupgradeneeded?.(); req.onsuccess?.(); + }); + return req; + }, + deleteDatabase() { deletes++; throw Error('Forbidden database deletion'); } + } as unknown as IDBFactory; + return { indexedDB, connections, late, rows, set mode(next: 'ok' | 'error' | 'blocked') { mode = next; }, get opens() { return opens; }, get deletes() { return deletes; } }; +} + +describe('NOESIS-CV-002 — durable IndexedDB open retry without any purge', () => { + it('retries a rejected first open rather than caching a rejected promise', async () => { + const f = backend(), store = DriveStore.create('fixture-reopen', f.indexedDB); + f.mode = 'error'; + await expect(store.read()).rejects.toThrow('stockage'); + await expect(store.read()).resolves.toBeNull(); + expect(f.opens).toBe(2); + }); + it('rejects a blocked open, closes any late success, and can reopen', async () => { + const f = backend(), store = DriveStore.create('fixture-reopen', f.indexedDB); + f.mode = 'blocked'; + await expect(store.read()).rejects.toThrow('stockage'); + await expect(store.read()).resolves.toBeNull(); + f.late.forEach(release => release()); + await Promise.resolve(); + expect(f.connections[0].closed).toBe(true); + expect(f.deletes).toBe(0); + }); + it('closes and invalidates on versionchange and can read existing bytes', async () => { + const f = backend(), store = DriveStore.create('fixture-reopen', f.indexedDB); + await store.write({v: 1, job: null, receipts: [{fileId: 'synthetic'}]}); + f.connections[0].onversionchange(); + expect(f.connections[0].closed).toBe(true); + expect((await store.read() as { receipts: Array<{fileId:string}> }).receipts[0].fileId).toBe('synthetic'); + expect(f.opens).toBe(2); + }); + it('retries after a manually closed transaction and never deletes data', async () => { + const f = backend(), store = DriveStore.create('fixture-reopen', f.indexedDB); + await store.write({v: 1, job: null, receipts: []}); + f.connections[0].close(); + await expect(store.read()).rejects.toThrow('stockage'); + await expect(store.read()).resolves.toMatchObject({v:1}); + expect(f.opens).toBe(2); + expect(f.deletes).toBe(0); + }); + it('coalesces simultaneous opens', async () => { + const f = backend(), store = DriveStore.create('fixture-reopen', f.indexedDB); + const reads = await Promise.all([store.read(), store.read(), store.read()]); + expect(reads).toEqual([null,null,null]); + expect(f.opens).toBe(1); + }); +}); From 9b0abdfd796acda4eec6245eedb5c704f8129bbb Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:20:45 +0200 Subject: [PATCH 07/39] fix(drive): persist and readback download receipts under shared outbox lock --- src/services/connected-drive.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/services/connected-drive.ts b/src/services/connected-drive.ts index 09dad43..e4cc5aa 100644 --- a/src/services/connected-drive.ts +++ b/src/services/connected-drive.ts @@ -123,8 +123,29 @@ export class ConnectedDrive { await this.action(async epoch => { const { provider, folder } = this.selected(); const back = await provider.download(ref, undefined, folder); this.check(epoch); await this.archive.save(back.packet); this.check(epoch); + // The archive and its transport receipt are separate guarantees. Serialize the + // receipt read/modify/write with uploads under the existing outbox lock. + let receipts: ByteProof[] = []; + await this.locked(async () => { + const previous = await this.durable.read() || { v: 1 as const, job: null, receipts: [] }; + this.check(epoch); + if (previous.v !== 1 || !Array.isArray(previous.receipts)) throw new Error('stockage'); + const updated: Durable = { + ...previous, + receipts: [...previous.receipts.filter(p => p.fileId !== back.proof.fileId), back.proof] + }; + await this.durable.write(updated); this.check(epoch); + const confirmed = await this.durable.read(); this.check(epoch); + const sameProof = (p: ByteProof) => p.fileId === back.proof.fileId && + p.fileSha256 === back.proof.fileSha256 && p.cipherSha256 === back.proof.cipherSha256 && + p.size === back.proof.size && p.verifiedAt === back.proof.verifiedAt; + if (!confirmed || confirmed.v !== 1 || !Array.isArray(confirmed.receipts) || + !confirmed.receipts.some(sameProof) || JSON.stringify(confirmed.job) !== JSON.stringify(previous.job)) + throw new Error('stockage'); + receipts = confirmed.receipts; + }); bytes = back.packet.copyBytes(); - this.change({ phase: 'VERIFIED_BYTES', lastOperationAt: Date.now(), receipts: [...this.state.receipts.filter(p => p.fileId !== ref.fileId), back.proof], message: 'Octets vérifiés et copie chiffrée gardée sur cet appareil. Ouvre ce fichier dans Race Control pour le déchiffrer.' }); + this.change({ phase: 'VERIFIED_BYTES', lastOperationAt: Date.now(), receipts, message: 'Octets vérifiés et copie chiffrée gardée sur cet appareil. Ouvre ce fichier dans Race Control pour le déchiffrer.' }); }); return bytes; } From bd71f9175bb022ccafc338df217f79d0dd7e2f8a Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:21:05 +0200 Subject: [PATCH 08/39] test(drive): receipt readback, reload, idempotence, pending job and failed provider --- tests/unit/connected-drive-receipts.test.ts | 80 +++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/unit/connected-drive-receipts.test.ts diff --git a/tests/unit/connected-drive-receipts.test.ts b/tests/unit/connected-drive-receipts.test.ts new file mode 100644 index 0000000..c6bdcc8 --- /dev/null +++ b/tests/unit/connected-drive-receipts.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConnectedDrive } from '../../src/services/connected-drive'; +import type { ByteProof, RemotePacket } from '../../src/services/opaque-drive'; + +const sha = (c: string) => c.repeat(64); +const proof: ByteProof = { fileId:'fixture-packet', fileSha256:sha('a'), cipherSha256:sha('b'), size:3, verifiedAt:1730000000000, state:'verified-bytes' }; +const ref: RemotePacket = { fileId:proof.fileId, fileSha256:proof.fileSha256, cipherSha256:proof.cipherSha256, size:proof.size, state:'unverified' }; +const bytes = new Uint8Array([42,43,44]); +function storage(seed: { v:1; job:any; receipts:ByteProof[] } = {v:1,job:null,receipts:[]}) { + let saved = structuredClone(seed); + let failReadback = false; + const read = vi.fn(async () => { + if (failReadback) return structuredClone(seed); + return structuredClone(saved); + }); + const write = vi.fn(async (value:typeof saved) => { saved=structuredClone(value); }); + return { read,write, get saved(){return saved;},set failReadback(x:boolean){failReadback=x;} }; +} +function setup(store: ReturnType) { + const app = new ConnectedDrive(); + const p = { + download: vi.fn(async () => ({packet:{copyBytes:()=>bytes.slice()},proof})), + list: vi.fn(async () => [ref]) + }; + const archive={save:vi.fn(async () => {})}; + const internal=app as any; + internal.provider=p; + internal.session={connected:true}; + internal.selection={folder:'synthetic-dedicated-folder',binding:'test-binding'}; + internal.archive=archive; + internal.durable=store; + return {app,p,archive}; +} +beforeEach(()=>{ + vi.stubGlobal('navigator',{locks:{request:async (_name:string,cb:()=>Promise)=>cb()}}); +}); +afterEach(()=>vi.unstubAllGlobals()); + +describe('NOESIS-CV-001 — proof of opaque download survives reload',()=>{ + it('archives packet, commits receipt once, and reloads verified receipt without losing a concurrent upload',async()=>{ + const job={binding:'test-binding',bytes:new Uint8Array([1,2]),fileId:'reserved',sha:sha('c')}; + const disk=storage({v:1,job,receipts:[]}); + const {app,p,archive}=setup(disk); + const ret=await app.download(ref); + expect(ret).toEqual(bytes); + expect(archive.save).toHaveBeenCalledOnce(); + expect(disk.saved.job).toEqual(job); + expect(disk.saved.receipts).toHaveLength(1); + expect(disk.saved.receipts[0]).toEqual(proof); + expect(p.download).toHaveBeenCalledTimes(1); + const fresh=setup(disk).app; + await fresh.refresh(); + expect(fresh.state.receipts).toEqual([proof]); + }); + it('redownloading is idempotent and does not create duplicate receipts',async()=>{ + const disk=storage(),{app}=setup(disk); + await app.download(ref); await app.download(ref); + expect(disk.saved.receipts).toEqual([proof]); + expect(disk.write).toHaveBeenCalledTimes(2); + }); + it('a failed receipt readback cannot return bytes or claim VERIFIED_BYTES',async()=>{ + const disk=storage(); + disk.failReadback=true; + const {app}=setup(disk); + const result=await app.download(ref); + expect(result).toBeNull(); + expect(app.state.phase).toBe('ERROR'); + expect(app.state.message).toMatch(/Opération non confirmée/); + expect(app.state.receipts).toEqual([]); + expect(disk.write).toHaveBeenCalledOnce(); + }); + it('provider error retains the original pending upload and its receipts',async()=>{ + const disk=storage({v:1,job:{binding:'test-binding',bytes:new Uint8Array([7]),fileId:null,sha:sha('d')},receipts:[]}); + const {app,p}=setup(disk); + p.download.mockRejectedValueOnce(new Error('synthetic network fault')); + expect(await app.download(ref)).toBeNull(); + expect(disk.saved.job).not.toBeNull(); + expect(disk.saved.receipts).toEqual([]); + }); +}); From 1403c62db8b51603b9fffd6f9a668b0b26f2d55b Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:22:59 +0200 Subject: [PATCH 09/39] test(drive): initialize synthetic IndexedDB before importing eager archive singleton --- tests/unit/connected-drive-receipts.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/unit/connected-drive-receipts.test.ts b/tests/unit/connected-drive-receipts.test.ts index c6bdcc8..bc58907 100644 --- a/tests/unit/connected-drive-receipts.test.ts +++ b/tests/unit/connected-drive-receipts.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ConnectedDrive } from '../../src/services/connected-drive'; +let ConnectedDrive: typeof import('../../src/services/connected-drive')['ConnectedDrive']; import type { ByteProof, RemotePacket } from '../../src/services/opaque-drive'; const sha = (c: string) => c.repeat(64); @@ -31,8 +31,11 @@ function setup(store: ReturnType) { internal.durable=store; return {app,p,archive}; } -beforeEach(()=>{ +beforeEach(async()=>{ + vi.stubGlobal('indexedDB',{open:vi.fn()}); vi.stubGlobal('navigator',{locks:{request:async (_name:string,cb:()=>Promise)=>cb()}}); + vi.resetModules(); + ({ConnectedDrive}=await import('../../src/services/connected-drive')); }); afterEach(()=>vi.unstubAllGlobals()); From 3efb23eb1505653689431577a9aea8bb6509878e Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:42:23 +0200 Subject: [PATCH 10/39] test(e2e): prove Control Vault downloaded packet decrypts fully on virgin Race Control browser --- tests/e2e/connected-drive.spec.ts | 56 +++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/tests/e2e/connected-drive.spec.ts b/tests/e2e/connected-drive.spec.ts index 50227ef..386f1dd 100644 --- a/tests/e2e/connected-drive.spec.ts +++ b/tests/e2e/connected-drive.spec.ts @@ -1,12 +1,14 @@ import { test, expect } from '@playwright/test'; import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { fixtureCode } from '../fixtures/race-packet'; import { resolve } from 'node:path'; import { packetFixture } from '../fixtures/race-packet'; const origin = 'https://vault.example.com'; const scope = 'https://www.googleapis.com/auth/drive.file'; const config = { approved: true, clientId: ['123456', 'fake.apps.googleusercontent.com'].join('-'), apiKey: 'AIza' + 'a'.repeat(35), appId: '123456', raceOrigin: 'https://race.example.com', vaultOrigin: origin }; -test('application complète, Google simulé : confirmation compte, dossier dédié, upload opaque, relecture et jeton oublié', async ({ context, page }) => { +test('application complète, Google simulé : confirmation compte, dossier dédié, upload opaque, relecture et jeton oublié', async ({ context, page, browser }) => { await context.route('**/*', r => r.abort()); const fixture = await packetFixture(61), files = new Map; bytes: Buffer }>(), pending = new Map>(); let next = 0, posts = 0, folderPosts = 0, folderReady = false; await context.route(origin + '/**', async r => { @@ -61,7 +63,57 @@ test('application complète, Google simulé : confirmation compte, dossier dédi await expect(page.getByRole('status').filter({ hasText: 'Copie envoyée' })).toBeVisible(); expect(files.size).toBe(1); expect(posts).toBe(1); await page.getByRole('button', { name: 'Actualiser les versions' }).click(); - const download = page.waitForEvent('download'); await page.getByRole('button', { name: 'Vérifier et télécharger' }).click(); await download; + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Vérifier et télécharger' }).click(); + const archive = await download; + const downloaded = readFileSync(await archive.path()); + expect(createHash('sha256').update(downloaded).digest('hex')).toBe(fixture.hash); + // Full business payload recovered with the ACTUAL pinned Race Control owner + // codec, not with a second crypto implementation in Control Vault. + // A virgin isolated browser has no producer storage, key or sessions. + const virgin = await browser.newContext({ serviceWorkers: 'block' }); + try { + const ownerSource = readFileSync('tests/fixtures/race-owner/control-vault-packet.js', 'utf8'); + await virgin.route('https://race.example.com/**', route => { + const p = new URL(route.request().url()).pathname; + return p === '/owner.js' + ? route.fulfill({contentType: 'text/javascript', body: ownerSource + '\\nwindow.rcOwner=ControlVaultPacket;'}) + : route.fulfill({contentType: 'text/html', body: 'Virgin synthetic recovery'}); + }); + const newDevice = await virgin.newPage(); + await newDevice.goto('https://race.example.com/'); + const result = await newDevice.evaluate(async ({ bytes, code, hash }) => { + const nativeCodec = (window as unknown as { rcOwner: {create: (o: unknown) => {open: (...args: unknown[])=>Promise}}}).rcOwner; + if (!nativeCodec) throw Error('Race Control owner codec not loaded'); + const codec = nativeCodec.create({validateBusiness: (x: unknown) => x !== null && typeof x === 'object'}); + const localBefore = localStorage.length, sessionBefore = sessionStorage.length; + const databasesBefore = typeof indexedDB.databases === 'function' ? (await indexedDB.databases()).length : null; + const decoded = await codec.open(Uint8Array.from(bytes), code, 'prod-156', hash); + let wrongCode = ''; + try { await codec.open(Uint8Array.from(bytes), 'incorrect-synthetic-code', 'prod-156', hash); } + catch (e) { wrongCode = String((e as {code?:string}).code || (e as Error).message); } + const modified = Uint8Array.from(bytes); modified[modified.length - 12] ^= 1; + let tampered = ''; + try { await codec.open(modified, code, 'prod-156', hash); } + catch (e) { tampered = String((e as {code?:string}).code || (e as Error).message); } + return {decoded, localBefore, sessionBefore, databasesBefore, wrongCode, tampered, + localAfter:localStorage.length, sessionAfter:sessionStorage.length}; + }, { bytes: Array.from(downloaded), code: fixtureCode, hash: fixture.hash }); + expect(result.localBefore).toBe(0); + expect(result.sessionBefore).toBe(0); + if (result.databasesBefore !== null) expect(result.databasesBefore).toBe(0); + expect(result.decoded).toEqual(fixture.snapshot.data); + expect(result.wrongCode).toBe('AUTHENTICATION_FAILED'); + expect(result.tampered).not.toBe(''); + expect(result.localAfter).toBe(0); + expect(result.sessionAfter).toBe(0); + const clear = result.decoded as typeof fixture.snapshot.data; + expect(clear.settings.cars[0].odo[0].km).toBe(44000); + expect(clear.settings.cars[0].tire.treads[0].mm).toBe(6.2); + expect(clear.durable.context.debrief.archive.records).toHaveLength(63); + } finally { + await virgin.close(); + } await expect(page.locator('input[type=password]')).toHaveCount(0); await page.getByLabel('Copie chiffrée Race Control').setInputFiles({ name: 'clear.json', mimeType: 'application/json', buffer: Buffer.from('{"settings":{"private":true}}') }); await expect(page.getByRole('status').filter({ hasText: 'Opération non confirmée' })).toBeVisible(); expect(posts).toBe(1); From aa0f85279ba3aab6409cafb8e71323d4ab8295fc Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:42:40 +0200 Subject: [PATCH 11/39] test(e2e): inject actual owner codec using a valid JavaScript newline --- tests/e2e/connected-drive.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/connected-drive.spec.ts b/tests/e2e/connected-drive.spec.ts index 386f1dd..f9a4357 100644 --- a/tests/e2e/connected-drive.spec.ts +++ b/tests/e2e/connected-drive.spec.ts @@ -77,7 +77,7 @@ test('application complète, Google simulé : confirmation compte, dossier dédi await virgin.route('https://race.example.com/**', route => { const p = new URL(route.request().url()).pathname; return p === '/owner.js' - ? route.fulfill({contentType: 'text/javascript', body: ownerSource + '\\nwindow.rcOwner=ControlVaultPacket;'}) + ? route.fulfill({contentType: 'text/javascript', body: ownerSource + '\nwindow.rcOwner=ControlVaultPacket;'}) : route.fulfill({contentType: 'text/html', body: 'Virgin synthetic recovery'}); }); const newDevice = await virgin.newPage(); From 7a2b78b037711816a99b4c47c666223a0cdb8031 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:53:50 +0100 Subject: [PATCH 12/39] fix(drive): compare pending encrypted bytes without JSON expansion --- docs/DRIVE_RECOVERY_HANDOFF_20261025.md | 56 +++++++++++++++++++++ src/services/connected-drive.ts | 11 +++- src/views/drive.ts | 2 + tests/e2e/connected-drive.spec.ts | 4 ++ tests/unit/connected-drive-receipts.test.ts | 27 ++++++++++ 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 docs/DRIVE_RECOVERY_HANDOFF_20261025.md diff --git a/docs/DRIVE_RECOVERY_HANDOFF_20261025.md b/docs/DRIVE_RECOVERY_HANDOFF_20261025.md new file mode 100644 index 0000000..bbfb033 --- /dev/null +++ b/docs/DRIVE_RECOVERY_HANDOFF_20261025.md @@ -0,0 +1,56 @@ +# Reçus et récupération RC — 25 octobre 2026 + +Auteur : Bryan Fiarda. Correction sensible, non fusionnée, non déployée. +Base dépendante : #19, qui inclut #17. #16 et #18 restent séparées ; #20 est +une preuve temporaire à ne pas fusionner. Aucune branche source modifiée. + +## Défaut reproduit et correction + +Lors d'un téléchargement, #19 relit le reçu durable et s'assure que le job +d'upload concurrent est intact. La comparaison par JSON.stringify transforme +son Uint8Array chiffré entier en objet JSON numérique, deux fois. Avec la limite +contractuelle de fichier, ces chaînes peuvent occuper bien plus de mémoire que +le fichier. Aucune panne physique ni perte personnelle n'est supposée. + +Le nouveau contre-test place un upload synthétique de 2 Mio en attente et refuse +sa sérialisation JSON. Il échoue sur #19 (null au lieu des octets téléchargés), +puis passe avec sameJob. La comparaison vérifie la forme connue, la liaison, +l'identifiant réservé, l'empreinte, la longueur et chaque octet sans conversion +JSON. Une seule différence d'octet interdit VERIFIED_BYTES. Les quatre tests +antérieurs restent intacts ; le journal avant correction est conservé. + +La copie Uint8Array reste gérée par les mêmes archives et transactions IDB. +Aucune réduction de taille du ciphertext, aucune suppression ou nouvelle +compression n'est revendiquée. Ce changement évite une allocation inutile ; il +ne chiffre pas un gain de RAM d'un iPhone réel non mesuré. + +## Frontière de confiance et lien de récupération + +L'écran Drive garde le parcours d'import dans Race Control et ajoute le lien +`/recovery.html`. Cette page appartient au candidat RC de récupération +A2 du 25 octobre. Publier les deux candidats compatibles après leurs GO séparés. +Ne pas présenter ce lien comme un parcours disponible dans la production actuelle. + +Control Vault ne demande jamais le code, ne déchiffre pas et ne choisit pas une +tête A2. Le code et la comparaison métier restent dans RC. Le lien utilise +uniquement l'origine validée de la configuration, aucun paquet, code, jeton ou +identifiant privé dans l'URL, avec noopener/noreferrer. Les tests de l'interface +vérifient le lien et l'absence de champ mot de passe. + +## Validation et limites + +Reproduire : `npm run typecheck`, `npm test`, `npm run build`, `npm run security`, +`CI=1 npm run test:e2e`. Le workflow Chromium/WebKit existant reste inchangé. +Vérifier la CI sur le SHA exact de cette PR ; les anciennes CI ne la valident pas. + +Les réponses Google des tests sont fictives. Les comptes, restrictions de clé +Picker, appProperties et visibilité entre les origines doivent être vérifiés +sur un compte de test autorisé, puis sur iPhone physique. Aucun essai réel, +configuration Google, déploiement ou transmission personnelle dans ce lot. + +## Impact et retour arrière + +Changement local au contrôle de reçu et à un lien explicite ; aucun format, +scope, secret, schéma IDB, cache, contrat V1/A1/A2 ou CI modifié. Ne jamais fusionner +la PR temporaire #20. Revert logiciel par PR après GO, sans purger les jobs, +archives, reçus, fichiers Drive ou stockages d'autres applications. diff --git a/src/services/connected-drive.ts b/src/services/connected-drive.ts index e4cc5aa..4b98812 100644 --- a/src/services/connected-drive.ts +++ b/src/services/connected-drive.ts @@ -8,6 +8,15 @@ import { OpaqueDrive, type RemotePacket, type ByteProof } from './opaque-drive'; interface Job { binding: string; bytes: Uint8Array; fileId: string | null; sha: string } interface Durable { v: 1; job: Job | null; receipts: ByteProof[] } +// Ne jamais sérialiser un Uint8Array pouvant atteindre 68 Mio en deux chaînes +// JSON géantes pour confirmer qu'un téléchargement n'a pas changé l'envoi. +function sameJob(a: Job | null, b: Job | null): boolean { + if (a === null || b === null) return a === b; + return !!a && !!b && Object.keys(a).length === 4 && Object.keys(b).length === 4 && + a.binding === b.binding && a.fileId === b.fileId && a.sha === b.sha && + a.bytes instanceof Uint8Array && b.bytes instanceof Uint8Array && a.bytes.length === b.bytes.length && + a.bytes.every((value, i) => value === b.bytes[i]); +} export interface ConnectedState { configured: boolean; prepared: boolean; connected: boolean; selected: boolean; busy: boolean; message: string; versions: RemotePacket[]; receipts: ByteProof[]; recoveryOrigin: string | null; account: string | null; accountConfirmed: boolean; folder: string | null; phase: 'WAITING' | 'SENDING' | 'UPLOADED' | 'VERIFIED_BYTES' | 'CONFLICT' | 'SYNC_BLOCKED' | 'ERROR'; pending: boolean; lastOperationAt: number | null } @@ -140,7 +149,7 @@ export class ConnectedDrive { p.fileSha256 === back.proof.fileSha256 && p.cipherSha256 === back.proof.cipherSha256 && p.size === back.proof.size && p.verifiedAt === back.proof.verifiedAt; if (!confirmed || confirmed.v !== 1 || !Array.isArray(confirmed.receipts) || - !confirmed.receipts.some(sameProof) || JSON.stringify(confirmed.job) !== JSON.stringify(previous.job)) + !confirmed.receipts.some(sameProof) || !sameJob(confirmed.job, previous.job)) throw new Error('stockage'); receipts = confirmed.receipts; }); diff --git a/src/views/drive.ts b/src/views/drive.ts index ae43365..f97a86c 100644 --- a/src/views/drive.ts +++ b/src/views/drive.ts @@ -46,6 +46,8 @@ export function driveView(): View { h('section', { class: 'panel' }, h('div', { class: 'panel-body' }, h('h2', null, 'Récupération'), h('p', null, 'Vérifie et télécharge une version, puis importe ce fichier dans Réglages → Sauvegarde de Race Control. Une empreinte identique prouve les octets reçus ; seul Race Control vérifie le chiffrement et prépare la fusion avec tes données actuelles.'), s.recoveryOrigin ? h('a', { class: 'btn', href: s.recoveryOrigin, target: '_blank', rel: 'noopener noreferrer' }, 'Ouvrir Race Control') : null, + s.recoveryOrigin ? h('a', { class: 'btn', href: new URL('/recovery.html', s.recoveryOrigin).href, target: '_blank', rel: 'noopener noreferrer' }, 'Récupérer sur un appareil neuf dans Race Control') : null, + h('p', { class: 'muted small' }, 'Sur appareil neuf, la récupération A2 se fait uniquement dans Race Control : compte et dossier confirmés, code local, copie indépendante puis vérification après réouverture. Tout stockage existant bloque le parcours vierge.'), h('p', { class: 'muted small' }, 'Aucune purge automatique. Aucun envoi de JSON personnel en clair. Une session Google expirée exige une reconnexion volontaire.'))), ); }; diff --git a/tests/e2e/connected-drive.spec.ts b/tests/e2e/connected-drive.spec.ts index f9a4357..0680787 100644 --- a/tests/e2e/connected-drive.spec.ts +++ b/tests/e2e/connected-drive.spec.ts @@ -47,6 +47,10 @@ test('application complète, Google simulé : confirmation compte, dossier dédi await page.goto(origin + '/#/reglages'); await page.getByRole('link', { name: /Copies chiffrées Race Control/ }).click(); await expect(page.getByRole('heading', { name: 'Google Drive', exact: true })).toBeVisible(); + const recoveryLink=page.getByRole('link',{name:'Récupérer sur un appareil neuf dans Race Control'}); + await expect(recoveryLink).toHaveAttribute('href','https://race.example.com/recovery.html'); + await expect(recoveryLink).toHaveAttribute('rel','noopener noreferrer'); + await expect(page.locator('input[type=password]')).toHaveCount(0); await page.getByRole('button', { name: 'Préparer la connexion Google' }).click(); await page.getByRole('button', { name: 'Se connecter à Google', exact: true }).click(); await expect(page.getByText('Compte Google sélectionné : fixture-backup@example.invalid')).toBeVisible(); diff --git a/tests/unit/connected-drive-receipts.test.ts b/tests/unit/connected-drive-receipts.test.ts index bc58907..9e37ad3 100644 --- a/tests/unit/connected-drive-receipts.test.ts +++ b/tests/unit/connected-drive-receipts.test.ts @@ -80,4 +80,31 @@ describe('NOESIS-CV-001 — proof of opaque download survives reload',()=>{ expect(disk.saved.job).not.toBeNull(); expect(disk.saved.receipts).toEqual([]); }); + it('compares an encrypted pending upload without expanding its bytes into JSON strings',async()=>{ + const job={binding:'test-binding',bytes:new Uint8Array(2*1024*1024).fill(73),fileId:'reserved',sha:sha('c')}; + const disk=storage({v:1,job,receipts:[]}),{app}=setup(disk); + const stringify=JSON.stringify; + const guard=vi.spyOn(JSON,'stringify').mockImplementation(((value:any,...args:any[])=>{ + if(value?.bytes instanceof Uint8Array)throw new Error('encrypted upload must not be expanded into JSON'); + return stringify(value,...args); + }) as typeof JSON.stringify); + try { expect(await app.download(ref)).toEqual(bytes); expect(app.state.phase).toBe('VERIFIED_BYTES'); } + finally { guard.mockRestore(); } + const after=disk.saved.job; + expect(after?.binding).toBe(job.binding);expect(after?.fileId).toBe(job.fileId);expect(after?.sha).toBe(job.sha); + expect(after?.bytes.length).toBe(job.bytes.length); + expect(after&&Buffer.from(after.bytes).equals(Buffer.from(job.bytes))).toBe(true); + }); + it('rejects a one-byte change to a pending upload during receipt readback',async()=>{ + const job={binding:'test-binding',bytes:new Uint8Array([7,8,9]),fileId:'reserved',sha:sha('c')}; + const disk=storage({v:1,job,receipts:[]}),{app}=setup(disk); + const read=disk.read; + disk.read.mockImplementation(async()=>{ + const value=structuredClone(disk.saved); + if(value.receipts.length&&value.job)value.job.bytes[1]^=1; + return value; + }); + expect(await app.download(ref)).toBeNull();expect(app.state.phase).toBe('ERROR'); + expect(disk.saved.job).toEqual(job);expect(read).toHaveBeenCalled(); + }); }); From 37d9d81bbb0646e1381c6623829229ba8389c23d Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:52:56 +0100 Subject: [PATCH 13/39] fix(ci): run CodeQL inside the single guarded pipeline --- .github/workflows/codeql-test.yml | 28 --------------------------- .github/workflows/pipeline.yml | 23 ++++++++++++++++++++++ tests/unit/drive-test-profile.test.ts | 8 ++++++++ 3 files changed, 31 insertions(+), 28 deletions(-) delete mode 100644 .github/workflows/codeql-test.yml diff --git a/.github/workflows/codeql-test.yml b/.github/workflows/codeql-test.yml deleted file mode 100644 index 9e055ed..0000000 --- a/.github/workflows/codeql-test.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: CodeQL TEST -on: - pull_request: - branches: [main] -permissions: - contents: read -jobs: - analyze: - name: CodeQL JavaScript/TypeScript - runs-on: ubuntu-24.04 - timeout-minutes: 20 - permissions: - contents: read - actions: read - security-events: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - - uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 - with: - languages: javascript-typescript - build-mode: none - queries: security-extended - - uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 - with: - category: drive-test-javascript diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 1d596ed..4e9a81b 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -31,8 +31,31 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + codeql: + name: CodeQL JavaScript/TypeScript + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + actions: read + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 + with: + languages: javascript-typescript + build-mode: none + queries: security-extended + - uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 + with: + category: drive-test-javascript + verify: name: Vérification complète + needs: codeql runs-on: ubuntu-24.04 timeout-minutes: 30 # Même version que package-lock.json : navigateurs et bibliothèques déjà présents. diff --git a/tests/unit/drive-test-profile.test.ts b/tests/unit/drive-test-profile.test.ts index 35b2363..b10d27e 100644 --- a/tests/unit/drive-test-profile.test.ts +++ b/tests/unit/drive-test-profile.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { createHash } from 'node:crypto'; import { spawnSync } from 'node:child_process'; +import { parse } from 'yaml'; import { run } from '../../scripts/security-check'; it('publie uniquement un profil TEST identifié, vérifiable et fermé sans client Google', () => { const result = spawnSync(process.execPath, ['scripts/build-drive-test.mjs'], { encoding: 'utf8', env: { ...process.env, BUILD_SHA: 'a'.repeat(40), GOOGLE_DRIVE_CONFIG: '' }, timeout: 120000 }); @@ -17,3 +18,10 @@ it('publie uniquement un profil TEST identifié, vérifiable et fermé sans clie const other = { approved: true, clientId: ['123456', 'a'.repeat(24) + '.apps.googleusercontent.com'].join('-'), apiKey: 'AIza' + 'a'.repeat(35), appId: '123456', raceOrigin: 'https://race.example.com', vaultOrigin: 'https://vault.example.com' }; expect(spawnSync(process.execPath, ['scripts/build-drive-test.mjs'], { encoding: 'utf8', env: { ...process.env, BUILD_SHA: 'a'.repeat(40), GOOGLE_DRIVE_CONFIG: JSON.stringify(other) } }).status).not.toBe(0); }, 30000); +it('bloque aussi les portes de publication lorsque CodeQL échoue, sans secret dans ce job', () => { + const pipeline = parse(readFileSync('.github/workflows/pipeline.yml', 'utf8')); + expect(pipeline.jobs.verify.needs).toBe('codeql'); + expect(pipeline.jobs.codeql.permissions).toEqual({ contents: 'read', actions: 'read', 'security-events': 'write' }); + expect(JSON.stringify(pipeline.jobs.codeql)).not.toMatch(/secrets\.|deploy-pages|pages deploy|always\(\)/); + expect(pipeline.jobs.codeql.steps.filter((s: { uses: string }) => s.uses.startsWith('github/codeql-action/')).every((s: { uses: string }) => /@[0-9a-f]{40}$/.test(s.uses))).toBe(true); +}); 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 14/39] 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 15/39] 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 16/39] 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 17/39] 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 18/39] 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 19/39] 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 20/39] 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 21/39] 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 22/39] 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); + } + }); + }); 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 23/39] =?UTF-8?q?test(fortress):=20Refuser=20le=20HTML=20r?= =?UTF-8?q?edirig=C3=A9=20et=20=C3=A9prouver=20la=20premi=C3=A8re=20ouvert?= =?UTF-8?q?ure=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 24/39] 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); From 51031e8d1a4b69565bc36830f6ef8efbfd986ca3 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:58:44 +0100 Subject: [PATCH 25/39] fix(test-pwa): preserve first offline navigation after index redirect --- public/sw.js | 15 ++++++++++++--- tests/e2e/service-worker.spec.ts | 11 ++++++++++- tests/unit/service-worker.test.ts | 30 ++++++++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/public/sw.js b/public/sw.js index d224b13..3e20530 100644 --- a/public/sw.js +++ b/public/sw.js @@ -15,7 +15,7 @@ const cached = (request) => caches.open(VERSION).then((cache) => cache.match(req // 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; + if (!response?.ok || response.redirected || !/^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); @@ -37,7 +37,14 @@ 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', ...BUILD_ASSETS]))); + event.waitUntil(caches.open(VERSION).then(async (c) => { + await c.addAll(['./', './manifest.webmanifest', './icons/icon.svg', ...BUILD_ASSETS]); + // Cloudflare peut rediriger index.html vers / ; une réponse redirigée + // ne constitue pas un repli de navigation compatible avec les navigateurs. + const root = await c.match('./'); + if (!validShell(root) || root.url !== self.registration.scope) throw new Error('Shell racine non vérifié'); + await c.put('./index.html', root); + })); self.skipWaiting(); }); @@ -60,8 +67,10 @@ self.addEventListener('fetch', (event) => { // 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; + // Récupérer la racine directement pour les alias que Pages redirige. + const networkRequest = url.pathname === APP_PATH ? req : new URL(APP_PATH + url.search, url.origin).href; event.respondWith( - fetch(req) + fetch(networkRequest) .then((res) => { if (validShell(res)) { return remember('./index.html', res); diff --git a/tests/e2e/service-worker.spec.ts b/tests/e2e/service-worker.spec.ts index 1d696bf..5104444 100644 --- a/tests/e2e/service-worker.spec.ts +++ b/tests/e2e/service-worker.spec.ts @@ -4,12 +4,13 @@ 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 }) => { +for (const redirectsIndex of [false, true]) test(`première visite : redémarrage hors ligne après ${redirectsIndex ? 'redirection HTTP index vers racine' : 'réponse directe'}`, 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; + if (redirectsIndex && name === '/index.html') { res.writeHead(308, { Location: '/' }); res.end(); return; } const file = resolve(root, '.' + (name === '/' ? '/index.html' : name)); if (!file.startsWith(root + '/')) throw Error('path'); const bytes = await readFile(file); @@ -37,12 +38,20 @@ test('première visite : le build complet redémarre hors ligne sans second char 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); + const shell = await page.evaluate(async () => { + const response = await (await caches.open('cv-shell-v4')).match('./index.html'); + return response && { redirected: response.redirected, url: response.url }; + }); + expect(shell?.redirected).toBe(false); + expect(shell?.url).toBe(origin + '/'); // 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 page.goto(origin + '/index.html', { 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(); diff --git a/tests/unit/service-worker.test.ts b/tests/unit/service-worker.test.ts index 81fd71a..6324339 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; cachedUrl?: string } = {}) { +function harness(initial: string[], options: { fetch?: (input?: unknown) => Promise; hit?: boolean; failPut?: boolean; origin?: string; scope?: string; cachedUrl?: string; redirectRoot?: boolean; redirectCached?: boolean } = {}) { 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, ok: true, url: options.cachedUrl ?? self.registration.scope, headers: { get: () => 'text/html' } }; } }; + match: async (request: string) => { matched.push(key + ':' + request); return options.hit === false ? undefined : { offlineShell: true, ok: true, redirected: request === './' ? !!options.redirectRoot : !!options.redirectCached, url: options.cachedUrl ?? self.registration.scope, headers: { get: () => 'text/html' } }; } }; }, match: async (key: string) => { matched.push(key); @@ -56,6 +56,32 @@ function harness(initial: string[], options: { fetch?: () => Promise; h } describe('Service Worker : isolation interprojets', () => { + it('installe le shell sous index.html depuis la racine non redirigée', async () => { + const h = harness([]); let pending: Promise | undefined; + h.listeners.get('install')?.({ waitUntil: p => { pending = p; } }); + await pending; + expect(h.matched).toContain('cv-shell-v4:./'); + expect(h.written).toEqual([{ cache: 'cv-shell-v4', request: './index.html' }]); + }); + it('refuse une racine redirigée lors de l’installation', async () => { + const h = harness([], { redirectRoot: true }); let pending: Promise | undefined; + h.listeners.get('install')?.({ waitUntil: p => { pending = p; } }); + await expect(pending).rejects.toThrow(/shell|racine|redirect/i); + }); + it('ne sert pas une ancienne réponse redirigée comme navigation hors ligne', async () => { + const h = harness([], { redirectCached: true }); + let pending: Promise | undefined; + h.listeners.get('fetch')?.({ request: {method:'GET',mode:'navigate',url:'https://ipoower.github.io/Control-Vault/'}, respondWith:p=>{ pending=p; } }); + await expect(pending).rejects.toThrow('Aucun shell hors ligne vérifié'); + }); + it('demande directement la racine réseau pour un alias index.html', async () => { + let target: unknown; + const h = harness([], { fetch: async input => { target = input; return {ok:true,url:'https://ipoower.github.io/Control-Vault/',headers:{get:()=> 'text/html'},clone(){return this;}}; } }); + let pending: Promise | undefined; + h.listeners.get('fetch')?.({ request: {method:'GET',mode:'navigate',url:'https://ipoower.github.io/Control-Vault/index.html?fixture=1'}, respondWith:p=>{ pending=p; } }); + await pending; + expect(target).toBe('https://ipoower.github.io/Control-Vault/?fixture=1'); + }); 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', 'cv-shell-v4', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache']); const activate = h.listeners.get('activate'); From 63a035bf71791e2f266521c2d03e0d07f6936a48 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:48:37 +0100 Subject: [PATCH 26/39] test(pwa): expose read-only offline readiness diagnostics --- src/main.ts | 52 ++++++++++++++++- src/services/offline-diagnostic.ts | 44 ++++++++++++++ tests/e2e/offline-diagnostic.spec.ts | 52 +++++++++++++++++ tests/unit/offline-diagnostic.test.ts | 82 +++++++++++++++++++++++++++ 4 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 src/services/offline-diagnostic.ts create mode 100644 tests/e2e/offline-diagnostic.spec.ts create mode 100644 tests/unit/offline-diagnostic.test.ts diff --git a/src/main.ts b/src/main.ts index df43cfc..ad28bc9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,7 @@ import { securityView } from './views/security'; import { settingsView } from './views/settings'; import { driveView } from './views/drive'; import { connectedDrive } from './services/connected-drive'; +import { inspectOfflineElements, type OfflineDiagnostic } from './services/offline-diagnostic'; import type { View, ViewContext } from './views/types'; // ─── Routes ──────────────────────────────────────────────────────────────── @@ -63,6 +64,8 @@ const sideNav = h('nav', { class: 'side-nav', 'aria-label': 'Navigation principa const tabbar = h('nav', { class: 'tabbar', 'aria-label': 'Navigation principale' }); const sideFoot = h('div', { class: 'side-foot' }); const topbar = h('header', { class: 'topbar' }, h('span', { class: 'brand-mini mobile-only' }, logo(26)), topTitle, topStatus, searchMobile); +const testDiagnostic = import.meta.env.MODE === 'fortress-test'; +const offlineDiagnostic = h('div', { id: 'offline-diagnostic', class: 'banner banner-demo', role: 'status', 'aria-live': 'polite' }); const shell = h( 'div', @@ -75,7 +78,7 @@ const shell = h( sideNav, sideFoot, ), - h('div', { class: 'main-col' }, topbar, banners, viewEl, + h('div', { class: 'main-col' }, topbar, testDiagnostic && offlineDiagnostic, banners, viewEl, h('nav', { class: 'app-links', 'aria-label': 'Mes applications' }, h('strong', null, 'Mes applications'), h('div', null, @@ -308,6 +311,51 @@ boot() }); // Service worker : uniquement en production et hors aperçu intégré. -if ('serviceWorker' in navigator && import.meta.env.PROD && location.protocol === 'https:' && !location.hostname.endsWith('claude.ai') && !location.hostname.includes('claudeusercontent')) { +const canRegisterWorker = 'serviceWorker' in navigator && import.meta.env.PROD && location.protocol === 'https:' && !location.hostname.endsWith('claude.ai') && !location.hostname.includes('claudeusercontent'); +let diagnosticRevision = 0; +const diagnosticText: Record = { + 'unsupported': 'Contrôle hors ligne : navigateur non compatible ou stockage indisponible.', + 'not-registered': 'Contrôle hors ligne : préparation non confirmée.', + 'not-active': 'Contrôle hors ligne : préparation non terminée.', + 'not-controlling': 'Contrôle hors ligne : service non actif sur cette page.', + 'wrong-worker': 'Contrôle hors ligne : service inattendu.', + 'cache-missing': 'Contrôle hors ligne : cache de l’interface absent.', + 'shell-invalid': 'Contrôle hors ligne : page locale absente ou non vérifiée.', + 'assets-missing': 'Contrôle hors ligne : fichiers de l’interface manquants.', + 'local-elements-present': 'Contrôle local : service actif et fichiers de l’interface présents. Redémarrage hors ligne à vérifier.', + 'unavailable': 'Contrôle hors ligne : vérification indisponible.', +}; +async function refreshOfflineDiagnostic() { + if (!testDiagnostic) return; + const revision = ++diagnosticRevision; + const scope = new URL('./', location.href); + const assets = [...document.querySelectorAll('script[src], link[rel="stylesheet"][href]')] + .map((element) => new URL(element instanceof HTMLScriptElement ? element.src : element.href, scope)) + .filter((url) => url.origin === scope.origin && url.pathname.startsWith(scope.pathname + 'assets/')) + .map((url) => url.href); + const result = await inspectOfflineElements('serviceWorker' in navigator ? navigator.serviceWorker : undefined, 'caches' in window ? window.caches : undefined, scope.href, assets); + if (revision !== diagnosticRevision) return; + offlineDiagnostic.dataset.diagnostic = result; + replace(offlineDiagnostic, icon(result === 'local-elements-present' ? 'info' : 'alert'), h('p', null, diagnosticText[result])); +} +if (testDiagnostic) { + replace(offlineDiagnostic, icon('info'), h('p', null, 'Contrôle hors ligne : vérification en cours.')); + if ('serviceWorker' in navigator) navigator.serviceWorker.addEventListener('controllerchange', () => void refreshOfflineDiagnostic()); + window.addEventListener('pageshow', () => void refreshOfflineDiagnostic()); + window.setTimeout(() => void refreshOfflineDiagnostic(), 12000); +} +if (canRegisterWorker && !testDiagnostic) { window.addEventListener('load', () => navigator.serviceWorker.register('./sw.js').catch(() => {})); +} else if (canRegisterWorker) { + const registerWorker = () => { + void navigator.serviceWorker.register('./sw.js').then((registration) => { + const watch = () => registration.installing?.addEventListener('statechange', () => void refreshOfflineDiagnostic()); + registration.addEventListener('updatefound', watch); + watch(); + void refreshOfflineDiagnostic(); + }).catch(() => void refreshOfflineDiagnostic()); + }; + window.addEventListener('load', registerWorker, { once: true }); +} else if (testDiagnostic) { + void refreshOfflineDiagnostic(); } diff --git a/src/services/offline-diagnostic.ts b/src/services/offline-diagnostic.ts new file mode 100644 index 0000000..1a372ba --- /dev/null +++ b/src/services/offline-diagnostic.ts @@ -0,0 +1,44 @@ +// Diagnostic TEST en lecture seule : aucune clé métier ni cache n'est modifié. +// Il décrit des éléments présents maintenant, pas la réussite d'un futur redémarrage. +export const CV_SHELL_CACHE = 'cv-shell-v4'; + +export type OfflineDiagnostic = + | 'unsupported' | 'not-registered' | 'not-active' | 'not-controlling' + | 'wrong-worker' | 'cache-missing' | 'shell-invalid' | 'assets-missing' + | 'local-elements-present' | 'unavailable'; + +export async function inspectOfflineElements( + workers: ServiceWorkerContainer | undefined, + storage: CacheStorage | undefined, + scope: string, + assets: string[], +): Promise { + if (!workers || !storage) return 'unsupported'; + try { + const root = new URL(scope); + if (root.search || root.hash || !root.pathname.endsWith('/')) return 'unavailable'; + const registration = await workers.getRegistration(root.href); + if (!registration) return 'not-registered'; + if (registration.scope !== root.href || !registration.active || registration.active.state !== 'activated') return 'not-active'; + const expectedWorker = new URL('sw.js', root).href; + const controller = workers.controller; + if (!controller) return 'not-controlling'; + if (controller.scriptURL !== expectedWorker || registration.active.scriptURL !== expectedWorker) return 'wrong-worker'; + + // CacheStorage.match est en lecture seule, même si le cache disparaît entre les opérations. + if (!(await storage.keys()).includes(CV_SHELL_CACHE)) return 'cache-missing'; + const match = (url: string) => storage.match(url, { cacheName: CV_SHELL_CACHE }); + const shell = await match(new URL('index.html', root).href); + if (!shell?.ok || shell.redirected || shell.url !== root.href || !/^text\/html(?:\s*;|$)/i.test(shell.headers.get('content-type') || '')) return 'shell-invalid'; + + const required = [...new Set(assets)]; + const parsed = required.map((url) => new URL(url, root)); + const allowed = parsed.every((url) => url.origin === root.origin && url.pathname.startsWith(root.pathname + 'assets/') && !url.search && !url.hash); + if (!allowed || !parsed.some((url) => url.pathname.endsWith('.js')) || !parsed.some((url) => url.pathname.endsWith('.css'))) return 'assets-missing'; + const responses = await Promise.all(parsed.map((url) => match(url.href))); + if (responses.some((response) => !response?.ok || response.redirected)) return 'assets-missing'; + return 'local-elements-present'; + } catch { + return 'unavailable'; + } +} diff --git a/tests/e2e/offline-diagnostic.spec.ts b/tests/e2e/offline-diagnostic.spec.ts new file mode 100644 index 0000000..517ade4 --- /dev/null +++ b/tests/e2e/offline-diagnostic.spec.ts @@ -0,0 +1,52 @@ +// Rendu de la version TEST avec stockage fictif ; aucun Google ni donnée réelle. +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { extname, join, resolve } from 'node:path'; +import { expect, test } from '@playwright/test'; + +let root: string; +test.beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'cv-offline-diagnostic-fixture-')); + execFileSync(process.execPath, ['node_modules/vite/bin/vite.js', 'build', '--mode', 'fortress-test'], { + env: { ...process.env, VAULT_OUT: root, GOOGLE_DRIVE_CONFIG: '' }, timeout: 90000, stdio: 'pipe', + }); +}); + +for (const scenario of ['registration-refused', 'cache-missing', 'local-elements-present'] as const) { + test(`diagnostic hors ligne TEST : ${scenario}`, async ({ page }) => { + await page.addInitScript((mode) => { + const scope = 'https://cv.fixture.test/'; + const worker = { state: 'activated', scriptURL: scope + 'sw.js', addEventListener() {} }; + const registration = { scope, active: worker, installing: null, addEventListener() {} }; + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { + controller: mode === 'registration-refused' ? null : worker, + getRegistration: async () => mode === 'registration-refused' ? undefined : registration, + register: async () => { if (mode === 'registration-refused') throw Error('fixture refused'); return registration; }, + addEventListener() {}, + } }); + Object.defineProperty(window, 'caches', { configurable: true, value: { + keys: async () => mode === 'cache-missing' ? [] : ['cv-shell-v4'], + match: async (key: string) => key.endsWith('/index.html') + ? { ok: true, redirected: false, url: scope, headers: { get: () => 'text/html' } } + : { ok: true, redirected: false }, + } }); + }, scenario); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'https://cv.fixture.test') return route.abort(); + const filename = resolve(root, '.' + (url.pathname === '/' ? '/index.html' : url.pathname)); + if (!filename.startsWith(root + '/')) return route.abort(); + 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' }; + try { await route.fulfill({ status: 200, contentType: types[extname(filename)] || 'application/octet-stream', body: readFileSync(filename) }); } + catch { await route.fulfill({ status: 404, body: 'fixture absente' }); } + }); + await page.goto('https://cv.fixture.test/'); + await expect(page.locator('#hero-title')).toBeVisible(); + const diagnostic = page.locator('#offline-diagnostic'); + const expected = scenario === 'registration-refused' ? 'not-registered' : scenario; + await expect(diagnostic).toHaveAttribute('data-diagnostic', expected); + if (scenario === 'local-elements-present') await expect(diagnostic).toContainText('Redémarrage hors ligne à vérifier.'); + else await expect(diagnostic).not.toContainText('service actif et fichiers'); + }); +} diff --git a/tests/unit/offline-diagnostic.test.ts b/tests/unit/offline-diagnostic.test.ts new file mode 100644 index 0000000..4cc7210 --- /dev/null +++ b/tests/unit/offline-diagnostic.test.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import { CV_SHELL_CACHE, inspectOfflineElements } from '../../src/services/offline-diagnostic'; + +const origin = 'https://cv.fixture.test/'; +const js = origin + 'assets/app-123.js'; +const css = origin + 'assets/app-456.css'; +const asset = () => ({ ok: true, redirected: false }) as Response; +const shell = () => ({ ok: true, redirected: false, url: origin, headers: { get: () => 'text/html; charset=utf-8' } }) as unknown as Response; +function fixture() { + const worker = { state: 'activated', scriptURL: origin + 'sw.js' } as ServiceWorker; + const registration = { scope: origin, active: worker } as ServiceWorkerRegistration; + const getRegistration = vi.fn(async () => registration as ServiceWorkerRegistration | undefined); + const workers = { getRegistration, controller: worker } as unknown as ServiceWorkerContainer; + const entries = new Map([[origin + 'index.html', shell()], [js, asset()], [css, asset()]]); + const match = vi.fn(async (key: string, _options: MultiCacheQueryOptions) => entries.get(key)); + const keys = vi.fn(async () => [CV_SHELL_CACHE, 'other-application']); + const open = vi.fn(); + const storage = { keys, open, match } as unknown as CacheStorage; + const check = () => inspectOfflineElements(workers, storage, origin, [js, css]); + return { worker, registration, getRegistration, workers, storage, keys, open, match, entries, check }; +} + +describe('diagnostic de préparation hors ligne TEST, en lecture seule', () => { + it('signale seulement les éléments contrôlés sans déclarer la recette iPhone réussie', async () => { + const f = fixture(); + expect(await f.check()).toBe('local-elements-present'); + expect(f.keys).toHaveBeenCalledOnce(); + expect(f.open).not.toHaveBeenCalled(); + expect(f.match.mock.calls.map(([key]) => key).sort()).toEqual([origin + 'index.html', js, css].sort()); + expect(f.match.mock.calls.every(([, options]) => options.cacheName === CV_SHELL_CACHE)).toBe(true); + expect(readFileSync('src/main.ts', 'utf8')).toContain('Redémarrage hors ligne à vérifier.'); + }); + it('le nom du cache contrôlé correspond au Service Worker du candidat', () => { + const worker = readFileSync('public/sw.js', 'utf8'); + expect(worker).toContain("const CACHE_PREFIX = 'cv-shell-';"); + expect(worker).toContain('const VERSION = `${CACHE_PREFIX}v4`;'); + expect(CV_SHELL_CACHE).toBe('cv-shell-v4'); + }); + it('repère l’absence de l’API, de registration, de contrôle ou de service actif', async () => { + const f = fixture(); + expect(await inspectOfflineElements(undefined, f.storage, origin, [js, css])).toBe('unsupported'); + expect(await inspectOfflineElements(f.workers, undefined, origin, [js, css])).toBe('unsupported'); + f.getRegistration.mockResolvedValueOnce(undefined); + expect(await f.check()).toBe('not-registered'); + Object.defineProperty(f.registration, 'active', { value: null, configurable: true }); + expect(await f.check()).toBe('not-active'); + Object.defineProperty(f.registration, 'active', { value: f.worker, configurable: true }); + Object.defineProperty(f.workers, 'controller', { value: null, configurable: true }); + expect(await f.check()).toBe('not-controlling'); + expect(f.open).not.toHaveBeenCalled(); + }); + it('rejette une mauvaise origine de service sans toucher au cache', async () => { + const f = fixture(); + Object.defineProperty(f.worker, 'scriptURL', { value: 'https://elsewhere.fixture.test/sw.js' }); + expect(await f.check()).toBe('wrong-worker'); + expect(f.open).not.toHaveBeenCalled(); + }); + it('ne crée pas le cache manquant', async () => { + const f = fixture(); f.keys.mockResolvedValueOnce(['other-application']); + expect(await f.check()).toBe('cache-missing'); expect(f.open).not.toHaveBeenCalled(); + }); + it('refuse une page absente, redirigée, externe ou qui n’est pas du HTML', async () => { + for (const bad of [undefined, { ...shell(), redirected: true }, { ...shell(), url: 'https://outside.fixture.test/' }, { ...shell(), headers: { get: () => 'application/json' } }]) { + const f = fixture(); + if (bad) f.entries.set(origin + 'index.html', bad as Response); else f.entries.delete(origin + 'index.html'); + expect(await f.check()).toBe('shell-invalid'); + } + }); + it('refuse les scripts et styles manquants, redirigés ou hors origine', async () => { + const f = fixture(); f.entries.delete(css); + expect(await f.check()).toBe('assets-missing'); + f.entries.set(css, { ...asset(), redirected: true } as Response); + expect(await f.check()).toBe('assets-missing'); + expect(await inspectOfflineElements(f.workers, f.storage, origin, [js, 'https://outside.fixture.test/assets/a.css'])).toBe('assets-missing'); + expect(await inspectOfflineElements(f.workers, f.storage, origin, [js])).toBe('assets-missing'); + }); + it('signale les erreurs de lecture sans suppression ni correction automatique', async () => { + const f = fixture(); f.keys.mockRejectedValueOnce(new Error('fixture stockage indisponible')); + expect(await f.check()).toBe('unavailable'); expect(f.open).not.toHaveBeenCalled(); + }); +}); From f8a6143c92e658515590c3dbd030e53642b3a9f6 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:53:27 +0100 Subject: [PATCH 27/39] test(pwa): exercise diagnostic with native worker and offline cache --- tests/e2e/offline-diagnostic.spec.ts | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/e2e/offline-diagnostic.spec.ts b/tests/e2e/offline-diagnostic.spec.ts index 517ade4..03a2fc7 100644 --- a/tests/e2e/offline-diagnostic.spec.ts +++ b/tests/e2e/offline-diagnostic.spec.ts @@ -1,6 +1,7 @@ // Rendu de la version TEST avec stockage fictif ; aucun Google ni donnée réelle. import { execFileSync } from 'node:child_process'; import { mkdtempSync, readFileSync } from 'node:fs'; +import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { extname, join, resolve } from 'node:path'; import { expect, test } from '@playwright/test'; @@ -50,3 +51,42 @@ for (const scenario of ['registration-refused', 'cache-missing', 'local-elements else await expect(diagnostic).not.toContainText('service actif et fichiers'); }); } + +test('diagnostic : Service Worker et CacheStorage natifs, puis rechargement sans réseau', async ({ page }) => { + test.setTimeout(60000); + 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((req, res) => { + const url = new URL(req.url || '/', 'http://127.0.0.1'); + const filename = resolve(root, '.' + (url.pathname === '/' ? '/index.html' : url.pathname)); + if (!filename.startsWith(root + '/')) { res.writeHead(403); res.end(); return; } + try { res.writeHead(200, { 'Content-Type': types[extname(filename)] || 'application/octet-stream', 'Cache-Control': 'no-store' }); res.end(readFileSync(filename)); } + catch { res.writeHead(404); res.end(); } + }); + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + const address = server.address(); + if (!address || typeof address === 'string') throw Error('fixture 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(); + // Sur loopback HTTP, l'application ne s'auto-enregistre pas. C'est le vrai + // fichier du build ; enregistrer ici explicitement pour tester le cache natif. + 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 })); + localStorage.setItem('fixture-cv-diagnostic', 'fictif'); + }); + await expect(page.locator('#offline-diagnostic')).toHaveAttribute('data-diagnostic', 'local-elements-present'); + 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.locator('#offline-diagnostic')).toHaveAttribute('data-diagnostic', 'local-elements-present'); + await expect(page.locator('#offline-diagnostic')).toContainText('Redémarrage hors ligne à vérifier.'); + expect(await page.evaluate(() => localStorage.getItem('fixture-cv-diagnostic'))).toBe('fictif'); + } finally { + if (!closed) await new Promise((done) => { server.close(() => done()); server.closeAllConnections(); }); + } +}); From a3395ec4e92f8563a36641ad67f489e27602e147 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:58:49 +0100 Subject: [PATCH 28/39] test(pwa): bound diagnostic failures and blocked storage --- src/main.ts | 18 ++++++++++++------ tests/e2e/offline-diagnostic.spec.ts | 14 ++++++++------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main.ts b/src/main.ts index ad28bc9..910e148 100644 --- a/src/main.ts +++ b/src/main.ts @@ -328,12 +328,18 @@ const diagnosticText: Record = { async function refreshOfflineDiagnostic() { if (!testDiagnostic) return; const revision = ++diagnosticRevision; - const scope = new URL('./', location.href); - const assets = [...document.querySelectorAll('script[src], link[rel="stylesheet"][href]')] - .map((element) => new URL(element instanceof HTMLScriptElement ? element.src : element.href, scope)) - .filter((url) => url.origin === scope.origin && url.pathname.startsWith(scope.pathname + 'assets/')) - .map((url) => url.href); - const result = await inspectOfflineElements('serviceWorker' in navigator ? navigator.serviceWorker : undefined, 'caches' in window ? window.caches : undefined, scope.href, assets); + let timer = 0; + const check = Promise.resolve().then(async () => { + const scope = new URL('./', location.href); + const assets = [...document.querySelectorAll('script[src], link[rel="stylesheet"][href]')] + .map((element) => new URL(element instanceof HTMLScriptElement ? element.src : element.href, scope)) + .filter((url) => url.origin === scope.origin && url.pathname.startsWith(scope.pathname + 'assets/')) + .map((url) => url.href); + return inspectOfflineElements('serviceWorker' in navigator ? navigator.serviceWorker : undefined, 'caches' in window ? window.caches : undefined, scope.href, assets); + }).catch((): OfflineDiagnostic => 'unavailable'); + const timeout = new Promise((done) => { timer = window.setTimeout(() => done('unavailable'), 6000); }); + const result = await Promise.race([check, timeout]); + window.clearTimeout(timer); if (revision !== diagnosticRevision) return; offlineDiagnostic.dataset.diagnostic = result; replace(offlineDiagnostic, icon(result === 'local-elements-present' ? 'info' : 'alert'), h('p', null, diagnosticText[result])); diff --git a/tests/e2e/offline-diagnostic.spec.ts b/tests/e2e/offline-diagnostic.spec.ts index 03a2fc7..cec0adc 100644 --- a/tests/e2e/offline-diagnostic.spec.ts +++ b/tests/e2e/offline-diagnostic.spec.ts @@ -14,7 +14,7 @@ test.beforeAll(() => { }); }); -for (const scenario of ['registration-refused', 'cache-missing', 'local-elements-present'] as const) { +for (const scenario of ['registration-refused', 'cache-missing', 'local-elements-present', 'storage-denied', 'registration-stalled'] as const) { test(`diagnostic hors ligne TEST : ${scenario}`, async ({ page }) => { await page.addInitScript((mode) => { const scope = 'https://cv.fixture.test/'; @@ -22,16 +22,18 @@ for (const scenario of ['registration-refused', 'cache-missing', 'local-elements const registration = { scope, active: worker, installing: null, addEventListener() {} }; Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { controller: mode === 'registration-refused' ? null : worker, - getRegistration: async () => mode === 'registration-refused' ? undefined : registration, + getRegistration: async () => mode === 'registration-stalled' ? new Promise(() => {}) : mode === 'registration-refused' ? undefined : registration, register: async () => { if (mode === 'registration-refused') throw Error('fixture refused'); return registration; }, addEventListener() {}, } }); - Object.defineProperty(window, 'caches', { configurable: true, value: { + const cacheFixture = { keys: async () => mode === 'cache-missing' ? [] : ['cv-shell-v4'], match: async (key: string) => key.endsWith('/index.html') ? { ok: true, redirected: false, url: scope, headers: { get: () => 'text/html' } } : { ok: true, redirected: false }, - } }); + }; + if (mode === 'storage-denied') Object.defineProperty(window, 'caches', { configurable: true, get() { throw Error('fixture denied'); } }); + else Object.defineProperty(window, 'caches', { configurable: true, value: cacheFixture }); }, scenario); await page.route('**/*', async (route) => { const url = new URL(route.request().url()); @@ -45,8 +47,8 @@ for (const scenario of ['registration-refused', 'cache-missing', 'local-elements await page.goto('https://cv.fixture.test/'); await expect(page.locator('#hero-title')).toBeVisible(); const diagnostic = page.locator('#offline-diagnostic'); - const expected = scenario === 'registration-refused' ? 'not-registered' : scenario; - await expect(diagnostic).toHaveAttribute('data-diagnostic', expected); + const expected = scenario === 'registration-refused' ? 'not-registered' : (scenario === 'storage-denied' || scenario === 'registration-stalled') ? 'unavailable' : scenario; + await expect(diagnostic).toHaveAttribute('data-diagnostic', expected, { timeout: 10000 }); if (scenario === 'local-elements-present') await expect(diagnostic).toContainText('Redémarrage hors ligne à vérifier.'); else await expect(diagnostic).not.toContainText('service actif et fichiers'); }); From 7855f40a3831c605e9b7030abcdc2dbc6c779f5a Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:01:56 +0100 Subject: [PATCH 29/39] test(pwa): track native WebKit activation state --- .../workflows/fortress-offline-diagnostic.yml | 42 +++++++++++++++++++ src/main.ts | 14 ++++++- tests/e2e/offline-diagnostic.spec.ts | 8 +++- 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/fortress-offline-diagnostic.yml diff --git a/.github/workflows/fortress-offline-diagnostic.yml b/.github/workflows/fortress-offline-diagnostic.yml new file mode 100644 index 0000000..2912ecd --- /dev/null +++ b/.github/workflows/fortress-offline-diagnostic.yml @@ -0,0 +1,42 @@ +name: FORTRESS TEST - diagnostic hors ligne WebKit +on: + pull_request: + paths: + - 'src/main.ts' + - 'src/services/offline-diagnostic.ts' + - 'tests/e2e/offline-diagnostic.spec.ts' + - '.github/workflows/fortress-offline-diagnostic.yml' + +permissions: + contents: read + +concurrency: + group: fortress-offline-diagnostic-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + native-webkit: + if: github.event.pull_request.head.ref == 'test/fortress-final-acceptance-ios-offline-diagnostic-20261027' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + container: + image: mcr.microsoft.com/playwright:v1.56.1-noble + options: --ipc=host + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 22 + cache: npm + - name: Dépendances verrouillées + run: npm ci --no-audit --no-fund + - name: Build de fixture + run: npm run build + - name: Deux profils WebKit avec service worker et cache natifs + run: | + npx playwright test tests/e2e/offline-diagnostic.spec.ts -g 'diagnostic : Service Worker et CacheStorage natifs' --project=webkit-iphone-11-pro-max --project=webkit-desktop + env: + CI: '1' diff --git a/src/main.ts b/src/main.ts index 910e148..7ed5d61 100644 --- a/src/main.ts +++ b/src/main.ts @@ -346,7 +346,19 @@ async function refreshOfflineDiagnostic() { } if (testDiagnostic) { replace(offlineDiagnostic, icon('info'), h('p', null, 'Contrôle hors ligne : vérification en cours.')); - if ('serviceWorker' in navigator) navigator.serviceWorker.addEventListener('controllerchange', () => void refreshOfflineDiagnostic()); + if ('serviceWorker' in navigator) { + let watchedController: ServiceWorker | null = null; + const watchController = () => { + const controller = navigator.serviceWorker.controller; + if (controller && controller !== watchedController) { + watchedController = controller; + controller.addEventListener('statechange', () => void refreshOfflineDiagnostic()); + } + void refreshOfflineDiagnostic(); + }; + navigator.serviceWorker.addEventListener('controllerchange', watchController); + watchController(); + } window.addEventListener('pageshow', () => void refreshOfflineDiagnostic()); window.setTimeout(() => void refreshOfflineDiagnostic(), 12000); } diff --git a/tests/e2e/offline-diagnostic.spec.ts b/tests/e2e/offline-diagnostic.spec.ts index cec0adc..d074c73 100644 --- a/tests/e2e/offline-diagnostic.spec.ts +++ b/tests/e2e/offline-diagnostic.spec.ts @@ -80,7 +80,13 @@ test('diagnostic : Service Worker et CacheStorage natifs, puis rechargement sans if (!navigator.serviceWorker.controller) await new Promise((done) => navigator.serviceWorker.addEventListener('controllerchange', () => done(), { once: true })); localStorage.setItem('fixture-cv-diagnostic', 'fictif'); }); - await expect(page.locator('#offline-diagnostic')).toHaveAttribute('data-diagnostic', 'local-elements-present'); + const nativeState = await page.evaluate(async () => { + const reg = await navigator.serviceWorker.getRegistration(new URL('./', location.href).href); + return { expectedScope: new URL('./', location.href).href, actualScope: reg?.scope, activeState: reg?.active?.state, + activeScript: reg?.active?.scriptURL, controllerState: navigator.serviceWorker.controller?.state, controllerScript: navigator.serviceWorker.controller?.scriptURL }; + }); + console.log('NATIVE_WORKER_DIAGNOSTIC', JSON.stringify(nativeState)); + await expect(page.locator('#offline-diagnostic')).toHaveAttribute('data-diagnostic', 'local-elements-present', { timeout: 15000 }); await new Promise((done) => { server.close(() => done()); server.closeAllConnections(); }); closed = true; await page.reload({ waitUntil: 'domcontentloaded' }); From 08c1ae828a639c24cbc9b94093536a28076b087d Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:03:20 +0100 Subject: [PATCH 30/39] test(pwa): restore single-workflow repository invariant --- .../workflows/fortress-offline-diagnostic.yml | 42 ------------------- 1 file changed, 42 deletions(-) delete mode 100644 .github/workflows/fortress-offline-diagnostic.yml diff --git a/.github/workflows/fortress-offline-diagnostic.yml b/.github/workflows/fortress-offline-diagnostic.yml deleted file mode 100644 index 2912ecd..0000000 --- a/.github/workflows/fortress-offline-diagnostic.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: FORTRESS TEST - diagnostic hors ligne WebKit -on: - pull_request: - paths: - - 'src/main.ts' - - 'src/services/offline-diagnostic.ts' - - 'tests/e2e/offline-diagnostic.spec.ts' - - '.github/workflows/fortress-offline-diagnostic.yml' - -permissions: - contents: read - -concurrency: - group: fortress-offline-diagnostic-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - native-webkit: - if: github.event.pull_request.head.ref == 'test/fortress-final-acceptance-ios-offline-diagnostic-20261027' - runs-on: ubuntu-24.04 - timeout-minutes: 10 - container: - image: mcr.microsoft.com/playwright:v1.56.1-noble - options: --ipc=host - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: 22 - cache: npm - - name: Dépendances verrouillées - run: npm ci --no-audit --no-fund - - name: Build de fixture - run: npm run build - - name: Deux profils WebKit avec service worker et cache natifs - run: | - npx playwright test tests/e2e/offline-diagnostic.spec.ts -g 'diagnostic : Service Worker et CacheStorage natifs' --project=webkit-iphone-11-pro-max --project=webkit-desktop - env: - CI: '1' From f87834b581532de507a1eb3ca7b5b0ce5616c3c8 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:06:35 +0100 Subject: [PATCH 31/39] fix(test-pwa): keep diagnostic failures from blocking startup --- src/main.ts | 41 ++++++++++++++++------------ tests/e2e/offline-diagnostic.spec.ts | 10 ++++--- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/main.ts b/src/main.ts index 7ed5d61..d4e8cf1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -347,17 +347,22 @@ async function refreshOfflineDiagnostic() { if (testDiagnostic) { replace(offlineDiagnostic, icon('info'), h('p', null, 'Contrôle hors ligne : vérification en cours.')); if ('serviceWorker' in navigator) { - let watchedController: ServiceWorker | null = null; - const watchController = () => { - const controller = navigator.serviceWorker.controller; - if (controller && controller !== watchedController) { - watchedController = controller; - controller.addEventListener('statechange', () => void refreshOfflineDiagnostic()); - } - void refreshOfflineDiagnostic(); - }; - navigator.serviceWorker.addEventListener('controllerchange', watchController); - watchController(); + try { + const workers = navigator.serviceWorker; + let watchedController: ServiceWorker | null = null; + const watchController = () => { + try { + const controller = workers.controller; + if (controller && controller !== watchedController) { + watchedController = controller; + controller.addEventListener('statechange', () => void refreshOfflineDiagnostic()); + } + } catch { /* Un diagnostic ne doit jamais empêcher l'ouverture de l'application. */ } + void refreshOfflineDiagnostic(); + }; + workers.addEventListener('controllerchange', watchController); + watchController(); + } catch { void refreshOfflineDiagnostic(); } } window.addEventListener('pageshow', () => void refreshOfflineDiagnostic()); window.setTimeout(() => void refreshOfflineDiagnostic(), 12000); @@ -366,12 +371,14 @@ if (canRegisterWorker && !testDiagnostic) { window.addEventListener('load', () => navigator.serviceWorker.register('./sw.js').catch(() => {})); } else if (canRegisterWorker) { const registerWorker = () => { - void navigator.serviceWorker.register('./sw.js').then((registration) => { - const watch = () => registration.installing?.addEventListener('statechange', () => void refreshOfflineDiagnostic()); - registration.addEventListener('updatefound', watch); - watch(); - void refreshOfflineDiagnostic(); - }).catch(() => void refreshOfflineDiagnostic()); + try { + void navigator.serviceWorker.register('./sw.js').then((registration) => { + const watch = () => registration.installing?.addEventListener('statechange', () => void refreshOfflineDiagnostic()); + registration.addEventListener('updatefound', watch); + watch(); + void refreshOfflineDiagnostic(); + }).catch(() => void refreshOfflineDiagnostic()); + } catch { void refreshOfflineDiagnostic(); } }; window.addEventListener('load', registerWorker, { once: true }); } else if (testDiagnostic) { diff --git a/tests/e2e/offline-diagnostic.spec.ts b/tests/e2e/offline-diagnostic.spec.ts index d074c73..7240319 100644 --- a/tests/e2e/offline-diagnostic.spec.ts +++ b/tests/e2e/offline-diagnostic.spec.ts @@ -14,18 +14,20 @@ test.beforeAll(() => { }); }); -for (const scenario of ['registration-refused', 'cache-missing', 'local-elements-present', 'storage-denied', 'registration-stalled'] as const) { +for (const scenario of ['registration-refused', 'cache-missing', 'local-elements-present', 'storage-denied', 'worker-denied', 'registration-stalled'] as const) { test(`diagnostic hors ligne TEST : ${scenario}`, async ({ page }) => { await page.addInitScript((mode) => { const scope = 'https://cv.fixture.test/'; const worker = { state: 'activated', scriptURL: scope + 'sw.js', addEventListener() {} }; const registration = { scope, active: worker, installing: null, addEventListener() {} }; - Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { + const workerFixture = { controller: mode === 'registration-refused' ? null : worker, getRegistration: async () => mode === 'registration-stalled' ? new Promise(() => {}) : mode === 'registration-refused' ? undefined : registration, register: async () => { if (mode === 'registration-refused') throw Error('fixture refused'); return registration; }, addEventListener() {}, - } }); + }; + if (mode === 'worker-denied') Object.defineProperty(navigator, 'serviceWorker', { configurable: true, get() { throw Error('fixture denied'); } }); + else Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: workerFixture }); const cacheFixture = { keys: async () => mode === 'cache-missing' ? [] : ['cv-shell-v4'], match: async (key: string) => key.endsWith('/index.html') @@ -47,7 +49,7 @@ for (const scenario of ['registration-refused', 'cache-missing', 'local-elements await page.goto('https://cv.fixture.test/'); await expect(page.locator('#hero-title')).toBeVisible(); const diagnostic = page.locator('#offline-diagnostic'); - const expected = scenario === 'registration-refused' ? 'not-registered' : (scenario === 'storage-denied' || scenario === 'registration-stalled') ? 'unavailable' : scenario; + const expected = scenario === 'registration-refused' ? 'not-registered' : (scenario === 'storage-denied' || scenario === 'worker-denied' || scenario === 'registration-stalled') ? 'unavailable' : scenario; await expect(diagnostic).toHaveAttribute('data-diagnostic', expected, { timeout: 10000 }); if (scenario === 'local-elements-present') await expect(diagnostic).toContainText('Redémarrage hors ligne à vérifier.'); else await expect(diagnostic).not.toContainText('service actif et fichiers'); From 3675ec1128675e007a1e341379bf28c8eebe6431 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:42:27 +0100 Subject: [PATCH 32/39] security(test): disallow document base overrides in CSP --- index.html | 2 +- public/_headers | 2 +- scripts/security-check.ts | 4 +++- tests/unit/security-check.test.ts | 6 ++++++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 03d8f2b..757889a 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ Control Vault diff --git a/public/_headers b/public/_headers index 21a03d1..b7a1e1f 100644 --- a/public/_headers +++ b/public/_headers @@ -3,7 +3,7 @@ # Toute modification de ce fichier est une modification de sécurité : revue humaine obligatoire. /* - Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; manifest-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'self'; form-action 'none'; frame-ancestors 'none' + Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; manifest-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none' X-Frame-Options: DENY X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin diff --git a/scripts/security-check.ts b/scripts/security-check.ts index 13320c7..29007c3 100644 --- a/scripts/security-check.ts +++ b/scripts/security-check.ts @@ -53,7 +53,9 @@ export function checkCsp(csp: string, opts: { requireFrameAncestors: boolean }): if (/'unsafe-inline'/.test(script)) problems.push("'unsafe-inline' interdit dans script-src"); if (/(^|\s)\*(\s|;|$)/.test(csp)) problems.push('joker * interdit'); if (!dir('object-src')?.includes("'none'")) problems.push("object-src 'none' requis"); - if (!dir('base-uri')) problems.push('base-uri requis'); + const base = dir('base-uri'); + if (!base) problems.push('base-uri requis'); + else if (base.split(/\s+/).join(' ') !== "base-uri 'none'") problems.push("base-uri 'none' requis"); if (opts.requireFrameAncestors && !dir('frame-ancestors')?.includes("'none'")) problems.push("frame-ancestors 'none' requis"); return problems; } diff --git a/tests/unit/security-check.test.ts b/tests/unit/security-check.test.ts index 38828c8..d43927f 100644 --- a/tests/unit/security-check.test.ts +++ b/tests/unit/security-check.test.ts @@ -36,6 +36,12 @@ describe('détection de secrets', () => { }); describe('CSP', () => { + it.each(["'self'", 'https://fixture.invalid', "'none' 'self'"])( + 'refuse une base de document autorisée : %s', (base) => { + expect(checkCsp(`default-src 'self'; script-src 'self'; object-src 'none'; base-uri ${base}`, + { requireFrameAncestors: false })).toContain("base-uri 'none' requis"); + }); + it('refuse unsafe-eval, unsafe-inline dans script-src et le joker', () => { expect(checkCsp("default-src 'self'; script-src 'self' 'unsafe-eval'; object-src 'none'; base-uri 'self'", { requireFrameAncestors: false })).toContain("'unsafe-eval' interdit"); expect(checkCsp("default-src 'self'; script-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'", { requireFrameAncestors: false })).toContain("'unsafe-inline' interdit dans script-src"); From 36447d5a2b5fe4e417e332913d18fa80273b45b0 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:23:53 +0100 Subject: [PATCH 33/39] feat(test): select authorized dedicated Drive folders without Picker --- ...ORTRESS_DEDICATED_FOLDERS_TEST_20261028.md | 35 +++++++++ src/services/connected-drive.ts | 29 ++++++-- src/services/google-drive-session.d.ts | 4 +- src/services/google-drive-session.js | 24 ++++++- src/services/opaque-drive.ts | 34 +++++++++ src/views/drive.ts | 8 +++ tests/e2e/connected-drive.spec.ts | 14 +++- tests/unit/dedicated-folders.test.ts | 71 +++++++++++++++++++ 8 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 docs/FORTRESS_DEDICATED_FOLDERS_TEST_20261028.md create mode 100644 tests/unit/dedicated-folders.test.ts diff --git a/docs/FORTRESS_DEDICATED_FOLDERS_TEST_20261028.md b/docs/FORTRESS_DEDICATED_FOLDERS_TEST_20261028.md new file mode 100644 index 0000000..c9981e6 --- /dev/null +++ b/docs/FORTRESS_DEDICATED_FOLDERS_TEST_20261028.md @@ -0,0 +1,35 @@ +# Dossiers dédiés via l’API Drive — candidat TEST du 28 octobre 2026 + +Le parcours existant crée et relit un dossier dédié, mais impose ensuite Google Picker. Ce candidat ajoute une sélection explicite parmi les dossiers déjà autorisés à l’application. Il ne change aucun réglage de cookies ou de navigateur et ne prétend pas réparer le contrôle manuel du navigateur cloud. + +## Périmètre et autorité + +- RC : base 7e5cd990f7f736ab77220bb2b35fad643407e672 ; CV : base 3675ec1128675e007a1e341379bf28c8eebe6431, qui reprend le durcissement CSP séparé. +- Branches TEST isolées, PR en brouillon, aucune fusion ni production. Les résultats appartiennent au SHA effectivement testé. +- Origines autorisées : rc-drive-test-ipoower.pages.dev et cv-drive-test-ipoower.pages.dev. + +## Contrôles et impact + +La requête officielle files.list utilise le scope existant drive.file, l’espace drive, le propriétaire courant, le type dossier, l’absence de corbeille et la propriété privée cv_contract=rc-folder-v1. Chaque réponse est contrôlée à nouveau. Un résultat incomplet, un doublon, une boucle ou le dépassement de 10 pages / 1 000 dossiers bloque la liste au lieu de la tronquer. + +La sélection est explicite et limitée aux identifiants de la liste obtenue dans la session courante. Le compte est relu ; propriétaire, contrat et droit d’ajouter des fichiers sont relus avant de calculer la liaison existante client/compte/dossier. Une déconnexion ou expiration invalide la liste. Les noms sont rendus comme texte, y compris s’ils contiennent du HTML. + +Aucun ajout de scope, jeton persistant, nouvel endpoint serveur, crypto, schéma de coffre, file durable, migration, suppression, permission Google, règle de sécurité, dépendance ou workflow. Les paquets restent chiffrés par RC et opaques pour CV. Les lecteurs et le stockage existants sont réutilisés. Le transport RC est reconstruit par tools/vendor-vault-drive.js à partir des sources CV, avec empreintes dans son en-tête. + +Le parcours Picker est conservé, de même que les anciens échecs documentés. Les tests Google simulé vérifient les deux parcours ; la voie API est également éprouvée avec Picker rendu indisponible dans le harnais. Cela n’établit pas la cause d’une panne iPhone ou du contrôle manuel cloud. + +## Validation et limites + +Tests ciblés : pagination complète, propriétaire/contrat/type/capacité, réponse incomplète ou tardive, liste invalidée, compte changé, liaison équivalente à Picker, conservation des files et des refus de récupération avant écriture. Parcours navigateur : sélection explicite, nom hostile rendu comme texte, upload et relecture avec réseau Google simulé. + +La CI et le manifeste de chaque nouvel artefact doivent être attestés avant publication TEST. Une CI verte ne prouve ni transfert Google réel, ni installation vierge indépendante, ni persistance physique. Aucun RPO/RTO n’est déduit des minuteries. + +## Retour arrière + +Ne pas publier le candidat, ou remettre le précédent artefact TEST attesté après contrôle des octets. Le contrat de stockage et les codecs sont inchangés ; aucune purge n’est requise. Préserver tous les dossiers et révisions créés. Un retour du code ne constitue pas un retour des données. Les preuves de compatibilité existantes restent datées et ne couvrent pas des migrations futures. + +## Références officielles + +- https://developers.google.com/workspace/drive/api/guides/api-specific-auth +- https://developers.google.com/workspace/drive/api/guides/search-files +- https://developers.google.com/workspace/drive/api/reference/rest/v3/files/list diff --git a/src/services/connected-drive.ts b/src/services/connected-drive.ts index 4b98812..7a55a9b 100644 --- a/src/services/connected-drive.ts +++ b/src/services/connected-drive.ts @@ -4,7 +4,7 @@ import { readRacePacket, type RacePacket } from '../core/race-packet'; import { DriveStore } from './drive-store.js'; import { GoogleDriveSession, type DriveConfig, type DriveSession } from './google-drive-session.js'; import { OpaqueArchive } from './opaque-archive'; -import { OpaqueDrive, type RemotePacket, type ByteProof } from './opaque-drive'; +import { OpaqueDrive, type RemotePacket, type ByteProof, type DedicatedFolder } from './opaque-drive'; interface Job { binding: string; bytes: Uint8Array; fileId: string | null; sha: string } interface Durable { v: 1; job: Job | null; receipts: ByteProof[] } @@ -18,6 +18,7 @@ function sameJob(a: Job | null, b: Job | null): boolean { a.bytes.every((value, i) => value === b.bytes[i]); } export interface ConnectedState { configured: boolean; prepared: boolean; connected: boolean; selected: boolean; busy: boolean; message: string; + dedicatedFolders: DedicatedFolder[]; versions: RemotePacket[]; receipts: ByteProof[]; recoveryOrigin: string | null; account: string | null; accountConfirmed: boolean; folder: string | null; phase: 'WAITING' | 'SENDING' | 'UPLOADED' | 'VERIFIED_BYTES' | 'CONFLICT' | 'SYNC_BLOCKED' | 'ERROR'; pending: boolean; lastOperationAt: number | null } export class ConnectedDrive { @@ -30,7 +31,7 @@ export class ConnectedDrive { private listeners = new Set<() => void>(); private epoch = 0; state: ConnectedState = { configured: false, prepared: false, connected: false, selected: false, busy: false, - message: 'Connexion réelle non activée sur cette version.', versions: [], receipts: [], recoveryOrigin: null, account: null, accountConfirmed: false, folder: null, phase: 'WAITING', pending: false, lastOperationAt: null }; + message: 'Connexion réelle non activée sur cette version.', dedicatedFolders: [], versions: [], receipts: [], recoveryOrigin: null, account: null, accountConfirmed: false, folder: null, phase: 'WAITING', pending: false, lastOperationAt: null }; subscribe(fn: () => void): () => void { this.listeners.add(fn); return () => this.listeners.delete(fn); } private change(patch: Partial) { this.state = { ...this.state, ...patch }; this.listeners.forEach(fn => fn()); } async init() { @@ -59,19 +60,35 @@ export class ConnectedDrive { // Conserver le geste utilisateur : ouvrir Google avant tout await. connect() { if (!this.session || this.state.busy) return Promise.resolve(); - this.epoch++; this.selection = null; this.change({ selected: false, account: null, accountConfirmed: false, folder: null, versions: [], receipts: [], phase: 'WAITING' }); + this.epoch++; this.selection = null; this.change({ selected: false, account: null, accountConfirmed: false, folder: null, dedicatedFolders: [], versions: [], receipts: [], phase: 'WAITING' }); const promise = this.session.connect(); return this.action(async epoch => { await promise; this.check(epoch); this.change({ connected: true, account: this.session?.identity?.label || null, accountConfirmed: false, message: 'Compte affiché : confirme explicitement ce compte dédié avant tout accès aux fichiers.' }); }); } confirmAccount() { if (!this.session?.identity || this.state.busy) return; - try { this.session.confirmAccount(this.session.identity.id); this.change({ accountConfirmed: true, message: 'Compte confirmé. Crée un dossier neuf dédié ou retrouve celui créé par cette application dans Picker.' }); } + try { this.session.confirmAccount(this.session.identity.id); this.change({ accountConfirmed: true, message: 'Compte confirmé. Crée un dossier neuf dédié ou retrouve les dossiers déjà autorisés pour cette application.' }); } catch { this.change({ phase: 'SYNC_BLOCKED', message: 'Confirmation du compte refusée. Reconnecte le compte dédié.' }); } } newFolder() { if (!this.session || this.state.busy || !this.state.accountConfirmed) return Promise.resolve(); this.epoch++; this.selection = null; this.change({ selected: false, folder: null, versions: [], receipts: [] }); - return this.action(async epoch => { await this.session!.newFolder(); this.check(epoch); this.change({ phase: 'WAITING', lastOperationAt: Date.now(), message: 'Dossier neuf dédié créé et relu. Choisis-le maintenant dans Google Picker.' }); }); + return this.action(async epoch => { await this.session!.newFolder(); this.check(epoch); this.change({ dedicatedFolders: [], phase: 'WAITING', lastOperationAt: Date.now(), message: 'Dossier neuf dédié créé et relu. Retrouve les dossiers dédiés pour le sélectionner.' }); }); + } + async listDedicatedFolders() { + if (!this.session || this.state.busy || !this.state.accountConfirmed) return; + this.change({ dedicatedFolders: [] }); + return this.action(async epoch => { + const folders = await this.session!.listDedicatedFolders(); this.check(epoch); + this.change({ dedicatedFolders: folders, message: folders.length ? 'Dossiers dédiés retrouvés. Choisis explicitement celui à utiliser.' : 'Aucun dossier dédié autorisé trouvé. Crée un dossier neuf, puis actualise cette liste.' }); + }); + } + chooseDedicatedFolder(id: string) { + if (!this.session || this.state.busy || !this.state.accountConfirmed) return Promise.resolve(); + this.epoch++; this.selection = null; this.change({ selected: false, folder: null, versions: [], receipts: [], phase: 'WAITING' }); + return this.action(async epoch => { + const selection = await this.session!.chooseDedicatedFolder(id); this.check(epoch); this.selection = selection; + this.change({ selected: true, folder: selection.folder, versions: [], receipts: [], lastOperationAt: Date.now(), message: 'Dossier dédié relu et sélectionné. Actualise les versions pour les lire.' }); + }); } chooseFolder() { if (!this.session || this.state.busy) return Promise.resolve(); @@ -82,7 +99,7 @@ export class ConnectedDrive { } disconnect() { this.epoch++; this.session?.disconnect(); this.selection = null; - this.change({ busy: false, connected: false, selected: false, account: null, accountConfirmed: false, folder: null, phase: 'WAITING', versions: [], receipts: [], message: 'Déconnecté. Les fichiers et les envois en attente sont conservés.' }); + this.change({ busy: false, connected: false, selected: false, account: null, accountConfirmed: false, folder: null, dedicatedFolders: [], phase: 'WAITING', versions: [], receipts: [], message: 'Déconnecté. Les fichiers et les envois en attente sont conservés.' }); } private check(epoch: number) { if (this.epoch !== epoch || !this.session?.connected) throw Object.assign(new Error('auth'), { code: 'auth' }); } private selected() { if (!this.provider || !this.selection) throw new Error('Dossier requis'); return { provider: this.provider, ...this.selection }; } diff --git a/src/services/google-drive-session.d.ts b/src/services/google-drive-session.d.ts index 0496b70..3e3340b 100644 --- a/src/services/google-drive-session.d.ts +++ b/src/services/google-drive-session.d.ts @@ -1,6 +1,6 @@ -import type { OpaqueDrive } from './opaque-drive'; +import type { OpaqueDrive, DedicatedFolder } from './opaque-drive'; export interface DriveConfig { approved: true; clientId: string; apiKey: string; appId: string; raceOrigin: string; vaultOrigin: string } -export interface DriveSession { prepare(): Promise; connect(): Promise; confirmAccount(id: string): void; newFolder(): Promise; chooseFolder(): Promise<{ folder: string; binding: string; account: string }>; readonly identity: { id: string; label: string } | null; readonly confirmed: boolean; disconnect(): void; readonly connected: boolean } +export interface DriveSession { prepare(): Promise; connect(): Promise; confirmAccount(id: string): void; newFolder(): Promise; listDedicatedFolders(): Promise; chooseDedicatedFolder(id: string): Promise<{ folder: string; binding: string; account: string }>; chooseFolder(): Promise<{ folder: string; binding: string; account: string }>; readonly identity: { id: string; label: string } | null; readonly confirmed: boolean; disconnect(): void; readonly connected: boolean } export const GoogleDriveSession: { config(value: unknown, origin?: string): DriveConfig; load(fetcher?: typeof fetch): Promise; diff --git a/src/services/google-drive-session.js b/src/services/google-drive-session.js index be611d6..0252349 100644 --- a/src/services/google-drive-session.js +++ b/src/services/google-drive-session.js @@ -31,6 +31,7 @@ const GoogleDriveSession = (() => { function create(input, provider, host = globalThis) { const cfg = config(input, host.location.origin); let token = null, until = 0, epoch = 0, prepared = false, pending = null, picker = null, pickReject = null, pickerBusy = false, identity = null, approvedAccount = null, pendingFolder = null; + let dedicated = new Set(); const script = src => new Promise((resolve, reject) => { const el = host.document.createElement('script'); el.src = src; el.async = true; el.referrerPolicy = 'strict-origin-when-cross-origin'; const timer = setTimeout(() => { el.remove(); reject(Object.assign(new Error('network'), { code: 'network' })); }, 20000); @@ -38,7 +39,7 @@ const GoogleDriveSession = (() => { host.document.head.appendChild(el); }); function disconnect() { - token = null; until = 0; identity = approvedAccount = pendingFolder = null; pickerBusy = false; epoch++; provider.disconnect(); picker?.setVisible(false); picker = null; + token = null; until = 0; identity = approvedAccount = pendingFolder = null; dedicated = new Set(); pickerBusy = false; epoch++; provider.disconnect(); picker?.setVisible(false); picker = null; if (pickReject) { pickReject(Object.assign(new Error('cancelled'), { code: 'cancelled' })); pickReject = null; } if (pending) { pending.reject(Object.assign(new Error('cancelled'), { code: 'cancelled' })); pending = null; } } @@ -92,6 +93,25 @@ const GoogleDriveSession = (() => { if (lease !== epoch || Date.now() >= until) return fail('auth'); return folder; } + async function listDedicatedFolders() { + fileAccess(); dedicated = new Set(); const lease = epoch, account = await provider.account(); + if (lease !== epoch || account !== approvedAccount) return fail('auth'); + const folders = await provider.folders(); + if (lease !== epoch || Date.now() >= until) return fail('auth'); + dedicated = new Set(folders.map(f => f.id)); + return folders.map(f => Object.freeze({ ...f })); + } + async function chooseDedicatedFolder(folder) { + fileAccess(); + if (!dedicated.has(folder)) return fail('confirmation'); + const lease = epoch, account = await provider.account(); + if (lease !== epoch || account !== approvedAccount) return fail('auth'); + await provider.folder(folder); + if (lease !== epoch || Date.now() >= until) return fail('auth'); + const binding = [...new Uint8Array(await host.crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify([cfg.clientId, account, folder]))))].map(x => x.toString(16).padStart(2, '0')).join(''); + if (lease !== epoch || Date.now() >= until) return fail('auth'); + return { folder, binding, account: identity.label }; + } function chooseFolder() { if (pickReject) return Promise.reject(Object.assign(new Error('busy'), { code: 'busy' })); if (!token || Date.now() >= until) { disconnect(); return Promise.reject(Object.assign(new Error('auth'), { code: 'auth' })); } @@ -121,7 +141,7 @@ const GoogleDriveSession = (() => { picker.setVisible(true); }); } - return Object.freeze({ prepare, connect, confirmAccount, newFolder, chooseFolder, disconnect, + return Object.freeze({ prepare, connect, confirmAccount, newFolder, listDedicatedFolders, chooseDedicatedFolder, chooseFolder, disconnect, get identity() { return identity && { ...identity }; }, get confirmed() { return !!approvedAccount; }, get connected() { if (token && (Date.now() >= until || provider.connected === false)) disconnect(); return !!token; } }); } diff --git a/src/services/opaque-drive.ts b/src/services/opaque-drive.ts index 74809f0..d17d82c 100644 --- a/src/services/opaque-drive.ts +++ b/src/services/opaque-drive.ts @@ -24,6 +24,7 @@ export interface ByteProof { readonly revision?: SyncRevision; } export interface SyncProfile { readonly fileId: string; readonly sha256: string; readonly bytes: Uint8Array } +export interface DedicatedFolder { readonly id: string; readonly name: string; readonly createdTime: string } export class OpaqueDrive { #token: string | null = null; #until = 0; @@ -143,6 +144,39 @@ export class OpaqueDrive { !object(p.appProperties) || p.appProperties.cv_contract !== 'rc-folder-v1' || !object(p.capabilities) || p.capabilities.canAddChildren !== true) return fail('conflict'); } + // Only folders already visible to this OAuth application and bearing its + // private contract. This never grants access to an arbitrary Drive folder. + async folders(signal?: AbortSignal): Promise { + const epoch = this.lease(), out: DedicatedFolder[] = [], pages = new Set(), ids = new Set(); + let next = '', count = 0; + do { + if (++count > 10) return fail('incomplete'); + const params = new URLSearchParams({ + q: "mimeType = 'application/vnd.google-apps.folder' and trashed = false and 'me' in owners and appProperties has { key='cv_contract' and value='rc-folder-v1' }", + spaces: 'drive', pageSize: '100', + fields: 'nextPageToken,incompleteSearch,files(id,name,createdTime,mimeType,trashed,ownedByMe,appProperties,capabilities(canAddChildren))', + }); + if (next) params.set('pageToken', next); + const page = await this.json(await this.request(API + '/files?' + params, { signal }, epoch), signal); + this.check(epoch, signal); + if (page.incompleteSearch !== false || !Array.isArray(page.files) || page.files.length > 100) return fail('incomplete'); + for (const f of page.files) { + if (!object(f) || typeof f.id !== 'string' || typeof f.name !== 'string' || !f.name || f.name.length > 1024 || + typeof f.createdTime !== 'string' || f.createdTime.length > 40 || !Number.isFinite(Date.parse(f.createdTime)) || + f.mimeType !== 'application/vnd.google-apps.folder' || f.trashed !== false || f.ownedByMe !== true || + !object(f.appProperties) || f.appProperties.cv_contract !== 'rc-folder-v1' || + !object(f.capabilities) || f.capabilities.canAddChildren !== true) return fail('conflict'); + const folder = id(f.id); + if (ids.has(folder) || out.length >= 1000) return fail('incomplete'); + ids.add(folder); out.push(Object.freeze({ id: folder, name: f.name, createdTime: f.createdTime })); + } + if (page.nextPageToken === undefined) next = ''; + else if (typeof page.nextPageToken !== 'string' || !page.nextPageToken || page.nextPageToken.length > 4096 || pages.has(page.nextPageToken)) return fail('incomplete'); + else { next = page.nextPageToken; pages.add(next); } + } while (next); + this.check(epoch, signal); + return out.sort((a, b) => b.createdTime.localeCompare(a.createdTime) || a.id.localeCompare(b.id)); + } async createFolder(fileId: string, signal?: AbortSignal): Promise { id(fileId); const epoch = this.lease(); // Réserver l'ID avant l'appel ; une réponse perdue est relue, jamais remplacée. diff --git a/src/views/drive.ts b/src/views/drive.ts index f97a86c..fb9d077 100644 --- a/src/views/drive.ts +++ b/src/views/drive.ts @@ -25,9 +25,17 @@ export function driveView(): View { !s.prepared ? button('Préparer la connexion Google', () => connectedDrive.prepare()) : button(s.connected ? 'Changer de compte Google' : 'Se connecter à Google', () => connectedDrive.connect()), s.connected && !s.accountConfirmed ? button('Confirmer ce compte dédié', () => connectedDrive.confirmAccount()) : null, s.connected && s.accountConfirmed ? button('Créer un dossier neuf dédié', () => connectedDrive.newFolder()) : null, + s.connected && s.accountConfirmed ? button('Retrouver les dossiers dédiés', () => connectedDrive.listDedicatedFolders()) : null, s.connected && s.accountConfirmed ? button('Choisir le dossier Google Drive', () => connectedDrive.chooseFolder()) : null, s.connected ? h('button', { class: 'btn', type: 'button', onclick: () => connectedDrive.disconnect() }, 'Déconnecter') : null, ) : null, + s.connected && s.accountConfirmed && s.dedicatedFolders.length ? h('section', { 'aria-label': 'Dossiers dédiés autorisés', class: 'stack' }, + h('p', null, 'Cette liste contient uniquement les dossiers dédiés déjà autorisés pour l’application. Chaque dossier sera relu avant sélection.'), + ...s.dedicatedFolders.map(folder => h('div', { class: 'row' }, + h('div', { class: 'row-main' }, h('p', { class: 'row-title wrap' }, folder.name), + h('p', { class: 'row-sub' }, 'Créé le ' + new Date(folder.createdTime).toLocaleString('fr-FR') + ' · référence ' + folder.id.slice(-8))), + button('Utiliser ce dossier', () => connectedDrive.chooseDedicatedFolder(folder.id)))), + ) : null, s.selected ? h('div', { class: 'btn-row' }, button('Actualiser les versions', () => connectedDrive.refresh()), button('Envoyer une copie chiffrée', () => file.click()), file, button('Reprendre l’envoi en attente', () => connectedDrive.resume())) : null, diff --git a/tests/e2e/connected-drive.spec.ts b/tests/e2e/connected-drive.spec.ts index 0680787..374bc97 100644 --- a/tests/e2e/connected-drive.spec.ts +++ b/tests/e2e/connected-drive.spec.ts @@ -8,7 +8,7 @@ const origin = 'https://vault.example.com'; const scope = 'https://www.googleapis.com/auth/drive.file'; const config = { approved: true, clientId: ['123456', 'fake.apps.googleusercontent.com'].join('-'), apiKey: 'AIza' + 'a'.repeat(35), appId: '123456', raceOrigin: 'https://race.example.com', vaultOrigin: origin }; -test('application complète, Google simulé : confirmation compte, dossier dédié, upload opaque, relecture et jeton oublié', async ({ context, page, browser }) => { +for (const direct of [false, true]) test('application complète, Google simulé : confirmation compte, dossier dédié, upload opaque, relecture et jeton oublié' + (direct ? ' · liste dédiée sans Picker' : ' · Picker conservé'), async ({ context, page, browser }) => { await context.route('**/*', r => r.abort()); const fixture = await packetFixture(61), files = new Map; bytes: Buffer }>(), pending = new Map>(); let next = 0, posts = 0, folderPosts = 0, folderReady = false; await context.route(origin + '/**', async r => { @@ -25,7 +25,7 @@ test('application complète, Google simulé : confirmation compte, dossier dédi await context.route('https://apis.google.com/js/api.js', r => r.fulfill({ contentType: 'text/javascript', body: ` window.gapi={load:(name,o)=>o.callback()}; class View{setIncludeFolders(){return this}setSelectFolderEnabled(){return this}} - class Picker{addView(){return this}setOAuthToken(){return this}setDeveloperKey(){return this}setAppId(){return this}setOrigin(){return this}setCallback(c){this.cb=c;return this}build(){return this}setVisible(v){if(v)this.cb({action:'picked',docs:[{id:'folder-A'}]})}} + class Picker{addView(){return this}setOAuthToken(){return this}setDeveloperKey(){return this}setAppId(){return this}setOrigin(){return this}setCallback(c){this.cb=c;return this}build(){return this}setVisible(v){if(v){window.fixturePickerOpened=true;${direct ? "throw new Error('synthetic Picker unavailable')" : "this.cb({action:'picked',docs:[{id:'folder-A'}]})"}}}} google.picker={DocsView:View,PickerBuilder:Picker,ViewId:{FOLDERS:'folders'},Action:{PICKED:'picked',CANCEL:'cancel'}};` })); await context.route('https://www.googleapis.com/**', async r => { const request = r.request(), u = new URL(request.url()), key = u.pathname.split('/').at(-1)!; @@ -40,6 +40,7 @@ test('application complète, Google simulé : confirmation compte, dossier dédi if (request.method() === 'POST' && u.pathname === '/drive/v3/files') { const m=request.postDataJSON(); expect(m.mimeType).toBe('application/vnd.google-apps.folder');expect(m.appProperties).toEqual({cv_contract:'rc-folder-v1'});expect(m.id).toBe('folder-A');folderReady=true;folderPosts++;return json({id:m.id}); } if (request.method() === 'POST') { posts++; const m = request.postDataJSON() as Record; pending.set(String(m.id), m); return r.fulfill({ headers: { ...headers, Location: 'https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&upload_id=' + m.id } }); } if (request.method() === 'PUT') { const id = u.searchParams.get('upload_id')!, bytes = request.postDataBuffer()!; expect(bytes.equals(Buffer.from(fixture.bytes))).toBe(true); files.set(id, { meta: { ...pending.get(id), size: String(bytes.length) }, bytes }); return json({ id }); } + if (u.pathname === '/drive/v3/files' && (u.searchParams.get('q') || '').includes("value='rc-folder-v1'")) return json({ incompleteSearch: false, files: folderReady ? [{id:'folder-A',name:'Dossier fictif ',createdTime:'2026-10-28T00:00:00.000Z',mimeType:'application/vnd.google-apps.folder',trashed:false,ownedByMe:true,appProperties:{cv_contract:'rc-folder-v1'},capabilities:{canAddChildren:true}}] : [] }); if (u.pathname === '/drive/v3/files') return json({ incompleteSearch: false, files: [...files.values()].map(f => f.meta) }); const file = files.get(key); if (!file) return json({}, 404); return u.searchParams.get('alt') === 'media' ? r.fulfill({ headers, contentType: 'application/json', body: file.bytes }) : json(file.meta); @@ -62,7 +63,14 @@ test('application complète, Google simulé : confirmation compte, dossier dédi expect(folderPosts).toBe(1); await page.getByRole('button', {name:'Créer un dossier neuf dédié'}).click(); await expect(page.getByRole('status').filter({hasText:'Dossier neuf dédié créé'})).toBeVisible();expect(folderPosts).toBe(1); - await page.getByRole('button', { name: 'Choisir le dossier Google Drive' }).click(); + if (direct) { + await page.getByRole('button',{name:'Retrouver les dossiers dédiés'}).click(); + const list=page.getByRole('region',{name:'Dossiers dédiés autorisés'}); + await expect(list.getByText('Dossier fictif ',{exact:true})).toBeVisible(); + await expect(list.locator('img')).toHaveCount(0); + await list.getByRole('button',{name:'Utiliser ce dossier'}).click(); + } else await page.getByRole('button', { name: 'Choisir le dossier Google Drive' }).click(); + expect(await page.evaluate(() => Boolean((window as unknown as {fixturePickerOpened?:boolean}).fixturePickerOpened))).toBe(!direct); await page.getByLabel('Copie chiffrée Race Control').setInputFiles({ name: 'opaque.json', mimeType: 'application/json', buffer: Buffer.from(fixture.bytes) }); await expect(page.getByRole('status').filter({ hasText: 'Copie envoyée' })).toBeVisible(); expect(files.size).toBe(1); expect(posts).toBe(1); diff --git a/tests/unit/dedicated-folders.test.ts b/tests/unit/dedicated-folders.test.ts new file mode 100644 index 0000000..d8bbc48 --- /dev/null +++ b/tests/unit/dedicated-folders.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DRIVE_FILE_SCOPE, OpaqueDrive } from '../../src/services/opaque-drive'; + +const origin = 'https://vault.fixture.test'; +const entry = (id = 'dedicated-A') => ({ id, name: 'Dossier fictif', createdTime: '2026-10-28T00:00:00.000Z', + mimeType: 'application/vnd.google-apps.folder', trashed: false, ownedByMe: true, + appProperties: { cv_contract: 'rc-folder-v1' }, capabilities: { canAddChildren: true } }); +const json = (data: unknown) => new Response(JSON.stringify(data)); +const client = (network: typeof fetch) => { + const p = new OpaqueDrive([origin], network); p.connect('fixture-token', 3600, [DRIVE_FILE_SCOPE]); return p; +}; +beforeEach(() => vi.stubGlobal('location', { origin })); +afterEach(() => vi.unstubAllGlobals()); + +describe('dossiers dédiés déjà autorisés, transport simulé', () => { + it('borne la recherche aux dossiers privés de l’application et lit toutes les pages sans écrire', async () => { + const requests: URL[] = []; + const network = vi.fn(async (url, init) => { + const u = new URL(String(url)); requests.push(u); + expect(u.pathname).toBe('/drive/v3/files'); + expect(u.searchParams.get('q')).toContain("'me' in owners"); + expect(u.searchParams.get('q')).toContain("appProperties has { key='cv_contract' and value='rc-folder-v1' }"); + expect(u.searchParams.get('q')).toContain("mimeType = 'application/vnd.google-apps.folder'"); + expect(u.searchParams.get('q')).toContain('trashed = false'); + expect(u.searchParams.get('spaces')).toBe('drive'); + expect(init?.method ?? 'GET').toBe('GET'); + expect(init?.credentials).toBe('omit'); expect(init?.cache).toBe('no-store'); expect(init?.redirect).toBe('error'); + expect(String(url)).not.toContain('fixture-token'); + return json(u.searchParams.has('pageToken') ? { incompleteSearch: false, files: [entry('dedicated-B')] } + : { incompleteSearch: false, files: [entry()], nextPageToken: 'page-2' }); + }); + expect(await client(network).folders()).toEqual([ + { id: 'dedicated-A', name: 'Dossier fictif', createdTime: '2026-10-28T00:00:00.000Z' }, + { id: 'dedicated-B', name: 'Dossier fictif', createdTime: '2026-10-28T00:00:00.000Z' }, + ]); + expect(requests).toHaveLength(2); + }); + it.each([ + { ownedByMe: false }, { trashed: true }, { appProperties: { cv_contract: 'other' } }, + { mimeType: 'text/plain' }, { capabilities: { canAddChildren: false } }, + { id: '../other' }, { createdTime: 'unknown' }, { name: '' }, + ])('refuse une réponse incompatible avec le dossier autorisé : %j', async patch => { + const p = client(vi.fn(async () => json({ incompleteSearch: false, files: [{ ...entry(), ...patch }] }))); + await expect(p.folders()).rejects.toMatchObject({ code: 'conflict' }); + }); + it.each([ + { incompleteSearch: true, files: [] }, { files: [] }, { incompleteSearch: false, files: null }, + { incompleteSearch: false, files: Array.from({ length: 101 }, (_, n) => entry('id-' + n)) }, + ])('n’annonce pas de liste partielle comme complète', async response => { + await expect(client(vi.fn(async () => json(response))).folders()).rejects.toMatchObject({ code: 'incomplete' }); + }); + it('refuse doublon et boucle de pagination', async () => { + for (const duplicate of [true, false]) { + let n = 0; + const p = client(vi.fn(async () => json({ incompleteSearch: false, files: [entry(duplicate ? 'same' : 'id-' + n++)], nextPageToken: 'repeated' }))); + await expect(p.folders()).rejects.toMatchObject({ code: 'incomplete' }); + } + }); + it('refuse un historique de dossiers trop grand sans retourner un préfixe', async () => { + let n = 0; + const network = vi.fn(async () => json({ incompleteSearch: false, files: [], nextPageToken: 'page-' + ++n })); + await expect(client(network).folders()).rejects.toMatchObject({ code: 'incomplete' }); + expect(network).toHaveBeenCalledTimes(10); + }); + it('ne livre pas une liste reçue après déconnexion', async () => { + let release!: (r: Response) => void; + const p = client(vi.fn(() => new Promise(r => { release = r; }))); + const pending = p.folders(); p.disconnect(); release(json({ incompleteSearch: false, files: [entry()] })); + await expect(pending).rejects.toMatchObject({ code: 'auth' }); + }); +}); From 2e112f67487e45520338d95cf9a93fcbea21ff7e Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:35:31 +0100 Subject: [PATCH 34/39] fix(test): keep dedicated Drive folder actions usable on mobile --- docs/FORTRESS_DEDICATED_FOLDERS_TEST_20261028.md | 6 ++++++ src/styles/views.css | 7 +++++++ src/views/drive.ts | 4 ++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/FORTRESS_DEDICATED_FOLDERS_TEST_20261028.md b/docs/FORTRESS_DEDICATED_FOLDERS_TEST_20261028.md index c9981e6..602ece5 100644 --- a/docs/FORTRESS_DEDICATED_FOLDERS_TEST_20261028.md +++ b/docs/FORTRESS_DEDICATED_FOLDERS_TEST_20261028.md @@ -33,3 +33,9 @@ Ne pas publier le candidat, ou remettre le précédent artefact TEST attesté ap - https://developers.google.com/workspace/drive/api/guides/api-specific-auth - https://developers.google.com/workspace/drive/api/guides/search-files - https://developers.google.com/workspace/drive/api/reference/rest/v3/files/list + +## Réserve mobile reproduite puis corrigée + +Le run CV 40608745070, tête `36447d5a2b5fe4e417e332913d18fa80273b45b0`, conserve un échec Chromium 414×896 : le clic sur « Utiliser ce dossier » est intercepté après rendu d’un nom long. La classe générique de ligne prévoit trois colonnes dont une icône ; les entrées Drive n’ont que deux enfants. Le correctif donne aux seules entrées Drive deux colonnes puis une disposition verticale sur mobile, avec retour à la ligne des noms et métadonnées. Le même défaut de structure des versions distantes est corrigé. Aucun test supprimé, aucun clic forcé, aucune assertion assouplie. + +Avant : 161 parcours réussis, 14 ignorés, 1 échec (reproduit à la relance). Après : nouveau run requis sur le nouveau SHA. Build local réussi ; le navigateur local n’a pas démarré, car Vite sur 0.0.0.0 échoue sur `uv_interface_addresses` dans cet environnement. Cette limite locale ne vaut pas résultat navigateur. diff --git a/src/styles/views.css b/src/styles/views.css index eec261b..e278f57 100644 --- a/src/styles/views.css +++ b/src/styles/views.css @@ -10,6 +10,7 @@ } .dial { position: relative; width: min(212px, 62vw); aspect-ratio: 1; justify-self: center; } .dial-svg { width: 100%; height: 100%; overflow: visible; } + .dial-ticks line { stroke: var(--brass); stroke-width: 1.2; opacity: 0.32; stroke-linecap: round; } .dial-ticks line.long { opacity: 0.7; stroke-width: 1.6; } .dial-track { fill: none; stroke: var(--surface-3); stroke-width: 10; } @@ -275,3 +276,9 @@ a.btn { text-decoration: none; } .issues .row { grid-template-columns: auto minmax(0, 1fr); } .issues .row .btn { grid-column: 2; justify-self: start; } } + +/* Drive entries have two children; the generic row reserves an icon column. */ +.drive-entry { grid-template-columns: minmax(0, 1fr) auto; } +.drive-entry .row-title, .drive-entry .row-sub { white-space: normal; overflow-wrap: anywhere; } +.drive-entry > .btn { justify-self: start; } +@media (max-width: 600px) { .drive-entry { grid-template-columns: minmax(0, 1fr); } } diff --git a/src/views/drive.ts b/src/views/drive.ts index fb9d077..7cfdd1b 100644 --- a/src/views/drive.ts +++ b/src/views/drive.ts @@ -31,7 +31,7 @@ export function driveView(): View { ) : null, s.connected && s.accountConfirmed && s.dedicatedFolders.length ? h('section', { 'aria-label': 'Dossiers dédiés autorisés', class: 'stack' }, h('p', null, 'Cette liste contient uniquement les dossiers dédiés déjà autorisés pour l’application. Chaque dossier sera relu avant sélection.'), - ...s.dedicatedFolders.map(folder => h('div', { class: 'row' }, + ...s.dedicatedFolders.map(folder => h('div', { class: 'row drive-entry' }, h('div', { class: 'row-main' }, h('p', { class: 'row-title wrap' }, folder.name), h('p', { class: 'row-sub' }, 'Créé le ' + new Date(folder.createdTime).toLocaleString('fr-FR') + ' · référence ' + folder.id.slice(-8))), button('Utiliser ce dossier', () => connectedDrive.chooseDedicatedFolder(folder.id)))), @@ -44,7 +44,7 @@ export function driveView(): View { (() => { const parents = new Set(s.versions.flatMap(v => v.revision?.parents || [])); const heads = s.versions.filter(v => v.revision && !parents.has(v.revision.id)); return heads.length > 1 ? h('p', { role: 'status' }, 'Plusieurs têtes de révision déclarées : conserver les branches et vérifier le conflit dans Race Control. Control Vault ne les authentifie ni ne les fusionne.') : null; })(), h('div', { class: 'panel-body' }, s.versions.length ? s.versions.map(ref => { const proof = s.receipts.find(p => p.fileId === ref.fileId && p.fileSha256 === ref.fileSha256); - return h('div', { class: 'row' }, h('div', { class: 'row-main' }, h('p', { class: 'row-title wrap' }, ref.revision ? `Révision ${ref.revision.seq} · ${ref.revision.id.slice(0, 8)}` : 'Copie manuelle'), + return h('div', { class: 'row drive-entry' }, h('div', { class: 'row-main' }, h('p', { class: 'row-title wrap' }, ref.revision ? `Révision ${ref.revision.seq} · ${ref.revision.id.slice(0, 8)}` : 'Copie manuelle'), h('p', { class: 'row-sub wrap' }, `${Math.ceil(ref.size / 1024)} Ko · ${ref.fileSha256.slice(0, 12)} · ${proof ? 'octets vérifiés le ' + new Date(proof.verifiedAt).toLocaleString('fr-FR') : 'non vérifiée'}`)), button('Vérifier et télécharger', async () => { const bytes = await connectedDrive.download(ref); From fc33a9d4f5f713272e15d5183168f2de2be2c027 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:38:50 +0100 Subject: [PATCH 35/39] fix(fortress-test): make Google Picker optional for dedicated Drive API --- src/services/google-drive-session.js | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/services/google-drive-session.js b/src/services/google-drive-session.js index 0252349..f932081 100644 --- a/src/services/google-drive-session.js +++ b/src/services/google-drive-session.js @@ -43,12 +43,27 @@ const GoogleDriveSession = (() => { if (pickReject) { pickReject(Object.assign(new Error('cancelled'), { code: 'cancelled' })); pickReject = null; } if (pending) { pending.reject(Object.assign(new Error('cancelled'), { code: 'cancelled' })); pending = null; } } + // Picker is an optional UI, not a dependency of the drive.file API path. + // Preserve the synchronous user gesture when opening an already loaded Picker. + let pickerReady = false, pickerLoading = null; + function preparePicker() { + if (pickerReady) return Promise.resolve(); + if (!pickerLoading) { + pickerLoading = (async () => { + if (!host.gapi?.load) await script('https://apis.google.com/js/api.js'); + await new Promise((resolve, reject) => host.gapi.load('picker', { callback: resolve, onerror: () => reject(new Error('network'), { code: 'network' }), timeout: 20000, ontimeout: () => reject(Object.assign(new Error('network'), { code: 'network' })) })); + if (!host.google?.picker?.PickerBuilder) return fail('network'); + pickerReady = true; + })().finally(() => { pickerLoading = null; }); + } + return pickerLoading; + } async function prepare() { config(cfg, host.location.origin); if (!host.google?.accounts?.oauth2) await script('https://accounts.google.com/gsi/client'); - if (!host.gapi?.load) await script('https://apis.google.com/js/api.js'); - await new Promise((resolve, reject) => host.gapi.load('picker', { callback: resolve, onerror: () => reject(new Error('network')), timeout: 20000, ontimeout: () => reject(new Error('network')) })); prepared = true; + // Never make an optional Picker outage block OAuth or dedicated-folder API. + void preparePicker().catch(() => {}); } // Chaque demande a son propre callback/epoch : une réponse tardive de la // fenêtre de l'ancien compte ne peut pas authentifier la nouvelle demande. @@ -116,6 +131,12 @@ const GoogleDriveSession = (() => { if (pickReject) return Promise.reject(Object.assign(new Error('busy'), { code: 'busy' })); if (!token || Date.now() >= until) { disconnect(); return Promise.reject(Object.assign(new Error('auth'), { code: 'auth' })); } try { fileAccess(); } catch (e) { return Promise.reject(e); } + if (!pickerReady) { + // This button may be pressed before Picker is ready or after its load failed. + // No popup is attempted outside the next explicit user gesture. + void preparePicker().catch(() => {}); + return Promise.reject(Object.assign(new Error('network'), { code: 'network' })); + } const lease = epoch; return new Promise((resolve, reject) => { pickReject = reject; From 4b7da07e031b0d12c67e16ad079077d5fb61b176 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:39:04 +0100 Subject: [PATCH 36/39] test(fortress): normalize optional Picker network rejection --- src/services/google-drive-session.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/google-drive-session.js b/src/services/google-drive-session.js index f932081..8aa7ba6 100644 --- a/src/services/google-drive-session.js +++ b/src/services/google-drive-session.js @@ -51,7 +51,7 @@ const GoogleDriveSession = (() => { if (!pickerLoading) { pickerLoading = (async () => { if (!host.gapi?.load) await script('https://apis.google.com/js/api.js'); - await new Promise((resolve, reject) => host.gapi.load('picker', { callback: resolve, onerror: () => reject(new Error('network'), { code: 'network' }), timeout: 20000, ontimeout: () => reject(Object.assign(new Error('network'), { code: 'network' })) })); + await new Promise((resolve, reject) => host.gapi.load('picker', { callback: resolve, onerror: () => reject(Object.assign(new Error('network'), { code: 'network' })), timeout: 20000, ontimeout: () => reject(Object.assign(new Error('network'), { code: 'network' })) })); if (!host.google?.picker?.PickerBuilder) return fail('network'); pickerReady = true; })().finally(() => { pickerLoading = null; }); From 3e680b7ac0930918094d4c03870bb3dac1e1d017 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:39:23 +0100 Subject: [PATCH 37/39] test(fortress): prove Drive API survives optional Picker outage --- tests/unit/drive-boundary.test.ts | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit/drive-boundary.test.ts b/tests/unit/drive-boundary.test.ts index 361c92c..396bade 100644 --- a/tests/unit/drive-boundary.test.ts +++ b/tests/unit/drive-boundary.test.ts @@ -125,3 +125,35 @@ describe('référence du téléchargement figée avant le contrôle du dossier', expect(fetcher.mock.calls.some(c=>String(c[0]).includes('/other-archive'))).toBe(false); }); }); + + +describe('Picker facultatif : API Drive utilisable sans bibliothèque Picker', () => { + it('autorise OAuth et sélection dédiée quand le chargement Picker échoue', async () => { + const clients: any[] = []; + const host = { + location: { origin }, crypto: webcrypto, + gapi: { load: (_name: string, callbacks: any) => callbacks.onerror() }, + google: { accounts: { oauth2: { + initTokenClient: (options: any) => { clients.push(options); return { requestAccessToken: () => {} }; }, + hasGrantedAllScopes: () => true, + } } }, + }; + const folder = { id: 'folder-fixture', name: 'Dossier fictif', createdTime: '2026-10-28T00:00:00Z' }; + const provider = { + connect: vi.fn(), disconnect: vi.fn(), identity: vi.fn(async () => ({ id: 'fixture-account', label: 'fixture@example.invalid' })), + account: vi.fn(async () => 'fixture-account'), folders: vi.fn(async () => [folder]), + folder: vi.fn(async () => {}), + }; + const session = (GoogleDriveSession.create as any)(config, provider, host); + await expect(session.prepare()).resolves.toBeUndefined(); + const pending = session.connect(); + await clients.at(-1).callback({ access_token: 'fixture-token', scope, expires_in: 3600 }); + await pending; + session.confirmAccount('fixture-account'); + expect(await session.listDedicatedFolders()).toEqual([folder]); + expect((await session.chooseDedicatedFolder('folder-fixture')).folder).toBe('folder-fixture'); + await expect(session.chooseFolder()).rejects.toMatchObject({ code: 'network' }); + expect(provider.folder).toHaveBeenCalledWith('folder-fixture'); + session.disconnect(); + }); +}); From 012ddf6ef164d60b7ca677acfee34a49640eb514 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:39:52 +0100 Subject: [PATCH 38/39] fix(drive): accept omitted false incompleteSearch while rejecting incomplete pages --- src/services/opaque-drive.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/opaque-drive.ts b/src/services/opaque-drive.ts index d17d82c..254f04f 100644 --- a/src/services/opaque-drive.ts +++ b/src/services/opaque-drive.ts @@ -159,7 +159,7 @@ export class OpaqueDrive { if (next) params.set('pageToken', next); const page = await this.json(await this.request(API + '/files?' + params, { signal }, epoch), signal); this.check(epoch, signal); - if (page.incompleteSearch !== false || !Array.isArray(page.files) || page.files.length > 100) return fail('incomplete'); + if ((page.incompleteSearch !== undefined && page.incompleteSearch !== false) || !Array.isArray(page.files) || page.files.length > 100) return fail('incomplete'); for (const f of page.files) { if (!object(f) || typeof f.id !== 'string' || typeof f.name !== 'string' || !f.name || f.name.length > 1024 || typeof f.createdTime !== 'string' || f.createdTime.length > 40 || !Number.isFinite(Date.parse(f.createdTime)) || @@ -211,7 +211,7 @@ export class OpaqueDrive { pageSize: '1000', fields: 'nextPageToken,incompleteSearch,files(id,size,trashed,parents,appProperties)' }); if (next) params.set('pageToken', next); const page = await this.json(await this.request(API + '/files?' + params, { signal }, epoch), signal); - if (page.incompleteSearch !== false || !Array.isArray(page.files)) return fail('incomplete'); + if ((page.incompleteSearch !== undefined && page.incompleteSearch !== false) || !Array.isArray(page.files)) return fail('incomplete'); for (const f of page.files) { if (!object(f) || typeof f.id !== 'string' || f.trashed === true || !Array.isArray(f.parents) || !f.parents.includes(folder) || !object(f.appProperties) || f.appProperties.cv_contract !== 'rc-key-a1' || !hash(f.appProperties.cv_file) || @@ -267,7 +267,7 @@ export class OpaqueDrive { pageSize: '1000', fields: 'nextPageToken,incompleteSearch,files(id,size,trashed,parents,appProperties)' }); if (next) p.set('pageToken', next); const page = await this.json(await this.request(API + '/files?' + p, { signal }, epoch), signal); - if (page.incompleteSearch !== false || !Array.isArray(page.files)) return fail('incomplete'); + if ((page.incompleteSearch !== undefined && page.incompleteSearch !== false) || !Array.isArray(page.files)) return fail('incomplete'); for (const raw of page.files) { if (!object(raw) || !Array.isArray(raw.parents) || !raw.parents.includes(folder)) return fail('conflict'); const v = this.version(raw), old = versions.get(v.fileId); From 05d087939482a0f11ac0af6a821108f83bae38f4 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:40:01 +0100 Subject: [PATCH 39/39] test(drive): cover omitted optional incompleteSearch and malformed value --- tests/unit/dedicated-folders.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/unit/dedicated-folders.test.ts b/tests/unit/dedicated-folders.test.ts index d8bbc48..51678ca 100644 --- a/tests/unit/dedicated-folders.test.ts +++ b/tests/unit/dedicated-folders.test.ts @@ -44,11 +44,18 @@ describe('dossiers dédiés déjà autorisés, transport simulé', () => { await expect(p.folders()).rejects.toMatchObject({ code: 'conflict' }); }); it.each([ - { incompleteSearch: true, files: [] }, { files: [] }, { incompleteSearch: false, files: null }, + { incompleteSearch: true, files: [] }, { incompleteSearch: null, files: [] }, { incompleteSearch: false, files: null }, { incompleteSearch: false, files: Array.from({ length: 101 }, (_, n) => entry('id-' + n)) }, ])('n’annonce pas de liste partielle comme complète', async response => { await expect(client(vi.fn(async () => json(response))).folders()).rejects.toMatchObject({ code: 'incomplete' }); }); + it('accepte la valeur par défaut omise de incompleteSearch mais conserve les validations des dossiers', async () => { + const network = vi.fn(async () => json({ files: [entry()] })); + expect(await client(network).folders()).toEqual([ + { id: 'dedicated-A', name: 'Dossier fictif', createdTime: '2026-10-28T00:00:00.000Z' }, + ]); + expect(network).toHaveBeenCalledTimes(1); + }); it('refuse doublon et boucle de pagination', async () => { for (const duplicate of [true, false]) { let n = 0;