This repository has no description
1import { describe, expect, it, vi, type Mock } from "vitest";
2import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
3import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client";
4import { jsonGet, rawGet } from "./_request";
5
6const jsonResponse = (body: unknown, init: ResponseInit = {}): Response =>
7 new Response(JSON.stringify(body), {
8 status: 200,
9 headers: { "content-type": "application/json" },
10 ...init
11 });
12
13const makeCtx = (
14 fetchMock: typeof globalThis.fetch,
15 serviceUrl = "https://bobbin.test"
16): BobbinContext => createBobbinClient({ serviceUrl, fetch: fetchMock });
17
18const fetchedUrl = (mock: Mock<typeof globalThis.fetch>, n = 0): URL =>
19 new URL(String(mock.mock.calls[n][0]));
20
21describe("createBobbinClient", () => {
22 it("normalizes trailing slashes off the service url", () => {
23 const ctx = makeCtx(vi.fn(), "https://bobbin.test///");
24 expect(ctx.serviceUrl).toBe("https://bobbin.test");
25 });
26});
27
28describe("jsonGet URL construction", () => {
29 it("appends an array param as repeated keys", async () => {
30 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
31 await jsonGet(makeCtx(fetchMock), "sh.tangled.repo.getRepos", { repos: ["a", "b"] });
32 const url = fetchedUrl(fetchMock);
33 expect(url.searchParams.getAll("repos")).toEqual(["a", "b"]);
34 expect(url.search).toBe("?repos=a&repos=b");
35 });
36
37 it("omits undefined params entirely", async () => {
38 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
39 await jsonGet(makeCtx(fetchMock), "sh.tangled.x", { keep: "yes", drop: undefined });
40 const url = fetchedUrl(fetchMock);
41 expect(url.searchParams.has("drop")).toBe(false);
42 expect(url.searchParams.get("keep")).toBe("yes");
43 });
44
45 it("resolves the query against the normalized origin", async () => {
46 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
47 await jsonGet(makeCtx(fetchMock, "https://bobbin.test/"), "sh.tangled.x", { a: "1" });
48 expect(fetchedUrl(fetchMock).href).toBe("https://bobbin.test/xrpc/sh.tangled.x?a=1");
49 });
50});
51
52describe("jsonGet request headers & signal", () => {
53 it("sends accept: application/json and merges caller headers + signal", async () => {
54 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
55 const controller = new AbortController();
56 await jsonGet(makeCtx(fetchMock), "sh.tangled.x", undefined, {
57 headers: { "x-custom": "1" },
58 signal: controller.signal
59 });
60 const init = fetchMock.mock.calls[0][1] as RequestInit;
61 expect(init.headers).toMatchObject({ accept: "application/json", "x-custom": "1" });
62 expect(init.signal).toBe(controller.signal);
63 });
64});
65
66describe("jsonGet responses", () => {
67 it("throws ClientResponseError carrying status + error/description for a JSON error body", async () => {
68 const fetchMock = vi
69 .fn<typeof globalThis.fetch>()
70 .mockResolvedValue(
71 jsonResponse({ error: "RecordNotFound", message: "no such repo" }, { status: 404 })
72 );
73 const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e);
74 expect(err).toBeInstanceOf(ClientResponseError);
75 const cre = err as ClientResponseError;
76 expect(cre.status).toBe(404);
77 expect(cre.error).toBe("RecordNotFound");
78 expect(cre.description).toBe("no such repo");
79 });
80
81 it("falls back to the status line for a non-JSON error body (does not hang)", async () => {
82 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
83 new Response("<html>502 Bad Gateway</html>", {
84 status: 502,
85 statusText: "Bad Gateway"
86 })
87 );
88 const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e);
89 expect(err).toBeInstanceOf(ClientResponseError);
90 const cre = err as ClientResponseError;
91 expect(cre.status).toBe(502);
92 expect(cre.error).toBe("XRPCError");
93 expect(cre.description).toBe("Bad Gateway");
94 });
95});
96
97describe("createBobbinClient service auth", () => {
98 // mintServiceAuth goes through `new Client({ handler: agent })`, and
99 // OAuthUserAgent satisfies FetchHandlerObject — so `handle` is where the
100 // com.atproto.server.getServiceAuth call lands and where the minted claims
101 // can be read back off the query string.
102 const makeAgent = (token = "jwt-1") => {
103 const handle = vi
104 .fn<(pathname: string, init: RequestInit) => Promise<Response>>()
105 .mockImplementation(() => Promise.resolve(jsonResponse({ token })));
106 return { agent: { handle, sub: "did:plc:tester" } as unknown as OAuthUserAgent, handle };
107 };
108
109 const mintedParams = (handle: Mock, n = 0): URLSearchParams =>
110 new URL(String(handle.mock.calls[n][0]), "https://pds.test").searchParams;
111
112 const authHeader = (fetchMock: Mock<typeof globalThis.fetch>, n = 0): string | null =>
113 new Headers((fetchMock.mock.calls[n][1] as RequestInit | undefined)?.headers).get(
114 "authorization"
115 );
116
117 // a Response body reads once, so each call needs its own
118 const okFetch = () =>
119 vi
120 .fn<typeof globalThis.fetch>()
121 .mockImplementation(() => Promise.resolve(jsonResponse({ ok: 1 })));
122
123 it("sends no authorization header when no agent is given", async () => {
124 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
125 await jsonGet(makeCtx(fetchMock), "sh.tangled.x");
126 expect(authHeader(fetchMock)).toBeNull();
127 });
128
129 it("attaches a minted bearer token when an agent is given", async () => {
130 const fetchMock = okFetch();
131 const { agent } = makeAgent("jwt-abc");
132 const ctx = createBobbinClient({ serviceUrl: "https://appview.test", fetch: fetchMock, agent });
133
134 await jsonGet(ctx, "org.tangled.temp.search.searchCode", { q: "main" });
135
136 expect(authHeader(fetchMock)).toBe("Bearer jwt-abc");
137 });
138
139 it("scopes each token to the method being called", async () => {
140 const fetchMock = okFetch();
141 const { agent, handle } = makeAgent();
142 const ctx = createBobbinClient({ serviceUrl: "https://appview.test", fetch: fetchMock, agent });
143
144 await jsonGet(ctx, "org.tangled.temp.search.searchCode", { q: "main" });
145 await jsonGet(ctx, "org.tangled.temp.notification.getUnreadCount");
146
147 expect(handle).toHaveBeenCalledTimes(2);
148 expect(mintedParams(handle, 0).get("lxm")).toBe("org.tangled.temp.search.searchCode");
149 expect(mintedParams(handle, 1).get("lxm")).toBe("org.tangled.temp.notification.getUnreadCount");
150 });
151
152 it("audiences the token at did:web:<host>, percent-encoding the port", async () => {
153 const fetchMock = okFetch();
154 const { agent, handle } = makeAgent();
155 // the dev default from lib/server/config.ts
156 const ctx = createBobbinClient({
157 serviceUrl: "http://127.0.0.1:8080",
158 fetch: fetchMock,
159 agent
160 });
161
162 await jsonGet(ctx, "org.tangled.temp.search.searchCode", { q: "main" });
163
164 expect(mintedParams(handle).get("aud")).toBe("did:web:127.0.0.1%3A8080");
165 });
166
167 it("mints nothing for a fetch that is not an xrpc call", async () => {
168 const fetchMock = okFetch();
169 const { agent, handle } = makeAgent();
170 const ctx = createBobbinClient({ serviceUrl: "https://appview.test", fetch: fetchMock, agent });
171
172 await ctx.fetch("https://appview.test/healthz");
173
174 expect(handle).not.toHaveBeenCalled();
175 expect(authHeader(fetchMock)).toBeNull();
176 });
177});
178
179describe("rawGet", () => {
180 it("throws ClientResponseError on a non-2xx status", async () => {
181 const fetchMock = vi
182 .fn<typeof globalThis.fetch>()
183 .mockResolvedValue(
184 jsonResponse({ error: "UpstreamFailed", message: "knot down" }, { status: 502 })
185 );
186 const err = await rawGet(makeCtx(fetchMock), "sh.tangled.repo.archive").catch(
187 (e: unknown) => e
188 );
189 expect(err).toBeInstanceOf(ClientResponseError);
190 expect((err as ClientResponseError).status).toBe(502);
191 });
192});