~james/core

core/web/src/lib/api/delegation.test.ts -rw-r--r-- 7.5 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
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
import { describe, expect, it, vi } from "vitest";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import {
	provisionDelegate,
	CREATE_DELEGATE_NSID,
	GATE_CREATE_DELEGATE_NSID,
	LIST_DELEGATES_NSID
} from "#lib/api/delegation.ts";
import metadata from "#lib/oauth-client-metadata.ts";
import type { Fetch } from "#lib/fetch.ts";

const makeAgent = () => {
	const handle = vi.fn<(path: string, init: RequestInit) => Promise<Response>>(async () =>
		Response.json({ token: "service-jwt" })
	);
	return { handle, agent: { sub: "did:plc:alice", handle } as unknown as OAuthUserAgent };
};
const account = { did: "did:plc:org", handle: "org.example", controllerDid: "did:plc:alice" };

describe("delegate provisioning", () => {
	it("mints one method-bound service auth on the user's PDS for the create call", async () => {
		const { agent, handle } = makeAgent();
		const fetch = vi.fn<Fetch>(async () => Response.json(account));
		await expect(
			provisionDelegate(agent, "org.example", {
				serviceUrl: "https://gate.example/",
				serviceDid: "did:web:tranquil.example",
				fetch
			})
		).resolves.toEqual(account);
		const params = new URL(String(handle.mock.calls[0][0]), "https://pds.example").searchParams;
		expect(params.get("aud")).toBe("did:web:tranquil.example");
		expect(params.get("lxm")).toBe(CREATE_DELEGATE_NSID);
		// Tranquil enforces the delegate cap itself, so no listing token is minted.
		expect(handle).toHaveBeenCalledTimes(1);
		expect(String(fetch.mock.calls[0][0])).toBe(
			`https://gate.example/xrpc/${GATE_CREATE_DELEGATE_NSID}`
		);
		expect(fetch.mock.calls[0][1]).toMatchObject({
			method: "POST",
			headers: { authorization: "Bearer service-jwt" },
			body: JSON.stringify({ handle: "org.example" })
		});
	});
	it("allows the advertised DID to differ from a forwarded URL port", async () => {
		const { agent, handle } = makeAgent();
		await provisionDelegate(agent, "org.example", {
			serviceUrl: "https://gate.example:8443",
			serviceDid: "did:web:tranquil.example",
			fetch: vi.fn<Fetch>(async () => Response.json(account))
		});
		expect(
			new URL(String(handle.mock.calls[0][0]), "https://pds.example").searchParams.get("aud")
		).toBe("did:web:tranquil.example");
	});
	it.each([
		[
			"VerifiedEmailRequired",
			403,
			"Verify an email address in Settings → Emails before creating an organization."
		],
		[
			"UpstreamUnavailable",
			502,
			"Organization creation is temporarily unavailable. Try again in a few minutes."
		],
		["HandleNotAvailable", 400, "This organization name is taken. Choose another one."],
		["InvalidHandle", 400, "This organization name isn't allowed. Choose another one."]
	])("explains %s", async (error, status, message) => {
		const { agent } = makeAgent();
		await expect(
			provisionDelegate(agent, "org.example", {
				serviceUrl: "https://gate.example",
				serviceDid: "did:web:tranquil.example",
				fetch: vi.fn(async () =>
					Response.json({ error, message: "Handle already taken" }, { status })
				)
			})
		).rejects.toThrow(message);
	});
	it("falls back to a plain sentence when an unknown error has no message", async () => {
		const { agent } = makeAgent();
		const error = provisionDelegate(agent, "org.example", {
			serviceUrl: "https://gate.example",
			serviceDid: "did:web:tranquil.example",
			fetch: vi.fn(async () => Response.json({ error: "RateLimitExceeded" }, { status: 429 }))
		});
		await expect(error).rejects.toThrow("Unable to create the organization. Try again.");
		await expect(error).rejects.not.toThrow(/unspecified description/);
	});
	it.each([
		[401, "Sign in again to grant Tangled permission to create organizations."],
		[403, "Sign in again to grant Tangled permission to create organizations."],
		[500, "Unable to confirm your account with your PDS. Try again."]
	])("explains a %i from the user's PDS while minting service auth", async (status, message) => {
		const { agent, handle } = makeAgent();
		handle.mockResolvedValue(Response.json({ error: "Whatever" }, { status }));
		await expect(
			provisionDelegate(agent, "org.example", {
				serviceUrl: "https://gate.example",
				serviceDid: "did:web:tranquil.example",
				fetch: vi.fn()
			})
		).rejects.toThrow(message);
	});
	it("shows Tranquil's reason for InvalidDelegation, which is not only the delegate cap", async () => {
		const { agent } = makeAgent();
		const message = "Invite-only registration requires an existing account on this PDS";
		await expect(
			provisionDelegate(agent, "org.example", {
				serviceUrl: "https://gate.example",
				serviceDid: "did:web:tranquil.example",
				fetch: vi.fn(async () =>
					Response.json({ error: "InvalidDelegation", message }, { status: 400 })
				)
			})
		).rejects.toThrow(message);
	});
	it("does not request a token when the sidecar is not configured", async () => {
		const { agent, handle } = makeAgent();
		await expect(provisionDelegate(agent, "org.example", { serviceUrl: "" })).rejects.toThrow(
			"Organization creation isn't set up on this server."
		);
		expect(handle).not.toHaveBeenCalled();
	});
	it("rejects an unexpected controller", async () => {
		const { agent } = makeAgent();
		await expect(
			provisionDelegate(agent, "org.example", {
				serviceUrl: "https://gate.example",
				serviceDid: "did:web:tranquil.example",
				fetch: vi.fn(async () =>
					Response.json({ ...account, controllerDid: "did:plc:other" })
				)
			})
		).rejects.toThrow(/different account than the one you chose/);
	});
	it("requests the permission needed to mint delegation service auth", () => {
		expect(metadata.scope.split(" ")).toContain(`rpc:${CREATE_DELEGATE_NSID}?aud=*`);
		expect(metadata.scope.split(" ")).toContain(`rpc:${LIST_DELEGATES_NSID}?aud=*`);
	});
});

it("asks older sessions to reauthorize before any account is created", async () => {
	const handle = vi.fn(async () =>
		Response.json({ error: "InsufficientScope", message: "missing scope" }, { status: 403 })
	);
	const fetch = vi.fn<Fetch>();
	await expect(
		provisionDelegate(
			{ handle, sub: "did:plc:alice" } as unknown as OAuthUserAgent,
			"org.example",
			{ serviceUrl: "https://gate.example", serviceDid: "did:web:tranquil.example", fetch }
		)
	).rejects.toThrow(/Sign in again/);
	expect(fetch).not.toHaveBeenCalled();
});

describe("controlled organization accounts", () => {
	it("lists on Tranquil with method-bound auth from the controller's PDS", async () => {
		const { listDelegatedAccounts } = await import("./delegation");
		const { agent, handle } = makeAgent();
		const accounts = [
			{
				did: "did:plc:org",
				handle: "org.example",
				grantedScopes: "atproto repo:*",
				grantedAt: "2026-09-09T00:00:00Z"
			}
		];
		const fetch = vi.fn<Fetch>(async () => Response.json({ accounts }));
		await expect(
			listDelegatedAccounts(agent, "did:web:tranquil.example", fetch)
		).resolves.toEqual(accounts);
		const params = new URL(String(handle.mock.calls[0][0]), "https://pds.example").searchParams;
		expect(params.get("aud")).toBe("did:web:tranquil.example");
		expect(params.get("lxm")).toBe(LIST_DELEGATES_NSID);
		expect(String(fetch.mock.calls[0][0])).toBe(
			"https://tranquil.example/xrpc/_delegation.listControlledAccounts"
		);
		expect(fetch.mock.calls[0][1]).toEqual({
			headers: { authorization: "Bearer service-jwt" }
		});
	});
	it("reports a failed listing instead of treating it as no organizations", async () => {
		const { listDelegatedAccounts } = await import("./delegation");
		const { agent } = makeAgent();
		await expect(
			listDelegatedAccounts(agent, "did:web:tranquil.example", async () =>
				Response.json({ error: "InvalidToken" }, { status: 401 })
			)
		).rejects.toThrow();
	});
});