Files
quak/test/download/download.test.ts
sneak d8a4b0291e Rename quack to quak
German for 'quack', matching the Ente (German for 'duck') naming. All
references updated: package name, CLI binary, X-Client-Package header,
test descriptions, temp dir prefixes, README, Makefile docker tag.
2026-05-13 18:02:55 -07:00

180 lines
7.0 KiB
TypeScript

/**
* 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<Response> =>
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));
});
});