Add tests for thumbnail-helpers branch (20 new tests)
test/crypto/encrypt-blob.test.ts (8 tests): Round-trip with decryptBlob, zero-length payload, ciphertext overhead check, header size check, different keys produce different output, same key produces different output each call (random nonce), wrong-key rejection, tamper detection. test/api/upload.test.ts (4 tests): putJSON sends PUT with auth headers and JSON body. putFile sends PUT to the exact presigned URL with Content-Type octet-stream and does NOT send X-Auth-Token or X-Client-Package (S3 would reject them). getUploadURL POSTs with contentLength and contentMD5. updateThumbnail PUTs to /files/thumbnail with correct body shape. test/thumbnails/thumbnails.test.ts (8 tests): listMissingThumbnails identifies empty (0 byte) and 404 thumbnails while ignoring working ones; deduplicates across collections. fixMissingThumbnails verifies the full pipeline: download original, generate JPEG via sharp, encrypt with encryptBlob, upload via presigned URL, register via PUT /files/thumbnail. The test decrypts the uploaded ciphertext and verifies it starts with JPEG magic bytes (FF D8 FF). Also tests: nonexistent file ID reports failure without crashing; mixed success/failure across multiple files; Client.getApiClient() works when logged in, throws after logout.
This commit is contained in:
139
test/api/upload.test.ts
Normal file
139
test/api/upload.test.ts
Normal file
@@ -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<string, string> = {},
|
||||||
|
): 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<Response> => {
|
||||||
|
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==");
|
||||||
|
});
|
||||||
|
});
|
||||||
99
test/crypto/encrypt-blob.test.ts
Normal file
99
test/crypto/encrypt-blob.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
502
test/thumbnails/thumbnails.test.ts
Normal file
502
test/thumbnails/thumbnails.test.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
keyAttributes: KeyAttributes;
|
||||||
|
encryptedToken: string;
|
||||||
|
collections: Record<string, unknown>[];
|
||||||
|
filesByCollection: Record<number, Record<string, unknown>[]>;
|
||||||
|
fileCiphertexts: Record<number, Uint8Array>;
|
||||||
|
fileKeys: Record<number, Uint8Array>;
|
||||||
|
thumbnailBehavior: Record<number, "ok" | "empty" | "404">;
|
||||||
|
// Captures from fix operations
|
||||||
|
uploadedThumbnails: {
|
||||||
|
fileID: number;
|
||||||
|
objectKey: string;
|
||||||
|
decryptionHeader: string;
|
||||||
|
ciphertext: Uint8Array;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mock: ThumbMockState;
|
||||||
|
|
||||||
|
const buildThumbMock = async (): Promise<ThumbMockState> => {
|
||||||
|
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<number, Uint8Array> = {};
|
||||||
|
const fileCiphertexts: Record<number, Uint8Array> = {};
|
||||||
|
const rawFiles: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
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<Response> => {
|
||||||
|
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<string, unknown>)._lastUploadedCiphertext = body;
|
||||||
|
} else if (body instanceof ArrayBuffer) {
|
||||||
|
(m as Record<string, unknown>)._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<string, unknown>)
|
||||||
|
._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<string, unknown>,
|
||||||
|
);
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user