fix(issues): validate UUID query parameters on list and count endpoints (#17389)

Reject malformed UUID filters (participantAgentId, projectId, workspaceId, executionWorkspaceId, parentId, parentIssueId, descendantOf, createdFromIssueId, labelId) on GET /api/companies/:companyId/issues and /issues/count with a 422 instead of letting Postgres fail the uuid cast with a 500.

Fixes PAPERCLIP-BACKEND-4M.

Co-Authored-By: Devin Foley <devinfoley@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-24 14:31:24 -07:00
1 parent a7006a9506
commit b6591c93a0
2 files changed
+121 -20

No files matched your search

@@ -791,6 +791,62 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => {
});
});
it.each([
"projectId",
"parentId",
"parentIssueId",
"workspaceId",
"executionWorkspaceId",
"descendantOf",
"labelId",
"participantAgentId",
])("returns 422 instead of a DB error for malformed %s filters", async (param) => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: uniqueIssuePrefix(),
requireBoardApprovalForNewAgents: false,
});
await seedCloudTenantMember(companyId);
const app = createApp(companyId);
const res = await request(app)
.get(`/api/companies/${companyId}/issues`)
.query({ status: "todo", [param]: "d", limit: "20" });
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body).toMatchObject({ error: `${param} must be a UUID` });
});
it.each([
"projectId",
"parentId",
"parentIssueId",
"workspaceId",
"executionWorkspaceId",
"descendantOf",
"labelId",
"participantAgentId",
])("returns 422 from issues/count for malformed %s filters", async (param) => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: uniqueIssuePrefix(),
requireBoardApprovalForNewAgents: false,
});
await seedCloudTenantMember(companyId);
const app = createApp(companyId);
const res = await request(app)
.get(`/api/companies/${companyId}/issues/count`)
.query({ attention: "blocked", [param]: "d" });
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body).toMatchObject({ error: `${param} must be a UUID` });
});
it("returns opt-in live descendant counts for offscreen live descendants only", async () => {
const companyId = randomUUID();
const otherCompanyId = randomUUID();