From 2e9db75ecaa2d8b6138992100cc9172131c4157a Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 25 Jul 2026 18:10:15 +0200 Subject: [PATCH] test: add audit, staff-activity, and abuse-guard test suites (75 files, 376 tests) --- src/lib/services/abuse-guard.test.ts | 88 ++++++++++++++++ src/lib/services/audit.test.ts | 127 ++++++++++++++++++++++++ src/lib/services/staff-activity.test.ts | 84 ++++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 src/lib/services/abuse-guard.test.ts create mode 100644 src/lib/services/audit.test.ts create mode 100644 src/lib/services/staff-activity.test.ts diff --git a/src/lib/services/abuse-guard.test.ts b/src/lib/services/abuse-guard.test.ts new file mode 100644 index 00000000..aa1fafe5 --- /dev/null +++ b/src/lib/services/abuse-guard.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const findMany = vi.hoisted(() => vi.fn()); +const create = vi.hoisted(() => vi.fn()); +const getBool = vi.hoisted(() => vi.fn()); +const get = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/prisma", () => ({ + prisma: { websiteIpBlacklist: { findMany, create } }, +})); + +vi.mock("@/lib/services/alert", () => ({ + ddosDetected: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/services/site-settings", () => ({ + siteSettings: { getBool, get }, +})); + +vi.mock("@/env", () => ({ env: {} })); + +import { isIpBlacklisted, recordRequest } from "./abuse-guard"; + +describe("isIpBlacklisted", () => { + it("returns false for private IPs without DB call", async () => { + expect(await isIpBlacklisted("127.0.0.1")).toBe(false); + expect(await isIpBlacklisted("192.168.1.1")).toBe(false); + expect(await isIpBlacklisted("10.0.0.1")).toBe(false); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("loads, caches, and correctly checks multiple IPs", async () => { + findMany.mockResolvedValue([{ ipAddress: "1.2.3.4" }]); + + expect(await isIpBlacklisted("1.2.3.4")).toBe(true); + expect(await isIpBlacklisted("1.2.3.4")).toBe(true); + expect(await isIpBlacklisted("5.6.7.8")).toBe(false); + expect(findMany).toHaveBeenCalledTimes(1); + + findMany.mockResolvedValue([]); + expect(await isIpBlacklisted("1.2.3.4")).toBe(true); + }); + + it("handles DB error gracefully (uses stale cache)", async () => { + findMany.mockRejectedValue(new Error("DB error")); + expect(await isIpBlacklisted("1.2.3.4")).toBe(true); + expect(await isIpBlacklisted("9.9.9.9")).toBe(false); + }); +}); + +describe("recordRequest", () => { + beforeEach(() => { + getBool.mockReset(); + get.mockReset(); + create.mockReset(); + }); + + it("ignores private IPs", async () => { + await recordRequest("::1"); + expect(getBool).not.toHaveBeenCalled(); + }); + + it("respects abuse_guard_enabled setting", async () => { + getBool.mockResolvedValue(false); + await recordRequest("1.2.3.4"); + expect(get).not.toHaveBeenCalled(); + }); + + it("tracks request counts and blocks exceeding threshold", async () => { + getBool.mockResolvedValue(true); + get.mockImplementation(async (_key: string, fallback: string) => { + if (_key === "abuse_guard_threshold") return "3"; + return fallback; + }); + create.mockResolvedValue({}); + + await recordRequest("1.2.3.4"); + await recordRequest("1.2.3.4"); + expect(create).not.toHaveBeenCalled(); + + await recordRequest("1.2.3.4"); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ ipAddress: "1.2.3.4" }), + }), + ); + }); +}); diff --git a/src/lib/services/audit.test.ts b/src/lib/services/audit.test.ts new file mode 100644 index 00000000..16893873 --- /dev/null +++ b/src/lib/services/audit.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const create = vi.hoisted(() => vi.fn()); +const findMany = vi.hoisted(() => vi.fn()); +const count = vi.hoisted(() => vi.fn()); +const userFindMany = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + adminAuditLog: { create, findMany, count }, + user: { findMany: userFindMany }, + }, +})); + +vi.mock("@/env", () => ({ env: {} })); + +import { logAudit, getAuditLogs } from "./audit"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("logAudit", () => { + it("creates an audit entry", async () => { + create.mockResolvedValue({ id: 1 }); + await logAudit({ + userId: 1, + action: "test_action", + target: "user", + targetId: 42, + }); + const data = create.mock.calls[0][0].data; + expect(data.userId).toBe(1); + expect(data.action).toBe("test_action"); + expect(data.target).toBe("user"); + expect(data.targetId).toBe(42); + }); + + it("redacts sensitive keys in payload", async () => { + create.mockResolvedValue({ id: 1 }); + await logAudit({ + userId: 1, + action: "update", + target: "user", + targetId: 1, + before: { username: "foo", password: "secret123" }, + after: { username: "bar", password: "newsecret" }, + }); + const data = create.mock.calls[0][0].data; + expect(JSON.parse(data.before).password).toBe("[Redacted]"); + expect(JSON.parse(data.after).password).toBe("[Redacted]"); + expect(JSON.parse(data.diff).username).toEqual({ from: "foo", to: "bar" }); + }); + + it("omits diff when only before or after is missing", async () => { + create.mockResolvedValue({ id: 1 }); + await logAudit({ + userId: 1, + action: "delete", + target: "user", + before: { username: "foo" }, + }); + const data = create.mock.calls[0][0].data; + expect(data.diff).toBeNull(); + }); + + it("handles empty payloads", async () => { + create.mockResolvedValue({ id: 1 }); + await logAudit({ userId: 1, action: "view", target: "page" }); + expect(create).toHaveBeenCalledOnce(); + }); + + it("flattens nested objects", async () => { + create.mockResolvedValue({ id: 1 }); + await logAudit({ + userId: 1, + action: "update_settings", + target: "user", + before: { nested: { key: "val" } }, + after: {}, + }); + const data = create.mock.calls[0][0].data; + expect(JSON.parse(data.before)).toEqual({ nested: { key: "val" } }); + }); +}); + +describe("getAuditLogs", () => { + it("returns paginated logs with usernames", async () => { + findMany.mockResolvedValue([ + { id: 1, userId: 1, action: "test", target: "user", targetId: null, before: null, after: null, diff: null, createdAt: "2024-01-01" }, + { id: 2, userId: 2, action: "test2", target: "room", targetId: null, before: null, after: null, diff: null, createdAt: "2024-01-02" }, + ]); + count.mockResolvedValue(2); + userFindMany.mockResolvedValue([ + { id: 1, username: "alice" }, + { id: 2, username: "bob" }, + ]); + + const result = await getAuditLogs({ page: 1, perPage: 20 }); + expect(result.rows).toHaveLength(2); + expect(result.rows[0].username).toBe("alice"); + expect(result.rows[1].username).toBe("bob"); + expect(result.total).toBe(2); + expect(result.lastPage).toBe(1); + }); + + it("filters by search term", async () => { + findMany.mockResolvedValue([]); + count.mockResolvedValue(0); + await getAuditLogs({ search: "test" }); + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { OR: [{ action: { contains: "test" } }, { target: { contains: "test" } }] }, + }), + ); + }); + + it("falls back to User #id for unknown users", async () => { + findMany.mockResolvedValue([ + { id: 1, userId: 99, action: "x", target: "y", targetId: null, before: null, after: null, diff: null, createdAt: "2024-01-01" }, + ]); + count.mockResolvedValue(1); + userFindMany.mockResolvedValue([]); + const result = await getAuditLogs(); + expect(result.rows[0].username).toBe("User #99"); + }); +}); diff --git a/src/lib/services/staff-activity.test.ts b/src/lib/services/staff-activity.test.ts new file mode 100644 index 00000000..878b25c6 --- /dev/null +++ b/src/lib/services/staff-activity.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const create = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/prisma", () => ({ + prisma: { staffActivities: { create } }, +})); + +vi.mock("@/env", () => ({ env: {} })); + +vi.mock("next/headers", () => ({ + headers: () => + new Promise((resolve) => + resolve({ + get: (key: string) => + key === "x-real-client-ip" ? "192.168.1.1" : null, + }), + ), +})); + +import { logStaffActivity } from "./staff-activity"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("logStaffActivity", () => { + it("creates a staff activity entry with ip", async () => { + create.mockResolvedValue({ id: 1 }); + await logStaffActivity({ + staffId: 1, + action: "test_action", + description: "Test action performed", + }); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: BigInt(1), + action: "test_action", + description: "Test action performed", + ipAddress: "192.168.1.1", + }), + }), + ); + }); + + it("truncates action to 50 chars", async () => { + create.mockResolvedValue({ id: 1 }); + const longAction = "a".repeat(100); + await logStaffActivity({ + staffId: 1, + action: longAction, + description: "test", + }); + const call = create.mock.calls[0][0]; + expect(call.data.action.length).toBe(50); + expect(call.data.action).toBe("a".repeat(50)); + }); + + it("includes target type and target id when provided", async () => { + create.mockResolvedValue({ id: 1 }); + await logStaffActivity({ + staffId: 1, + action: "ban", + description: "Banned user", + targetType: "user", + targetId: 42, + }); + const data = create.mock.calls[0][0].data; + expect(data.targetType).toBe("user"); + expect(data.targetId).toBe(BigInt(42)); + }); + + it("does not throw on prisma error", async () => { + create.mockRejectedValue(new Error("DB down")); + await expect( + logStaffActivity({ + staffId: 1, + action: "test", + description: "test", + }), + ).resolves.toBeUndefined(); + }); +});