downloadFile streams the encrypted body from the CDN, buffers it to the 4 MiB + 17 encrypted chunk boundary, decrypts each chunk via secretstream pull, and writes the concatenated plaintext to disk. downloadThumbnail does the same for the thumbnail CDN. 4 unit tests (single-chunk, large single-chunk, filename fallback, thumbnail) + live integration test that downloads a real 472 KB JPEG from the dev account and verifies it lands on disk. Uses mkdtempSync for temp directories (not manual timestamp paths).
180 lines
7.0 KiB
TypeScript
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(), "quack-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 quack! 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));
|
|
});
|
|
});
|