111 lines
6.3 KiB
TypeScript
111 lines
6.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const state = vi.hoisted(() => ({
|
|
comment: {} as Record<string, unknown>, author: {} as Record<string, unknown>,
|
|
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<string, unknown>) {
|
|
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<string, unknown>) => ({ 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: "[email protected]", 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();
|
|
});
|
|
});
|