From 62f4861705141def15a10d2e9538af5ef36d6b60 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 13 Sep 2026 13:59:21 +0200 Subject: [PATCH] feat: Add comprehensive unit tests for admin-bans actions --- src/actions/admin-bans.test.ts | 254 +++++++++++++++++++++++++-------- 1 file changed, 196 insertions(+), 58 deletions(-) diff --git a/src/actions/admin-bans.test.ts b/src/actions/admin-bans.test.ts index 37b50d29..deba3b96 100644 --- a/src/actions/admin-bans.test.ts +++ b/src/actions/admin-bans.test.ts @@ -1,84 +1,222 @@ -// @ts-nocheck -import { revalidatePath } from "next/cache"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { requirePermissionRateLimited } from "@/lib/admin/guard"; -import { rcon } from "@/lib/services/rcon"; + +// Mock next/server, next-auth and @/lib/auth before imports +vi.mock("next/server", () => ({ + NextResponse: { + json: vi.fn(), + }, +})); + +vi.mock("next-auth", () => ({ + default: vi.fn(() => ({ + handlers: {}, + auth: vi.fn(), + signOut: vi.fn(), + })), +})); + +vi.mock("@/lib/auth", () => ({ + auth: vi.fn(), + handlers: {}, + signOut: vi.fn(), +})); + +import { Ban, db, User } from "@/lib/db"; +import { PERMS } from "@/lib/permissions"; import { createBan, liftBan } from "./admin-bans"; -const { selectLimit, insertValues, deleteWhere } = vi.hoisted(() => { - const selectLimit = vi.fn(); - const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]); - const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]); - return { selectLimit, insertValues, deleteWhere }; +// Mock eq from drizzle-orm +const mockEq = vi.hoisted(() => vi.fn()); +vi.mock("drizzle-orm", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + eq: mockEq, + }; }); -vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() })); -vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } })); -vi.mock("@/lib/db", () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: selectLimit, - })), - })), - })), - insert: vi.fn(() => ({ values: insertValues })), - delete: vi.fn(() => ({ where: deleteWhere })), - }, - Ban: { id: "id", userId: "userId" }, - User: { id: "id", username: "username" }, +// Mock external services +const mockRequirePermissionRateLimited = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/admin/guard", () => ({ + requirePermissionRateLimited: mockRequirePermissionRateLimited, })); -vi.mock("@/lib/services/rcon", () => ({ rcon: { disconnectUser: vi.fn() } })); -vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() })); -vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); -const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (key: string) => data[key] ?? null, +// Hoisted mocks for internal db functions +const hoistedInsertValues = vi.hoisted(() => vi.fn()); +const hoistedDeleteWhere = vi.hoisted(() => vi.fn()); +const hoistedSelectLimit = vi.hoisted(() => vi.fn()); +const hoistedSelectWhere = vi.hoisted(() => + vi.fn(() => ({ limit: hoistedSelectLimit })), +); +const hoistedSelectFrom = vi.hoisted(() => + vi.fn(() => ({ where: hoistedSelectWhere })), +); + +vi.mock("@/lib/db", () => { + return { + db: { + insert: vi.fn(() => ({ values: hoistedInsertValues })), + delete: vi.fn(() => ({ where: hoistedDeleteWhere })), + select: vi.fn(() => ({ from: hoistedSelectFrom })), + }, + Ban: { id: "Ban.id", userId: "Ban.userId" }, + User: { id: "User.id", username: "User.username" }, + }; }); +const mockRconDisconnectUser = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/services/rcon", () => ({ + rcon: { + disconnectUser: mockRconDisconnectUser, + }, +})); + +const mockLogStaffActivity = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/services/staff-activity", () => ({ + logStaffActivity: mockLogStaffActivity, +})); + +const mockRevalidatePath = vi.hoisted(() => vi.fn()); +vi.mock("next/cache", () => ({ + revalidatePath: mockRevalidatePath, + unstable_cache: (fn: any) => fn, +})); + +const mockHeaders = vi.hoisted(() => vi.fn()); +vi.mock("next/headers", () => ({ + headers: mockHeaders, +})); + beforeEach(() => { vi.clearAllMocks(); - vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never); - selectLimit.mockResolvedValue([{ username: "baduser" }]); - insertValues.mockResolvedValue([{ insertId: 1 }]); - deleteWhere.mockResolvedValue([{ affectedRows: 1 }]); + mockRequirePermissionRateLimited.mockResolvedValue({ id: 100, rank: 7 }); + hoistedInsertValues.mockResolvedValue([{ insertId: 1 }]); + hoistedDeleteWhere.mockResolvedValue([{ affectedRows: 1 }]); + hoistedSelectLimit.mockResolvedValue([{ username: "testuser" }]); + mockHeaders.mockReturnValue(new Map()); }); describe("createBan", () => { - it("creates a ban for valid inputs", async () => { - await createBan( - fakeForm({ - userId: "42", - reason: "Spam", - hours: "24", + it("requires USERS_BAN permission and logs activity", async () => { + const formData = new FormData(); + formData.append("userId", "1"); + formData.append("reason", "Test ban"); + formData.append("hours", "24"); + formData.append("type", "account"); + + await createBan(formData); + + expect(mockRequirePermissionRateLimited).toHaveBeenCalledWith( + PERMS.USERS_BAN, + ); + expect(db.insert).toHaveBeenCalledWith(Ban); + expect(hoistedInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 1, + banReason: "Test ban", + banExpire: expect.any(Number), type: "account", - }) as unknown as FormData, + }), ); - expect(insertValues).toHaveBeenCalledWith( - expect.objectContaining({ userId: 42, type: "account" }), + expect(mockLogStaffActivity).toHaveBeenCalledWith( + expect.objectContaining({ + action: "user_ban", + targetId: 1, + description: expect.stringContaining("Banned user #1"), + }), ); - expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser"); - expect(revalidatePath).toHaveBeenCalledWith("/admin/bans"); + expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/bans"); }); - it("returns early when userId is invalid", async () => { - await createBan( - fakeForm({ - userId: "0", - hours: "1", - type: "account", - }) as unknown as FormData, + it("sets permanent ban (expire 0) if hours is 0", async () => { + const formData = new FormData(); + formData.append("userId", "2"); + formData.append("reason", "Permanent ban"); + formData.append("hours", "0"); + formData.append("type", "ip"); + + await createBan(formData); + + expect(hoistedInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 2, + banExpire: 0, + type: "ip", + }), ); - expect(insertValues).not.toHaveBeenCalled(); + }); + + it("aborts when userId is invalid or zero", async () => { + const formData = new FormData(); + formData.append("userId", "0"); + formData.append("reason", "Invalid user"); + formData.append("hours", "1"); + formData.append("type", "account"); + + await createBan(formData); + + expect(db.insert).not.toHaveBeenCalled(); + expect(mockLogStaffActivity).not.toHaveBeenCalled(); + }); + + it("aborts when ban type is unsupported", async () => { + const formData = new FormData(); + formData.append("userId", "10"); + formData.append("reason", "Invalid type"); + formData.append("hours", "1"); + formData.append("type", "invalid_type"); + + await createBan(formData); + + expect(db.insert).not.toHaveBeenCalled(); + expect(mockLogStaffActivity).not.toHaveBeenCalled(); + }); + + it("disconnects user via RCON if user found", async () => { + const formData = new FormData(); + formData.append("userId", "3"); + formData.append("type", "account"); + formData.append("reason", "Test reason"); + + hoistedSelectLimit.mockResolvedValueOnce([{ username: "anotheruser" }]); + + await createBan(formData); + + expect(mockRconDisconnectUser).toHaveBeenCalledWith(3, "anotheruser"); + expect(mockEq).toHaveBeenCalledWith(User.id, 3); }); }); describe("liftBan", () => { - it("deletes ban and revalidates", async () => { - await liftBan(fakeForm({ id: "42" }) as unknown as FormData); - expect(deleteWhere).toHaveBeenCalled(); - expect(revalidatePath).toHaveBeenCalledWith("/admin/bans"); + it("lifts an existing ban and logs activity", async () => { + const formData = new FormData(); + formData.append("id", "123"); + + await liftBan(formData); + + expect(mockRequirePermissionRateLimited).toHaveBeenCalledWith( + PERMS.USERS_BAN, + ); + expect(db.delete).toHaveBeenCalledWith(Ban); + expect(hoistedDeleteWhere).toHaveBeenCalledWith(mockEq(Ban.id, 123)); + expect(mockLogStaffActivity).toHaveBeenCalledWith( + expect.objectContaining({ + action: "ban_lift", + description: "Lifted ban #123", + }), + ); + expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/bans"); + }); + + it("does not delete if ban id is not positive", async () => { + const formData = new FormData(); + formData.append("id", "0"); + + await liftBan(formData); + + expect(db.delete).not.toHaveBeenCalled(); + expect(hoistedDeleteWhere).not.toHaveBeenCalled(); + expect(mockLogStaffActivity).not.toHaveBeenCalled(); + expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/bans"); }); });