Skip to content
File

Blob: tests/worker/admin-clients.test.ts

typescript564 lines
1import { env } from "cloudflare:workers";
2import { beforeAll, describe, expect, it } from "vitest";
3 
4import {
5 DEFAULT_PASSWORD,
6 DEFAULT_REDIRECT_URI,
7 ISSUER,
8 SELF,
9 countOAuthTokensForClient,
10 getLatestSessionIdForUser,
11 getUserIdByEmail,
12 promoteUserToAdmin,
13 signInForCookie,
14 signUpAdmin,
15 signUpTestUser,
16 testHeaders,
17 type CreatedOAuthClient,
18 type JsonErrorBody,
19 type TestCredential,
20} from "./helpers";
21 
22describe("admin client registration", () => {
23 const adminCred = {
24 email: "admin-clients@example.com",
25 password: DEFAULT_PASSWORD,
26 name: "Admin Clients Tester",
27 } satisfies TestCredential;
28 const userCred = {
29 email: "user-clients@example.com",
30 password: DEFAULT_PASSWORD,
31 name: "Regular User",
32 } satisfies TestCredential;
33 
34 beforeAll(async () => {
35 await signUpAdmin(adminCred);
36 await signUpTestUser(userCred);
37 });
38 
39 it("rejects unauthenticated requests with 401", async () => {
40 const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
41 method: "POST",
42 headers: testHeaders(),
43 body: JSON.stringify({ name: "anon", redirectUris: [DEFAULT_REDIRECT_URI] }),
44 });
45 expect(res.status).toBe(401);
46 });
47 
48 it("rejects non-admin users with 403", async () => {
49 const cookie = await signInForCookie(userCred.email, userCred.password, "10.30.0.1");
50 const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
51 method: "POST",
52 headers: testHeaders({ cookie }),
53 body: JSON.stringify({ name: "non-admin try", redirectUris: [DEFAULT_REDIRECT_URI] }),
54 });
55 expect(res.status).toBe(403);
56 
57 // GET should also be gated.
58 const listRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie } });
59 expect(listRes.status).toBe(403);
60 });
61 
62 it("lets an admin create, list, rotate, and delete clients", async () => {
63 const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1");
64 
65 // 1. Create.
66 const createRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
67 method: "POST",
68 headers: testHeaders({ cookie }),
69 body: JSON.stringify({
70 name: "fixture rp",
71 redirectUris: [DEFAULT_REDIRECT_URI],
72 skipConsent: true,
73 }),
74 });
75 expect(createRes.status).toBe(201);
76 const created = (await createRes.json()) as CreatedOAuthClient;
77 expect(created.client_id).toBeTruthy();
78 expect(created.client_secret).toBeTruthy();
79 
80 // 2. List — the new client must show up.
81 const listRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie } });
82 expect(listRes.status).toBe(200);
83 const list = (await listRes.json()) as Array<{ client_id: string }>;
84 expect(list.some((c) => c.client_id === created.client_id)).toBe(true);
85 
86 // 3. Rotate the secret. The endpoint returns a fresh secret distinct
87 // from the original; we don't verify cryptographic strength here,
88 // just that rotation actually replaces the value.
89 const rotateRes = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}/rotate`, {
90 method: "POST",
91 headers: testHeaders({ cookie }),
92 });
93 expect(rotateRes.status).toBe(200);
94 const rotated = (await rotateRes.json()) as { client_secret?: string };
95 expect(rotated.client_secret).toBeTruthy();
96 expect(rotated.client_secret).not.toBe(created.client_secret);
97 
98 // 4. Delete.
99 const deleteRes = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, {
100 method: "DELETE",
101 headers: testHeaders({ cookie }),
102 });
103 expect(deleteRes.status).toBe(204);
104 
105 // 5. After delete, the client is gone from the list.
106 const listAfterRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie } });
107 const listAfter = (await listAfterRes.json()) as Array<{ client_id: string }>;
108 expect(listAfter.some((c) => c.client_id === created.client_id)).toBe(false);
109 }, 30_000);
110 
111 it("rejects invalid create bodies with 400", async () => {
112 const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1");
113 
114 const missingName = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
115 method: "POST",
116 headers: testHeaders({ cookie }),
117 body: JSON.stringify({ redirectUris: [DEFAULT_REDIRECT_URI] }),
118 });
119 expect(missingName.status).toBe(400);
120 
121 const missingRedirect = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
122 method: "POST",
123 headers: testHeaders({ cookie }),
124 body: JSON.stringify({ name: "no redirect" }),
125 });
126 expect(missingRedirect.status).toBe(400);
127 
128 const emptyRedirect = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
129 method: "POST",
130 headers: testHeaders({ cookie }),
131 body: JSON.stringify({ name: "empty redirect", redirectUris: [] }),
132 });
133 expect(emptyRedirect.status).toBe(400);
134 });
135 
136 it("rejects non-http(s) client_uri schemes with 400", async () => {
137 const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1");
138 const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
139 method: "POST",
140 headers: testHeaders({ cookie }),
141 body: JSON.stringify({
142 name: "xss attempt",
143 redirectUris: [DEFAULT_REDIRECT_URI],
144 uri: "javascript:alert(1)",
145 }),
146 });
147 expect(res.status).toBe(400);
148 const body = (await res.json()) as JsonErrorBody;
149 expect(body.error).toBe("invalid_uri");
150 });
151 
152 it("accepts localhost redirects and localhost subdomain client URIs with ports", async () => {
153 const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.2");
154 const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
155 method: "POST",
156 headers: testHeaders({ cookie }),
157 body: JSON.stringify({
158 name: "localhost-subdomain-rp",
159 redirectUris: ["http://localhost:5176/cb"],
160 uri: "http://acme.localhost:5176",
161 }),
162 });
163 expect(res.status).toBe(201);
164 const created = (await res.json()) as CreatedOAuthClient & {
165 application_type: string;
166 token_endpoint_auth_method: string;
167 };
168 expect(created.client_id).toBeTruthy();
169 expect(created.application_type).toBe("native");
170 expect(created.token_endpoint_auth_method).toBe("client_secret_basic");
171 expect(created.client_secret).toBeTruthy();
172 });
173 
174 it("rejects localhost subdomain redirects under the OAuth 1.7 redirect policy", async () => {
175 const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.3");
176 const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
177 method: "POST",
178 headers: testHeaders({ cookie }),
179 body: JSON.stringify({ name: "invalid-loopback", redirectUris: ["http://acme.localhost:5176/cb"] }),
180 });
181 expect(res.status).toBe(400);
182 expect(((await res.json()) as JsonErrorBody).error).toBe("invalid_redirect_uri");
183 });
184 
185 // The four raw OAuth client mutator routes are 404'd in
186 // src/worker/index.ts so OAuth client management can only happen
187 // through tessera's /api/admin/clients wrapper, which runs token
188 // cleanup on rotation/delete and emits structured logs.
189 //
190 // Trailing-slash variants must also 404: Better Auth's router
191 // normalizes them back to the same plugin endpoint, so an exact-match
192 // stub would leak through. The middleware strips trailing slashes
193 // before checking the blocked set.
194 it("blocks raw POST /api/auth/oauth2/{create,update,delete,rotate-secret} with 404 for all variants", async () => {
195 const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1");
196 const paths = [
197 "/api/auth/oauth2/create-client",
198 "/api/auth/oauth2/update-client",
199 "/api/auth/oauth2/delete-client",
200 "/api/auth/oauth2/client/rotate-secret",
201 ];
202 for (const path of paths) {
203 for (const variant of [path, `${path}/`, `${path}//`]) {
204 const res = await SELF.fetch(`${ISSUER}${variant}`, {
205 method: "POST",
206 headers: testHeaders({ cookie }),
207 body: JSON.stringify({
208 client_id: "any",
209 client_name: "any",
210 redirect_uris: [DEFAULT_REDIRECT_URI],
211 scope: "openid",
212 token_endpoint_auth_method: "client_secret_basic",
213 }),
214 });
215 expect(res.status, `expected 404 for ${variant}`).toBe(404);
216 }
217 }
218 });
219 
220 // Rotation revokes tokens: an admin rotating a leaked client_secret
221 // expects the kill-switch to extend to access/refresh tokens already
222 // issued under the old secret. Mirrors handleBanUser's batch-delete
223 // shape; see src/worker/api/admin/clients.ts handleRotateClientSecret.
224 it("revokes existing access/refresh tokens for the rotated client only", async () => {
225 const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1");
226 
227 // Create the client we'll rotate.
228 const targetRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
229 method: "POST",
230 headers: testHeaders({ cookie }),
231 body: JSON.stringify({ name: "rotate-target", redirectUris: [DEFAULT_REDIRECT_URI] }),
232 });
233 expect(targetRes.status).toBe(201);
234 const target = (await targetRes.json()) as CreatedOAuthClient;
235 
236 // Create a control client whose tokens MUST survive the rotation.
237 const controlRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
238 method: "POST",
239 headers: testHeaders({ cookie }),
240 body: JSON.stringify({ name: "rotate-control", redirectUris: [DEFAULT_REDIRECT_URI] }),
241 });
242 expect(controlRes.status).toBe(201);
243 const control = (await controlRes.json()) as CreatedOAuthClient;
244 
245 // Look up admin's user id so the FK on userId is satisfied.
246 const userId = await getUserIdByEmail(adminCred.email);
247 
248 // Seed access + refresh tokens for both clients so we can confirm
249 // per-client deletion. Refresh tokens carry session_id (FK to
250 // sessions); access tokens carry refresh_id pointing at the
251 // refresh row. Inline minimal seed values that satisfy the schema.
252 const sessionId = await getLatestSessionIdForUser(userId);
253 
254 const now = Date.now();
255 const expiresAt = now + 60_000;
256 const scopes = JSON.stringify(["openid"]);
257 const seed = async (clientId: string, tag: string) => {
258 const refreshId = `rt-${tag}-${clientId}`;
259 const accessId = `at-${tag}-${clientId}`;
260 await env.DB.batch([
261 env.DB.prepare(
262 `INSERT INTO oauth_refresh_tokens (id, token, client_id, session_id, user_id, expires_at, created_at, scopes)
263 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
264 ).bind(refreshId, `tok-${refreshId}`, clientId, sessionId, userId, expiresAt, now, scopes),
265 env.DB.prepare(
266 `INSERT INTO oauth_access_tokens (id, token, client_id, refresh_id, session_id, user_id, expires_at, created_at, scopes)
267 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
268 ).bind(accessId, `tok-${accessId}`, clientId, refreshId, sessionId, userId, expiresAt, now, scopes),
269 ]);
270 };
271 await seed(target.client_id, "target");
272 await seed(control.client_id, "control");
273 
274 // Sanity-check the seeds before rotation.
275 expect(await countOAuthTokensForClient("oauth_access_tokens", target.client_id)).toBe(1);
276 expect(await countOAuthTokensForClient("oauth_refresh_tokens", target.client_id)).toBe(1);
277 expect(await countOAuthTokensForClient("oauth_access_tokens", control.client_id)).toBe(1);
278 expect(await countOAuthTokensForClient("oauth_refresh_tokens", control.client_id)).toBe(1);
279 
280 const rotateRes = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(target.client_id)}/rotate`, {
281 method: "POST",
282 headers: testHeaders({ cookie }),
283 });
284 expect(rotateRes.status).toBe(200);
285 const rotated = (await rotateRes.json()) as { client_secret?: string };
286 expect(rotated.client_secret).toBeTruthy();
287 
288 expect(await countOAuthTokensForClient("oauth_access_tokens", target.client_id)).toBe(0);
289 expect(await countOAuthTokensForClient("oauth_refresh_tokens", target.client_id)).toBe(0);
290 // Control client's tokens MUST be untouched.
291 expect(await countOAuthTokensForClient("oauth_access_tokens", control.client_id)).toBe(1);
292 expect(await countOAuthTokensForClient("oauth_refresh_tokens", control.client_id)).toBe(1);
293 }, 30_000);
294 
295 // Multi-role admin: Better Auth's setRole accepts arrays and stores
296 // them as `role = "admin,user"` (parseRoles in admin/routes.mjs).
297 // tessera's role gating must accept membership, not scalar equality.
298 it("accepts a multi-role admin for requireAdmin and clientPrivileges", async () => {
299 const multiCred = {
300 email: "multi-role-admin@example.com",
301 password: DEFAULT_PASSWORD,
302 name: "Multi Role",
303 } satisfies TestCredential;
304 await signUpTestUser(multiCred);
305 // Stamp the comma-joined role directly; reaching this state via the
306 // admin/set-role endpoint is what the upstream plugin does when
307 // setRole receives an array.
308 await promoteUserToAdmin(multiCred.email);
309 await env.DB.prepare("UPDATE users SET role = ? WHERE email = ?").bind("admin,user", multiCred.email).run();
310 
311 const cookie = await signInForCookie(multiCred.email, multiCred.password, "10.30.0.1");
312 
313 // requireAdmin path.
314 const listRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie } });
315 expect(listRes.status).toBe(200);
316 
317 // clientPrivileges path via the tessera wrapper. The raw
318 // /oauth2/create-client endpoint is 404'd; the wrapper calls
319 // auth.api.createOAuthClient internally, which still routes through
320 // the same clientPrivileges predicate the array-role normalization
321 // protects.
322 const createRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
323 method: "POST",
324 headers: testHeaders({ cookie }),
325 body: JSON.stringify({
326 name: "multi-role-admin-client",
327 redirectUris: [DEFAULT_REDIRECT_URI],
328 }),
329 });
330 expect(createRes.status).toBe(201);
331 }, 30_000);
332 
333 // Multi-admin namespace: a client created by admin A must be visible
334 // to admin B. With clientReference set to a constant, Better Auth's
335 // per-row ownership check takes the referenceId branch instead of
336 // falling back to userId equality.
337 it("lets a second admin list and rotate a client created by the first admin", async () => {
338 const cookieA = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1");
339 const createRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
340 method: "POST",
341 headers: testHeaders({ cookie: cookieA }),
342 body: JSON.stringify({
343 name: "admin-A-client",
344 redirectUris: [DEFAULT_REDIRECT_URI],
345 }),
346 });
347 expect(createRes.status).toBe(201);
348 const created = (await createRes.json()) as CreatedOAuthClient;
349 
350 // Create a second admin and sign them in.
351 const adminBCred = {
352 email: "admin-clients-b@example.com",
353 password: DEFAULT_PASSWORD,
354 name: "Admin B",
355 } satisfies TestCredential;
356 await signUpAdmin(adminBCred);
357 const cookieB = await signInForCookie(adminBCred.email, adminBCred.password, "10.30.0.1");
358 
359 const listRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie: cookieB } });
360 expect(listRes.status).toBe(200);
361 const list = (await listRes.json()) as Array<{ client_id: string }>;
362 expect(list.some((c) => c.client_id === created.client_id)).toBe(true);
363 
364 const rotateRes = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}/rotate`, {
365 method: "POST",
366 headers: testHeaders({ cookie: cookieB }),
367 });
368 expect(rotateRes.status).toBe(200);
369 }, 30_000);
370 
371 describe("PATCH /api/admin/clients/:id", () => {
372 interface ListedClient {
373 client_id: string;
374 client_name?: string | null;
375 client_uri?: string | null;
376 skip_consent?: boolean;
377 redirect_uris?: string[] | null;
378 }
379 
380 let adminCookie = "";
381 
382 // Sign in once; per-test sign-ins would burn through RL_AUTH's
383 // 10-per-minute budget for this IP across the suite.
384 beforeAll(async () => {
385 adminCookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.5");
386 });
387 
388 const fetchClient = async (clientId: string): Promise<ListedClient | undefined> => {
389 const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie: adminCookie } });
390 const list = (await res.json()) as ListedClient[];
391 return list.find((c) => c.client_id === clientId);
392 };
393 
394 const createTargetClient = async (name: string): Promise<CreatedOAuthClient> => {
395 const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
396 method: "POST",
397 headers: testHeaders({ cookie: adminCookie }),
398 body: JSON.stringify({ name, redirectUris: [DEFAULT_REDIRECT_URI] }),
399 });
400 expect(res.status).toBe(201);
401 return (await res.json()) as CreatedOAuthClient;
402 };
403 
404 it("rejects unauthenticated PATCH with 401", async () => {
405 const res = await SELF.fetch(`${ISSUER}/api/admin/clients/anything`, {
406 method: "PATCH",
407 headers: testHeaders(),
408 body: JSON.stringify({ name: "changed" }),
409 });
410 expect(res.status).toBe(401);
411 });
412 
413 it("rejects non-admin PATCH with 403", async () => {
414 const userCookie = await signInForCookie(userCred.email, userCred.password, "10.30.0.6");
415 const res = await SELF.fetch(`${ISSUER}/api/admin/clients/anything`, {
416 method: "PATCH",
417 headers: testHeaders({ cookie: userCookie }),
418 body: JSON.stringify({ name: "changed" }),
419 });
420 expect(res.status).toBe(403);
421 });
422 
423 it("updates client_name in isolation", async () => {
424 const created = await createTargetClient("patch-name-target");
425 const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, {
426 method: "PATCH",
427 headers: testHeaders({ cookie: adminCookie }),
428 body: JSON.stringify({ name: "patch-name-renamed" }),
429 });
430 expect(patch.status).toBe(200);
431 const after = await fetchClient(created.client_id);
432 expect(after?.client_name).toBe("patch-name-renamed");
433 expect(after?.skip_consent).not.toBe(true);
434 });
435 
436 it("updates skipConsent in isolation", async () => {
437 const created = await createTargetClient("patch-skip-target");
438 const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, {
439 method: "PATCH",
440 headers: testHeaders({ cookie: adminCookie }),
441 body: JSON.stringify({ skipConsent: true }),
442 });
443 expect(patch.status).toBe(200);
444 const after = await fetchClient(created.client_id);
445 expect(after?.skip_consent).toBe(true);
446 expect(after?.client_name).toBe("patch-skip-target");
447 });
448 
449 it("ignores arbitrary fields (only name, skipConsent, uri are honored)", async () => {
450 const created = await createTargetClient("patch-strict");
451 const otherRedirect = "http://127.0.0.1:0/other";
452 const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, {
453 method: "PATCH",
454 headers: testHeaders({ cookie: adminCookie }),
455 body: JSON.stringify({
456 name: "patch-strict-renamed",
457 redirect_uris: [otherRedirect],
458 redirectUris: [otherRedirect],
459 metadata: { launcher: { lucide: "Hammer" } },
460 grant_types: ["client_credentials"],
461 }),
462 });
463 expect(patch.status).toBe(200);
464 const after = await fetchClient(created.client_id);
465 expect(after?.client_name).toBe("patch-strict-renamed");
466 // redirect_uris must not have been replaced.
467 expect(after?.redirect_uris).toEqual([DEFAULT_REDIRECT_URI]);
468 });
469 
470 it("rejects empty PATCH body with 400", async () => {
471 const created = await createTargetClient("patch-empty-target");
472 const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, {
473 method: "PATCH",
474 headers: testHeaders({ cookie: adminCookie }),
475 body: JSON.stringify({}),
476 });
477 expect(patch.status).toBe(400);
478 const body = (await patch.json()) as JsonErrorBody;
479 expect(body.error).toBe("invalid_body");
480 });
481 
482 it("rejects non-boolean skipConsent with 400", async () => {
483 const created = await createTargetClient("patch-bad-skip");
484 const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, {
485 method: "PATCH",
486 headers: testHeaders({ cookie: adminCookie }),
487 body: JSON.stringify({ skipConsent: "yes" }),
488 });
489 expect(patch.status).toBe(400);
490 const body = (await patch.json()) as JsonErrorBody;
491 expect(body.error).toBe("invalid_skip_consent");
492 });
493 
494 it("updates uri in isolation", async () => {
495 const created = await createTargetClient("patch-uri-target");
496 const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, {
497 method: "PATCH",
498 headers: testHeaders({ cookie: adminCookie }),
499 body: JSON.stringify({ uri: "https://anvil.limic.dev" }),
500 });
501 expect(patch.status).toBe(200);
502 const after = await fetchClient(created.client_id);
503 expect(after?.client_uri).toBe("https://anvil.limic.dev");
504 expect(after?.client_name).toBe("patch-uri-target");
505 });
506 
507 it("clears uri when sent as empty string", async () => {
508 const created = await createTargetClient("patch-uri-clear");
509 const set = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, {
510 method: "PATCH",
511 headers: testHeaders({ cookie: adminCookie }),
512 body: JSON.stringify({ uri: "https://anvil.limic.dev" }),
513 });
514 expect(set.status).toBe(200);
515 const cleared = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, {
516 method: "PATCH",
517 headers: testHeaders({ cookie: adminCookie }),
518 body: JSON.stringify({ uri: " " }),
519 });
520 expect(cleared.status).toBe(200);
521 const after = await fetchClient(created.client_id);
522 // Stored as empty string (Better Auth's PATCH schema rejects null);
523 // consumers gate on truthiness, so absent and "" are equivalent.
524 expect(after?.client_uri ?? "").toBe("");
525 });
526 
527 it("rejects non-loopback http uri with 400", async () => {
528 const created = await createTargetClient("patch-uri-http");
529 const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, {
530 method: "PATCH",
531 headers: testHeaders({ cookie: adminCookie }),
532 body: JSON.stringify({ uri: "http://anvil.limic.dev" }),
533 });
534 expect(patch.status).toBe(400);
535 const body = (await patch.json()) as JsonErrorBody;
536 expect(body.error).toBe("invalid_uri");
537 });
538 
539 it("rejects javascript: uri with 400", async () => {
540 const created = await createTargetClient("patch-uri-js");
541 const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, {
542 method: "PATCH",
543 headers: testHeaders({ cookie: adminCookie }),
544 body: JSON.stringify({ uri: "javascript:alert(1)" }),
545 });
546 expect(patch.status).toBe(400);
547 const body = (await patch.json()) as JsonErrorBody;
548 expect(body.error).toBe("invalid_uri");
549 });
550 
551 it("rejects non-string uri with 400", async () => {
552 const created = await createTargetClient("patch-uri-non-string");
553 const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, {
554 method: "PATCH",
555 headers: testHeaders({ cookie: adminCookie }),
556 body: JSON.stringify({ uri: 42 }),
557 });
558 expect(patch.status).toBe(400);
559 const body = (await patch.json()) as JsonErrorBody;
560 expect(body.error).toBe("invalid_uri");
561 });
562 });
563});