Files
gunshiz 20c52fac04
CI / Verify (push) Successful in 1m41s
CI / Build immutable images and deploy (push) Successful in 2m30s
feat(comments) : filter abusive language and prevent spam
2026-10-08 04:45:45 +07:00

69 lines
3.9 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
const mocks = vi.hoisted(() => ({ limit: vi.fn(), set: vi.fn(), eval: vi.fn() }));
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: mocks.limit }));
vi.mock("@/lib/redis/client", () => ({ getRedisClient: async () => ({ set: mocks.set, eval: mocks.eval }) }));
import { withCommentSpamProtection } from "./spam";
import { HttpError } from "@/lib/security/http";
describe("comment spam protection", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.limit.mockResolvedValue(undefined);
mocks.set.mockResolvedValue("OK");
mocks.eval.mockResolvedValue(1);
});
it("limits all writes and retains the duplicate reservation on success", async () => {
const publish = vi.fn().mockResolvedValue({ id: "saved" });
await expect(withCommentSpamProtection("author", "Hello", false, publish)).resolves.toEqual({ id: "saved" });
expect(mocks.limit.mock.calls).toEqual([
["comment-write-hour", "author", 30, 3600],
["comment-write-minute", "author", 5],
["comment-write-cooldown", "author", 1, 5],
]);
expect(mocks.set).toHaveBeenCalledWith(expect.any(String), expect.any(String), "EX", 300, "NX");
expect(mocks.eval).not.toHaveBeenCalled();
});
it("uses the same private duplicate key for normalized text across targets", async () => {
await withCommentSpamProtection("author", " HELLO\u200b\n world", false, async () => undefined);
await withCommentSpamProtection("author", "hello world", false, async () => undefined);
expect(mocks.set.mock.calls[0][0]).toBe(mocks.set.mock.calls[1][0]);
expect(mocks.set.mock.calls[0][0]).not.toContain("hello");
await withCommentSpamProtection("other", "hello world", false, async () => undefined);
expect(mocks.set.mock.calls[2][0]).not.toBe(mocks.set.mock.calls[0][0]);
});
it("rejects simultaneous or recent duplicate posts before uploading or saving", async () => {
mocks.set.mockResolvedValue(null);
const publish = vi.fn();
await expect(withCommentSpamProtection("author", "Hello", false, publish)).rejects.toMatchObject({ status: 429, message: "comment-duplicate", retryAfter: 300 });
expect(publish).not.toHaveBeenCalled();
});
it("releases only its own reservation when publication fails", async () => {
const failure = new Error("upload failed");
await expect(withCommentSpamProtection("author", "Hello", false, async () => { throw failure; })).rejects.toBe(failure);
const [key, token] = mocks.set.mock.calls[0];
expect(mocks.eval).toHaveBeenCalledWith(expect.stringContaining("ARGV[1]"), 1, key, token);
});
it("blocks abuse in edits and leaves persistence untouched", async () => {
const publish = vi.fn();
await expect(withCommentSpamProtection("author", "fuck you", true, publish)).rejects.toMatchObject({ status: 400, message: "comment-abusive-language" });
expect(publish).not.toHaveBeenCalled();
expect(mocks.set).not.toHaveBeenCalled();
});
it.each([true, false])("keeps edits and image-only comments usable while rate limiting them (editing=%s)", async (editing) => {
const publish = vi.fn().mockResolvedValue("saved");
await expect(withCommentSpamProtection("author", editing ? "same text" : "", editing, publish)).resolves.toBe("saved");
expect(mocks.limit).toHaveBeenCalledTimes(3);
expect(mocks.set).not.toHaveBeenCalled();
});
it("stops publication if the shared rate limit or Redis is unavailable", async () => {
const publish = vi.fn();
mocks.limit.mockRejectedValueOnce(new HttpError(429, "too-many-requests", 5));
await expect(withCommentSpamProtection("author", "Hello", false, publish)).rejects.toMatchObject({ status: 429 });
mocks.set.mockRejectedValueOnce(new Error("Redis unavailable"));
await expect(withCommentSpamProtection("author", "Hello", false, publish)).rejects.toThrow("Redis unavailable");
expect(publish).not.toHaveBeenCalled();
});
});