import { beforeEach, describe, expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ comment: {} as Record, author: {} as Record, rootHidden: false, trashed: false, deletes: vi.fn(), updates: vi.fn(), audit: vi.fn(), changed: vi.fn(), })); vi.mock("server-only", () => ({})); vi.mock("@/lib/notifications/events", () => ({ notifyNotificationChange: vi.fn() })); vi.mock("@/lib/comments/events", () => ({ notifyCommentChange: state.changed })); vi.mock("@/lib/auth/server", () => ({ getCustomerSession: vi.fn() })); vi.mock("@/lib/audit-log", () => ({ auditActor: (actor: unknown) => actor, writeAuditLog: state.audit })); vi.mock("@/lib/media/storage", () => ({ getMediaStorage: vi.fn(), publicMediaUrl: vi.fn() })); vi.mock("@/db", () => ({ getDb: () => database })); import { comments, guides, sessions, users } from "@/db/schema"; import { mutateComment } from "./repository"; import { publishComment } from "./publish"; const id = "72df08ab-50dd-4cbd-9a69-70949d34cf9f"; const guideId = "72df08ab-50dd-4cbd-9a69-70949d34cf9e"; const admin = { id: "admin", admin: true }; const reader = { id: "reader", admin: false }; function select(fields: Record) { let table: unknown; const rows = () => { if (table === comments) return fields.comment ? [{ comment: state.comment, thread: { id: "thread", guideId } }] : [{ hidden: state.rootHidden }]; if (table === guides) return [{ id: guideId, name: "Amber", slug: "amber", public: true, trashedAt: state.trashed ? new Date() : null }]; if (table === users) return fields.role || fields.banned ? [state.author] : [{ id: admin.id, name: "Admin" }]; return []; }; const query = { from(value: unknown) { table = value; return query; }, innerJoin() { return query; }, where() { return query; }, limit() { return query; }, for() { return query; }, then(resolve: (value: unknown[]) => unknown) { return Promise.resolve(rows()).then(resolve); }, }; return query; } const database = { transaction: async (task: (tx: unknown) => unknown) => task(database), select, selectDistinct: select, update: (table: unknown) => ({ set: (value: Record) => ({ where: async () => { state.updates(table, value); if (table === users) Object.assign(state.author, value); } }) }), delete: (table: unknown) => ({ where: async () => { state.deletes(table); } }), }; beforeEach(() => { state.comment = { id, authorId: reader.id, threadId: "thread", rootId: null, hidden: false, deletedAt: null }; state.author = { id: reader.id, email: "reader@example.test", role: "user", emailVerified: true, banned: false }; state.rootHidden = false; state.trashed = false; state.deletes.mockReset(); state.updates.mockReset(); state.audit.mockReset(); state.changed.mockReset(); }); describe("admin comment deletion", () => { it.each(["visible", "hidden", "hidden root", "deleted placeholder"])("allows an admin to delete another author's %s comment and records it", async (visibility) => { state.comment.hidden = visibility === "hidden"; state.comment.deletedAt = visibility === "deleted placeholder" ? new Date() : null; if (visibility === "hidden root") { state.comment.rootId = id; state.rootHidden = true; } await mutateComment(id, admin, "delete"); expect(state.deletes).toHaveBeenCalledWith(comments); expect(state.audit).toHaveBeenCalledWith(database, expect.anything(), expect.objectContaining({ action: "comment.deleted", targetId: id })); expect(state.changed).toHaveBeenCalledWith(`guide:${guideId}`); }); it("rejects deletion by another regular user", async () => { await expect(mutateComment(id, { id: "other", admin: false }, "delete")).rejects.toMatchObject({ status: 403 }); expect(state.deletes).not.toHaveBeenCalled(); }); it("retains author deletion", async () => { await mutateComment(id, reader, "delete"); expect(state.deletes).toHaveBeenCalledWith(comments); }); it("rejects deletion for a trashed guide", async () => { state.trashed = true; await expect(mutateComment(id, admin, "delete")).rejects.toMatchObject({ status: 409 }); expect(state.deletes).not.toHaveBeenCalled(); }); }); describe("account bans from comment moderation", () => { it("sets Better Auth's ban fields, revokes sessions, and audits without deleting comments", async () => { await mutateComment(id, admin, "ban", true); expect(state.updates).toHaveBeenCalledWith(users, { banned: true, banReason: "Banned by comment moderation", banExpires: null }); expect(state.deletes).toHaveBeenCalledWith(sessions); expect(state.deletes).not.toHaveBeenCalledWith(comments); expect(state.audit).toHaveBeenCalledWith(database, expect.anything(), expect.objectContaining({ action: "comment.author_banned" })); }); it("clears the ban and audits without revoking sessions", async () => { state.author.banned = true; await mutateComment(id, admin, "ban", false); expect(state.author).toMatchObject({ banned: false, banReason: null, banExpires: null }); expect(state.deletes).not.toHaveBeenCalled(); expect(state.audit).toHaveBeenCalledWith(database, expect.anything(), expect.objectContaining({ action: "comment.author_unbanned" })); }); it.each(["regular user", "self", "another admin"])("rejects bans for %s", async (scenario) => { const actor = scenario === "regular user" ? reader : admin; if (scenario === "self") { state.author.id = admin.id; state.comment.authorId = admin.id; } if (scenario === "another admin") state.author.role = "admin"; await expect(mutateComment(id, actor, "ban", true)).rejects.toMatchObject({ status: 403 }); expect(state.updates).not.toHaveBeenCalled(); expect(state.deletes).not.toHaveBeenCalled(); }); it.each(["new comment", "reply", "edit"])("rejects a banned author's %s before processing uploads", async (kind) => { state.author.banned = true; const body = new FormData(); body.set("text", "New comment"); if (kind === "reply") body.set("replyToId", id); await expect(publishComment(new Request("https://guide.example.test/api/comments", { method: "POST", body }), reader, kind === "edit" ? { id } : { target: `guide:${guideId}` })).rejects.toMatchObject({ status: 403, message: "comment-author-banned" }); expect(state.updates).not.toHaveBeenCalled(); }); });