~james/core

core/web/src/lib/api/client.test.ts -rw-r--r-- 5.8 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
import { describe, expect, it, vi, type Mock } from "vitest";
import { ClientResponseError, createBobbinClient, type BobbinContext } from "#lib/api/client.ts";
import { jsonGet, rawGet } from "#lib/api/_request.ts";
import type { Fetch } from "#lib/fetch.ts";

const jsonResponse = (body: unknown, init: ResponseInit = {}): Response =>
	new Response(JSON.stringify(body), {
		status: 200,
		headers: { "content-type": "application/json" },
		...init
	});

const makeCtx = (fetchMock: Fetch, serviceUrl = "https://bobbin.test"): BobbinContext =>
	createBobbinClient({ serviceUrl, fetch: fetchMock });

const fetchedUrl = (mock: Mock<Fetch>, n = 0): URL => new URL(String(mock.mock.calls[n][0]));

describe("createBobbinClient", () => {
	it("normalizes trailing slashes off the service url", () => {
		const ctx = makeCtx(vi.fn(), "https://bobbin.test///");
		expect(ctx.serviceUrl).toBe("https://bobbin.test");
	});
});

describe("jsonGet URL construction", () => {
	it("appends an array param as repeated keys", async () => {
		const fetchMock = vi.fn<Fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
		await jsonGet(makeCtx(fetchMock), "org.tangled.repo.getRepos", { repos: ["a", "b"] });
		const url = fetchedUrl(fetchMock);
		expect(url.searchParams.getAll("repos")).toEqual(["a", "b"]);
		expect(url.search).toBe("?repos=a&repos=b");
	});

	it("omits undefined params entirely", async () => {
		const fetchMock = vi.fn<Fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
		await jsonGet(makeCtx(fetchMock), "sh.tangled.x", { keep: "yes", drop: undefined });
		const url = fetchedUrl(fetchMock);
		expect(url.searchParams.has("drop")).toBe(false);
		expect(url.searchParams.get("keep")).toBe("yes");
	});

	it("resolves the query against the normalized origin", async () => {
		const fetchMock = vi.fn<Fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
		await jsonGet(makeCtx(fetchMock, "https://bobbin.test/"), "sh.tangled.x", { a: "1" });
		expect(fetchedUrl(fetchMock).href).toBe("https://bobbin.test/xrpc/sh.tangled.x?a=1");
	});
});

describe("jsonGet request headers & signal", () => {
	it("sends accept: application/json and merges caller headers + signal", async () => {
		const fetchMock = vi.fn<Fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
		const controller = new AbortController();
		await jsonGet(makeCtx(fetchMock), "sh.tangled.x", undefined, {
			headers: { "x-custom": "1" },
			signal: controller.signal
		});
		const init = fetchMock.mock.calls[0][1] as RequestInit;
		expect(init.headers).toMatchObject({ accept: "application/json", "x-custom": "1" });
		expect(init.signal).toBe(controller.signal);
	});
});

describe("jsonGet responses", () => {
	it("throws ClientResponseError carrying status + error/description for a JSON error body", async () => {
		const fetchMock = vi
			.fn<Fetch>()
			.mockResolvedValue(
				jsonResponse({ error: "RecordNotFound", message: "no such repo" }, { status: 404 })
			);
		const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e);
		expect(err).toBeInstanceOf(ClientResponseError);
		const cre = err as ClientResponseError;
		expect(cre.status).toBe(404);
		expect(cre.error).toBe("RecordNotFound");
		expect(cre.description).toBe("no such repo");
	});

	it("falls back to the status line for a non-JSON error body (does not hang)", async () => {
		const fetchMock = vi.fn<Fetch>().mockResolvedValue(
			new Response("<html>502 Bad Gateway</html>", {
				status: 502,
				statusText: "Bad Gateway"
			})
		);
		const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e);
		expect(err).toBeInstanceOf(ClientResponseError);
		const cre = err as ClientResponseError;
		expect(cre.status).toBe(502);
		expect(cre.error).toBe("XRPCError");
		expect(cre.description).toBe("Bad Gateway");
	});
});

describe("rawGet", () => {
	it("throws ClientResponseError on a non-2xx status", async () => {
		const fetchMock = vi
			.fn<Fetch>()
			.mockResolvedValue(
				jsonResponse({ error: "UpstreamFailed", message: "knot down" }, { status: 502 })
			);
		const err = await rawGet(makeCtx(fetchMock), "sh.tangled.repo.archive").catch(
			(e: unknown) => e
		);
		expect(err).toBeInstanceOf(ClientResponseError);
		expect((err as ClientResponseError).status).toBe(502);
	});
});

describe("service auth aud", () => {
	const minted = async (nsid: string) => {
		vi.resetModules();
		const calls: { aud: string; lxm: string }[] = [];
		vi.doMock("#lib/auth/agent.ts", async (original) => ({
			...(await original<typeof import("#lib/auth/agent.ts")>()),
			mintServiceAuth: async (_agent: unknown, options: { aud: string; lxm: string }) => {
				calls.push(options);
				return "jwt";
			}
		}));
		const { createBobbinClient: create } = await import("#lib/api/client.ts");
		const fetchMock = vi.fn<Fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
		const ctx = create({
			serviceUrl: "https://bobbin.test",
			fetch: fetchMock,
			agent: { sub: "did:plc:limpet" } as never
		});
		await ctx.fetch(`https://bobbin.test/xrpc/${nsid}`);
		return { calls, headers: new Headers(fetchMock.mock.calls[0][1]?.headers) };
	};

	it("pocket preferences are minted for the app, not for whoever serves them", async () => {
		const { calls, headers } = await minted("com.bad-example.pocket.getPreferences");

		expect(calls).toEqual([
			{ aud: "did:web:tangled.org", lxm: "com.bad-example.pocket.getPreferences" }
		]);
		expect(headers.get("authorization")).toBe("Bearer jwt");
	});

	it("putting them lands on the same aud", async () => {
		const { calls } = await minted("com.bad-example.pocket.putPreferences");

		expect(calls[0].aud).toBe("did:web:tangled.org");
	});

	it("every other method is still minted for the service it is sent to", async () => {
		const { calls } = await minted("org.tangled.temp.search.searchCode");

		expect(calls).toEqual([
			{ aud: "did:web:bobbin.test", lxm: "org.tangled.temp.search.searchCode" }
		]);
	});
});