From cb9ac29cb49303d4a1b8610d5df555b84d2f9032 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 10 Jun 2026 11:49:11 -0700 Subject: [PATCH] Red: listCollections must drop deleted-collection tombstones /collections/v2 is a sync API: deleted collections remain in the response forever with isDeleted: true, and their /collections/v2/diff endpoint returns HTTP 404. A long-lived account accumulates hundreds of tombstones, so any caller that iterates listCollections() output (backup, backup-metadata) dies on the first one. --- test/client/usage.test.ts | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/client/usage.test.ts b/test/client/usage.test.ts index 0c2beeb..800fb32 100644 --- a/test/client/usage.test.ts +++ b/test/client/usage.test.ts @@ -241,6 +241,40 @@ const buildServer = async (): Promise => { updationTime: 1700000000000000, }; + // A DELETED collection. /collections/v2 is a sync API: deleted + // collections stay in the response forever as tombstones with + // isDeleted: true (a long-lived real account accumulates hundreds). + // Their keys still decrypt, but /collections/v2/diff returns + // HTTP 404 for them, so they must never surface from + // listCollections(); a client that naively iterates them dies on + // the first deleted album. + const deletedKey = sodium.crypto_secretbox_keygen(); + const dkNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encDeletedKey = sodium.crypto_secretbox_easy( + deletedKey, + dkNonce, + masterKey, + ); + const deletedNameNonce = sodium.randombytes_buf( + sodium.crypto_secretbox_NONCEBYTES, + ); + const encDeletedName = sodium.crypto_secretbox_easy( + new TextEncoder().encode("Old Album"), + deletedNameNonce, + deletedKey, + ); + const rawDeletedCollection = { + id: 3, + owner: { id: 42 }, + encryptedKey: toBase64(encDeletedKey), + keyDecryptionNonce: toBase64(dkNonce), + encryptedName: toBase64(encDeletedName), + nameDecryptionNonce: toBase64(deletedNameNonce), + type: "album", + updationTime: 1700000000000000, + isDeleted: true, + }; + const rawFile = { id: 100, collectionID: 1, @@ -262,6 +296,8 @@ const buildServer = async (): Promise => { (globalThis as Record).__mockRawCollection = rawCollection; (globalThis as Record).__mockRawSharedCollection = rawSharedCollection; + (globalThis as Record).__mockRawDeletedCollection = + rawDeletedCollection; (globalThis as Record).__mockRawFile = rawFile; return { @@ -341,6 +377,7 @@ const buildMockFetch = (s: ServerState) => { collections: [ g.__mockRawCollection, g.__mockRawSharedCollection, + g.__mockRawDeletedCollection, ], }); } @@ -469,7 +506,11 @@ describe("quak Client usage guide", () => { const collections = await client.listCollections(); + // The mock server also returns a deleted collection (id 3). + // Deleted collections are tombstones in the sync protocol: their + // file diff endpoint 404s, so listCollections must drop them. expect(collections.length).toBe(2); + expect(collections.find((c) => c.id === 3)).toBeUndefined(); const owned = collections.find((c) => c.id === 1)!; expect(owned.name).toBe("Vacation");