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 1/2] 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 2/2] 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);