diff --git a/test/api/upload.test.ts b/test/api/upload.test.ts new file mode 100644 index 0000000..e9426e0 --- /dev/null +++ b/test/api/upload.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for the upload-related ApiClient methods added for thumbnail + * repair: `putJSON`, `putFile`, `getUploadURL`, and `updateThumbnail`. + * + * `putFile` sends a raw PUT to a presigned S3 URL. It must NOT send + * quak's auth headers (X-Auth-Token, X-Client-Package) because the + * presigned URL carries its own S3 auth in the query string. Sending + * extra headers can cause S3 to reject the request. + * + * `putJSON` is like `postJSON` but sends PUT. Used by `updateThumbnail` + * to register the uploaded thumbnail's object key with the Ente API. + */ + +import { describe, expect, it } from "vitest"; +import { ApiClient } from "../../src/api/client.js"; + +const jsonResponse = ( + body: unknown, + status = 200, + headers: Record = {}, +): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); + +const recordingFetch = ( + ...responses: Response[] +): { + fetch: typeof globalThis.fetch; + calls: { url: string; init: RequestInit | undefined }[]; +} => { + const calls: { url: string; init: RequestInit | undefined }[] = []; + let i = 0; + const fake = async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + calls.push({ url, init }); + if (i >= responses.length) { + throw new Error(`recordingFetch: no response for call #${i}`); + } + return responses[i++]!; + }; + return { fetch: fake as typeof globalThis.fetch, calls }; +}; + +describe("ApiClient.putJSON", () => { + it("sends a PUT request with JSON body and auth headers", async () => { + const { fetch, calls } = recordingFetch(jsonResponse({ ok: true })); + const client = new ApiClient({ fetch, authToken: "tok" }); + + await client.putJSON("/files/thumbnail", { + fileID: 42, + thumbnail: { objectKey: "k", decryptionHeader: "h" }, + }); + + expect(calls[0]!.init?.method).toBe("PUT"); + const headers = new Headers(calls[0]!.init?.headers as HeadersInit); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.get("X-Auth-Token")).toBe("tok"); + expect(headers.get("X-Client-Package")).toBe("berlin.sneak.quak"); + const body = JSON.parse(calls[0]!.init?.body as string); + expect(body.fileID).toBe(42); + expect(body.thumbnail.objectKey).toBe("k"); + }); +}); + +describe("ApiClient.putFile", () => { + it("PUTs raw bytes to the presigned URL without auth headers", async () => { + const { fetch, calls } = recordingFetch( + new Response(null, { status: 200 }), + ); + const client = new ApiClient({ fetch, authToken: "secret-tok" }); + const data = new Uint8Array([1, 2, 3, 4, 5]); + + await client.putFile("https://s3.example.com/presigned?sig=abc", data); + + expect(calls[0]!.url).toBe("https://s3.example.com/presigned?sig=abc"); + expect(calls[0]!.init?.method).toBe("PUT"); + const headers = new Headers(calls[0]!.init?.headers as HeadersInit); + expect(headers.get("Content-Type")).toBe("application/octet-stream"); + expect(headers.get("Content-Length")).toBe("5"); + // Must NOT leak auth headers to S3 + expect(headers.has("X-Auth-Token")).toBe(false); + expect(headers.has("X-Client-Package")).toBe(false); + }); + + it("throws on non-2xx response from presigned URL", async () => { + const { fetch } = recordingFetch( + new Response("Forbidden", { status: 403 }), + ); + const client = new ApiClient({ fetch }); + + await expect( + client.putFile("https://s3.example.com/bad", new Uint8Array(10)), + ).rejects.toThrow(/403/); + }); +}); + +describe("ApiClient.getUploadURL", () => { + it("POSTs to /files/upload-url with contentLength and contentMD5", async () => { + const { fetch, calls } = recordingFetch( + jsonResponse({ objectKey: "user/thumb123", url: "https://s3/put" }), + ); + const client = new ApiClient({ fetch, authToken: "tok" }); + + const result = await client.getUploadURL(5000, "abc123=="); + + expect(calls[0]!.url).toBe("https://api.ente.io/files/upload-url"); + const body = JSON.parse(calls[0]!.init?.body as string); + expect(body.contentLength).toBe(5000); + expect(body.contentMD5).toBe("abc123=="); + expect(result.objectKey).toBe("user/thumb123"); + expect(result.url).toBe("https://s3/put"); + }); +}); + +describe("ApiClient.updateThumbnail", () => { + it("PUTs to /files/thumbnail with fileID, objectKey, and decryptionHeader", async () => { + const { fetch, calls } = recordingFetch(jsonResponse({})); + const client = new ApiClient({ fetch, authToken: "tok" }); + + await client.updateThumbnail(42, "user/obj", "headerBase64=="); + + expect(calls[0]!.url).toBe("https://api.ente.io/files/thumbnail"); + expect(calls[0]!.init?.method).toBe("PUT"); + const body = JSON.parse(calls[0]!.init?.body as string); + expect(body.fileID).toBe(42); + expect(body.thumbnail.objectKey).toBe("user/obj"); + expect(body.thumbnail.decryptionHeader).toBe("headerBase64=="); + }); +}); diff --git a/test/crypto/encrypt-blob.test.ts b/test/crypto/encrypt-blob.test.ts new file mode 100644 index 0000000..86a1c1a --- /dev/null +++ b/test/crypto/encrypt-blob.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for `crypto.encryptBlob`. + * + * `encryptBlob` is the push-side counterpart to `decryptBlob`. It + * encrypts a small payload as a single secretstream chunk with + * TAG_FINAL and returns the header + ciphertext. Used for encrypting + * thumbnails before upload to the Ente server. + * + * The critical invariant is that `decryptBlob(encryptBlob(...))` is the + * identity function. If either side drifts, uploaded thumbnails become + * unreadable. + */ + +import sodium from "libsodium-wrappers-sumo"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + decryptBlob, + encryptBlob, + init, + STREAM_CHUNK_OVERHEAD, +} from "../../src/crypto/index.js"; + +describe("crypto.encryptBlob", () => { + beforeAll(async () => { + await init(); + await sodium.ready; + }); + + it("round-trips with decryptBlob for arbitrary data", () => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const plaintext = sodium.randombytes_buf(500); + const { header, ciphertext } = encryptBlob(plaintext, key); + const recovered = decryptBlob(ciphertext, header, key); + expect(recovered).toEqual(plaintext); + }); + + it("round-trips a zero-length payload", () => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const { header, ciphertext } = encryptBlob(new Uint8Array(0), key); + const recovered = decryptBlob(ciphertext, header, key); + expect(recovered.length).toBe(0); + }); + + it("ciphertext is exactly STREAM_CHUNK_OVERHEAD longer than plaintext", () => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const plaintext = sodium.randombytes_buf(1234); + const { ciphertext } = encryptBlob(plaintext, key); + expect(ciphertext.length).toBe( + plaintext.length + STREAM_CHUNK_OVERHEAD, + ); + }); + + it("header is 24 bytes (secretstream XChaCha20 header size)", () => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const { header } = encryptBlob(new Uint8Array([1, 2, 3]), key); + expect(header.length).toBe( + sodium.crypto_secretstream_xchacha20poly1305_HEADERBYTES, + ); + }); + + it("produces different ciphertext for different keys", () => { + const k1 = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const k2 = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const plaintext = new Uint8Array([1, 2, 3, 4, 5]); + const enc1 = encryptBlob(plaintext, k1); + const enc2 = encryptBlob(plaintext, k2); + expect(enc1.ciphertext).not.toEqual(enc2.ciphertext); + }); + + it("produces different ciphertext on each call (random nonce in header)", () => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const plaintext = new Uint8Array([9, 9, 9]); + const a = encryptBlob(plaintext, key); + const b = encryptBlob(plaintext, key); + expect(a.header).not.toEqual(b.header); + expect(a.ciphertext).not.toEqual(b.ciphertext); + }); + + it("decryptBlob rejects ciphertext encrypted with a different key", () => { + const k1 = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const k2 = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const { header, ciphertext } = encryptBlob( + new Uint8Array([1, 2, 3]), + k1, + ); + expect(() => decryptBlob(ciphertext, header, k2)).toThrow(); + }); + + it("decryptBlob rejects tampered ciphertext from encryptBlob", () => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const { header, ciphertext } = encryptBlob( + sodium.randombytes_buf(100), + key, + ); + ciphertext[ciphertext.length - 1] = + ciphertext[ciphertext.length - 1]! ^ 0x01; + expect(() => decryptBlob(ciphertext, header, key)).toThrow(); + }); +}); diff --git a/test/thumbnails/thumbnails.test.ts b/test/thumbnails/thumbnails.test.ts new file mode 100644 index 0000000..ac00a87 --- /dev/null +++ b/test/thumbnails/thumbnails.test.ts @@ -0,0 +1,502 @@ +/** + * Tests for `listMissingThumbnails` and `fixMissingThumbnails`. + * + * These use a mock Ente server that serves encrypted collections, files, + * and thumbnails. The mock server has deliberate gaps: some files have + * working thumbnails, others return 404 or empty bodies. The tests + * verify that the detection and repair logic handles each case correctly. + * + * `fixMissingThumbnails` is the most complex function in quak: it + * downloads the original file, generates a JPEG thumbnail with sharp, + * encrypts it with secretstream push, gets a presigned upload URL, + * uploads to S3, and registers the new thumbnail with the API. The + * test verifies each step actually happened and the uploaded data is + * a valid encrypted blob that decrypts to a JPEG. + */ + +import sodium from "libsodium-wrappers-sumo"; +import sharp from "sharp"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + init, + toBase64, + decryptBlob, + fromBase64, + deriveKEK, + deriveLoginSubkey, +} from "../../src/crypto/index.js"; +import { SRP, SrpServer } from "fast-srp-hap"; +import { Client } from "../../src/client.js"; +import { + listMissingThumbnails, + fixMissingThumbnails, +} from "../../src/thumbnails.js"; +import type { KeyAttributes } from "../../src/auth/types.js"; + +// --------------------------------------------------------------------------- +// Mock server with controllable thumbnail behavior +// --------------------------------------------------------------------------- + +const TEST_EMAIL = "thumb@example.com"; +const TEST_PASSWORD = "thumbpass"; +const TEST_OPS = 2; +const TEST_MEM = 64 * 1024 * 1024; + +interface ThumbMockState { + verifier: Buffer; + srpAttributes: Record; + keyAttributes: KeyAttributes; + encryptedToken: string; + collections: Record[]; + filesByCollection: Record[]>; + fileCiphertexts: Record; + fileKeys: Record; + thumbnailBehavior: Record; + // Captures from fix operations + uploadedThumbnails: { + fileID: number; + objectKey: string; + decryptionHeader: string; + ciphertext: Uint8Array; + }[]; +} + +let mock: ThumbMockState; + +const buildThumbMock = async (): Promise => { + const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); + const kek = await deriveKEK(TEST_PASSWORD, kekSalt, TEST_OPS, TEST_MEM); + const loginSubKeyBytes = deriveLoginSubkey(kek); + + const srpUserID = "thumb-srp"; + const srpSalt = sodium.randombytes_buf(16); + const verifier = SRP.computeVerifier( + SRP.params["4096"], + Buffer.from(srpSalt), + Buffer.from(srpUserID), + Buffer.from(loginSubKeyBytes), + ); + + const masterKey = sodium.randombytes_buf(32); + const keyNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encryptedKey = sodium.crypto_secretbox_easy(masterKey, keyNonce, kek); + const kp = sodium.crypto_box_keypair(); + const skNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encSK = sodium.crypto_secretbox_easy( + kp.privateKey, + skNonce, + masterKey, + ); + const tokenBytes = sodium.randombytes_buf(32); + const encToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey); + + const keyAttributes: KeyAttributes = { + kekSalt: toBase64(kekSalt), + encryptedKey: toBase64(encryptedKey), + keyDecryptionNonce: toBase64(keyNonce), + publicKey: toBase64(kp.publicKey), + encryptedSecretKey: toBase64(encSK), + secretKeyDecryptionNonce: toBase64(skNonce), + memLimit: TEST_MEM, + opsLimit: TEST_OPS, + }; + + // One collection with 3 files: ok thumbnail, empty thumbnail, 404 thumbnail + const collKey = sodium.crypto_secretbox_keygen(); + const ckN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encCK = sodium.crypto_secretbox_easy(collKey, ckN, masterKey); + const nameB = new TextEncoder().encode("Photos"); + const cnN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encCN = sodium.crypto_secretbox_easy(nameB, cnN, collKey); + + const rawCollection = { + id: 1, + owner: { id: 42 }, + encryptedKey: toBase64(encCK), + keyDecryptionNonce: toBase64(ckN), + encryptedName: toBase64(encCN), + nameDecryptionNonce: toBase64(cnN), + type: "album", + updationTime: 1700000000000000, + }; + + // Generate a real tiny JPEG that sharp can process + const tinyJpeg = await sharp({ + create: { width: 100, height: 80, channels: 3, background: "red" }, + }) + .jpeg({ quality: 80 }) + .toBuffer(); + + const fileKeys: Record = {}; + const fileCiphertexts: Record = {}; + const rawFiles: Record[] = []; + + for (const fileID of [100, 101, 102]) { + const fk = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + fileKeys[fileID] = fk; + const fkN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encFK = sodium.crypto_secretbox_easy(fk, fkN, collKey); + + const meta = JSON.stringify({ + title: `file-${fileID}.jpg`, + fileType: 0, + creationTime: 1700000000000000, + modificationTime: 1700000000000000, + }); + const metaPush = + sodium.crypto_secretstream_xchacha20poly1305_init_push(fk); + const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push( + metaPush.state, + new TextEncoder().encode(meta), + null, + sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, + ); + + // Encrypt the tiny JPEG as the file body + const filePush = + sodium.crypto_secretstream_xchacha20poly1305_init_push(fk); + const encFile = sodium.crypto_secretstream_xchacha20poly1305_push( + filePush.state, + new Uint8Array(tinyJpeg), + null, + sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, + ); + fileCiphertexts[fileID] = encFile; + + rawFiles.push({ + id: fileID, + collectionID: 1, + ownerID: 42, + encryptedKey: toBase64(encFK), + keyDecryptionNonce: toBase64(fkN), + metadata: { + encryptedData: toBase64(encMeta), + decryptionHeader: toBase64(metaPush.header), + }, + file: { decryptionHeader: toBase64(filePush.header) }, + thumbnail: { + decryptionHeader: toBase64(sodium.randombytes_buf(24)), + }, + updationTime: 1700000000000000, + }); + } + + return { + verifier, + srpAttributes: { + srpUserID, + srpSalt: toBase64(srpSalt), + memLimit: TEST_MEM, + opsLimit: TEST_OPS, + kekSalt: toBase64(kekSalt), + isEmailMFAEnabled: false, + }, + keyAttributes, + encryptedToken: toBase64(encToken), + collections: [rawCollection], + filesByCollection: { 1: rawFiles }, + fileCiphertexts, + fileKeys, + thumbnailBehavior: { + 100: "ok", + 101: "empty", + 102: "404", + }, + uploadedThumbnails: [], + }; +}; + +const buildThumbFetch = (m: ThumbMockState) => { + let srpServer: SrpServer; + + return (async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const parsed = new URL(url); + const path = parsed.pathname; + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + + // SRP auth flow + if (path === "/users/srp/attributes") + return json({ attributes: m.srpAttributes }); + if (path === "/users/srp/create-session") { + const body = JSON.parse(init?.body as string); + const serverKey = await SRP.genKey(); + srpServer = new SrpServer( + SRP.params["4096"], + m.verifier, + serverKey, + ); + const B = srpServer.computeB(); + srpServer.setA(Buffer.from(body.srpA, "base64")); + return json({ sessionID: "s1", srpB: B.toString("base64") }); + } + if (path === "/users/srp/verify-session") { + const body = JSON.parse(init?.body as string); + srpServer.checkM1(Buffer.from(body.srpM1, "base64")); + return json({ + srpM2: srpServer.computeM2().toString("base64"), + id: 42, + keyAttributes: m.keyAttributes, + encryptedToken: m.encryptedToken, + }); + } + + // Collections & files + if (path === "/collections/v2") + return json({ collections: m.collections }); + if (path === "/collections/v2/diff") { + const collID = Number(parsed.searchParams.get("collectionID")); + return json({ + diff: m.filesByCollection[collID] ?? [], + hasMore: false, + }); + } + + // File download (for fix: download original to generate thumb) + if ( + url.includes("files.ente.io") || + path.startsWith("/files/download/") + ) { + const fileID = Number( + parsed.searchParams.get("fileID") ?? path.split("/").pop(), + ); + const ct = m.fileCiphertexts[fileID]; + if (ct) return new Response(ct, { status: 200 }); + return new Response("not found", { status: 404 }); + } + + // Thumbnail download (for list: check if thumbnail exists) + if ( + url.includes("thumbnails.ente.io") || + path.startsWith("/files/preview/") + ) { + const fileID = Number( + parsed.searchParams.get("fileID") ?? path.split("/").pop(), + ); + const behavior = m.thumbnailBehavior[fileID]; + if (behavior === "ok") { + return new Response(new Uint8Array([0xff, 0xd8, 0xff]), { + status: 200, + }); + } + if (behavior === "empty") { + return new Response(new Uint8Array(0), { status: 200 }); + } + return new Response("not found", { status: 404 }); + } + + // Upload URL minting + if (path === "/files/upload-url") { + return json({ + objectKey: `42/thumb-${Date.now()}`, + url: "https://s3.mock.test/presigned-put", + }); + } + + // Presigned PUT (S3 upload) + if (url.startsWith("https://s3.mock.test/")) { + const body = init?.body; + // Store the uploaded bytes for later inspection + if (body instanceof Uint8Array) { + (m as Record)._lastUploadedCiphertext = body; + } else if (body instanceof ArrayBuffer) { + (m as Record)._lastUploadedCiphertext = + new Uint8Array(body); + } + return new Response(null, { status: 200 }); + } + + // Update thumbnail metadata + if (path === "/files/thumbnail" && init?.method === "PUT") { + const reqBody = JSON.parse(init?.body as string); + m.uploadedThumbnails.push({ + fileID: reqBody.fileID, + objectKey: reqBody.thumbnail.objectKey, + decryptionHeader: reqBody.thumbnail.decryptionHeader, + ciphertext: + ((m as Record) + ._lastUploadedCiphertext as Uint8Array) ?? + new Uint8Array(0), + }); + return json({}); + } + + return new Response("not found", { status: 404 }); + }) as typeof globalThis.fetch; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeAll(async () => { + await init(); + await sodium.ready; + mock = await buildThumbMock(); +}); + +describe("listMissingThumbnails", () => { + it("identifies files with empty and 404 thumbnails, ignores working ones", async () => { + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildThumbFetch(mock) }, + }); + + const missing = await listMissingThumbnails(client); + + // File 100 has a working thumbnail → not reported + // File 101 has an empty thumbnail → reported + // File 102 has a 404 thumbnail → reported + expect(missing.length).toBe(2); + const ids = missing.map((m) => m.fileID).sort(); + expect(ids).toEqual([101, 102]); + + const emptyEntry = missing.find((m) => m.fileID === 101)!; + expect(emptyEntry.reason).toContain("empty"); + expect(emptyEntry.title).toBe("file-101.jpg"); + expect(emptyEntry.collection).toBe("Photos"); + + const notFoundEntry = missing.find((m) => m.fileID === 102)!; + expect(notFoundEntry.reason).toContain("fetch failed"); + }); + + it("deduplicates files seen in multiple collections", async () => { + // Add the same files to a second collection in the mock + const mockWithDupes = await buildThumbMock(); + const dupeCollection = { + ...mockWithDupes.collections[0]!, + id: 2, + }; + mockWithDupes.collections.push( + dupeCollection as Record, + ); + mockWithDupes.filesByCollection[2] = + mockWithDupes.filesByCollection[1]!; + + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildThumbFetch(mockWithDupes) }, + }); + + const missing = await listMissingThumbnails(client); + + // Should still be 2, not 4 (each file checked only once) + expect(missing.length).toBe(2); + }); +}); + +describe("fixMissingThumbnails", () => { + it("downloads original, generates thumbnail, encrypts, uploads, and registers", async () => { + const fixMock = await buildThumbMock(); + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildThumbFetch(fixMock) }, + }); + + const results = await fixMissingThumbnails(client, [101]); + + expect(results.length).toBe(1); + expect(results[0]!.success).toBe(true); + expect(results[0]!.fileID).toBe(101); + expect(results[0]!.title).toBe("file-101.jpg"); + expect(results[0]!.collection).toBe("Photos"); + + // Verify the uploaded thumbnail was registered + expect(fixMock.uploadedThumbnails.length).toBe(1); + const upload = fixMock.uploadedThumbnails[0]!; + expect(upload.fileID).toBe(101); + expect(upload.objectKey).toContain("42/"); + expect(upload.decryptionHeader.length).toBeGreaterThan(0); + + // Verify the uploaded ciphertext can be decrypted back to a JPEG + const fileKey = fixMock.fileKeys[101]!; + const decrypted = decryptBlob( + upload.ciphertext, + fromBase64(upload.decryptionHeader), + fileKey, + ); + // JPEG magic bytes: FF D8 FF + expect(decrypted[0]).toBe(0xff); + expect(decrypted[1]).toBe(0xd8); + expect(decrypted[2]).toBe(0xff); + // Verify sharp produced a reasonably sized thumbnail + expect(decrypted.length).toBeGreaterThan(100); + expect(decrypted.length).toBeLessThan(50000); + }); + + it("reports failure for nonexistent file IDs without crashing", async () => { + const fixMock = await buildThumbMock(); + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildThumbFetch(fixMock) }, + }); + + const results = await fixMissingThumbnails(client, [999]); + + expect(results.length).toBe(1); + expect(results[0]!.success).toBe(false); + expect(results[0]!.fileID).toBe(999); + expect(results[0]!.error).toContain("not found"); + }); + + it("continues after one file fails and reports mixed results", async () => { + const fixMock = await buildThumbMock(); + // Make file 102 fail by removing its ciphertext so download fails + delete fixMock.fileCiphertexts[102]; + + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildThumbFetch(fixMock) }, + }); + + const results = await fixMissingThumbnails(client, [101, 102]); + + expect(results.length).toBe(2); + const success = results.find((r) => r.fileID === 101)!; + const failure = results.find((r) => r.fileID === 102)!; + expect(success.success).toBe(true); + expect(failure.success).toBe(false); + }); +}); + +describe("Client.getApiClient", () => { + it("returns the ApiClient when logged in", async () => { + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildThumbFetch(mock) }, + }); + + const api = client.getApiClient(); + expect(api).toBeDefined(); + expect(typeof api.getJSON).toBe("function"); + }); + + it("throws after logout", async () => { + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildThumbFetch(mock) }, + }); + client.logout(); + + expect(() => client.getApiClient()).toThrow(/logged out/); + }); +});