check / check (push) Waiting to run
collectionsSince and filesSince put a row that does not decrypt or parse in an undecryptable list with the reason instead of throwing. The refresh leaves it out, an album with its files, and the store keeps the list in metadata.json until the server sends a copy that decrypts or deletes it, since the next refresh asks only for changes. lib.backup() records each such file in failures.json as "could not be decrypted: <reason>", unless a copy in another album decrypted, logs one line per such album and lists their IDs in undecryptableAlbums; quak backup exits 1 while any remain. Tests use a stand-in Ente server with real encryption. Model: opus-5-5
484 lines
17 KiB
TypeScript
484 lines
17 KiB
TypeScript
/**
|
|
* Tests for the resumable, deletion-aware enumeration variants on `Client`:
|
|
* `collectionsSince` and `filesSince`.
|
|
*
|
|
* The whole-account methods `listCollections` / `listFiles` always start at
|
|
* `sinceTime: 0` and hide deletions. The cache refresh needs the opposite:
|
|
* start from a saved cursor, learn what was deleted, and get back a cursor to
|
|
* resume from next time. These two methods provide that.
|
|
*
|
|
* The return shape keeps live records and tombstones apart — `collections` /
|
|
* `files` are decrypted live records, `deleted` is a plain list of the ids the
|
|
* server tombstoned. A tombstone carries no decryptable key or metadata, so it
|
|
* is a bare id rather than a hollowed-out `Collection` / `EnteFile`. A live row
|
|
* that does not decrypt, or whose metadata is not JSON, is listed under
|
|
* `undecryptable` with the reason, so one bad row does not fail the call.
|
|
*
|
|
* All tests inject a fake `fetch` and drive a real `Client` (built with
|
|
* `Client.fromJSON`) so the decryption path runs for real. Live rows are built
|
|
* with libsodium exactly as the server would encrypt them; tombstone rows carry
|
|
* only the fields the code reads (`id`, `updationTime`, `isDeleted`), because
|
|
* they are never decrypted.
|
|
*/
|
|
|
|
import sodium from "libsodium-wrappers-sumo";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
import { init, toBase64 } from "../../src/crypto/index.js";
|
|
import { Client, type ClientSnapshot } from "../../src/client.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const USER_ID = 42;
|
|
|
|
interface Keys {
|
|
masterKey: Uint8Array;
|
|
publicKey: Uint8Array;
|
|
secretKey: Uint8Array;
|
|
}
|
|
|
|
const buildKeys = (): Keys => {
|
|
const kp = sodium.crypto_box_keypair();
|
|
return {
|
|
masterKey: sodium.crypto_secretbox_keygen(),
|
|
publicKey: kp.publicKey,
|
|
secretKey: kp.privateKey,
|
|
};
|
|
};
|
|
|
|
const snapshotFor = (keys: Keys): ClientSnapshot => ({
|
|
email: "user@example.com",
|
|
userID: USER_ID,
|
|
token: "test-token",
|
|
masterKey: toBase64(keys.masterKey),
|
|
secretKey: toBase64(keys.secretKey),
|
|
publicKey: toBase64(keys.publicKey),
|
|
});
|
|
|
|
const secretboxEncrypt = (
|
|
plaintext: Uint8Array,
|
|
key: Uint8Array,
|
|
): { ciphertext: Uint8Array; nonce: Uint8Array } => {
|
|
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
|
return {
|
|
ciphertext: sodium.crypto_secretbox_easy(plaintext, nonce, key),
|
|
nonce,
|
|
};
|
|
};
|
|
|
|
/** An owned collection row as the server sends it, keyed under the master key. */
|
|
const ownedCollectionRow = (
|
|
masterKey: Uint8Array,
|
|
opts: { id: number; name: string; updationTime: number },
|
|
): Record<string, unknown> => {
|
|
const collectionKey = sodium.crypto_secretbox_keygen();
|
|
const { ciphertext: encKey, nonce: keyNonce } = secretboxEncrypt(
|
|
collectionKey,
|
|
masterKey,
|
|
);
|
|
const { ciphertext: encName, nonce: nameNonce } = secretboxEncrypt(
|
|
new TextEncoder().encode(opts.name),
|
|
collectionKey,
|
|
);
|
|
return {
|
|
id: opts.id,
|
|
owner: { id: USER_ID },
|
|
encryptedKey: toBase64(encKey),
|
|
keyDecryptionNonce: toBase64(keyNonce),
|
|
encryptedName: toBase64(encName),
|
|
nameDecryptionNonce: toBase64(nameNonce),
|
|
type: "album",
|
|
updationTime: opts.updationTime,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* A live file row inside a collection, keyed under that collection's key. Its
|
|
* metadata decrypts to `metadata` when given, otherwise to the JSON a client
|
|
* uploads for `title`.
|
|
*/
|
|
const fileRow = (
|
|
collectionKey: Uint8Array,
|
|
opts: {
|
|
id: number;
|
|
title: string;
|
|
updationTime: number;
|
|
metadata?: string;
|
|
},
|
|
): Record<string, unknown> => {
|
|
const fileKey = sodium.crypto_secretbox_keygen();
|
|
const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt(
|
|
fileKey,
|
|
collectionKey,
|
|
);
|
|
const metadata =
|
|
opts.metadata ??
|
|
JSON.stringify({
|
|
title: opts.title,
|
|
fileType: 0,
|
|
creationTime: opts.updationTime,
|
|
modificationTime: opts.updationTime,
|
|
});
|
|
const push =
|
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey);
|
|
const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push(
|
|
push.state,
|
|
new TextEncoder().encode(metadata),
|
|
null,
|
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
|
);
|
|
return {
|
|
id: opts.id,
|
|
collectionID: 1,
|
|
ownerID: USER_ID,
|
|
encryptedKey: toBase64(encFileKey),
|
|
keyDecryptionNonce: toBase64(fileKeyNonce),
|
|
metadata: {
|
|
encryptedData: toBase64(encMeta),
|
|
decryptionHeader: toBase64(push.header),
|
|
},
|
|
file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
|
thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
|
updationTime: opts.updationTime,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* `row` with its sealed key replaced by random bytes of the same length, as a
|
|
* corrupt row arrives: no key opens it, so the row cannot be decrypted.
|
|
*/
|
|
const withCorruptKey = (
|
|
row: Record<string, unknown>,
|
|
): Record<string, unknown> => ({
|
|
...row,
|
|
encryptedKey: toBase64(
|
|
sodium.randombytes_buf(
|
|
sodium.crypto_secretbox_KEYBYTES + sodium.crypto_secretbox_MACBYTES,
|
|
),
|
|
),
|
|
});
|
|
|
|
/** A tombstone row. Never decrypted, so only these fields are ever read. */
|
|
const tombstoneRow = (
|
|
id: number,
|
|
updationTime: number,
|
|
): Record<string, unknown> => ({
|
|
id,
|
|
updationTime,
|
|
isDeleted: true,
|
|
});
|
|
|
|
const jsonResponse = (body: unknown): Response =>
|
|
new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
|
|
/**
|
|
* A fetch that serves canned responses in order and records the `sinceTime`
|
|
* query parameter each request carried, so tests can prove the cursor is
|
|
* threaded from one page (and one call) to the next.
|
|
*/
|
|
const recordingFetch = (
|
|
...responses: Response[]
|
|
): { fetch: typeof globalThis.fetch; sinceTimes: (string | null)[] } => {
|
|
const sinceTimes: (string | null)[] = [];
|
|
let i = 0;
|
|
const fake = async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url =
|
|
typeof input === "string"
|
|
? input
|
|
: input instanceof URL
|
|
? input.href
|
|
: input.url;
|
|
sinceTimes.push(new URL(url).searchParams.get("sinceTime"));
|
|
if (i >= responses.length) {
|
|
throw new Error(`recordingFetch: no response for call #${i}`);
|
|
}
|
|
return responses[i++]!;
|
|
};
|
|
return { fetch: fake as typeof globalThis.fetch, sinceTimes };
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("Client.filesSince", () => {
|
|
beforeAll(async () => {
|
|
await init();
|
|
await sodium.ready;
|
|
});
|
|
|
|
it("pages from the given cursor, decrypts live rows, and collects tombstones", async () => {
|
|
const keys = buildKeys();
|
|
const collectionKey = sodium.crypto_secretbox_keygen();
|
|
|
|
// Page 1 mixes a live file and a tombstone; the tombstone has the
|
|
// higher updationTime, so it — not the live row — sets the cursor the
|
|
// second page must be fetched from.
|
|
const { fetch, sinceTimes } = recordingFetch(
|
|
jsonResponse({
|
|
diff: [
|
|
fileRow(collectionKey, {
|
|
id: 1001,
|
|
title: "first.jpg",
|
|
updationTime: 100,
|
|
}),
|
|
tombstoneRow(1002, 150),
|
|
],
|
|
hasMore: true,
|
|
}),
|
|
jsonResponse({
|
|
diff: [
|
|
fileRow(collectionKey, {
|
|
id: 1003,
|
|
title: "second.jpg",
|
|
updationTime: 200,
|
|
}),
|
|
],
|
|
hasMore: false,
|
|
}),
|
|
);
|
|
const client = Client.fromJSON(snapshotFor(keys), { fetch });
|
|
|
|
const { files, deleted, cursor } = await client.filesSince({
|
|
collectionID: 1,
|
|
collectionKey,
|
|
sinceTime: 0,
|
|
});
|
|
|
|
expect(files.map((f) => f.id)).toEqual([1001, 1003]);
|
|
expect(files.map((f) => f.metadata.title)).toEqual([
|
|
"first.jpg",
|
|
"second.jpg",
|
|
]);
|
|
expect(deleted).toEqual([1002]);
|
|
expect(cursor).toBe(200);
|
|
// First request started at the caller's cursor; the second resumed
|
|
// from the max updationTime seen on the first page (the tombstone's).
|
|
expect(sinceTimes).toEqual(["0", "150"]);
|
|
});
|
|
|
|
it("fetches only newer rows when the returned cursor is passed back in", async () => {
|
|
const keys = buildKeys();
|
|
const collectionKey = sodium.crypto_secretbox_keygen();
|
|
|
|
const { fetch, sinceTimes } = recordingFetch(
|
|
jsonResponse({ diff: [], hasMore: false }),
|
|
);
|
|
const client = Client.fromJSON(snapshotFor(keys), { fetch });
|
|
|
|
const result = await client.filesSince({
|
|
collectionID: 1,
|
|
collectionKey,
|
|
sinceTime: 200,
|
|
});
|
|
|
|
expect(result.files).toEqual([]);
|
|
expect(result.deleted).toEqual([]);
|
|
// An empty diff advances nothing: the cursor falls back to the input.
|
|
expect(result.cursor).toBe(200);
|
|
expect(sinceTimes).toEqual(["200"]);
|
|
});
|
|
|
|
it("stops and throws when the server claims more but does not advance (#7)", async () => {
|
|
const keys = buildKeys();
|
|
const collectionKey = sodium.crypto_secretbox_keygen();
|
|
|
|
// hasMore is true, but the page's max updationTime (50) does not exceed
|
|
// the cursor the request was made with (50). Following hasMore here
|
|
// would refetch this same page forever.
|
|
const { fetch, sinceTimes } = recordingFetch(
|
|
jsonResponse({ diff: [tombstoneRow(1, 50)], hasMore: true }),
|
|
jsonResponse({ diff: [tombstoneRow(1, 50)], hasMore: true }),
|
|
);
|
|
const client = Client.fromJSON(snapshotFor(keys), { fetch });
|
|
|
|
await expect(
|
|
client.filesSince({
|
|
collectionID: 1,
|
|
collectionKey,
|
|
sinceTime: 50,
|
|
}),
|
|
).rejects.toThrow(/not advance|non-advancing/i);
|
|
// It gave up after the first page rather than looping.
|
|
expect(sinceTimes).toEqual(["50"]);
|
|
});
|
|
|
|
it("leaves out each file it cannot decrypt or parse, with the reason, and decrypts the rest", async () => {
|
|
const keys = buildKeys();
|
|
const collectionKey = sodium.crypto_secretbox_keygen();
|
|
|
|
// File 1002's key is sealed under no key quak holds, and file 1003's
|
|
// metadata decrypts to text that is not JSON. Neither may cost the
|
|
// caller file 1001: each is left out of `files` and reported under
|
|
// `undecryptable` with the reason instead of failing the call.
|
|
const { fetch } = recordingFetch(
|
|
jsonResponse({
|
|
diff: [
|
|
fileRow(collectionKey, {
|
|
id: 1001,
|
|
title: "fine.jpg",
|
|
updationTime: 100,
|
|
}),
|
|
withCorruptKey(
|
|
fileRow(collectionKey, {
|
|
id: 1002,
|
|
title: "bad-key.jpg",
|
|
updationTime: 200,
|
|
}),
|
|
),
|
|
fileRow(collectionKey, {
|
|
id: 1003,
|
|
title: "bad-metadata.jpg",
|
|
updationTime: 300,
|
|
metadata: "not json",
|
|
}),
|
|
],
|
|
hasMore: false,
|
|
}),
|
|
);
|
|
const client = Client.fromJSON(snapshotFor(keys), { fetch });
|
|
|
|
const { files, undecryptable, cursor } = await client.filesSince({
|
|
collectionID: 1,
|
|
collectionKey,
|
|
sinceTime: 0,
|
|
});
|
|
|
|
expect(files.map((f) => f.id)).toEqual([1001]);
|
|
expect(undecryptable).toEqual([
|
|
{ id: 1002, error: expect.stringContaining("wrong secret key") },
|
|
{ id: 1003, error: expect.stringContaining("JSON") },
|
|
]);
|
|
// The cursor moves past the two left out, as past any other row, so
|
|
// the server does not send them again until its copy changes. The
|
|
// caller has to keep what it was told here.
|
|
expect(cursor).toBe(300);
|
|
});
|
|
});
|
|
|
|
describe("Client.collectionsSince", () => {
|
|
beforeAll(async () => {
|
|
await init();
|
|
await sodium.ready;
|
|
});
|
|
|
|
it("decrypts live collections, collects tombstones, and returns a cursor", async () => {
|
|
const keys = buildKeys();
|
|
|
|
const { fetch, sinceTimes } = recordingFetch(
|
|
jsonResponse({
|
|
collections: [
|
|
ownedCollectionRow(keys.masterKey, {
|
|
id: 1,
|
|
name: "Vacation",
|
|
updationTime: 100,
|
|
}),
|
|
tombstoneRow(3, 150),
|
|
],
|
|
}),
|
|
);
|
|
const client = Client.fromJSON(snapshotFor(keys), { fetch });
|
|
|
|
const { collections, deleted, cursor } = await client.collectionsSince({
|
|
sinceTime: 0,
|
|
});
|
|
|
|
expect(collections.map((c) => c.id)).toEqual([1]);
|
|
expect(collections[0]!.name).toBe("Vacation");
|
|
expect(deleted).toEqual([3]);
|
|
// The tombstone's updationTime advances the cursor too, so the next
|
|
// sync starts after it rather than seeing it again.
|
|
expect(cursor).toBe(150);
|
|
expect(sinceTimes).toEqual(["0"]);
|
|
});
|
|
|
|
it("falls back to the input cursor on an empty response", async () => {
|
|
const keys = buildKeys();
|
|
|
|
const { fetch, sinceTimes } = recordingFetch(
|
|
jsonResponse({ collections: [] }),
|
|
);
|
|
const client = Client.fromJSON(snapshotFor(keys), { fetch });
|
|
|
|
const result = await client.collectionsSince({ sinceTime: 150 });
|
|
|
|
expect(result.collections).toEqual([]);
|
|
expect(result.deleted).toEqual([]);
|
|
expect(result.cursor).toBe(150);
|
|
expect(sinceTimes).toEqual(["150"]);
|
|
});
|
|
|
|
it("leaves out an album it cannot decrypt, with the reason, and decrypts the rest", async () => {
|
|
const keys = buildKeys();
|
|
|
|
// Album 2's key is sealed under no key quak holds. Without its key
|
|
// neither its name nor its files can be read, so it is left out and
|
|
// reported under `undecryptable`; album 1 is unaffected.
|
|
const { fetch } = recordingFetch(
|
|
jsonResponse({
|
|
collections: [
|
|
ownedCollectionRow(keys.masterKey, {
|
|
id: 1,
|
|
name: "Vacation",
|
|
updationTime: 100,
|
|
}),
|
|
withCorruptKey(
|
|
ownedCollectionRow(keys.masterKey, {
|
|
id: 2,
|
|
name: "Broken",
|
|
updationTime: 200,
|
|
}),
|
|
),
|
|
],
|
|
}),
|
|
);
|
|
const client = Client.fromJSON(snapshotFor(keys), { fetch });
|
|
|
|
const { collections, undecryptable, cursor } =
|
|
await client.collectionsSince({ sinceTime: 0 });
|
|
|
|
expect(collections.map((c) => c.name)).toEqual(["Vacation"]);
|
|
expect(undecryptable).toEqual([
|
|
{ id: 2, error: expect.stringContaining("wrong secret key") },
|
|
]);
|
|
expect(cursor).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("Client list wrappers still hide deletions", () => {
|
|
beforeAll(async () => {
|
|
await init();
|
|
await sodium.ready;
|
|
});
|
|
|
|
it("listFiles drops tombstones and returns only live files", async () => {
|
|
const keys = buildKeys();
|
|
const collectionKey = sodium.crypto_secretbox_keygen();
|
|
|
|
const { fetch, sinceTimes } = recordingFetch(
|
|
jsonResponse({
|
|
diff: [
|
|
fileRow(collectionKey, {
|
|
id: 7,
|
|
title: "keep.jpg",
|
|
updationTime: 100,
|
|
}),
|
|
tombstoneRow(8, 150),
|
|
],
|
|
hasMore: false,
|
|
}),
|
|
);
|
|
const client = Client.fromJSON(snapshotFor(keys), { fetch });
|
|
|
|
const files = await client.listFiles(1, collectionKey);
|
|
|
|
expect(files.map((f) => f.id)).toEqual([7]);
|
|
// The wrapper starts a full enumeration from zero.
|
|
expect(sinceTimes).toEqual(["0"]);
|
|
});
|
|
});
|