Files
quak/test/api/upload.test.ts
T
clawbot 1bf18baf00
check / check (push) Successful in 5m34s
Thumbnail upload PUT sends the signed Content-MD5 (closes #238)
`/files/upload-url` signs the content MD5 into the presigned URL, so
storage refused the PUT that `putFile` sent without it. `putFile` now
takes the MD5 given to `getUploadURL` and sends it. The thumbnail test's
fake storage refuses a PUT without the signed header.

Model: opus-5-5
2026-11-11 22:59:10 +01:00

151 lines
5.6 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,
"md5base64==",
);
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");
// The server signs the MD5 into the URL; storage refuses the PUT
// without it.
expect(headers.get("Content-MD5")).toBe("md5base64==");
// 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),
"md5base64==",
),
).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==");
});
});