Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 87 additions & 3 deletions app/api/posts/edit/[token]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand All @@ -113,9 +114,7 @@ describe("PATCH /api/posts/edit/[token]", () => {
single: async () => ({ data: { id: "agent-1" }, error: null }),
}),
}),
update: () => ({
eq: updateEq,
}),
update: updateSpy,
};
}

Expand Down Expand Up @@ -171,13 +170,98 @@ 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([
{ post_id: "post-1", tag_id: "tag-1" },
]);
});

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") {
Expand Down
7 changes: 4 additions & 3 deletions app/api/posts/edit/[token]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
61 changes: 61 additions & 0 deletions app/api/posts/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => ({
Expand Down Expand Up @@ -377,6 +437,7 @@ describe("POST /api/posts", () => {
expect(insertSpy).toHaveBeenCalledWith(
expect.objectContaining({
submitter_email: null,
submitter_handle: "ops-team",
}),
);
expect(sendEditTokenEmail).toHaveBeenCalledWith(
Expand Down
5 changes: 4 additions & 1 deletion app/api/posts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
77 changes: 77 additions & 0 deletions lib/db/posts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
7 changes: 5 additions & 2 deletions lib/db/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type FeedTab = "hot" | "new" | "week" | "hof";
function rowToPost(row: Record<string, unknown>): Post {
const agent = row.agents as Record<string, unknown> | 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,
Expand All @@ -27,8 +28,10 @@ function rowToPost(row: Record<string, unknown>): 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,
Expand Down