Files
quak/test/api/client.test.ts
sneak d8a4b0291e Rename quack to quak
German for 'quack', matching the Ente (German for 'duck') naming. All
references updated: package name, CLI binary, X-Client-Package header,
test descriptions, temp dir prefixes, README, Makefile docker tag.
2026-05-13 18:02:55 -07:00

386 lines
14 KiB
TypeScript

/**
* Tests for `ApiClient`.
*
* `ApiClient` is the HTTP layer that every other module in quak calls to
* reach the Ente server. It handles:
*
* - Base URL resolution. Production uses `https://api.ente.io` for the
* API, `https://files.ente.io` for file downloads, and
* `https://thumbnails.ente.io` for thumbnail downloads. Self-hosted
* deployments override the API origin via the constructor or via the
* `ENTE_API_ENDPOINT` environment variable. When a custom API origin
* is set, file and thumbnail downloads route through that same origin
* at `/files/download/<id>` and `/files/preview/<id>` instead of the
* dedicated CDN hosts.
*
* - Required headers. Every request carries `X-Client-Package`
* (`berlin.sneak.quak`). Authenticated requests also carry
* `X-Auth-Token` with the token recovered by `unwrapAuth`.
*
* - JSON serialization. `getJSON` and `postJSON` handle Accept /
* Content-Type headers and JSON parsing.
*
* - Error mapping. Non-2xx responses become `ApiError` instances that
* expose `.status`, `.code` (from the server's JSON error body, if
* present), `.requestID` (from the `x-request-id` response header),
* and `.body` (the raw parsed body).
*
* - Streaming downloads. `getFileStream` and `getThumbnailStream`
* return a `ReadableStream<Uint8Array>` from the appropriate CDN
* (or the self-hosted fallback path).
*
* All tests inject a fake `fetch` via the constructor so nothing touches
* the network. The fake records every call for assertion.
*/
import { describe, expect, it } from "vitest";
import { ApiClient, ApiError } from "../../src/api/client.js";
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
/**
* A minimal fake Response that satisfies the subset of the Response API
* that ApiClient uses. Keeps tests short.
*/
const jsonResponse = (
body: unknown,
status = 200,
headers: Record<string, string> = {},
): Response =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json", ...headers },
});
const textResponse = (
body: string,
status = 200,
headers: Record<string, string> = {},
): Response =>
new Response(body, {
status,
headers: { "content-type": "text/plain", ...headers },
});
const streamResponse = (
body: Uint8Array,
status = 200,
headers: Record<string, string> = {},
): Response => new Response(body, { status, headers });
/**
* Build a recording fetch. Returns the fake function and a list of
* captured `{ url, init }` pairs. Each call pops the next canned
* response; if the list runs out, the call rejects.
*/
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 };
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("ApiClient defaults", () => {
it("uses production origins when no overrides are given", async () => {
const { fetch, calls } = recordingFetch(jsonResponse({ ok: true }));
const client = new ApiClient({ fetch });
await client.getJSON("/health");
expect(calls[0]!.url).toBe("https://api.ente.io/health");
});
it("attaches X-Client-Package to every request", async () => {
const { fetch, calls } = recordingFetch(jsonResponse({}));
const client = new ApiClient({ fetch });
await client.getJSON("/ping");
const headers = new Headers(calls[0]!.init?.headers as HeadersInit);
expect(headers.get("X-Client-Package")).toBe("berlin.sneak.quak");
});
});
describe("ApiClient auth token", () => {
it("does not send X-Auth-Token when no token is set", async () => {
const { fetch, calls } = recordingFetch(jsonResponse({}));
const client = new ApiClient({ fetch });
await client.getJSON("/public");
const headers = new Headers(calls[0]!.init?.headers as HeadersInit);
expect(headers.has("X-Auth-Token")).toBe(false);
});
it("sends X-Auth-Token after setAuthToken", async () => {
const { fetch, calls } = recordingFetch(jsonResponse({}));
const client = new ApiClient({ fetch });
client.setAuthToken("my-token-123");
await client.getJSON("/private");
const headers = new Headers(calls[0]!.init?.headers as HeadersInit);
expect(headers.get("X-Auth-Token")).toBe("my-token-123");
});
it("accepts authToken in constructor options", async () => {
const { fetch, calls } = recordingFetch(jsonResponse({}));
const client = new ApiClient({ fetch, authToken: "ctor-token" });
await client.getJSON("/private");
const headers = new Headers(calls[0]!.init?.headers as HeadersInit);
expect(headers.get("X-Auth-Token")).toBe("ctor-token");
});
it("stops sending X-Auth-Token after clearAuthToken", async () => {
const { fetch, calls } = recordingFetch(
jsonResponse({}),
jsonResponse({}),
);
const client = new ApiClient({ fetch, authToken: "temp" });
await client.getJSON("/a");
client.clearAuthToken();
await client.getJSON("/b");
const h0 = new Headers(calls[0]!.init?.headers as HeadersInit);
const h1 = new Headers(calls[1]!.init?.headers as HeadersInit);
expect(h0.has("X-Auth-Token")).toBe(true);
expect(h1.has("X-Auth-Token")).toBe(false);
});
});
describe("ApiClient.getJSON", () => {
it("sends a GET request and parses JSON", async () => {
const { fetch, calls } = recordingFetch(
jsonResponse({ collections: [1, 2, 3] }),
);
const client = new ApiClient({ fetch });
const result = await client.getJSON<{ collections: number[] }>(
"/collections/v2",
);
expect(calls[0]!.init?.method).toBe("GET");
expect(result.collections).toEqual([1, 2, 3]);
});
it("appends query parameters to the URL", async () => {
const { fetch, calls } = recordingFetch(jsonResponse({}));
const client = new ApiClient({ fetch });
await client.getJSON("/users/srp/attributes", {
email: "a@b.c",
sinceTime: 12345,
});
const url = new URL(calls[0]!.url);
expect(url.searchParams.get("email")).toBe("a@b.c");
expect(url.searchParams.get("sinceTime")).toBe("12345");
});
it("omits query parameters whose value is undefined", async () => {
const { fetch, calls } = recordingFetch(jsonResponse({}));
const client = new ApiClient({ fetch });
await client.getJSON("/x", { keep: "yes", drop: undefined });
const url = new URL(calls[0]!.url);
expect(url.searchParams.has("keep")).toBe(true);
expect(url.searchParams.has("drop")).toBe(false);
});
});
describe("ApiClient.postJSON", () => {
it("sends a POST with JSON body and Content-Type", async () => {
const { fetch, calls } = recordingFetch(
jsonResponse({ sessionID: "abc" }),
);
const client = new ApiClient({ fetch });
const result = await client.postJSON<{ sessionID: string }>(
"/users/srp/create-session",
{ userID: "u1", A: "aaa" },
);
expect(calls[0]!.init?.method).toBe("POST");
const headers = new Headers(calls[0]!.init?.headers as HeadersInit);
expect(headers.get("Content-Type")).toBe("application/json");
expect(JSON.parse(calls[0]!.init?.body as string)).toEqual({
userID: "u1",
A: "aaa",
});
expect(result.sessionID).toBe("abc");
});
});
describe("ApiClient custom origins", () => {
it("uses a custom apiOrigin for API calls", async () => {
const { fetch, calls } = recordingFetch(jsonResponse({}));
const client = new ApiClient({
fetch,
apiOrigin: "https://my-ente.example.com",
});
await client.getJSON("/health");
expect(calls[0]!.url).toBe("https://my-ente.example.com/health");
});
it("routes file downloads through apiOrigin when custom", async () => {
// Self-hosted servers don't have dedicated CDN hosts. File
// downloads use the API origin at /files/download/<id>.
const body = new Uint8Array([0xca, 0xfe]);
const { fetch, calls } = recordingFetch(streamResponse(body));
const client = new ApiClient({
fetch,
apiOrigin: "https://my-ente.example.com",
});
await client.getFileStream(99);
expect(calls[0]!.url).toBe(
"https://my-ente.example.com/files/download/99",
);
});
it("routes thumbnail downloads through apiOrigin when custom", async () => {
const body = new Uint8Array([0xde, 0xad]);
const { fetch, calls } = recordingFetch(streamResponse(body));
const client = new ApiClient({
fetch,
apiOrigin: "https://my-ente.example.com",
});
await client.getThumbnailStream(77);
expect(calls[0]!.url).toBe(
"https://my-ente.example.com/files/preview/77",
);
});
it("uses production CDN hosts when apiOrigin is default", async () => {
const body = new Uint8Array([1, 2, 3]);
const { fetch, calls } = recordingFetch(
streamResponse(body),
streamResponse(body),
);
const client = new ApiClient({ fetch });
await client.getFileStream(10);
await client.getThumbnailStream(20);
expect(calls[0]!.url).toBe("https://files.ente.io/?fileID=10");
expect(calls[1]!.url).toBe("https://thumbnails.ente.io/?fileID=20");
});
});
describe("ApiError", () => {
it("throws ApiError on 4xx with status, code, requestID", async () => {
const { fetch } = recordingFetch(
jsonResponse({ code: "INVALID_TOKEN", message: "bad token" }, 401, {
"x-request-id": "req-abc",
}),
);
const client = new ApiClient({ fetch });
try {
await client.getJSON("/protected");
expect.unreachable("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ApiError);
const apiErr = err as ApiError;
expect(apiErr.status).toBe(401);
expect(apiErr.code).toBe("INVALID_TOKEN");
expect(apiErr.requestID).toBe("req-abc");
expect(apiErr.body).toEqual({
code: "INVALID_TOKEN",
message: "bad token",
});
}
});
it("throws ApiError on 5xx", async () => {
const { fetch } = recordingFetch(
textResponse("Internal Server Error", 500, {
"x-request-id": "req-xyz",
}),
);
const client = new ApiClient({ fetch });
try {
await client.postJSON("/boom", {});
expect.unreachable("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ApiError);
const apiErr = err as ApiError;
expect(apiErr.status).toBe(500);
expect(apiErr.requestID).toBe("req-xyz");
}
});
it("throws ApiError on non-2xx stream downloads", async () => {
const { fetch } = recordingFetch(
textResponse("Not Found", 404, { "x-request-id": "req-404" }),
);
const client = new ApiClient({ fetch });
try {
await client.getFileStream(999);
expect.unreachable("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).status).toBe(404);
}
});
});
describe("ApiClient.getFileStream / getThumbnailStream", () => {
it("returns a ReadableStream on success", async () => {
const payload = new Uint8Array([10, 20, 30, 40]);
const { fetch } = recordingFetch(streamResponse(payload));
const client = new ApiClient({ fetch });
const stream = await client.getFileStream(5);
expect(stream).toBeInstanceOf(ReadableStream);
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const all = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
let offset = 0;
for (const c of chunks) {
all.set(c, offset);
offset += c.length;
}
expect(all).toEqual(payload);
});
it("attaches X-Auth-Token to CDN downloads", async () => {
const { fetch, calls } = recordingFetch(
streamResponse(new Uint8Array(0)),
);
const client = new ApiClient({ fetch, authToken: "tk" });
await client.getFileStream(1);
const headers = new Headers(calls[0]!.init?.headers as HeadersInit);
expect(headers.get("X-Auth-Token")).toBe("tk");
});
});