import { env, exports } from "cloudflare:workers"; import { eq } from "drizzle-orm"; import { beforeAll, describe, expect, it } from "vitest"; const SELF = exports.default; import { makeAuth } from "@/worker/auth"; import { makeDb } from "@/worker/db"; import { oauthAccessTokens, oauthClients, oauthRefreshTokens, sessions, users } from "@/worker/db/schema"; import { ISSUER, signInForCookie, testHeaders } from "./helpers"; // Invariant: after a successful ban response, the target user has // banned=true AND zero session rows AND zero oauth_access_tokens rows AND // zero oauth_refresh_tokens rows. Better Auth's plugin only does the // first two; tessera's handler at api/admin/users.ts adds the OAuth // token cleanup in one D1 batch so all four state changes commit // together. describe("admin ban cleanup", () => { const adminCred = { email: "ban-admin@example.com", password: "correct-horse-battery-staple", name: "Ban Admin", }; const targetCred = { email: "ban-target@example.com", password: "correct-horse-battery-staple", name: "Ban Target", }; let adminCookie: string; let targetUserId: string; beforeAll(async () => { const auth = makeAuth(env, { baseURL: ISSUER, issuer: ISSUER }); await auth.api.signUpEmail({ body: adminCred, asResponse: false }); await auth.api.signUpEmail({ body: targetCred, asResponse: false }); await env.DB.prepare("UPDATE users SET role = ? WHERE email = ?").bind("admin", adminCred.email).run(); adminCookie = await signInForCookie(adminCred.email, adminCred.password, "10.60.0.1"); const row = await env.DB.prepare("SELECT id FROM users WHERE email = ?") .bind(targetCred.email) .first<{ id: string }>(); if (!row) throw new Error("seeded target missing"); targetUserId = row.id; }); it("deletes sessions plus oauth_access_tokens + oauth_refresh_tokens atomically", async () => { // Sign the target in to seed a real session row. await signInForCookie(targetCred.email, targetCred.password, "10.60.0.2"); // Seed a fixture oauth_clients row so the FK constraint on the token // tables is satisfied; we don't drive a real /authorize+/token flow // because the handler's contract is purely "delete WHERE user_id = ?". const db = makeDb(env); const fixtureClientId = `fixture-client-${crypto.randomUUID()}`; await db.insert(oauthClients).values({ id: crypto.randomUUID(), clientId: fixtureClientId, redirectUris: ["http://127.0.0.1:0/cb"], }); await db.insert(oauthRefreshTokens).values({ id: `rt_${crypto.randomUUID()}`, token: "fake-refresh", clientId: fixtureClientId, userId: targetUserId, scopes: ["openid"], createdAt: new Date(), expiresAt: new Date(Date.now() + 60_000), }); await db.insert(oauthAccessTokens).values({ id: `at_${crypto.randomUUID()}`, token: `fake-access-${crypto.randomUUID()}`, clientId: fixtureClientId, userId: targetUserId, scopes: ["openid"], createdAt: new Date(), expiresAt: new Date(Date.now() + 60_000), }); // Sanity: the seed rows exist. const sessionsBefore = await db.select().from(sessions).where(eq(sessions.userId, targetUserId)); const accessBefore = await db.select().from(oauthAccessTokens).where(eq(oauthAccessTokens.userId, targetUserId)); const refreshBefore = await db.select().from(oauthRefreshTokens).where(eq(oauthRefreshTokens.userId, targetUserId)); expect(sessionsBefore.length).toBeGreaterThan(0); expect(accessBefore.length).toBeGreaterThan(0); expect(refreshBefore.length).toBeGreaterThan(0); const res = await SELF.fetch(`${ISSUER}/api/auth/admin/ban-user`, { method: "POST", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({ userId: targetUserId, banReason: "policy violation" }), }); expect(res.status).toBe(200); const body = (await res.json()) as { user?: { banned?: boolean } }; expect(body.user?.banned).toBe(true); const userRow = await db .select({ banned: users.banned, banReason: users.banReason }) .from(users) .where(eq(users.id, targetUserId)); expect(userRow[0]?.banned).toBe(true); expect(userRow[0]?.banReason).toBe("policy violation"); const sessionsAfter = await db.select().from(sessions).where(eq(sessions.userId, targetUserId)); const accessAfter = await db.select().from(oauthAccessTokens).where(eq(oauthAccessTokens.userId, targetUserId)); const refreshAfter = await db.select().from(oauthRefreshTokens).where(eq(oauthRefreshTokens.userId, targetUserId)); expect(sessionsAfter.length).toBe(0); expect(accessAfter.length).toBe(0); expect(refreshAfter.length).toBe(0); }, 30_000); it("rejects self-ban with 400", async () => { const adminId = ( await env.DB.prepare("SELECT id FROM users WHERE email = ?").bind(adminCred.email).first<{ id: string }>() )?.id; const res = await SELF.fetch(`${ISSUER}/api/auth/admin/ban-user`, { method: "POST", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({ userId: adminId }), }); expect(res.status).toBe(400); const body = (await res.json()) as { error?: string }; expect(body.error).toBe("YOU_CANNOT_BAN_YOURSELF"); }); it("rejects unauthenticated ban with 401", async () => { const res = await SELF.fetch(`${ISSUER}/api/auth/admin/ban-user`, { method: "POST", headers: testHeaders(), body: JSON.stringify({ userId: targetUserId }), }); expect(res.status).toBe(401); }); });