check / check (push) Successful in 3m19s
`/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
151 lines
5.6 KiB
TypeScript
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==");
|
|
});
|
|
});
|