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<{
collections: RawCollection[];
}>("/collections/v2", { sinceTime: 0 });
return collections.map((raw) =>
decryptCollection(
raw,
{
masterKey: this.masterKey,
publicKey: this.publicKey,
secretKey: this.secretKey,
},
this.userID,
),
);
// The sync API keeps returning deleted collections as tombstones
// (isDeleted: true); their diff endpoint 404s, so drop them.
return collections
.filter((raw) => !raw.isDeleted)
.map((raw) =>
decryptCollection(
raw,
{
masterKey: this.masterKey,
publicKey: this.publicKey,
secretKey: this.secretKey,
},
this.userID,
),
);
}
async listFiles(

View File

@@ -241,6 +241,40 @@ const buildServer = async (): Promise<ServerState> => {
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<ServerState> => {
(globalThis as Record<string, unknown>).__mockRawCollection = rawCollection;
(globalThis as Record<string, unknown>).__mockRawSharedCollection =
rawSharedCollection;
(globalThis as Record<string, unknown>).__mockRawDeletedCollection =
rawDeletedCollection;
(globalThis as Record<string, unknown>).__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");