feat : comment feature
CI / Verify (push) Successful in 2m30s
CI / Build immutable images and deploy (push) Successful in 3m9s

This commit is contained in:
2026-10-08 01:40:08 +07:00 Unverified
parent 8c8815cda5
commit 5812d99715
65 changed files with 27013 additions and 10 deletions
+42
View File
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { HttpError } from "@/lib/security/http";
const mocks = vi.hoisted(() => ({ viewer: vi.fn(), target: vi.fn(), stream: vi.fn(), limit: vi.fn() }));
vi.mock("@/lib/comments/repository", () => ({
getCommentViewer: mocks.viewer, getCommentTarget: mocks.target,
requireCommentAdmin: (viewer: { admin: boolean } | null) => { if (!viewer?.admin) throw new HttpError(403, "forbidden"); },
}));
vi.mock("@/lib/events/redis-stream", () => ({ createRedisNamedEventResponse: mocks.stream }));
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: mocks.limit, trustedClientAddress: () => "127.0.0.1" }));
import { GET } from "./route";
describe("comment event authorization", () => {
beforeEach(() => {
vi.clearAllMocks(); mocks.viewer.mockResolvedValue(null); mocks.target.mockResolvedValue({ target: "stygian:123" });
mocks.stream.mockResolvedValue(new Response("event: changed\ndata: changed\n\n", { headers: { "Content-Type": "text/event-stream" } }));
});
it("allows anonymous subscribers for a readable target", async () => {
const request = new Request("https://guide.test/api/comments/events?target=stygian:123");
expect((await GET(request)).status).toBe(200);
expect(mocks.target).toHaveBeenCalledWith("stygian:123", null);
expect(mocks.stream).toHaveBeenCalledWith("comments:stygian:123", "changed", request.signal);
});
it("does not subscribe to an inaccessible guide", async () => {
mocks.target.mockRejectedValue(new HttpError(404, "guide-not-found"));
expect((await GET(new Request("https://guide.test/api/comments/events?target=guide:private"))).status).toBe(404);
expect(mocks.stream).not.toHaveBeenCalled();
});
it("rejects anonymous admin inbox subscriptions", async () => {
expect((await GET(new Request("https://guide.test/api/comments/events?scope=admin"))).status).toBe(403);
expect(mocks.stream).not.toHaveBeenCalled();
});
it("allows verified admin inbox subscriptions", async () => {
mocks.viewer.mockResolvedValue({ id: "admin", admin: true });
const request = new Request("https://guide.test/api/comments/events?scope=admin");
expect((await GET(request)).status).toBe(200);
expect(mocks.stream).toHaveBeenCalledWith("comments:admin", "changed", request.signal);
});
it("requires a target for public subscriptions", async () => {
expect((await GET(new Request("https://guide.test/api/comments/events"))).status).toBe(400);
expect(mocks.stream).not.toHaveBeenCalled();
});
});
+25
View File
@@ -0,0 +1,25 @@
import { createRedisNamedEventResponse } from "@/lib/events/redis-stream";
import { getCommentTarget, getCommentViewer, requireCommentAdmin } from "@/lib/comments/repository";
import { errorResponse, HttpError } from "@/lib/security/http";
import { limitRequest, trustedClientAddress } from "@/lib/security/rate-limit";
export async function GET(request: Request) {
try {
await limitRequest("stream-open", trustedClientAddress(request.headers), 60);
const query = new URL(request.url).searchParams;
const viewer = await getCommentViewer();
let topic: string;
if (query.get("scope") === "admin") {
requireCommentAdmin(viewer);
topic = "comments:admin";
} else {
const target = query.get("target");
if (!target) throw new HttpError(400, "target-required");
const destination = await getCommentTarget(target, viewer);
topic = `comments:${destination.target}`;
}
// Events contain no comment text, images, or author information.
// Every refresh rechecks current guide visibility and viewer permissions.
return await createRedisNamedEventResponse(topic, "changed", request.signal);
} catch (cause) { return errorResponse(cause); }
}