Skip to content
File

Blob: tests/worker/admin-self-modify.test.ts

typescript79 lines
1import { env, exports } from "cloudflare:workers";
2import { beforeAll, describe, expect, it } from "vitest";
3 
4const SELF = exports.default;
5 
6import { makeAuth } from "@/worker/auth";
7 
8import { ISSUER, signInForCookie, testHeaders } from "./helpers";
9 
10// auth/index.ts has a `before` hook on /admin/set-role / ban-user /
11// remove-user that returns FORBIDDEN if the caller targets themselves
12// (set-role only allows the no-op of keeping role=admin; ban/remove
13// always 403 self). This test exercises the load-bearing case: an
14// admin demoting themselves to user via the raw /api/auth/admin/set-role
15// path; without the guard, an operator could lock themselves out.
16describe("admin self-modify guard", () => {
17 const cred = {
18 email: "self-modify-admin@example.com",
19 password: "correct-horse-battery-staple",
20 name: "Self Modify Admin",
21 };
22 let cookie: string;
23 let userId: string;
24 
25 beforeAll(async () => {
26 const auth = makeAuth(env, { baseURL: ISSUER, issuer: ISSUER });
27 await auth.api.signUpEmail({ body: cred, asResponse: false });
28 await env.DB.prepare("UPDATE users SET role = ? WHERE email = ?").bind("admin", cred.email).run();
29 cookie = await signInForCookie(cred.email, cred.password, "10.50.0.1");
30 const row = await env.DB.prepare("SELECT id FROM users WHERE email = ?").bind(cred.email).first<{ id: string }>();
31 if (!row) throw new Error("seeded admin missing");
32 userId = row.id;
33 });
34 
35 it("blocks self-demotion via /admin/set-role with 403", async () => {
36 // `origin` is required by Better Auth's CSRF gate for state-changing
37 // auth endpoints; without it we get 403 MISSING_OR_NULL_ORIGIN before
38 // the self-modify hook runs.
39 const res = await SELF.fetch(`${ISSUER}/api/auth/admin/set-role`, {
40 method: "POST",
41 headers: testHeaders({ cookie }),
42 body: JSON.stringify({ userId, role: "user" }),
43 });
44 expect(res.status).toBe(403);
45 const body = (await res.json()) as { code?: string };
46 expect(body.code).toBe("CANNOT_SELF_DEMOTE");
47 
48 // Confirm the role didn't actually change.
49 const row = await env.DB.prepare("SELECT role FROM users WHERE id = ?").bind(userId).first<{ role: string }>();
50 expect(row?.role).toBe("admin");
51 });
52 
53 it("allows self set-role to admin (no-op) without 403", async () => {
54 const res = await SELF.fetch(`${ISSUER}/api/auth/admin/set-role`, {
55 method: "POST",
56 headers: testHeaders({ cookie }),
57 body: JSON.stringify({ userId, role: "admin" }),
58 });
59 expect([200, 204]).toContain(res.status);
60 });
61 
62 it('blocks self-demotion via array role body (`role: ["user"]`)', async () => {
63 // Better Auth's set-role schema accepts `role: string | string[]`.
64 // The self-demote guard normalizes both shapes so an admin posting
65 // an array form against their own user id still hits CANNOT_SELF_DEMOTE.
66 const res = await SELF.fetch(`${ISSUER}/api/auth/admin/set-role`, {
67 method: "POST",
68 headers: testHeaders({ cookie }),
69 body: JSON.stringify({ userId, role: ["user"] }),
70 });
71 expect(res.status).toBe(403);
72 const body = (await res.json()) as { code?: string };
73 expect(body.code).toBe("CANNOT_SELF_DEMOTE");
74 
75 const row = await env.DB.prepare("SELECT role FROM users WHERE id = ?").bind(userId).first<{ role: string }>();
76 expect(row?.role).toBe("admin");
77 });
78});