Test: quak backup asks the server for changes only as it starts
A backup whose single download is held open while the clock moves on two hours must make only the two refreshes it waits for, counted at the fake server's /collections/v2. It runs the real session loader and client against a fake of the Ente HTTP API serving one encrypted album and photo. This fails until the backup's library stops refreshing on a timer. Model: opus-5-5
This commit is contained in:
+142
-1
@@ -25,6 +25,7 @@ import { join } from "node:path";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import * as jpegJs from "jpeg-js";
|
import * as jpegJs from "jpeg-js";
|
||||||
|
import sodium from "libsodium-wrappers-sumo";
|
||||||
import {
|
import {
|
||||||
describe,
|
describe,
|
||||||
it,
|
it,
|
||||||
@@ -55,7 +56,7 @@ import { loadSession } from "../../src/cli-session.js";
|
|||||||
import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js";
|
import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js";
|
||||||
import { savePath, type ContentSource } from "../../src/library/content.js";
|
import { savePath, type ContentSource } from "../../src/library/content.js";
|
||||||
import type { Collection, EnteFile } from "../../src/model/types.js";
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||||
import { init, toBase64 } from "../../src/crypto/index.js";
|
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
|
||||||
import { defaultCacheDirectory } from "../../src/library/index.js";
|
import { defaultCacheDirectory } from "../../src/library/index.js";
|
||||||
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
||||||
import {
|
import {
|
||||||
@@ -903,6 +904,146 @@ describe("backup", () => {
|
|||||||
// Opening the library would have made its cache directory.
|
// Opening the library would have made its cache directory.
|
||||||
expect(existsSync(join(root, "cache"))).toBe(false);
|
expect(existsSync(join(root, "cache"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A backup runs for as long as its downloads take, hours on a large
|
||||||
|
// account. It lists its files from the refresh it waits for as it starts,
|
||||||
|
// so it must not ask the server for changes again on a timer while it
|
||||||
|
// runs: each refresh is a request, and each also starts a pass that asks
|
||||||
|
// again for the ML data of every file Ente holds none for.
|
||||||
|
//
|
||||||
|
// A saved session is read back by `loadSession`, its `fetch` a fake of the
|
||||||
|
// Ente HTTP API serving one album with one photo, encrypted as the server
|
||||||
|
// serves them. The fake holds the photo's download open while the clock
|
||||||
|
// moves on by two hours, then serves it. Only `setTimeout` and
|
||||||
|
// `clearTimeout` are faked: the library's refresh timer runs on them.
|
||||||
|
it("asks the server for changes only as it starts, however long it runs", async () => {
|
||||||
|
const masterKey = new Uint8Array(32).fill(1);
|
||||||
|
const albumKey = new Uint8Array(32).fill(2);
|
||||||
|
const photoKey = new Uint8Array(32).fill(3);
|
||||||
|
saveSession(join(root, "session"), {
|
||||||
|
email: "cli@example.com",
|
||||||
|
userID: USER_ID,
|
||||||
|
token: "token",
|
||||||
|
masterKey: toBase64(masterKey),
|
||||||
|
secretKey: toBase64(new Uint8Array(32)),
|
||||||
|
publicKey: toBase64(new Uint8Array(32)),
|
||||||
|
});
|
||||||
|
// A secretbox as the server sends one: ciphertext and nonce, base64.
|
||||||
|
const box = (plaintext: Uint8Array, key: Uint8Array) => {
|
||||||
|
const nonce = sodium.randombytes_buf(
|
||||||
|
sodium.crypto_secretbox_NONCEBYTES,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ciphertext: toBase64(
|
||||||
|
sodium.crypto_secretbox_easy(plaintext, nonce, key),
|
||||||
|
),
|
||||||
|
nonce: toBase64(nonce),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const albumKeyBox = box(albumKey, masterKey);
|
||||||
|
const albumName = box(new TextEncoder().encode("Vacation"), albumKey);
|
||||||
|
const photoKeyBox = box(photoKey, albumKey);
|
||||||
|
const metadata = encryptBlob(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
JSON.stringify({
|
||||||
|
title: "beach.jpg",
|
||||||
|
fileType: 0,
|
||||||
|
creationTime: 1_700_000_000_000_000,
|
||||||
|
modificationTime: 1_700_000_000_000_000,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
photoKey,
|
||||||
|
);
|
||||||
|
const content = encryptBlob(
|
||||||
|
new TextEncoder().encode("the photo's bytes"),
|
||||||
|
photoKey,
|
||||||
|
);
|
||||||
|
const album = {
|
||||||
|
id: 1,
|
||||||
|
owner: { id: USER_ID },
|
||||||
|
encryptedKey: albumKeyBox.ciphertext,
|
||||||
|
keyDecryptionNonce: albumKeyBox.nonce,
|
||||||
|
encryptedName: albumName.ciphertext,
|
||||||
|
nameDecryptionNonce: albumName.nonce,
|
||||||
|
type: "album",
|
||||||
|
updationTime: 1,
|
||||||
|
};
|
||||||
|
const photo = {
|
||||||
|
id: 100,
|
||||||
|
collectionID: 1,
|
||||||
|
ownerID: USER_ID,
|
||||||
|
encryptedKey: photoKeyBox.ciphertext,
|
||||||
|
keyDecryptionNonce: photoKeyBox.nonce,
|
||||||
|
metadata: {
|
||||||
|
encryptedData: toBase64(metadata.ciphertext),
|
||||||
|
decryptionHeader: toBase64(metadata.header),
|
||||||
|
},
|
||||||
|
file: { decryptionHeader: toBase64(content.header) },
|
||||||
|
// The backup fetches no thumbnail.
|
||||||
|
thumbnail: { decryptionHeader: "" },
|
||||||
|
updationTime: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each refresh starts with `collectionsSince`, which sends one
|
||||||
|
// `/collections/v2` request.
|
||||||
|
let refreshes = 0;
|
||||||
|
let downloadRequested!: () => void;
|
||||||
|
const requested = new Promise<void>((resolve) => {
|
||||||
|
downloadRequested = resolve;
|
||||||
|
});
|
||||||
|
let releaseDownload!: () => void;
|
||||||
|
const released = new Promise<void>((resolve) => {
|
||||||
|
releaseDownload = resolve;
|
||||||
|
});
|
||||||
|
const json = (body: unknown): Response =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
const fetch = async (url: string | URL): Promise<Response> => {
|
||||||
|
const { hostname, pathname } = new URL(String(url));
|
||||||
|
if (hostname === "files.ente.io") {
|
||||||
|
downloadRequested();
|
||||||
|
await released;
|
||||||
|
return new Response(content.ciphertext);
|
||||||
|
}
|
||||||
|
if (pathname === "/collections/v2") {
|
||||||
|
refreshes++;
|
||||||
|
return json({ collections: [album] });
|
||||||
|
}
|
||||||
|
if (pathname === "/collections/v2/diff") {
|
||||||
|
return json({ diff: [photo], hasMore: false });
|
||||||
|
}
|
||||||
|
// Ente holds no ML data for the photo.
|
||||||
|
if (pathname === "/files/data/fetch") return json({ data: [] });
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
...context(),
|
||||||
|
// The download's idle deadline is set past the two hours, or quak
|
||||||
|
// would give up the held download as stalled.
|
||||||
|
loadSession: (path: string) =>
|
||||||
|
loadSession(path, {
|
||||||
|
fetch: fetch as typeof globalThis.fetch,
|
||||||
|
downloadTimeoutMs: 3 * 3_600_000,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
try {
|
||||||
|
const backup = backupCommand(ctx, join(root, "backup"), {});
|
||||||
|
await requested;
|
||||||
|
vi.advanceTimersByTime(2 * 3_600_000);
|
||||||
|
releaseDownload();
|
||||||
|
expect(await backup).toBe(0);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two refreshes, both before the download: the library opens onto the
|
||||||
|
// empty cache with one, and the backup waits for the other before it
|
||||||
|
// lists its files.
|
||||||
|
expect(refreshes).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("helper list-missing-thumbnails", () => {
|
describe("helper list-missing-thumbnails", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user