From d1b5d04661b594391dac6a16fbd8b56f3cb7c64d Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 00:18:58 +0000 Subject: [PATCH] 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 --- test/cli/commands.test.ts | 143 +++++++++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 8e068c8..e4cbe40 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -25,6 +25,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { PassThrough } from "node:stream"; import * as jpegJs from "jpeg-js"; +import sodium from "libsodium-wrappers-sumo"; import { describe, it, @@ -55,7 +56,7 @@ import { loadSession } from "../../src/cli-session.js"; import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js"; import { savePath, type ContentSource } from "../../src/library/content.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 { HEIC_WITH_EXIF } from "../exif-heic.js"; import { @@ -914,6 +915,146 @@ describe("backup", () => { // Opening the library would have made its cache directory. 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((resolve) => { + downloadRequested = resolve; + }); + let releaseDownload!: () => void; + const released = new Promise((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 => { + 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", () => {