mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## 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>
577 lines
23 KiB
TypeScript
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();
|
|
});
|
|
});
|