/** * Tests for `downloadFile` and `downloadThumbnail`. * * These functions combine the ApiClient's streaming download with the * secretstream pull decryption to recover the plaintext file content and * write it to disk. * * The encrypted body returned by the CDN is a concatenation of * secretstream ciphertext chunks. Each chunk is at most * `STREAM_CHUNK_SIZE + STREAM_CHUNK_OVERHEAD` bytes (4 MiB + 17 bytes). * The download function buffers incoming network data, splits it on the * chunk boundary, and feeds each piece to `pullStreamChunk`. The last * chunk carries `TAG_FINAL`; any truncation is detected because the tag * will be missing. * * These tests build synthetic encrypted files using sodium's push API, * serve them from a mock fetch, and verify the decrypted output on disk. */ import { existsSync, readFileSync, rmSync, mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import sodium from "libsodium-wrappers-sumo"; import { beforeAll, afterAll, describe, expect, it } from "vitest"; import { init, toBase64 } from "../../src/crypto/index.js"; import { ApiClient } from "../../src/api/client.js"; import { downloadFile, downloadThumbnail } from "../../src/download/index.js"; import type { EnteFile, FileMetadata } from "../../src/model/types.js"; // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- let testDir: string; beforeAll(async () => { await init(); await sodium.ready; testDir = mkdtempSync(join(tmpdir(), "quak-test-")); }); afterAll(() => { if (testDir && existsSync(testDir)) { rmSync(testDir, { recursive: true, force: true }); } }); /** * Encrypt `plaintext` as a secretstream file body (single chunk with * TAG_FINAL). Returns the key, header, and ciphertext that the mock CDN * will serve. */ const encryptFileBody = ( plaintext: Uint8Array, key: Uint8Array, ): { header: Uint8Array; ciphertext: Uint8Array } => { const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(key); const ciphertext = sodium.crypto_secretstream_xchacha20poly1305_push( push.state, plaintext, null, sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, ); return { header: push.header, ciphertext }; }; const buildMockEnteFile = ( key: Uint8Array, fileHeader: Uint8Array, thumbHeader: Uint8Array, ): EnteFile => ({ id: 999, collectionID: 1, ownerID: 1, key, metadata: { title: "test-photo.jpg", fileType: "image", creationTime: 0, modificationTime: 0, } as FileMetadata, file: { decryptionHeader: toBase64(fileHeader) }, thumbnail: { decryptionHeader: toBase64(thumbHeader) }, updationTime: 0, }); const mockFetchForBody = (body: Uint8Array) => { const fake = async (): Promise => new Response(body, { status: 200 }); return fake as typeof globalThis.fetch; }; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("downloadFile", () => { it("downloads, decrypts, and writes a single-chunk file", async () => { const plaintext = new TextEncoder().encode( "Hello from quak! This is a test photo payload.", ); const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const { header, ciphertext } = encryptFileBody(plaintext, key); // Separate header for thumbnail (not used in this test path but // needed to construct the EnteFile) const thumbPush = sodium.crypto_secretstream_xchacha20poly1305_init_push(key); const file = buildMockEnteFile(key, header, thumbPush.header); const api = new ApiClient({ fetch: mockFetchForBody(ciphertext) }); const outPath = join(testDir, "single-chunk.jpg"); const result = await downloadFile(api, file, outPath); expect(result.path).toBe(outPath); expect(result.bytesWritten).toBe(plaintext.length); expect(readFileSync(outPath)).toEqual(Buffer.from(plaintext)); }); it("uses metadata.title as filename when outPath is omitted", async () => { const plaintext = new Uint8Array([1, 2, 3]); const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const { header, ciphertext } = encryptFileBody(plaintext, key); const thumbPush = sodium.crypto_secretstream_xchacha20poly1305_init_push(key); const file = buildMockEnteFile(key, header, thumbPush.header); file.metadata.title = "fallback-name.png"; const api = new ApiClient({ fetch: mockFetchForBody(ciphertext) }); const result = await downloadFile(api, file); expect(result.path).toBe("fallback-name.png"); // Clean up since it writes to cwd if (existsSync(result.path)) rmSync(result.path); }); it("handles a larger single-chunk file (random binary payload)", async () => { // Most photos are under 4 MiB and therefore a single secretstream // chunk. This test exercises a non-trivial payload size with // random binary data (not just ASCII) to verify no encoding bugs. // Multi-chunk (>4 MiB) decryption is verified by the live // integration test against real photos from the dev account. const plaintext = sodium.randombytes_buf(100_000); const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const { header, ciphertext } = encryptFileBody(plaintext, key); const thumbPush = sodium.crypto_secretstream_xchacha20poly1305_init_push(key); const file = buildMockEnteFile(key, header, thumbPush.header); const api = new ApiClient({ fetch: mockFetchForBody(ciphertext) }); const outPath = join(testDir, "large-single.bin"); const result = await downloadFile(api, file, outPath); expect(result.bytesWritten).toBe(100_000); expect(readFileSync(outPath)).toEqual(Buffer.from(plaintext)); }); }); describe("downloadThumbnail", () => { it("downloads and decrypts the thumbnail stream", async () => { const plaintext = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]); // JPEG SOI const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const filePush = sodium.crypto_secretstream_xchacha20poly1305_init_push(key); const { header: thumbHeader, ciphertext: thumbCipher } = encryptFileBody(plaintext, key); const file = buildMockEnteFile(key, filePush.header, thumbHeader); const api = new ApiClient({ fetch: mockFetchForBody(thumbCipher) }); const outPath = join(testDir, "thumb.jpg"); const result = await downloadThumbnail(api, file, outPath); expect(result.bytesWritten).toBe(4); expect(readFileSync(outPath)).toEqual(Buffer.from(plaintext)); }); });