Merge: skip deleted-collection tombstones in listCollections

This commit is contained in:
2026-06-10 11:49:48 -07:00
2 changed files with 56 additions and 11 deletions

View File

@@ -155,17 +155,21 @@ export class Client {
const { collections } = await this.api.getJSON<{ const { collections } = await this.api.getJSON<{
collections: RawCollection[]; collections: RawCollection[];
}>("/collections/v2", { sinceTime: 0 }); }>("/collections/v2", { sinceTime: 0 });
return collections.map((raw) => // The sync API keeps returning deleted collections as tombstones
decryptCollection( // (isDeleted: true); their diff endpoint 404s, so drop them.
raw, return collections
{ .filter((raw) => !raw.isDeleted)
masterKey: this.masterKey, .map((raw) =>
publicKey: this.publicKey, decryptCollection(
secretKey: this.secretKey, raw,
}, {
this.userID, masterKey: this.masterKey,
), publicKey: this.publicKey,
); secretKey: this.secretKey,
},
this.userID,
),
);
} }
async listFiles( async listFiles(

View File

@@ -241,6 +241,40 @@ const buildServer = async (): Promise<ServerState> => {
updationTime: 1700000000000000, 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 = { const rawFile = {
id: 100, id: 100,
collectionID: 1, collectionID: 1,
@@ -262,6 +296,8 @@ const buildServer = async (): Promise<ServerState> => {
(globalThis as Record<string, unknown>).__mockRawCollection = rawCollection; (globalThis as Record<string, unknown>).__mockRawCollection = rawCollection;
(globalThis as Record<string, unknown>).__mockRawSharedCollection = (globalThis as Record<string, unknown>).__mockRawSharedCollection =
rawSharedCollection; rawSharedCollection;
(globalThis as Record<string, unknown>).__mockRawDeletedCollection =
rawDeletedCollection;
(globalThis as Record<string, unknown>).__mockRawFile = rawFile; (globalThis as Record<string, unknown>).__mockRawFile = rawFile;
return { return {
@@ -341,6 +377,7 @@ const buildMockFetch = (s: ServerState) => {
collections: [ collections: [
g.__mockRawCollection, g.__mockRawCollection,
g.__mockRawSharedCollection, g.__mockRawSharedCollection,
g.__mockRawDeletedCollection,
], ],
}); });
} }
@@ -469,7 +506,11 @@ describe("quak Client usage guide", () => {
const collections = await client.listCollections(); 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.length).toBe(2);
expect(collections.find((c) => c.id === 3)).toBeUndefined();
const owned = collections.find((c) => c.id === 1)!; const owned = collections.find((c) => c.id === 1)!;
expect(owned.name).toBe("Vacation"); expect(owned.name).toBe("Vacation");