check / check (push) Waiting to run
A file or album whose key, metadata or name does not decrypt used to throw out of the refresh, so quak backup stopped before storing anything, every run. Client.collectionsSince and filesSince now return such a row in undecryptable with the reason, the refresh leaves it out, and the store keeps the list in metadata.json until a later refresh decrypts or deletes the record. lib.backup() records each such file in failures.json unless a copy in another album decrypted, in which case it stores the original from that copy. Each such album is listed in BackupResult.undecryptableAlbums and printed as one FAILED album line. quak backup exits 1 while either remains. An album or file that no longer decrypts loses its album folder, JSON and links; its original stays. 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"]);
|
|
});
|
|
});
|