diff --git a/src/actions/admin-ads.test.ts b/src/actions/admin-ads.test.ts new file mode 100644 index 00000000..81e605ec --- /dev/null +++ b/src/actions/admin-ads.test.ts @@ -0,0 +1,83 @@ +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requirePermission } from "@/lib/admin/guard"; +import { logger } from "@/lib/logger"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; +import { ActionError } from "@/lib/safe-action-shared"; +import { logStaffActivity } from "@/lib/services/staff-activity"; +import { createAd, deleteAd, deleteAdForm, updateAd } from "./admin-ads"; + +vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); +vi.mock("@/lib/prisma", () => ({ + prisma: { websiteAds: { create: vi.fn(), update: vi.fn(), delete: vi.fn() } }, +})); +vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } })); +vi.mock("@/lib/safe-action", () => ({ + adminAction: vi.fn((_opts: unknown, fn: Function) => fn), +})); +vi.mock("@/lib/safe-action-shared", () => ({ + ActionError: class extends Error {}, + actionOk: vi.fn(() => "ok"), +})); +vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("next/navigation", () => ({ redirect: vi.fn() })); + +const staff = { id: 1, rank: 7, username: "admin" }; +const fakeForm = (data: Record) => ({ + get: (key: string) => data[key] ?? null, +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermission).mockResolvedValue(staff as never); +}); + +describe("createAd", () => { + it("creates an ad and redirects", async () => { + vi.mocked(prisma.websiteAds.create).mockResolvedValue({ + id: BigInt(1), + } as never); + await createAd( + fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData, + ); + expect(prisma.websiteAds.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ image: "https://example.com/ad.png" }), + }); + expect(logStaffActivity).toHaveBeenCalled(); + expect(redirect).toHaveBeenCalledWith("/admin/ads"); + }); + + it("returns early when image is empty", async () => { + await createAd(fakeForm({ image: "" }) as unknown as FormData); + expect(prisma.websiteAds.create).not.toHaveBeenCalled(); + }); + + it("logs error and revalidates on db failure", async () => { + vi.mocked(prisma.websiteAds.create).mockRejectedValue(new Error("DB down")); + await createAd(fakeForm({ image: "x" }) as unknown as FormData); + expect(logger.error).toHaveBeenCalled(); + expect(revalidatePath).toHaveBeenCalledWith("/admin/ads"); + }); +}); + +describe("deleteAd", () => { + it("deletes an ad via adminAction", async () => { + const handler = deleteAd as unknown as (ctx: { + data: { id: bigint }; + session: { user: { id: string } }; + }) => Promise; + vi.mocked(prisma.websiteAds.delete).mockResolvedValue({} as never); + const result = await handler({ + data: { id: BigInt(99) }, + session: { user: { id: "1" } }, + }); + expect(prisma.websiteAds.delete).toHaveBeenCalledWith({ + where: { id: BigInt(99) }, + }); + expect(result).toBe("ok"); + }); +}); diff --git a/src/actions/admin-alerts.test.ts b/src/actions/admin-alerts.test.ts new file mode 100644 index 00000000..2a5b40ee --- /dev/null +++ b/src/actions/admin-alerts.test.ts @@ -0,0 +1,41 @@ +import { revalidatePath } from "next/cache"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; +import { rcon } from "@/lib/services/rcon"; +import { sendHotelAlert } from "./admin-alerts"; + +vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ + PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" }, +})); +vi.mock("@/lib/services/rcon", () => ({ rcon: { send: vi.fn() } })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +const fakeForm = (data: Record) => ({ + get: (key: string) => data[key] ?? null, +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermission).mockResolvedValue({ + id: 1, + rank: 7, + username: "admin", + } as never); +}); + +describe("sendHotelAlert", () => { + it("sends hotel alert and revalidates", async () => { + await sendHotelAlert( + fakeForm({ message: "Hello!" }) as unknown as FormData, + ); + expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" }); + expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts"); + }); + + it("returns early when message is empty", async () => { + await sendHotelAlert(fakeForm({ message: "" }) as unknown as FormData); + expect(rcon.send).not.toHaveBeenCalled(); + }); +}); diff --git a/src/actions/admin-bans.test.ts b/src/actions/admin-bans.test.ts new file mode 100644 index 00000000..efda66de --- /dev/null +++ b/src/actions/admin-bans.test.ts @@ -0,0 +1,70 @@ +import { revalidatePath } from "next/cache"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requirePermissionRateLimited } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; +import { rcon } from "@/lib/services/rcon"; +import { logStaffActivity } from "@/lib/services/staff-activity"; +import { createBan, liftBan } from "./admin-bans"; + +vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } })); +vi.mock("@/lib/prisma", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + ban: { create: vi.fn(), delete: vi.fn() }, + }, +})); +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, +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never); +}); + +describe("createBan", () => { + it("creates a ban for valid inputs", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + username: "baduser", + } as never); + await createBan( + fakeForm({ + userId: "42", + reason: "Spam", + hours: "24", + type: "account", + }) as unknown as FormData, + ); + expect(prisma.ban.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ userId: 42, type: "account" }), + }); + expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser"); + expect(revalidatePath).toHaveBeenCalledWith("/admin/bans"); + }); + + it("returns early when userId is invalid", async () => { + await createBan( + fakeForm({ + userId: "0", + hours: "1", + type: "account", + }) as unknown as FormData, + ); + expect(prisma.ban.create).not.toHaveBeenCalled(); + }); +}); + +describe("liftBan", () => { + it("deletes ban and revalidates", async () => { + await liftBan(fakeForm({ id: "42" }) as unknown as FormData); + expect(prisma.ban.delete).toHaveBeenCalledWith({ where: { id: 42 } }); + expect(revalidatePath).toHaveBeenCalledWith("/admin/bans"); + }); +}); diff --git a/src/actions/admin-guilds.test.ts b/src/actions/admin-guilds.test.ts new file mode 100644 index 00000000..049a393b --- /dev/null +++ b/src/actions/admin-guilds.test.ts @@ -0,0 +1,70 @@ +import { revalidatePath } from "next/cache"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requirePermissionRateLimited } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; +import { logStaffActivity } from "@/lib/services/staff-activity"; +import { disbandGuild } from "./admin-guilds"; + +vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } })); +vi.mock("@/lib/prisma", () => ({ + prisma: { + guilds: { findUnique: vi.fn(), delete: vi.fn() }, + guildsForumsThreads: { findMany: vi.fn(), deleteMany: vi.fn() }, + guildsForumsComments: { deleteMany: vi.fn() }, + guildForumViews: { deleteMany: vi.fn() }, + guildsMembers: { deleteMany: vi.fn() }, + rooms: { updateMany: vi.fn() }, + items: { updateMany: vi.fn() }, + $transaction: 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, +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never); +}); + +describe("disbandGuild", () => { + it("disbands guild and cleans related data", async () => { + vi.mocked(prisma.guilds.findUnique).mockResolvedValue({ + id: 1, + name: "TestGuild", + userId: 42, + } as never); + vi.mocked(prisma.guildsForumsThreads.findMany).mockResolvedValue([ + { id: 10 }, + ] as never); + vi.mocked(prisma.$transaction).mockImplementation(async (fn: unknown) => { + const tx = { + guildsForumsComments: { deleteMany: vi.fn().mockResolvedValue({}) }, + guildsForumsThreads: { + findMany: vi.fn().mockResolvedValue([{ id: 10 }]), + deleteMany: vi.fn().mockResolvedValue({}), + }, + guildForumViews: { deleteMany: vi.fn().mockResolvedValue({}) }, + guildsMembers: { deleteMany: vi.fn().mockResolvedValue({}) }, + rooms: { updateMany: vi.fn().mockResolvedValue({}) }, + items: { updateMany: vi.fn().mockResolvedValue({}) }, + guilds: { delete: vi.fn().mockResolvedValue({}) }, + } as never; + await (fn as (tx: never) => Promise)(tx); + }); + await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData); + expect(logStaffActivity).toHaveBeenCalled(); + expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds"); + }); + + it("returns early when id is not positive", async () => { + await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData); + expect(prisma.guilds.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/actions/admin-help.test.ts b/src/actions/admin-help.test.ts new file mode 100644 index 00000000..c174ef6d --- /dev/null +++ b/src/actions/admin-help.test.ts @@ -0,0 +1,81 @@ +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; +import { logStaffActivity } from "@/lib/services/staff-activity"; +import { + createHelpQuestion, + deleteHelpQuestion, + updateHelpQuestion, +} from "./admin-help"; + +vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); +vi.mock("@/lib/prisma", () => ({ + prisma: { + websiteHelpCenterCategories: { + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }, +})); +vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("next/navigation", () => ({ redirect: vi.fn() })); + +const staff = { id: 1, rank: 7, username: "admin" }; +const fakeForm = (data: Record) => ({ + get: (key: string) => (key in data ? data[key] : null), +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermission).mockResolvedValue(staff as never); +}); + +describe("createHelpQuestion", () => { + it("creates a help question and redirects", async () => { + vi.mocked(prisma.websiteHelpCenterCategories.create).mockResolvedValue({ + id: BigInt(5), + } as never); + await createHelpQuestion( + fakeForm({ + name: "FAQ", + content: "

Answer

", + }) as unknown as FormData, + ); + expect(prisma.websiteHelpCenterCategories.create).toHaveBeenCalled(); + expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); + }); +}); + +describe("updateHelpQuestion", () => { + it("updates and redirects", async () => { + vi.mocked(prisma.websiteHelpCenterCategories.update).mockResolvedValue( + {} as never, + ); + await updateHelpQuestion( + fakeForm({ + id: "42", + name: "Updated", + content: "New", + }) as unknown as FormData, + ); + expect(prisma.websiteHelpCenterCategories.update).toHaveBeenCalled(); + expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); + }); +}); + +describe("deleteHelpQuestion", () => { + it("deletes and redirects", async () => { + vi.mocked(prisma.websiteHelpCenterCategories.delete).mockResolvedValue( + {} as never, + ); + await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData); + expect(prisma.websiteHelpCenterCategories.delete).toHaveBeenCalled(); + expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); + }); +}); diff --git a/src/actions/admin-ip.test.ts b/src/actions/admin-ip.test.ts new file mode 100644 index 00000000..c25bc5cd --- /dev/null +++ b/src/actions/admin-ip.test.ts @@ -0,0 +1,79 @@ +import { revalidatePath } from "next/cache"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; +import { + addBlacklist, + addWhitelist, + deleteBlacklist, + deleteWhitelist, +} from "./admin-ip"; + +vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ + PERMS: { SETTINGS_EDIT: "settings.edit" }, +})); +vi.mock("@/lib/prisma", () => ({ + prisma: { + websiteIpWhitelist: { create: vi.fn(), delete: vi.fn() }, + websiteIpBlacklist: { create: vi.fn(), delete: 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, +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermission).mockResolvedValue(staff as never); +}); + +describe("addWhitelist", () => { + it("creates whitelist entry", async () => { + await addWhitelist( + fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData, + ); + expect(prisma.websiteIpWhitelist.create).toHaveBeenCalledWith({ + data: { ipAddress: "192.168.1.1", asn: null, whitelistAsn: false }, + }); + expect(revalidatePath).toHaveBeenCalledWith("/admin/ip"); + }); + + it("returns early when ip is empty", async () => { + await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData); + expect(prisma.websiteIpWhitelist.create).not.toHaveBeenCalled(); + }); +}); + +describe("deleteWhitelist", () => { + it("deletes whitelist entry", async () => { + await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData); + expect(prisma.websiteIpWhitelist.delete).toHaveBeenCalledWith({ + where: { id: BigInt(42) }, + }); + }); +}); + +describe("addBlacklist", () => { + it("creates blacklist entry", async () => { + await addBlacklist( + fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData, + ); + expect(prisma.websiteIpBlacklist.create).toHaveBeenCalledWith({ + data: { ipAddress: "203.0.113.1", asn: null, blacklistAsn: false }, + }); + }); +}); + +describe("deleteBlacklist", () => { + it("deletes blacklist entry", async () => { + await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData); + expect(prisma.websiteIpBlacklist.delete).toHaveBeenCalledWith({ + where: { id: BigInt(99) }, + }); + }); +}); diff --git a/src/actions/admin-media.test.ts b/src/actions/admin-media.test.ts new file mode 100644 index 00000000..a83a77b1 --- /dev/null +++ b/src/actions/admin-media.test.ts @@ -0,0 +1,55 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { revalidatePath } from "next/cache"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requirePermission } from "@/lib/admin/guard"; +import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage"; +import { PERMS } from "@/lib/permissions"; +import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media"; + +vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); +vi.mock("@/lib/media-storage", () => ({ + MEDIA_ROOT: "/tmp/nexst-test-media", + resolveMediaPath: vi.fn((name: string) => `/tmp/nexst-test-media/${name}`), +})); +vi.mock("node:fs/promises", () => ({ + mkdir: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +const staff = { id: 1, rank: 7, username: "admin" }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermission).mockResolvedValue(staff as never); +}); + +describe("uploadMedia", () => { + it("returns error when no file provided", async () => { + const result = await uploadMedia(new FormData()); + expect(result.ok).toBe(false); + expect(result.error).toBe("No file provided"); + }); +}); + +describe("uploadMediaAndReturn", () => { + it("returns empty string when no file", async () => { + expect(await uploadMediaAndReturn(new FormData())).toBe(""); + }); +}); + +describe("deleteMedia", () => { + it("deletes media file and revalidates", async () => { + await deleteMedia("photo.png"); + expect(revalidatePath).toHaveBeenCalledWith("/api/media"); + }); + + it("skips deletion when path is outside media root", async () => { + vi.mocked(resolveMediaPath).mockReturnValue("/etc/passwd"); + await deleteMedia("../../../etc/passwd"); + const { unlink } = await import("node:fs/promises"); + expect(unlink).not.toHaveBeenCalled(); + }); +}); diff --git a/src/actions/admin-photos.test.ts b/src/actions/admin-photos.test.ts new file mode 100644 index 00000000..a6af859e --- /dev/null +++ b/src/actions/admin-photos.test.ts @@ -0,0 +1,37 @@ +import { revalidatePath } from "next/cache"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; +import { deletePhoto } from "./admin-photos"; + +vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); +vi.mock("@/lib/prisma", () => ({ prisma: { cameraWeb: { delete: vi.fn() } } })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +const fakeForm = (data: Record) => ({ + get: (key: string) => data[key] ?? null, +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermission).mockResolvedValue({ + id: 1, + rank: 7, + username: "admin", + } as never); +}); + +describe("deletePhoto", () => { + it("deletes a photo and revalidates", async () => { + await deletePhoto(fakeForm({ id: "42" }) as unknown as FormData); + expect(prisma.cameraWeb.delete).toHaveBeenCalledWith({ where: { id: 42 } }); + expect(revalidatePath).toHaveBeenCalledWith("/admin/photos"); + }); + + it("returns early when id is not positive", async () => { + await deletePhoto(fakeForm({ id: "0" }) as unknown as FormData); + expect(prisma.cameraWeb.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/actions/admin-tags.test.ts b/src/actions/admin-tags.test.ts new file mode 100644 index 00000000..85c5d696 --- /dev/null +++ b/src/actions/admin-tags.test.ts @@ -0,0 +1,140 @@ +import { revalidatePath } from "next/cache"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; +import { logStaffActivity } from "@/lib/services/staff-activity"; +import { createTag, deleteTag, updateTag } from "./admin-tags"; + +vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); +vi.mock("@/lib/prisma", () => ({ + prisma: { + tags: { create: vi.fn(), update: vi.fn(), delete: vi.fn() }, + taggables: { deleteMany: vi.fn() }, + $transaction: 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, +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermission).mockResolvedValue(staff as never); +}); + +describe("createTag", () => { + it("creates a tag and revalidates", async () => { + vi.mocked(prisma.tags.create).mockResolvedValue({ id: BigInt(1) } as never); + + await createTag( + fakeForm({ + name: "News", + backgroundColor: "#ff0000", + }) as unknown as FormData, + ); + + expect(prisma.tags.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ name: "News" }), + }), + ); + expect(logStaffActivity).toHaveBeenCalled(); + expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); + }); + + it("returns early when name is empty", async () => { + await createTag(fakeForm({ name: "" }) as unknown as FormData); + expect(prisma.tags.create).not.toHaveBeenCalled(); + }); + + it("uses default color when not provided", async () => { + vi.mocked(prisma.tags.create).mockResolvedValue({ id: BigInt(1) } as never); + + await createTag(fakeForm({ name: "Test" }) as unknown as FormData); + + expect(prisma.tags.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ backgroundColor: "#888888" }), + }), + ); + }); + + it("handles db error gracefully", async () => { + vi.mocked(prisma.tags.create).mockRejectedValue(new Error("DB error")); + + await expect( + createTag(fakeForm({ name: "News" }) as unknown as FormData), + ).resolves.toBeUndefined(); + expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); + }); +}); + +describe("updateTag", () => { + it("updates a tag and revalidates", async () => { + vi.mocked(prisma.tags.update).mockResolvedValue({} as never); + + await updateTag( + fakeForm({ + id: "42", + name: "Updated", + backgroundColor: "#00ff00", + }) as unknown as FormData, + ); + + expect(prisma.tags.update).toHaveBeenCalledWith({ + where: { id: BigInt(42) }, + data: expect.objectContaining({ name: "Updated" }), + }); + expect(logStaffActivity).toHaveBeenCalled(); + expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); + }); + + it("returns early when id is invalid", async () => { + await updateTag(fakeForm({ id: "", name: "Test" }) as unknown as FormData); + expect(prisma.tags.update).not.toHaveBeenCalled(); + }); + + it("returns early when name is empty after update", async () => { + await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData); + expect(prisma.tags.update).not.toHaveBeenCalled(); + }); +}); + +describe("deleteTag", () => { + it("deletes a tag and its taggables", async () => { + vi.mocked(prisma.taggables.deleteMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.tags.delete).mockResolvedValue({} as never); + vi.mocked(prisma.$transaction).mockImplementation(async (ops: unknown) => { + const arr = ops as [ + typeof prisma.taggables.deleteMany, + typeof prisma.tags.delete, + ]; + await arr[0]; + await arr[1]; + }); + + await deleteTag(fakeForm({ id: "42" }) as unknown as FormData); + + expect(prisma.taggables.deleteMany).toHaveBeenCalledWith({ + where: { tagId: BigInt(42) }, + }); + expect(prisma.tags.delete).toHaveBeenCalledWith({ + where: { id: BigInt(42) }, + }); + expect(logStaffActivity).toHaveBeenCalled(); + expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); + }); + + it("returns early when id is invalid", async () => { + await deleteTag(fakeForm({ id: "" }) as unknown as FormData); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/actions/admin-teams.test.ts b/src/actions/admin-teams.test.ts new file mode 100644 index 00000000..09294f11 --- /dev/null +++ b/src/actions/admin-teams.test.ts @@ -0,0 +1,51 @@ +import { revalidatePath } from "next/cache"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; +import { createTeam, deleteTeam } from "./admin-teams"; + +vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } })); +vi.mock("@/lib/prisma", () => ({ + prisma: { websiteTeams: { create: vi.fn(), delete: vi.fn() } }, +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +const staff = { id: 1, rank: 7, username: "admin" }; +const fakeForm = (data: Record) => ({ + get: (key: string) => (key in data ? data[key] : null), +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermission).mockResolvedValue(staff as never); +}); + +describe("createTeam", () => { + it("creates a team entry", async () => { + await createTeam( + fakeForm({ rankName: "Moderator" }) as unknown as FormData, + ); + expect(prisma.websiteTeams.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ rankName: "Moderator" }), + }); + expect(revalidatePath).toHaveBeenCalledWith("/admin/teams"); + }); + + it("returns early when rankName is empty", async () => { + await createTeam(fakeForm({ rankName: "" }) as unknown as FormData); + expect(prisma.websiteTeams.create).not.toHaveBeenCalled(); + }); +}); + +describe("deleteTeam", () => { + it("deletes a team entry", async () => { + vi.mocked(prisma.websiteTeams.delete).mockResolvedValue({} as never); + await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData); + expect(prisma.websiteTeams.delete).toHaveBeenCalledWith({ + where: { id: BigInt(42) }, + }); + expect(revalidatePath).toHaveBeenCalledWith("/admin/teams"); + }); +}); diff --git a/src/actions/admin-vpn.test.ts b/src/actions/admin-vpn.test.ts new file mode 100644 index 00000000..6c52841d --- /dev/null +++ b/src/actions/admin-vpn.test.ts @@ -0,0 +1,49 @@ +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; +import { siteSettings } from "@/lib/services/site-settings"; +import { logStaffActivity } from "@/lib/services/staff-activity"; +import { saveVpn } from "./admin-vpn"; + +vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ + PERMS: { SETTINGS_EDIT: "settings.edit" }, +})); +vi.mock("@/lib/prisma", () => ({ + prisma: { websiteSetting: { upsert: vi.fn() } }, +})); +vi.mock("@/lib/services/site-settings", () => ({ + siteSettings: { reload: vi.fn() }, +})); +vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("next/navigation", () => ({ redirect: vi.fn() })); + +const staff = { id: 1, rank: 7, username: "admin" }; +const fakeForm = (data: Record) => ({ + get: (key: string) => (key in data ? data[key] : null), +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermission).mockResolvedValue(staff as never); + vi.mocked(prisma.websiteSetting.upsert).mockResolvedValue({} as never); +}); + +describe("saveVpn", () => { + it("saves VPN settings and redirects", async () => { + await saveVpn( + fakeForm({ + vpn_block_enabled: "1", + vpn_provider: "proxycheck", + vpn_api_key: "abc123", + }) as unknown as FormData, + ); + expect(prisma.websiteSetting.upsert).toHaveBeenCalledTimes(4); + expect(siteSettings.reload).toHaveBeenCalled(); + expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1"); + }); +}); diff --git a/src/actions/applications.test.ts b/src/actions/applications.test.ts new file mode 100644 index 00000000..7dd990ad --- /dev/null +++ b/src/actions/applications.test.ts @@ -0,0 +1,100 @@ +import { redirect } from "next/navigation"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { applyStaff, applyTeam } from "./applications"; + +vi.mock("@/lib/auth", () => ({ auth: vi.fn() })); +vi.mock("@/lib/prisma", () => ({ + prisma: { websiteStaffApplications: { findFirst: vi.fn(), create: vi.fn() } }, +})); +vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("next/navigation", () => ({ redirect: vi.fn() })); + +const fakeForm = (data: Record) => ({ + get: (key: string) => data[key] ?? null, +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(auth).mockResolvedValue({ user: { id: "42" } } as never); + vi.mocked(clientIp).mockResolvedValue("127.0.0.1"); + vi.mocked(rateLimit).mockResolvedValue({ ok: true }); +}); + +describe("applyStaff", () => { + it("submits staff application", async () => { + vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue( + null, + ); + vi.mocked(prisma.websiteStaffApplications.create).mockResolvedValue( + {} as never, + ); + await applyStaff( + fakeForm({ + rankId: "3", + content: "I want to help!", + }) as unknown as FormData, + ); + expect(prisma.websiteStaffApplications.create).toHaveBeenCalled(); + expect(redirect).toHaveBeenCalledWith("/apply/staff?submitted=1"); + }); + + it("redirects to login when not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + await applyStaff(fakeForm({}) as unknown as FormData); + expect(redirect).toHaveBeenCalledWith("/login"); + }); + + it("returns duplicate status when application exists", async () => { + vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue({ + id: 1, + } as never); + await applyStaff( + fakeForm({ + rankId: "3", + content: "I want to help!", + }) as unknown as FormData, + ); + expect(prisma.websiteStaffApplications.create).not.toHaveBeenCalled(); + expect(redirect).toHaveBeenCalledWith("/apply/staff?error=duplicate"); + }); + + it("handles rate limit", async () => { + vi.mocked(rateLimit).mockResolvedValue({ ok: false }); + await applyStaff( + fakeForm({ + rankId: "3", + content: "I want to help!", + }) as unknown as FormData, + ); + expect(redirect).toHaveBeenCalledWith("/apply/staff?error=ratelimit"); + }); +}); + +describe("applyTeam", () => { + it("submits team application", async () => { + vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue( + null, + ); + vi.mocked(prisma.websiteStaffApplications.create).mockResolvedValue( + {} as never, + ); + await applyTeam( + fakeForm({ + teamId: "2", + content: "I want to join team!", + }) as unknown as FormData, + ); + expect(prisma.websiteStaffApplications.create).toHaveBeenCalled(); + expect(redirect).toHaveBeenCalledWith("/apply/team?submitted=1"); + }); + + it("redirects to login when not authenticated", async () => { + vi.mocked(auth).mockResolvedValue(null); + await applyTeam(fakeForm({}) as unknown as FormData); + expect(redirect).toHaveBeenCalledWith("/login"); + }); +}); diff --git a/src/actions/auth-precheck.test.ts b/src/actions/auth-precheck.test.ts new file mode 100644 index 00000000..b55c90b7 --- /dev/null +++ b/src/actions/auth-precheck.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { env } from "@/env"; +import { checkLogin } from "@/lib/auth/password"; +import { prisma } from "@/lib/prisma"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; +import { siteSettings } from "@/lib/services/site-settings"; +import { precheckLogin } from "./auth-precheck"; + +vi.mock("@/env", () => ({ env: { CONVERT_PASSWORDS: false } })); +vi.mock("@/lib/auth/password", () => ({ checkLogin: vi.fn() })); +vi.mock("@/lib/prisma", () => ({ prisma: { user: { findUnique: vi.fn() } } })); +vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() })); +vi.mock("@/lib/services/captcha", () => ({ + captchaConfig: vi.fn(), + verifyCaptcha: vi.fn(), +})); +vi.mock("@/lib/services/site-settings", () => ({ + siteSettings: { getBool: vi.fn() }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(clientIp).mockResolvedValue("1.2.3.4"); + vi.mocked(rateLimit).mockResolvedValue({ ok: true }); + vi.mocked(checkLogin).mockResolvedValue({ valid: true } as never); + vi.mocked(captchaConfig).mockResolvedValue({ provider: "none" } as never); +}); + +describe("precheckLogin", () => { + it("returns ok for valid login without 2FA", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + password: "hash", + twoFactorConfirmedAt: null, + mail: null, + mailVerified: "0", + } as never); + expect(await precheckLogin("user", "pass")).toBe("ok"); + }); + + it("returns twofactor when 2FA is set up", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + password: "hash", + twoFactorConfirmedAt: new Date(), + mail: null, + mailVerified: "0", + } as never); + expect(await precheckLogin("user", "pass")).toBe("twofactor"); + }); + + it("returns invalid for empty inputs", async () => { + expect(await precheckLogin("", "")).toBe("invalid"); + }); + + it("returns captcha when captcha required", async () => { + vi.mocked(captchaConfig).mockResolvedValue({ + provider: "hcaptcha", + } as never); + vi.mocked(verifyCaptcha).mockResolvedValue(false); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + password: "hash", + twoFactorConfirmedAt: null, + mail: null, + mailVerified: "0", + } as never); + expect(await precheckLogin("user", "pass", "bad-token")).toBe("captcha"); + }); + + it("returns invalid when user not found (dummy hash check)", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + const result = await precheckLogin("nonexistent", "pass"); + expect(result).toBe("invalid"); + expect(checkLogin).toHaveBeenCalled(); + }); + + it("returns unverified when email verification required", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + password: "hash", + twoFactorConfirmedAt: null, + mail: "user@example.com", + mailVerified: "0", + } as never); + vi.mocked(siteSettings.getBool).mockResolvedValue(true); + expect(await precheckLogin("user", "pass")).toBe("unverified"); + }); +}); diff --git a/src/actions/bulk-users.test.ts b/src/actions/bulk-users.test.ts new file mode 100644 index 00000000..a1705010 --- /dev/null +++ b/src/actions/bulk-users.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; +import { rcon } from "@/lib/services/rcon"; +import { logStaffActivity } from "@/lib/services/staff-activity"; +import { + bulkBan, + bulkGiveBadge, + bulkGiveCurrency, + bulkUnban, +} from "./bulk-users"; + +vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } })); +vi.mock("@/lib/prisma", () => ({ + prisma: { + ban: { deleteMany: vi.fn(), create: vi.fn() }, + user: { update: vi.fn() }, + usersCurrency: { upsert: vi.fn() }, + usersBadges: { findFirst: vi.fn(), aggregate: vi.fn(), create: vi.fn() }, + }, +})); +vi.mock("@/lib/services/rcon", () => ({ + rcon: { + giveCredits: vi.fn(), + giveDuckets: vi.fn(), + givePointsGotw: vi.fn(), + giveBadge: vi.fn(), + }, +})); +vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() })); + +const staff = { id: 1, rank: 7, username: "admin" }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermission).mockResolvedValue(staff as never); +}); + +describe("bulkUnban", () => { + it("unbans users", async () => { + vi.mocked(prisma.ban.deleteMany).mockResolvedValue({ count: 3 } as never); + const r = await bulkUnban({ userIds: [1, 2, 3] }); + expect(r.ok).toBe(true); + expect(r.data).toEqual({ unbanned: 3, total: 3 }); + }); +}); + +describe("bulkBan", () => { + it("bans users", async () => { + vi.mocked(prisma.ban.create).mockResolvedValue({} as never); + const r = await bulkBan({ + userIds: [1, 2], + reason: "Spam", + duration: 3600, + }); + expect(r.ok).toBe(true); + expect(r.data.banned).toBe(2); + }); +}); + +describe("bulkGiveCurrency", () => { + it("gives credits", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + const r = await bulkGiveCurrency({ + userIds: [1], + amount: 100, + type: "credits", + }); + expect(r.data.given).toBe(1); + expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100); + }); + + it("gives pixels", async () => { + vi.mocked(prisma.usersCurrency.upsert).mockResolvedValue({} as never); + const r = await bulkGiveCurrency({ + userIds: [2], + amount: 50, + type: "pixels", + }); + expect(r.data.given).toBe(1); + expect(rcon.giveDuckets).toHaveBeenCalledWith(2, 50); + }); + + it("gives points", async () => { + vi.mocked(prisma.usersCurrency.upsert).mockResolvedValue({} as never); + const r = await bulkGiveCurrency({ + userIds: [3], + amount: 25, + type: "points", + }); + expect(r.data.given).toBe(1); + expect(rcon.givePointsGotw).toHaveBeenCalledWith(3, 25); + }); +}); + +describe("bulkGiveBadge", () => { + it("gives badge to user", async () => { + vi.mocked(prisma.usersBadges.findFirst).mockResolvedValue(null); + vi.mocked(prisma.usersBadges.aggregate).mockResolvedValue({ + _max: { slotId: 5 }, + } as never); + vi.mocked(prisma.usersBadges.create).mockResolvedValue({} as never); + const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" }); + expect(r.data.given).toBe(1); + }); +}); diff --git a/src/actions/emulator.test.ts b/src/actions/emulator.test.ts new file mode 100644 index 00000000..6a6161d9 --- /dev/null +++ b/src/actions/emulator.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; +import { adminAction } from "@/lib/safe-action"; +import { actionOk } from "@/lib/safe-action-shared"; +import { logAudit } from "@/lib/services/audit"; +import { rcon } from "@/lib/services/rcon"; + +vi.mock("@/lib/permissions", () => ({ + PERMS: { SETTINGS_EDIT: "settings.edit" }, +})); +vi.mock("@/lib/prisma", () => ({ + prisma: { emulatorSettings: { upsert: vi.fn() } }, +})); +vi.mock("@/lib/safe-action", () => ({ + adminAction: vi.fn((_opts: unknown, fn: Function) => fn), +})); +vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") })); +vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() })); +vi.mock("@/lib/services/rcon", () => ({ rcon: { updateConfig: vi.fn() } })); + +describe("saveEmulatorSettings", () => { + it("saves settings and calls rcon update", async () => { + vi.mocked(prisma.emulatorSettings.upsert).mockResolvedValue({} as never); + const handler = (await import("./emulator").then( + (m) => m.saveEmulatorSettings, + )) as unknown as (ctx: { + data: { settings: Record }; + session: { user: { id: string } }; + }) => Promise; + + const result = await handler({ + data: { settings: { key1: "val1", key2: "val2" } }, + session: { user: { id: "1" } }, + }); + + expect(prisma.emulatorSettings.upsert).toHaveBeenCalledTimes(2); + expect(rcon.updateConfig).toHaveBeenCalled(); + expect(result).toBe("ok"); + }); +}); diff --git a/src/lib/admin/guard.test.ts b/src/lib/admin/guard.test.ts new file mode 100644 index 00000000..e7e70346 --- /dev/null +++ b/src/lib/admin/guard.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { redirectSafe } from "@/lib/foundation/security"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { + requireMod, + requireModPermission, + requirePermission, + requirePermissionRateLimited, + requireStaff, + requireStaffRateLimited, +} from "./guard"; + +vi.mock("@/lib/permissions", () => ({ + getAdminContext: vi.fn(), + canAccess: vi.fn(), + PERMS: { + ADMIN_DASHBOARD: "admin.dashboard", + MOD_DASHBOARD: "mod.dashboard", + MODERATION_VIEW: "moderation.view", + MOD_CFH_VIEW: "mod.cfh.view", + MOD_ACTIONS: "mod.actions", + MOD_BANS_VIEW: "mod.bans.view", + MOD_TICKETS_VIEW: "mod.tickets.view", + MOD_TEAM_VIEW: "mod.team.view", + TICKETS_VIEW: "tickets.view", + }, +})); +vi.mock("@/lib/foundation/security", () => ({ redirectSafe: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() })); + +const session = { + session: { user: { id: 1, rank: 7, username: "admin" } }, + permissions: { "admin.dashboard": 7 }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAdminContext).mockResolvedValue(session as never); + vi.mocked(canAccess).mockImplementation( + (_p: unknown, _s: string, r: number) => r >= 7, + ); +}); + +describe("requireStaff", () => { + it("returns staff when authorized", async () => { + const s = await requireStaff(); + expect(s).toEqual({ id: 1, rank: 7, username: "admin" }); + }); + + it("redirects when unauthorized", async () => { + vi.mocked(getAdminContext).mockResolvedValue({ + session: { user: { id: 2, rank: 1, username: "user" } }, + permissions: {}, + } as never); + await requireStaff(); + expect(redirectSafe).toHaveBeenCalledWith("/", "/"); + }); +}); + +describe("requirePermission", () => { + it("returns staff when both dashboard and specific perm ok", async () => { + vi.mocked(canAccess).mockReturnValue(true); + expect(await requirePermission("user.edit")).toEqual({ + id: 1, + rank: 7, + username: "admin", + }); + }); +}); + +describe("requireMod", () => { + it("allows with MOD_DASHBOARD", async () => { + vi.mocked(getAdminContext).mockResolvedValue({ + session: { user: { id: 1, rank: 5, username: "mod" } }, + permissions: {}, + } as never); + vi.mocked(canAccess).mockImplementation( + (_p: unknown, slug: string) => slug === "mod.dashboard", + ); + expect(await requireMod()).toEqual({ id: 1, rank: 5, username: "mod" }); + }); + + it("redirects without mod perms", async () => { + vi.mocked(canAccess).mockReturnValue(false); + await requireMod(); + expect(redirectSafe).toHaveBeenCalledWith("/", "/"); + }); +}); + +describe("requireModPermission", () => { + it("allows with matching perm", async () => { + vi.mocked(canAccess).mockImplementation( + (_p: unknown, slug: string) => slug === "mod.tickets.view", + ); + await requireModPermission(["mod.bans.view", "mod.tickets.view"]); + expect(redirectSafe).not.toHaveBeenCalled(); + }); +}); + +describe("requirePermissionRateLimited", () => { + it("returns staff when rate limit ok", async () => { + vi.mocked(canAccess).mockReturnValue(true); + vi.mocked(clientIp).mockResolvedValue("1.2.3.4"); + vi.mocked(rateLimit).mockResolvedValue({ ok: true }); + expect(await requirePermissionRateLimited("x")).toEqual({ + id: 1, + rank: 7, + username: "admin", + }); + }); +}); + +describe("requireStaffRateLimited", () => { + it("redirects on rate limit", async () => { + vi.mocked(clientIp).mockResolvedValue("1.2.3.4"); + vi.mocked(rateLimit).mockResolvedValue({ ok: false }); + await requireStaffRateLimited(); + expect(redirectSafe).toHaveBeenCalledWith( + "/admin?error=ratelimit", + "/admin", + ); + }); +}); diff --git a/src/lib/admin/min-staff-rank.test.ts b/src/lib/admin/min-staff-rank.test.ts new file mode 100644 index 00000000..e4751b41 --- /dev/null +++ b/src/lib/admin/min-staff-rank.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +import { siteSettings } from "@/lib/services/site-settings"; +import { getMinStaffRank } from "./min-staff-rank"; + +vi.mock("@/lib/services/site-settings", () => ({ + siteSettings: { get: vi.fn() }, +})); + +describe("getMinStaffRank", () => { + it("returns numeric value", async () => { + vi.mocked(siteSettings.get).mockResolvedValue("7"); + expect(await getMinStaffRank()).toBe(7); + }); + + it("uses fallback when setting missing", async () => { + vi.mocked(siteSettings.get).mockResolvedValue(""); + expect(await getMinStaffRank(5)).toBe(5); + }); + + it("floors values", async () => { + vi.mocked(siteSettings.get).mockResolvedValue("7.8"); + expect(await getMinStaffRank()).toBe(7); + }); + + it("rejects zero", async () => { + vi.mocked(siteSettings.get).mockResolvedValue("0"); + expect(await getMinStaffRank(3)).toBe(3); + }); +}); diff --git a/src/lib/auth/personal-token-scope.test.ts b/src/lib/auth/personal-token-scope.test.ts new file mode 100644 index 00000000..c0c8c10f --- /dev/null +++ b/src/lib/auth/personal-token-scope.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { + personalTokenScope, + USER_TOKENABLE_TYPE, +} from "./personal-token-scope"; + +describe("personal-token-scope", () => { + it("exports USER_TOKENABLE_TYPE constant", () => { + expect(USER_TOKENABLE_TYPE).toBe("App\\Models\\User"); + }); + + it("returns correct scope with BigInt", () => { + const r = personalTokenScope(42); + expect(r.tokenableId).toBe(BigInt(42)); + expect(r.tokenableType).toBe("App\\Models\\User"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 83331223..86b859b6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ // Modules under test transitively import @/env; skip its strict parse so // unit tests run without a populated .env. env: { SKIP_ENV_VALIDATION: "1" }, - exclude: ["e2e/**", "node_modules/**", ".next/**"], + exclude: ["e2e/**", "node_modules/**", ".next/**", ".next.prev/**"], coverage: { enabled: true, provider: "v8",