Skip to content
File

Blob: tests/worker/ban-cleanup.test.ts

typescript134 lines
1import { env, exports } from "cloudflare:workers";
2import { eq } from "drizzle-orm";
3import { beforeAll, describe, expect, it } from "vitest";
4 
5const SELF = exports.default;
6 
7import { makeAuth } from "@/worker/auth";
8import { makeDb } from "@/worker/db";
9import { oauthAccessTokens, oauthClients, oauthRefreshTokens, sessions, users } from "@/worker/db/schema";
10 
11import { ISSUER, signInForCookie, testHeaders } from "./helpers";
12 
13// Invariant: after a successful ban response, the target user has
14// banned=true AND zero session rows AND zero oauth_access_tokens rows AND
15// zero oauth_refresh_tokens rows. Better Auth's plugin only does the
16// first two; tessera's handler at api/admin/users.ts adds the OAuth
17// token cleanup in one D1 batch so all four state changes commit
18// together.
19describe("admin ban cleanup", () => {
20 const adminCred = {
21 email: "ban-admin@example.com",
22 password: "correct-horse-battery-staple",
23 name: "Ban Admin",
24 };
25 const targetCred = {
26 email: "ban-target@example.com",
27 password: "correct-horse-battery-staple",
28 name: "Ban Target",
29 };
30 let adminCookie: string;
31 let targetUserId: string;
32 
33 beforeAll(async () => {
34 const auth = makeAuth(env, { baseURL: ISSUER, issuer: ISSUER });
35 await auth.api.signUpEmail({ body: adminCred, asResponse: false });
36 await auth.api.signUpEmail({ body: targetCred, asResponse: false });
37 await env.DB.prepare("UPDATE users SET role = ? WHERE email = ?").bind("admin", adminCred.email).run();
38 adminCookie = await signInForCookie(adminCred.email, adminCred.password, "10.60.0.1");
39 const row = await env.DB.prepare("SELECT id FROM users WHERE email = ?")
40 .bind(targetCred.email)
41 .first<{ id: string }>();
42 if (!row) throw new Error("seeded target missing");
43 targetUserId = row.id;
44 });
45 
46 it("deletes sessions plus oauth_access_tokens + oauth_refresh_tokens atomically", async () => {
47 // Sign the target in to seed a real session row.
48 await signInForCookie(targetCred.email, targetCred.password, "10.60.0.2");
49 
50 // Seed a fixture oauth_clients row so the FK constraint on the token
51 // tables is satisfied; we don't drive a real /authorize+/token flow
52 // because the handler's contract is purely "delete WHERE user_id = ?".
53 const db = makeDb(env);
54 const fixtureClientId = `fixture-client-${crypto.randomUUID()}`;
55 await db.insert(oauthClients).values({
56 id: crypto.randomUUID(),
57 clientId: fixtureClientId,
58 redirectUris: ["http://127.0.0.1:0/cb"],
59 });
60 await db.insert(oauthRefreshTokens).values({
61 id: `rt_${crypto.randomUUID()}`,
62 token: "fake-refresh",
63 clientId: fixtureClientId,
64 userId: targetUserId,
65 scopes: ["openid"],
66 createdAt: new Date(),
67 expiresAt: new Date(Date.now() + 60_000),
68 });
69 await db.insert(oauthAccessTokens).values({
70 id: `at_${crypto.randomUUID()}`,
71 token: `fake-access-${crypto.randomUUID()}`,
72 clientId: fixtureClientId,
73 userId: targetUserId,
74 scopes: ["openid"],
75 createdAt: new Date(),
76 expiresAt: new Date(Date.now() + 60_000),
77 });
78 
79 // Sanity: the seed rows exist.
80 const sessionsBefore = await db.select().from(sessions).where(eq(sessions.userId, targetUserId));
81 const accessBefore = await db.select().from(oauthAccessTokens).where(eq(oauthAccessTokens.userId, targetUserId));
82 const refreshBefore = await db.select().from(oauthRefreshTokens).where(eq(oauthRefreshTokens.userId, targetUserId));
83 expect(sessionsBefore.length).toBeGreaterThan(0);
84 expect(accessBefore.length).toBeGreaterThan(0);
85 expect(refreshBefore.length).toBeGreaterThan(0);
86 
87 const res = await SELF.fetch(`${ISSUER}/api/auth/admin/ban-user`, {
88 method: "POST",
89 headers: testHeaders({ cookie: adminCookie }),
90 body: JSON.stringify({ userId: targetUserId, banReason: "policy violation" }),
91 });
92 expect(res.status).toBe(200);
93 const body = (await res.json()) as { user?: { banned?: boolean } };
94 expect(body.user?.banned).toBe(true);
95 
96 const userRow = await db
97 .select({ banned: users.banned, banReason: users.banReason })
98 .from(users)
99 .where(eq(users.id, targetUserId));
100 expect(userRow[0]?.banned).toBe(true);
101 expect(userRow[0]?.banReason).toBe("policy violation");
102 
103 const sessionsAfter = await db.select().from(sessions).where(eq(sessions.userId, targetUserId));
104 const accessAfter = await db.select().from(oauthAccessTokens).where(eq(oauthAccessTokens.userId, targetUserId));
105 const refreshAfter = await db.select().from(oauthRefreshTokens).where(eq(oauthRefreshTokens.userId, targetUserId));
106 expect(sessionsAfter.length).toBe(0);
107 expect(accessAfter.length).toBe(0);
108 expect(refreshAfter.length).toBe(0);
109 }, 30_000);
110 
111 it("rejects self-ban with 400", async () => {
112 const adminId = (
113 await env.DB.prepare("SELECT id FROM users WHERE email = ?").bind(adminCred.email).first<{ id: string }>()
114 )?.id;
115 const res = await SELF.fetch(`${ISSUER}/api/auth/admin/ban-user`, {
116 method: "POST",
117 headers: testHeaders({ cookie: adminCookie }),
118 body: JSON.stringify({ userId: adminId }),
119 });
120 expect(res.status).toBe(400);
121 const body = (await res.json()) as { error?: string };
122 expect(body.error).toBe("YOU_CANNOT_BAN_YOURSELF");
123 });
124 
125 it("rejects unauthenticated ban with 401", async () => {
126 const res = await SELF.fetch(`${ISSUER}/api/auth/admin/ban-user`, {
127 method: "POST",
128 headers: testHeaders(),
129 body: JSON.stringify({ userId: targetUserId }),
130 });
131 expect(res.status).toBe(401);
132 });
133});