From 501e717fe9d1ef6343b9e448362a4d3fc5a786b7 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 13 Sep 2026 14:27:06 +0200 Subject: [PATCH] test(actions): add unit tests for user management actions --- src/actions/users.test.ts | 291 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 src/actions/users.test.ts diff --git a/src/actions/users.test.ts b/src/actions/users.test.ts new file mode 100644 index 00000000..ee61d77d --- /dev/null +++ b/src/actions/users.test.ts @@ -0,0 +1,291 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +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(), + invalidateLoginCache: vi.fn(), +})); + +vi.mock("@/lib/auth/password", () => ({ + hashPassword: vi.fn().mockResolvedValue("hashed_pass_123"), +})); + +const { insertValues, deleteWhere, updateSet, selectLimit } = vi.hoisted( + () => ({ + insertValues: vi.fn(), + deleteWhere: vi.fn(), + updateSet: vi.fn(), + selectLimit: vi.fn(), + }), +); + +vi.mock("@/lib/db", () => ({ + db: { + insert: vi.fn(() => ({ + values: insertValues, + onDuplicateKeyUpdate: vi.fn().mockResolvedValue([]), + })), + update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateSet })) })), + delete: vi.fn(() => ({ where: deleteWhere })), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ limit: selectLimit })), + })), + })), + transaction: vi.fn(async (cb: (tx: any) => Promise) => { + const mockTx = { + insert: vi.fn(() => ({ + values: vi.fn().mockResolvedValue([{ insertId: 42n }]), + })), + }; + return cb(mockTx); + }), + }, + User: { id: "User.id", username: "User.username", rank: "User.rank" }, + Ban: { userId: "Ban.userId" }, + UsersSettings: {}, + UsersCurrency: {}, + UsersBadges: { id: "UsersBadges.id" }, +})); + +vi.mock("@/lib/permissions", () => ({ + PERMS: { + USERS_EDIT: "users.edit", + USERS_BAN: "users.ban", + USERS_RESET_PASSWORD: "users.reset_password", + }, +})); + +vi.mock("@/lib/safe-action", () => ({ + adminAction: vi.fn((_o: unknown, f: (...args: unknown[]) => unknown) => f), +})); + +vi.mock("@/lib/safe-action-shared", () => ({ + ActionError: class ActionError extends Error {}, + actionOk: vi.fn((res) => ({ ok: true, data: res })), +})); + +vi.mock("@/lib/services/audit", () => ({ + logAudit: vi.fn(), +})); + +vi.mock("@/lib/services/webhook", () => ({ + notify: vi.fn(), +})); + +vi.mock("@/lib/services/rcon", () => ({ + rcon: { + disconnectUser: vi.fn().mockResolvedValue(true), + giveBadge: vi.fn().mockResolvedValue(true), + removeBadge: vi.fn().mockResolvedValue(true), + alertUser: vi.fn().mockResolvedValue(true), + muteUser: vi.fn().mockResolvedValue(true), + unmuteUser: vi.fn().mockResolvedValue(true), + giveCredits: vi.fn().mockResolvedValue(true), + }, +})); + +import { ActionError } from "@/lib/safe-action-shared"; +import { logAudit } from "@/lib/services/audit"; +import { rcon } from "@/lib/services/rcon"; +import { + alertUser, + banUser, + createUser, + disconnectUser, + giveBadge, + muteUser, + removeBadge, + resetPassword, + unbanUser, + unmuteUser, +} from "./users"; + +const mockContext = (data: any, rank = 7) => ({ + data, + session: { + user: { + id: 1, + username: "superadmin", + rank, + }, + }, +}); + +beforeEach(() => { + vi.clearAllMocks(); + selectLimit.mockResolvedValue([ + { username: "targetuser", rank: 1, mail: "target@example.com" }, + ]); + deleteWhere.mockResolvedValue([{ affectedRows: 1 }]); + updateSet.mockResolvedValue([{ affectedRows: 1 }]); + insertValues.mockResolvedValue([{ insertId: 100n }]); +}); + +describe("createUser action", () => { + it("creates a user successfully and logs audit", async () => { + const ctx = mockContext({ + username: "newuser", + mail: "new@example.com", + password: "password123", + rank: 1, + motto: "Hello world", + }); + + const res = await (createUser as any)(ctx); + + expect(res).toEqual({ ok: true, data: { id: 42, username: "newuser" } }); + expect(logAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: "user_create", + targetId: 42, + }), + ); + }); + + it("throws error if admin assigns rank equal or higher than their own", async () => { + const ctx = mockContext( + { + username: "moduser", + mail: "mod@example.com", + password: "password123", + rank: 5, + }, + 5, // admin with rank 5 trying to set rank 5 + ); + + await expect((createUser as any)(ctx)).rejects.toThrow(ActionError); + }); +}); + +describe("banUser & unbanUser actions", () => { + it("bans target user, disconnects via RCON, and logs audit", async () => { + const ctx = mockContext({ + userId: 10, + reason: "Rule breaking", + duration: 24, + type: "account", + }); + + await (banUser as any)(ctx); + + expect(rcon.disconnectUser).toHaveBeenCalledWith(10); + expect(logAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: "ban", + targetId: 10, + }), + ); + }); + + it("unbans target user and logs audit", async () => { + const ctx = mockContext({ userId: 10 }); + + await (unbanUser as any)(ctx); + + expect(logAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: "unban", + targetId: 10, + }), + ); + }); +}); + +describe("giveBadge & removeBadge actions", () => { + it("gives badge if user doesn't have it yet", async () => { + selectLimit.mockResolvedValueOnce([ + { username: "targetuser", rank: 1, mail: "target@example.com" }, + ]); // guardRank + selectLimit.mockResolvedValueOnce([]); // existing badge check (none) + + const ctx = mockContext({ userId: 10, badgeCode: "ADM" }); + await (giveBadge as any)(ctx); + + expect(rcon.giveBadge).toHaveBeenCalledWith(10, "ADM"); + }); + + it("throws ActionError if user already has the badge", async () => { + selectLimit.mockResolvedValueOnce([ + { username: "targetuser", rank: 1, mail: "target@example.com" }, + ]); // guardRank + selectLimit.mockResolvedValueOnce([{ id: 1 }]); // existing badge check (found) + + const ctx = mockContext({ userId: 10, badgeCode: "ADM" }); + await expect((giveBadge as any)(ctx)).rejects.toThrow( + "Badge already assigned", + ); + }); + + it("removes badge if user has it", async () => { + selectLimit.mockResolvedValueOnce([ + { username: "targetuser", rank: 1, mail: "target@example.com" }, + ]); // guardRank + selectLimit.mockResolvedValueOnce([{ id: 99 }]); // existing badge check (found) + + const ctx = mockContext({ userId: 10, badgeCode: "ADM" }); + await (removeBadge as any)(ctx); + + expect(rcon.removeBadge).toHaveBeenCalledWith(10, "ADM"); + }); +}); + +describe("resetPassword action", () => { + it("resets password, invalidates login cache and logs audit", async () => { + const ctx = mockContext({ userId: 10 }); + const res = await (resetPassword as any)(ctx); + + expect(res.data).toHaveProperty("newPassword"); + expect(logAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: "reset_password", + targetId: 10, + }), + ); + }); +}); + +describe("moderation tools (disconnect, alert, mute, unmute)", () => { + it("disconnects user via RCON", async () => { + const ctx = mockContext({ userId: 10 }); + await (disconnectUser as any)(ctx); + expect(rcon.disconnectUser).toHaveBeenCalledWith(10); + }); + + it("alerts user via RCON", async () => { + const ctx = mockContext({ userId: 10, message: "Hello user!" }); + await (alertUser as any)(ctx); + expect(rcon.alertUser).toHaveBeenCalledWith(10, "Hello user!"); + }); + + it("mutes user via RCON and logs audit", async () => { + const ctx = mockContext({ userId: 10, duration: 600 }); + await (muteUser as any)(ctx); + expect(rcon.muteUser).toHaveBeenCalledWith(10, 600); + expect(logAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: "user_mute", + targetId: 10, + }), + ); + }); + + it("unmutes user via RCON and logs audit", async () => { + const ctx = mockContext({ userId: 10 }); + await (unmuteUser as any)(ctx); + expect(rcon.unmuteUser).toHaveBeenCalledWith(10); + expect(logAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: "user_unmute", + targetId: 10, + }), + ); + }); +});