/** * 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 = {}, ): 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 => { 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=="); }); });