fix: list files on Stalwart 1.0 when the account holds more nodes than one get allows #1385

This commit is contained in:
Linus Rath
2026-10-27 23:20:07 +01:00
parent 85fb392ab3
commit 0e026cab2e
2 changed files with 27 additions and 3 deletions
@@ -44,6 +44,8 @@ function fakeFilesServer(nodes: FakeNode[], opts: {
// Stalwart before 0.16.6 returns leaf files only.
queryOmitsFolders?: boolean;
queryFails?: boolean;
// Stalwart 1.0 refuses `ids: null` past maxObjectsInGet instead of cutting it short.
getAllRefused?: boolean;
}) {
const sent: MethodCall[][] = [];
vi.spyOn(globalThis, 'fetch').mockImplementation((async (_url: string, init: RequestInit) => {
@@ -52,6 +54,7 @@ function fakeFilesServer(nodes: FakeNode[], opts: {
const methodResponses = methodCalls.map(([method, args, callId]) => {
if (method === 'FileNode/get') {
const ids = args.ids as string[] | null;
if (ids === null && opts.getAllRefused && nodes.length > opts.maxObjectsInGet) return ['error', { type: 'requestTooLarge' }, callId];
if (ids === null) return [method, { list: nodes.slice(0, opts.maxObjectsInGet), notFound: [] }, callId];
if (ids.length > opts.maxObjectsInGet) return ['error', { type: 'requestTooLarge' }, callId];
return [method, { list: nodes.filter(n => ids.includes(n.id)), notFound: ids.filter(id => !nodes.some(n => n.id === id)) }, callId];
@@ -147,6 +150,22 @@ describe('JMAPClient FileNode listing past maxObjectsInGet (#1069)', () => {
expect(sortedIds(nodes)).toEqual(['f0', 'f1']);
});
it('lists everything from the query when Stalwart 1.0 refuses the return-all /get (#1385)', async () => {
const all = [folder('d1'), file('f0', 'd1'), file('f1'), file('f2', 'd1')];
const client = createLimitedClient({ maxObjectsInGet: 2 });
fakeFilesServer(all, { maxObjectsInGet: 2, getAllRefused: true });
const nodes = await client.listAllFileNodes();
expect(sortedIds(nodes)).toEqual(sortedIds(all));
});
it('reports the failure when the return-all /get is refused and the query fails too', async () => {
const client = createLimitedClient({ maxObjectsInGet: 2 });
fakeFilesServer([file('f0'), file('f1'), file('f2')], { maxObjectsInGet: 2, getAllRefused: true, queryFails: true });
await expect(client.listAllFileNodes()).rejects.toThrow();
});
it('lists shared accounts past the limit too', async () => {
const all = [file('f0'), file('f1'), file('f2'), file('f3')];
const client = createLimitedClient({ maxObjectsInGet: 2 });
+8 -3
View File
@@ -9272,13 +9272,16 @@ export class JMAPClient implements IJMAPClient {
);
const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get" || (r[0] === "error" && r[2] === "fng0"));
if (!getResult || getResult[0] === "error") {
// Stalwart 1.0 refuses the return-all /get instead of cutting it short
// (#1385); then every node comes from the query below.
const tooMany = getResult?.[0] === "error" && getResult[1]?.type === "requestTooLarge";
if (!getResult || (getResult[0] === "error" && !tooMany)) {
console.error('[Files] FileNode/get error:', getResult?.[1]);
throw new Error(getResult?.[1]?.description || "FileNode list failed");
}
const firstPage = ((getResult[1].list || []) as FileNode[]).map(fromDraftFileNode);
const firstPage = tooMany ? [] : ((getResult[1].list || []) as FileNode[]).map(fromDraftFileNode);
const maxObjects = this.getMaxObjectsInGet();
if (firstPage.length < maxObjects) return firstPage;
if (!tooMany && firstPage.length < maxObjects) return firstPage;
const known = new Map(firstPage.map(node => [node.id, node]));
// Several /get calls share one request, so a large account costs a few
@@ -9334,6 +9337,8 @@ export class JMAPClient implements IJMAPClient {
}
} catch (error) {
// A partial tree beats none: keep what was read, as before #1069.
// With nothing read there is no tree to show, so say why.
if (known.size === 0) throw error;
console.warn(`[Files] Listing for account ${accountId} is incomplete past ${maxObjects} nodes:`, error);
}