Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/services/google-drive-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions src/services/opaque-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) ||
Expand Down Expand Up @@ -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) ||
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion tests/unit/dedicated-folders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fetch>(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;
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/drive-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading