Redact deleted issue comments
This commit is contained in:
@@ -7,6 +7,7 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
assertCheckoutOwner: vi.fn(),
|
||||
getComment: vi.fn(),
|
||||
removeComment: vi.fn(),
|
||||
tombstoneComment: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
@@ -38,6 +39,24 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||
}));
|
||||
const mockDocumentAnnotationService = vi.hoisted(() => ({
|
||||
cleanupForIssueCommentDeletion: vi.fn(async () => ({ deletedCommentIds: [], resolvedThreadIds: [] })),
|
||||
remapOpenThreadsForDocument: vi.fn(async () => []),
|
||||
}));
|
||||
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),
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
@@ -79,7 +98,7 @@ function registerModuleMocks() {
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({ getById: vi.fn(async () => null) }),
|
||||
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||
documentAnnotationService: () => mockDocumentAnnotationService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => mockFeedbackService,
|
||||
@@ -91,19 +110,7 @@ function registerModuleMocks() {
|
||||
getActiveForIssue: vi.fn(async () => null),
|
||||
listActiveForIssues: vi.fn(async () => new Map()),
|
||||
}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueReferenceService: () => mockIssueReferenceService,
|
||||
issueService: () => mockIssueService,
|
||||
issueThreadInteractionService: () => mockIssueThreadInteractionService,
|
||||
logActivity: mockLogActivity,
|
||||
@@ -188,6 +195,15 @@ describe.sequential("issue comment cancel routes", () => {
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockIssueService.getComment.mockResolvedValue(makeComment());
|
||||
mockIssueService.removeComment.mockResolvedValue(makeComment());
|
||||
mockIssueService.tombstoneComment.mockResolvedValue(makeComment({
|
||||
body: "",
|
||||
metadata: null,
|
||||
deletedAt: new Date("2026-04-11T15:05:00.000Z"),
|
||||
deletedByType: "user",
|
||||
deletedByAgentId: null,
|
||||
deletedByUserId: "local-board",
|
||||
deletedByRunId: null,
|
||||
}));
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
|
||||
@@ -214,13 +230,23 @@ describe.sequential("issue comment cancel routes", () => {
|
||||
});
|
||||
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockDocumentAnnotationService.cleanupForIssueCommentDeletion.mockResolvedValue({
|
||||
deletedCommentIds: [],
|
||||
resolvedThreadIds: [],
|
||||
});
|
||||
mockIssueReferenceService.deleteCommentSource.mockResolvedValue(undefined);
|
||||
mockIssueReferenceService.syncComment.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");
|
||||
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
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",
|
||||
@@ -239,6 +265,18 @@ describe.sequential("issue comment cancel routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -248,11 +286,12 @@ describe.sequential("issue comment cancel routes", () => {
|
||||
);
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
|
||||
.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 () => {
|
||||
@@ -263,10 +302,87 @@ describe.sequential("issue comment cancel routes", () => {
|
||||
);
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
|
||||
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Only the comment author can cancel queued comments");
|
||||
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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, JSON.stringify(res.body)).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(mockIssueReferenceService.syncComment).toHaveBeenCalledWith("comment-1");
|
||||
expect(mockDocumentAnnotationService.cleanupForIssueCommentDeletion).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"comment-1",
|
||||
expect.objectContaining({
|
||||
actorType: "user",
|
||||
userId: "local-board",
|
||||
}),
|
||||
);
|
||||
expect(mockIssueReferenceService.deleteCommentSource).toHaveBeenCalledWith("annotation-comment-1");
|
||||
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("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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user