69 lines
3.9 KiB
TypeScript
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();
|
|
});
|
|
});
|