Files
PaperClipAI/server/src/__tests__/issue-comment-cancel-routes.test.ts
T
DottaandClaude Opus 5.5 4638e5ea30 fix(issues): make the queued-comment discard denial final and link the task (#19990)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - People queue follow-up messages on a task while its agent works, and
only the author can discard a queued message
> - When a person asks a chat agent to cancel their queued message, the
agent tries the discard API and gets a 403
> - The 403 text does not say that the denial is final, so the agent
searches for a board or admin route before it answers
> - In a slow model this search takes many minutes, and the queued
message can be delivered before the person gets the answer
> - This pull request makes both denials say that only the author can
discard the message, where to do it (with the task link), and that no
override exists
> - The benefit is that the agent can relay the human route at once

## Linked Issues or Issue Description

**What happened**
A person asked the chat agent to cancel a contact-section request that
they had queued on a worker's task. The agent called `DELETE
/api/issues/:id/queued-comments/:commentId` and got 403 "Board access
required". It then called `DELETE /api/issues/:id/comments/:commentId`
and got 403 "Only the comment author can cancel queued comments". In two
eval runs the agent then continued to search the API for an override. It
answered after 10 minutes and 24 minutes. In the second run, the worker
consumed the queued message before the answer arrived.

**Expected behavior**
The denial tells the agent that the action is author-only and final, and
gives the task link where the author can discard the message. The agent
can then answer the person immediately.

**Steps to reproduce**
1. Start an agent run on a task. Queue a comment on that task as a board
user.
2. As a different agent, call `DELETE
/api/issues/<task>/queued-comments/<comment>` with the current `queueId`
and `revision`.
3. See 403 "Board access required", with no next step and no statement
that the denial is final.

Refs the chat eval case `steering.discard-queued-message`.

## What Changed

- `DELETE /issues/:id/queued-comments/:commentId`: an agent actor now
gets a 403 that says only the author can discard the queued comment from
its menu on the linked task, and that no board, admin or agent override
exists. The task must be in the agent's company. Otherwise the route
returns 404 as before.
- `DELETE /issues/:id/comments/:commentId` (queued cancel by a
non-author): uses the same message.
- Tests: the agent denial on the queued-comments route, the
cross-company 404 without task details, and the updated non-author
cancel message.

## Verification

- `cd server && pnpm exec vitest run
src/__tests__/issue-comment-cancel-routes.test.ts` passes (9 tests).
- I will run the chat eval `steering.discard-queued-message` with this
change cherry-picked, and record the result in a comment.

## Risks

- Low risk. Only the 403 text changes, and agents now get the 403 after
the company access check instead of before it. Board behavior and the
author-only rule do not change.
- The UI shows this text if a non-author person tries to cancel a queued
comment. The text stays correct for that person.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), agentic coding with
tool use (shell, file editing) in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:56:56 -06:00

577 lines
23 KiB
TypeScript

import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LogActivityInput } from "../services/activity-log.js";
import { hoistModuleGraph } from "./helpers/hoist-module-graph.js";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
assertCheckoutOwner: vi.fn(),
getComment: vi.fn(),
removeComment: vi.fn(),
tombstoneComment: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(async ({ action }: { action: string }) => ({
action, allowed: action === "issue:read", reason: "allow_default", explanation: "Fixture task is readable",
})),
canUser: vi.fn(),
hasPermission: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
getRun: vi.fn(async (): Promise<unknown> => null),
getActiveRunForAgent: vi.fn(async () => null),
}));
const mockAuthoritativeQueueWakes = vi.hoisted(() => [] as Array<Record<string, unknown>>);
const mockLogActivity = vi.hoisted(() => vi.fn(async (_db: unknown, _input: LogActivityInput) => undefined));
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
getExperimental: vi.fn(async () => ({
enableExternalObjects: false,
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}));
const mockIssueThreadInteractionService = vi.hoisted(() => ({
expirePendingInteractionsForTerminalIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}));
const mockDocumentAnnotationService = vi.hoisted(() => ({
cleanupForIssueCommentDeletion: vi.fn(async () => ({ deletedCommentIds: [] as string[], resolvedThreadIds: [] as string[] })),
remapOpenThreadsForDocument: vi.fn(async () => []),
}));
const mockDecisionTrainingService = vi.hoisted(() => ({
scrubDeletedComments: vi.fn(async () => ({ updatedCount: 0 })),
}));
const mockIssueReferenceService = vi.hoisted(() => ({
deleteCommentSource: vi.fn(async () => undefined),
deleteDocumentSource: vi.fn(async () => undefined),
diffIssueReferenceSummary: vi.fn(() => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
})),
emptySummary: vi.fn(() => ({ outbound: [], inbound: [] })),
listIssueReferenceSummary: vi.fn(async () => ({ outbound: [], inbound: [] })),
syncComment: vi.fn(async () => undefined),
syncDocument: vi.fn(async () => undefined),
syncIssue: vi.fn(async () => undefined),
}));
const mockExternalObjectService = vi.hoisted(() => ({
getIssueSummaries: vi.fn(async () => ({ summaries: {} })),
getIssueSummary: vi.fn(async () => ({
authRequiredCount: 0,
byLiveness: {},
byStatusCategory: {},
highestSeverity: "muted",
objects: [],
staleCount: 0,
total: 0,
unreachableCount: 0,
})),
listForIssue: vi.fn(async () => []),
refreshIssueObjects: vi.fn(async () => []),
syncCommentSafely: vi.fn(async () => undefined),
syncDocumentSafely: vi.fn(async () => undefined),
syncIssueSafely: vi.fn(async () => undefined),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/decision-training.js", () => ({
decisionTrainingService: () => mockDecisionTrainingService,
}));
vi.doMock("../services/feedback.js", () => ({
feedbackService: () => mockFeedbackService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/external-objects.js", () => ({
externalObjectService: () => mockExternalObjectService,
}));
vi.doMock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1" })),
}),
accessService: () => mockAccessService,
agentService: () => ({ getById: vi.fn(async () => null) }),
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
documentAnnotationService: () => mockDocumentAnnotationService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueReferenceService: () => mockIssueReferenceService,
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }),
workProductService: () => ({}),
}));
}
let lastErrorContext: unknown;
function describeResponse(res: request.Response) {
return JSON.stringify({ body: res.body, errorContext: lastErrorContext });
}
function makeIssue() {
return {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
status: "in_progress",
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
assigneeUserId: null,
executionRunId: "run-1",
identifier: "PAP-1353",
title: "Queued cancel",
};
}
const finalDenial = "Only the author of a queued comment can discard it, from that queued message's menu on [PAP-1353](/PAP/issues/PAP-1353). "
+ "This denial is final: no board, admin or agent override exists, so do not try other routes. Tell the author to discard it there.";
function makeComment(overrides: Record<string, unknown> = {}) {
return {
id: "comment-1",
companyId: "company-1",
issueId: "11111111-1111-4111-8111-111111111111",
authorAgentId: null,
authorUserId: "local-board",
body: "Queued follow-up",
createdAt: new Date("2026-04-11T15:01:00.000Z"),
updatedAt: new Date("2026-04-11T15:01:00.000Z"),
...overrides,
};
}
describe("issue comment cancel routes", () => {
const routeModules = hoistModuleGraph(registerModuleMocks, async () => {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
return { issueRoutes, errorHandler };
});
function installActor(app: express.Express, actor?: Record<string, unknown>) {
const { issueRoutes, errorHandler } = routeModules.value;
app.use((req, res, next) => {
res.on("finish", () => {
lastErrorContext = (res as any).__errorContext ?? null;
});
(req as any).actor = actor ?? {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
const db = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
orderBy: vi.fn(async () => mockAuthoritativeQueueWakes),
// Agent mutation guards read a single row (no task-watchdog run here).
then: (resolve: (rows: unknown[]) => unknown, reject?: (error: unknown) => unknown) =>
Promise.resolve([]).then(resolve, reject),
})),
})),
})),
};
app.use("/api", issueRoutes(db as any, {} as any));
app.use(errorHandler);
return app;
}
function createApp() {
const app = express();
app.use(express.json());
return app;
}
beforeEach(() => {
lastErrorContext = undefined;
vi.clearAllMocks();
mockIssueService.getById.mockResolvedValue(makeIssue());
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.getComment.mockResolvedValue(makeComment());
mockIssueService.removeComment.mockResolvedValue(makeComment());
mockIssueService.tombstoneComment.mockImplementation(async (_commentId, _actor, options) => {
const deleted = makeComment({
body: "",
metadata: null,
deletedAt: new Date("2026-04-11T15:05:00.000Z"),
deletedByType: "user",
deletedByAgentId: null,
deletedByUserId: "local-board",
deletedByRunId: null,
});
await options?.afterTombstone?.(deleted, "tx");
return deleted;
});
mockAccessService.decide.mockImplementation(async ({ action }: { action: string }) => ({
action, allowed: action === "issue:read", reason: "allow_default", explanation: "Fixture task is readable",
}));
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.hasPermission.mockResolvedValue(false);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
mockFeedbackService.saveIssueVote.mockResolvedValue({
vote: null,
consentEnabledNow: false,
sharingEnabled: false,
});
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
status: "running",
startedAt: new Date("2026-04-11T15:00:00.000Z"),
createdAt: new Date("2026-04-11T14:59:00.000Z"),
});
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockAuthoritativeQueueWakes.length = 0;
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableExternalObjects: false,
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockLogActivity.mockResolvedValue(undefined);
mockDocumentAnnotationService.cleanupForIssueCommentDeletion.mockResolvedValue({
deletedCommentIds: [],
resolvedThreadIds: [],
});
mockIssueReferenceService.deleteCommentSource.mockResolvedValue(undefined);
mockIssueReferenceService.syncComment.mockResolvedValue(undefined);
mockExternalObjectService.syncCommentSafely.mockResolvedValue(undefined);
});
it("cancels a queued comment from its author and restores the deleted body", async () => {
const res = await request(await installActor(createApp()))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
expect(res.status, JSON.stringify({
body: res.body,
tombstoneCalls: mockIssueService.tombstoneComment.mock.calls,
activityCalls: mockLogActivity.mock.calls,
})).toBe(200);
expect(res.body).toMatchObject({
id: "comment-1",
body: "Queued follow-up",
});
expect(mockIssueService.removeComment).toHaveBeenCalledWith("comment-1");
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.comment_cancelled",
details: expect.objectContaining({
commentId: "comment-1",
source: "queue_cancel",
queueTargetRunId: "run-1",
}),
}),
);
});
it("rejects stale queued cancellation after the active run is gone", async () => {
mockHeartbeatService.getRun.mockResolvedValue(null);
const res = await request(await installActor(createApp()))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
expect(res.status).toBe(409);
expect(res.body.error).toBe("Queued comment can no longer be canceled");
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
expect(mockIssueService.tombstoneComment).not.toHaveBeenCalled();
});
it("rejects canceling comments that are no longer queued", async () => {
mockIssueService.getComment.mockResolvedValue(
makeComment({
createdAt: new Date("2026-04-11T14:58:00.000Z"),
updatedAt: new Date("2026-04-11T14:58:00.000Z"),
}),
);
const res = await request(await installActor(createApp()))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
expect(res.status).toBe(409);
expect(res.body.error).toBe("Only queued comments can be canceled");
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
expect(mockIssueService.tombstoneComment).not.toHaveBeenCalled();
});
it("rejects canceling another actor's queued comment", async () => {
mockIssueService.getComment.mockResolvedValue(
makeComment({
authorUserId: "someone-else",
}),
);
const res = await request(await installActor(createApp()))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
expect(res.status).toBe(403);
expect(res.body.error).toBe(finalDenial);
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
});
it("tells an agent that discarding a person's queued comment is author-only and final, with the task link", async () => {
const agent = { type: "agent", agentId: "22222222-2222-4222-8222-222222222222", companyId: "company-1", runId: "run-1", source: "agent_key" };
const res = await request(await installActor(createApp(), agent))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/queued-comments/comment-1")
.send({ queueId: "queue-1", revision: "revision-1" });
expect(res.status).toBe(403);
expect(res.body.error).toBe(finalDenial);
expect(res.body.error).not.toBe("Board access required");
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
});
it("returns 404 without task details when the agent cannot read the private task", async () => {
mockAccessService.decide.mockImplementation(async ({ action }: { action: string }) => ({
action, allowed: false, reason: "deny_issue_private", explanation: "Private task",
}));
const agent = { type: "agent", agentId: "33333333-3333-4333-8333-333333333333", companyId: "company-1", runId: "run-9", source: "agent_key" };
const res = await request(await installActor(createApp(), agent))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/queued-comments/comment-1")
.send({ queueId: "queue-1", revision: "revision-1" });
expect(res.status).toBe(404);
expect(JSON.stringify(res.body)).not.toContain("PAP-1353");
});
it("points an author agent at the cancel route it can use instead of claiming no route exists", async () => {
mockIssueService.getComment.mockResolvedValue(makeComment({ authorUserId: null, authorAgentId: "33333333-3333-4333-8333-333333333333" }));
const agent = { type: "agent", agentId: "33333333-3333-4333-8333-333333333333", companyId: "company-1", runId: "run-9", source: "agent_key" };
const res = await request(await installActor(createApp(), agent))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/queued-comments/comment-1")
.send({ queueId: "queue-1", revision: "revision-1" });
expect(res.status).toBe(403);
expect(res.body.error).toContain("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
expect(res.body.error).not.toContain("final");
});
it("lets an author agent discard its own queued comment through the cancel route", async () => {
// Another agent queued this comment behind the assignee's active run; it can read and write the task.
mockAccessService.decide.mockImplementation(async ({ action }: { action: string }) => ({
action, allowed: true, reason: "allow_default", explanation: "Assignee task",
}));
mockIssueService.getComment.mockResolvedValue(makeComment({ authorUserId: null, authorAgentId: "33333333-3333-4333-8333-333333333333" }));
mockIssueService.removeComment.mockResolvedValue(makeComment({ authorUserId: null, authorAgentId: "33333333-3333-4333-8333-333333333333" }));
const agent = { type: "agent", agentId: "33333333-3333-4333-8333-333333333333", companyId: "company-1", runId: "run-9", source: "agent_key" };
const res = await request(await installActor(createApp(), agent))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.removeComment).toHaveBeenCalledWith("comment-1");
});
it("returns 404 instead of the author-only denial when a non-author cannot read the task", async () => {
mockIssueService.getComment.mockResolvedValue(makeComment({ authorUserId: "someone-else" }));
mockAccessService.decide.mockImplementation(async ({ action }: { action: string }) => ({
action, allowed: false, reason: "deny_issue_private", explanation: "Private task",
}));
const res = await request(await installActor(createApp()))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
expect(res.status).not.toBe(403);
expect(JSON.stringify(res.body)).not.toContain("PAP-1353");
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
});
it("does not reveal another company's task through the agent queued-comment denial", async () => {
mockIssueService.getById.mockResolvedValue({ ...makeIssue(), companyId: "company-2" });
const agent = { type: "agent", agentId: "22222222-2222-4222-8222-222222222222", companyId: "company-1", runId: "run-1", source: "agent_key" };
const res = await request(await installActor(createApp(), agent))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/queued-comments/comment-1")
.send({ queueId: "queue-1", revision: "revision-1" });
expect(res.status).toBe(404);
expect(JSON.stringify(res.body)).not.toContain("PAP-1353");
});
it("deletes a normal authored comment as a tombstone without returning the original body", async () => {
mockIssueService.getComment.mockResolvedValue(
makeComment({
body: "Sensitive original comment body",
metadata: { version: 1, sections: [{ rows: [{ type: "text", text: "Sensitive metadata copy" }] }] },
createdAt: new Date("2026-04-11T14:58:00.000Z"),
updatedAt: new Date("2026-04-11T14:58:00.000Z"),
}),
);
mockDocumentAnnotationService.cleanupForIssueCommentDeletion.mockResolvedValue({
deletedCommentIds: ["annotation-comment-1"],
resolvedThreadIds: ["annotation-thread-1"],
});
const res = await request(await installActor(createApp()))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
expect(res.status, describeResponse(res)).toBe(200);
expect(res.body).toMatchObject({
id: "comment-1",
body: "",
metadata: null,
deletedByType: "user",
deletedByUserId: "local-board",
});
expect(JSON.stringify(res.body)).not.toContain("Sensitive original comment body");
expect(JSON.stringify(res.body)).not.toContain("Sensitive metadata copy");
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
expect(mockIssueService.tombstoneComment).toHaveBeenCalledWith(
"comment-1",
{
actorType: "user",
agentId: null,
userId: "local-board",
runId: null,
},
expect.objectContaining({ afterTombstone: expect.any(Function) }),
);
expect(mockIssueReferenceService.syncComment).toHaveBeenCalledWith("comment-1", "tx");
expect(mockExternalObjectService.syncCommentSafely).toHaveBeenCalledWith("comment-1", "tx");
expect(mockDocumentAnnotationService.cleanupForIssueCommentDeletion).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"comment-1",
expect.objectContaining({
actorType: "user",
userId: "local-board",
}),
"tx",
);
expect(mockIssueReferenceService.deleteCommentSource).toHaveBeenCalledWith("annotation-comment-1", "tx");
expect(mockExternalObjectService.syncCommentSafely).toHaveBeenCalledWith("annotation-comment-1", "tx");
expect(mockDecisionTrainingService.scrubDeletedComments).toHaveBeenCalledWith({
companyId: "company-1",
issueId: "11111111-1111-4111-8111-111111111111",
commentIds: ["comment-1", "annotation-comment-1"],
deletedAt: new Date("2026-04-11T15:05:00.000Z"),
}, "tx");
const deletedActivity = mockLogActivity.mock.calls.find((call) => call[1]?.action === "issue.comment_deleted")?.[1];
expect(deletedActivity).toEqual(expect.objectContaining({
action: "issue.comment_deleted",
details: expect.objectContaining({
deletedAnnotationCommentIds: ["annotation-comment-1"],
resolvedAnnotationThreadIds: ["annotation-thread-1"],
}),
}));
expect(deletedActivity?.details).toEqual(expect.not.objectContaining({
bodySnippet: expect.anything(),
}));
expect(JSON.stringify(deletedActivity?.details ?? {})).not.toContain("Sensitive original comment body");
expect(JSON.stringify(deletedActivity?.details ?? {})).not.toContain("Sensitive metadata copy");
});
it("tombstones normally after the comment's queued wake has completed", async () => {
mockHeartbeatService.getRun.mockResolvedValue(null);
mockAuthoritativeQueueWakes.push({
id: "wake-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
runId: "run-1",
status: "succeeded",
payload: {
issueId: "11111111-1111-4111-8111-111111111111",
_paperclipWakeContext: {
wakeCommentIds: ["comment-1"],
},
},
requestedAt: new Date("2026-04-11T15:02:00.000Z"),
});
const res = await request(await installActor(createApp()))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
expect(res.status, describeResponse(res)).toBe(200);
expect(mockIssueService.tombstoneComment).toHaveBeenCalledWith(
"comment-1",
expect.anything(),
expect.objectContaining({ afterTombstone: expect.any(Function) }),
);
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
});
it("rejects deleting another actor's normal comment", async () => {
mockIssueService.getComment.mockResolvedValue(
makeComment({
authorUserId: "someone-else",
createdAt: new Date("2026-04-11T14:58:00.000Z"),
updatedAt: new Date("2026-04-11T14:58:00.000Z"),
}),
);
const res = await request(await installActor(createApp()))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
expect(res.status).toBe(403);
expect(res.body.error).toBe("Only the comment author can delete comments");
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
expect(mockIssueService.tombstoneComment).not.toHaveBeenCalled();
});
});