diff --git a/app/api/posts/edit/[token]/route.test.ts b/app/api/posts/edit/[token]/route.test.ts index c870331..77ee2e4 100644 --- a/app/api/posts/edit/[token]/route.test.ts +++ b/app/api/posts/edit/[token]/route.test.ts @@ -98,6 +98,7 @@ describe("PATCH /api/posts/edit/[token]", () => { it("updates the post and rewrites its tags", async () => { const updateEq = vi.fn().mockResolvedValue({ error: null }); + const updateSpy = vi.fn().mockReturnValue({ eq: updateEq }); const deleteEq = vi.fn().mockResolvedValue({ error: null }); const insertTags = vi.fn().mockResolvedValue({ error: null }); @@ -113,9 +114,7 @@ describe("PATCH /api/posts/edit/[token]", () => { single: async () => ({ data: { id: "agent-1" }, error: null }), }), }), - update: () => ({ - eq: updateEq, - }), + update: updateSpy, }; } @@ -171,6 +170,12 @@ describe("PATCH /api/posts/edit/[token]", () => { ); expect(response.status).toBe(200); + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + is_anonymous: false, + submitter_handle: "ops-team", + }), + ); expect(updateEq).toHaveBeenCalledWith("id", "post-1"); expect(deleteEq).toHaveBeenCalledWith("post_id", "post-1"); expect(insertTags).toHaveBeenCalledWith([ @@ -178,6 +183,85 @@ describe("PATCH /api/posts/edit/[token]", () => { ]); }); + it("clears an author handle when an anonymous post is edited", async () => { + const updateEq = vi.fn().mockResolvedValue({ error: null }); + const updateSpy = vi.fn().mockReturnValue({ eq: updateEq }); + const deleteEq = vi.fn().mockResolvedValue({ error: null }); + const insertTags = vi.fn().mockResolvedValue({ error: null }); + + from.mockImplementation((table: string) => { + if (table === "posts") { + return { + select: () => ({ + eq: () => ({ + maybeSingle: async () => ({ + data: { id: "post-1" }, + error: null, + }), + }), + }), + update: updateSpy, + }; + } + + if (table === "agents") { + return { + select: () => ({ + eq: () => ({ + single: async () => ({ data: { id: "agent-1" }, error: null }), + }), + }), + }; + } + + if (table === "tags") { + return { + select: () => ({ + in: async () => ({ + data: [{ id: "tag-1", slug: "hallucination" }], + error: null, + }), + }), + }; + } + + if (table === "post_tags") { + return { + delete: () => ({ + eq: deleteEq, + }), + insert: insertTags, + }; + } + + throw new Error(`Unexpected table ${table}`); + }); + + const { PATCH } = await import("./route"); + const response = await PATCH( + createPatchRequest({ + agentSlug: "claude", + title: "Agent deleted a customer record during a routine sync", + outcome: + "The assistant misunderstood the task, deleted a live customer record, and forced the team into a manual restore that took several hours to unwind safely.", + damageLevel: 3, + tags: ["hallucination"], + isAnonymous: true, + authorHandle: "private-handle", + }), + { params: { token: "token-123" } }, + ); + + expect(response.status).toBe(200); + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + is_anonymous: true, + submitter_handle: null, + }), + ); + expect(updateEq).toHaveBeenCalledWith("id", "post-1"); + }); + it("rejects a well-formed R2 URL with a malformed object key", async () => { from.mockImplementation((table: string) => { if (table === "posts") { diff --git a/app/api/posts/edit/[token]/route.ts b/app/api/posts/edit/[token]/route.ts index cbcb6da..a0ad9b4 100644 --- a/app/api/posts/edit/[token]/route.ts +++ b/app/api/posts/edit/[token]/route.ts @@ -177,9 +177,10 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) { estimated_cost_usd: data.estimatedCostUsd ?? null, screenshot_urls: data.screenshotUrls ?? [], is_anonymous: data.isAnonymous, - submitter_handle: data.authorHandle - ? redactPii(data.authorHandle) - : null, + submitter_handle: + !data.isAnonymous && data.authorHandle + ? redactPii(data.authorHandle) + : null, status: "pending", }) .eq("id", existing.id); diff --git a/app/api/posts/route.test.ts b/app/api/posts/route.test.ts index 02f28a8..663ebea 100644 --- a/app/api/posts/route.test.ts +++ b/app/api/posts/route.test.ts @@ -310,6 +310,66 @@ describe("POST /api/posts", () => { }); }); + it("does not persist an author handle for anonymous submissions", async () => { + const insertSpy = vi.fn().mockReturnValue({ + select: () => ({ + single: async () => ({ data: { id: "post-1" }, error: null }), + }), + }); + + from.mockImplementation((table: string) => { + if (table === "agents") { + return { + select: () => ({ + eq: () => ({ + single: async () => ({ data: { id: "agent-1" }, error: null }), + }), + }), + }; + } + + if (table === "tags") { + return { + select: () => ({ + in: async () => ({ + data: [{ id: "tag-1", slug: "hallucination" }], + error: null, + }), + }), + }; + } + + if (table === "posts") return { insert: insertSpy }; + if (table === "post_tags") { + return { insert: vi.fn().mockResolvedValue({ error: null }) }; + } + + throw new Error(`Unexpected table ${table}`); + }); + + const { POST } = await import("./route"); + const response = await POST( + createRequest({ + agentSlug: "claude", + title: "Agent deleted a customer record during a routine sync", + outcome: + "The assistant misunderstood the task, deleted a live customer record, and forced the team into a manual restore that took several hours to unwind safely.", + damageLevel: 3, + tags: ["hallucination"], + isAnonymous: true, + authorHandle: "private-handle", + }), + ); + + expect(response.status).toBe(201); + expect(insertSpy).toHaveBeenCalledWith( + expect.objectContaining({ + is_anonymous: true, + submitter_handle: null, + }), + ); + }); + it("does not persist submitter emails and reports edit-link delivery", async () => { const insertSpy = vi.fn().mockReturnValue({ select: () => ({ @@ -377,6 +437,7 @@ describe("POST /api/posts", () => { expect(insertSpy).toHaveBeenCalledWith( expect.objectContaining({ submitter_email: null, + submitter_handle: "ops-team", }), ); expect(sendEditTokenEmail).toHaveBeenCalledWith( diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index 62ed768..244eef8 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -90,7 +90,10 @@ export async function POST(req: NextRequest) { const cleanTitle = redactPii(data.title); const cleanOutcome = redactPii(data.outcome); const cleanPrompt = data.prompt ? redactPii(data.prompt) : null; - const cleanHandle = data.authorHandle ? redactPii(data.authorHandle) : null; + const cleanHandle = + !data.isAnonymous && data.authorHandle + ? redactPii(data.authorHandle) + : null; // Generate edit token — raw token sent to user, hash stored in DB const rawToken = randomBytes(32).toString("hex"); diff --git a/lib/db/posts.test.ts b/lib/db/posts.test.ts index cb75a1b..172819c 100644 --- a/lib/db/posts.test.ts +++ b/lib/db/posts.test.ts @@ -6,6 +6,83 @@ vi.mock("@/lib/supabase/server", () => ({ createSupabaseServerClient: () => ({ from }), })); +describe("post privacy", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("does not expose a legacy handle for anonymous posts", async () => { + const query = { + select: vi.fn(), + eq: vi.fn(), + single: vi.fn(), + }; + query.select.mockReturnValue(query); + query.eq.mockReturnValue(query); + query.single.mockResolvedValue({ + data: { + id: "post-1", + case_number: "APM-0001", + title: "Anonymous case", + outcome: "A long enough outcome for the test fixture.", + damage_level: 3, + vote_score: 0, + created_at: "2026-09-01T00:00:00.000Z", + is_anonymous: true, + submitter_handle: "private-handle", + agents: { slug: "claude", name: "Claude" }, + post_tags: [], + }, + error: null, + }); + from.mockReturnValue(query); + + const { fetchPostByCase } = await import("./posts"); + const post = await fetchPostByCase("APM-0001"); + + expect(post).toMatchObject({ + isAnonymous: true, + authorHandle: undefined, + }); + }); + + it("keeps the public handle for attributed posts", async () => { + const query = { + select: vi.fn(), + eq: vi.fn(), + single: vi.fn(), + }; + query.select.mockReturnValue(query); + query.eq.mockReturnValue(query); + query.single.mockResolvedValue({ + data: { + id: "post-1", + case_number: "APM-0001", + title: "Attributed case", + outcome: "A long enough outcome for the test fixture.", + damage_level: 3, + vote_score: 0, + created_at: "2026-09-01T00:00:00.000Z", + is_anonymous: false, + submitter_handle: "ops-team", + agents: { slug: "claude", name: "Claude" }, + post_tags: [], + }, + error: null, + }); + from.mockReturnValue(query); + + const { fetchPostByCase } = await import("./posts"); + const post = await fetchPostByCase("APM-0001"); + + expect(post).toMatchObject({ + isAnonymous: false, + authorHandle: "ops-team", + }); + }); +}); + describe("fetchRelatedPosts", () => { beforeEach(() => { vi.resetModules(); diff --git a/lib/db/posts.ts b/lib/db/posts.ts index 166bf42..007a73a 100644 --- a/lib/db/posts.ts +++ b/lib/db/posts.ts @@ -13,6 +13,7 @@ export type FeedTab = "hot" | "new" | "week" | "hof"; function rowToPost(row: Record): Post { const agent = row.agents as Record | null; const postTags = row.post_tags as Array<{ tags: { slug: string } }> | null; + const isAnonymous = (row.is_anonymous as boolean) ?? true; return { id: row.id as string, @@ -27,8 +28,10 @@ function rowToPost(row: Record): Post { tags: postTags?.map((pt) => pt.tags.slug) ?? [], voteScore: (row.vote_score as number) ?? 0, createdAt: row.created_at as string, - isAnonymous: (row.is_anonymous as boolean) ?? true, - authorHandle: (row.submitter_handle as string | null) ?? undefined, + isAnonymous, + authorHandle: isAnonymous + ? undefined + : ((row.submitter_handle as string | null) ?? undefined), screenshots: (row.screenshot_urls as string[] | null) ?? [], sourceUrl: (row.source_url as string | null) ?? undefined, sourceTitle: (row.source_title as string | null) ?? undefined,