diff --git a/src/services/google-drive-session.js b/src/services/google-drive-session.js index 0252349..8aa7ba6 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(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; }); + } + 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; 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); 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; 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(); + }); +});