diff --git a/app/api/comments/route.test.ts b/app/api/comments/route.test.ts new file mode 100644 index 0000000..99d5c6f --- /dev/null +++ b/app/api/comments/route.test.ts @@ -0,0 +1,114 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const from = vi.fn(); +const consumeSharedRateLimit = vi.fn(); +const hashIp = vi.fn(() => "hashed-ip"); +const getClientIp = vi.fn(() => "127.0.0.1"); + +vi.mock("@/lib/supabase/server", () => ({ + createSupabaseServerClient: () => ({ from }), +})); + +vi.mock("@/lib/supabase/admin", () => ({ + createSupabaseAdminClient: () => ({ from }), +})); + +vi.mock("@/lib/rate-limit/shared", () => ({ + consumeSharedRateLimit, +})); + +vi.mock("@/lib/utils/hash", () => ({ + hashIp, + getClientIp, +})); + +function createRequest(body: unknown) { + return new NextRequest("http://localhost/api/comments", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/comments", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + consumeSharedRateLimit.mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: new Date().toISOString(), + currentCount: 1, + }); + from.mockReturnValue({ + insert: vi.fn().mockReturnValue({ + select: () => ({ + single: async () => ({ + data: { + id: "comment-1", + body: "This comment has enough content.", + is_anonymous: false, + author_handle: "saved-handle", + created_at: "2026-09-13T00:00:00.000Z", + }, + error: null, + }), + }), + }), + }); + }); + + it("rejects an author handle longer than 64 characters", async () => { + const { POST } = await import("./route"); + const response = await POST( + createRequest({ + post_id: "post-1", + body: "This comment has enough content.", + is_anonymous: false, + author_handle: "a".repeat(65), + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: "Handle must be 64 characters or fewer.", + }); + expect(consumeSharedRateLimit).not.toHaveBeenCalled(); + expect(from).not.toHaveBeenCalled(); + }); + + it("accepts a 64-character author handle", async () => { + const handle = "a".repeat(64); + const insertSpy = vi.fn().mockReturnValue({ + select: () => ({ + single: async () => ({ + data: { + id: "comment-1", + body: "This comment has enough content.", + is_anonymous: false, + author_handle: handle, + created_at: "2026-09-13T00:00:00.000Z", + }, + error: null, + }), + }), + }); + from.mockReturnValue({ insert: insertSpy }); + + const { POST } = await import("./route"); + const response = await POST( + createRequest({ + post_id: "post-1", + body: "This comment has enough content.", + is_anonymous: false, + author_handle: handle, + }), + ); + + expect(response.status).toBe(201); + expect(insertSpy).toHaveBeenCalledWith( + expect.objectContaining({ author_handle: handle }), + ); + }); +}); diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts index 6cc9a14..256277c 100644 --- a/app/api/comments/route.ts +++ b/app/api/comments/route.ts @@ -3,6 +3,7 @@ import { createSupabaseServerClient } from "@/lib/supabase/server"; import { createSupabaseAdminClient } from "@/lib/supabase/admin"; import { consumeSharedRateLimit } from "@/lib/rate-limit/shared"; import { hashIp, getClientIp } from "@/lib/utils/hash"; +import { MAX_AUTHOR_HANDLE_LENGTH } from "@/lib/schemas/submit"; interface CommentBody { post_id?: unknown; @@ -47,6 +48,14 @@ export async function POST(req: NextRequest) { const isAnon = typeof is_anonymous === "boolean" ? is_anonymous : true; const handle = typeof author_handle === "string" ? author_handle.trim() : null; + if (handle && handle.length > MAX_AUTHOR_HANDLE_LENGTH) { + return NextResponse.json( + { + error: `Handle must be ${MAX_AUTHOR_HANDLE_LENGTH} characters or fewer.`, + }, + { status: 400 }, + ); + } if (!isAnon && !handle) { return NextResponse.json( { error: "A handle is required when posting non-anonymously." }, diff --git a/lib/schemas/submit.ts b/lib/schemas/submit.ts index 371b130..09b38ba 100644 --- a/lib/schemas/submit.ts +++ b/lib/schemas/submit.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import { isOwnedScreenshotUrl } from "@/lib/utils/urls"; +export const MAX_AUTHOR_HANDLE_LENGTH = 64; + export const submitSchema = z.object({ /** Slug of the agent involved (from AGENTS constant) */ agentSlug: z.string().min(1, "Please select the AI agent involved.").max(64), @@ -39,7 +41,7 @@ export const submitSchema = z.object({ isAnonymous: z.boolean().default(true), /** Optional display handle or company name (when not anonymous) */ - authorHandle: z.string().max(64).optional(), + authorHandle: z.string().max(MAX_AUTHOR_HANDLE_LENGTH).optional(), /** Optional email to receive the edit token — never stored long-term */ email: z