Files
quak/test/api/upload.test.ts
sneak 6cb679d62f 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.
2026-06-09 12:29:24 -04:00

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==");
});
});