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.
140 lines
5.3 KiB
TypeScript
140 lines
5.3 KiB
TypeScript
/**
|
|
* 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==");
|
|
});
|
|
});
|