import { randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; import { sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi, } from "vitest"; import { assets, companies, closeRegisteredClients, companyMemberships, createDb, issueAttachments, issueComments, issueReferenceMentions, issues, } from "@paperclipai/db"; import { companySearchQuerySchema, LOW_TRUST_REVIEW_PRESET, } from "@paperclipai/shared"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { errorHandler } from "../middleware/index.js"; import { issueRoutes } from "../routes/issues.js"; import { companySearchService } from "../services/company-search.js"; import { buildPaperclipWakePayload } from "../services/heartbeat.js"; import { issueReferenceService } from "../services/issue-references.js"; import { issueService } from "../services/issues.js"; import type { StorageService } from "../storage/types.js"; const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL; const embeddedPostgresSupport = externalTestDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; if (!embeddedPostgresSupport.supported) { console.warn( `Skipping embedded Postgres issue comment redaction tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, ); } describeEmbeddedPostgres("deleted issue comment redaction", () => { let db!: ReturnType; let tempDb: Awaited< ReturnType > | null = null; beforeAll(async () => { if (externalTestDatabaseUrl) { db = createDb(externalTestDatabaseUrl); } else { tempDb = await startEmbeddedPostgresTestDatabase( "paperclip-comment-redaction-", ); db = createDb(tempDb.connectionString); } await db.execute(sql.raw("CREATE EXTENSION IF NOT EXISTS pg_trgm")); }, 20_000); afterEach(async () => { await db.delete(issueReferenceMentions); await db.delete(issueAttachments); await db.delete(issueComments); await db.delete(assets); await db.delete(issues); await db.delete(companyMemberships); await db.delete(companies); }); afterAll(async () => { if (externalTestDatabaseUrl) { await closeRegisteredClients(externalTestDatabaseUrl); } await tempDb?.cleanup(); }); async function seedIssue() { const companyId = randomUUID(); const issueId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Comment Redaction Co", issuePrefix: `R${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await db.insert(issues).values({ id: issueId, companyId, identifier: "RED-1", title: "Deleted comment redaction", status: "todo", priority: "medium", }); await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board-user-1", status: "active", membershipRole: "owner", updatedAt: new Date(), }); return { companyId, issueId }; } function createApp(companyId: string) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { (req as any).actor = { type: "board", userId: "board-user-1", companyIds: [companyId], memberships: [{ companyId, membershipRole: "owner", status: "active" }], source: "cloud_tenant", // cloud_tenant actors are never instance admins — reads flow through // the active company membership seeded in seedIssue(). isInstanceAdmin: false, }; next(); }); const storage: StorageService = { provider: "local_disk", putFile: vi.fn(async () => { throw new Error("Unexpected storage.putFile call"); }), getObject: vi.fn(async () => { throw new Error("Unexpected storage.getObject call"); }), headObject: vi.fn(async () => ({ exists: false })), deleteObject: vi.fn(async () => undefined), }; app.use("/api", issueRoutes(db, storage)); app.use(errorHandler); return app; } it("redacts deleted comment bodies from ordinary reads, heartbeat context, and wake payloads", async () => { const { companyId, issueId } = await seedIssue(); const commentId = randomUUID(); const deletedAt = new Date("2026-06-03T12:00:00.000Z"); await db.insert(issueComments).values({ id: commentId, companyId, issueId, authorUserId: "board-user-1", body: "secret deleted body", presentation: { kind: "system_notice", tone: "warning" }, metadata: { version: 1, sections: [{ rows: [{ type: "text", text: "secret metadata" }] }], }, deletedAt, deletedByType: "user", deletedByUserId: "board-user-1", }); const comments = await issueService(db).listComments(issueId, { order: "asc", }); expect(comments).toHaveLength(1); expect(comments[0]).toMatchObject({ id: commentId, body: "", presentation: null, metadata: null, deletedAt, deletedByType: "user", deletedByUserId: "board-user-1", }); const exactComment = await issueService(db).getComment(commentId); expect(exactComment?.body).toBe(""); expect(exactComment?.metadata).toBeNull(); const heartbeatContext = await request(createApp(companyId)) .get(`/api/issues/${issueId}/heartbeat-context`) .query({ wakeCommentId: commentId }); expect(heartbeatContext.status, JSON.stringify(heartbeatContext.body)).toBe( 200, ); expect(heartbeatContext.body.wakeComment).toMatchObject({ id: commentId, body: "", metadata: null, deletedByUserId: "board-user-1", }); expect(JSON.stringify(heartbeatContext.body)).not.toContain( "secret deleted body", ); expect(JSON.stringify(heartbeatContext.body)).not.toContain( "secret metadata", ); const wakePayload = await buildPaperclipWakePayload({ db, companyId, contextSnapshot: { issueId, commentId, wakeCommentIds: [commentId], wakeReason: "issue_commented", }, }); expect(wakePayload?.comments).toEqual([ expect.objectContaining({ id: commentId, body: "", bodyTruncated: false, presentation: null, metadata: null, deletedAt: deletedAt.toISOString(), deletedByUserId: "board-user-1", }), ]); expect(JSON.stringify(wakePayload)).not.toContain("secret deleted body"); expect(JSON.stringify(wakePayload)).not.toContain("secret metadata"); }); it("never includes a same-company comment from another issue in a wake payload", async () => { const { companyId, issueId } = await seedIssue(); const foreignIssueId = randomUUID(); await db.insert(issues).values({ id: foreignIssueId, companyId, identifier: "RED-2", title: "Foreign issue", status: "todo", priority: "medium", }); const [sourceComment, foreignComment] = await db .insert(issueComments) .values([ { companyId, issueId, authorUserId: "board-user-1", body: "Full external instruction. TRAILING-CLAUSE: keep this exact requirement.", }, { companyId, issueId: foreignIssueId, authorUserId: "board-user-1", body: "FOREIGN-ISSUE-SECRET must never cross the task boundary", }, ]) .returning(); const wakePayload = await buildPaperclipWakePayload({ db, companyId, contextSnapshot: { issueId, wakeCommentId: sourceComment!.id, wakeCommentIds: [sourceComment!.id, foreignComment!.id], wakeReason: "issue_commented", }, }); expect(wakePayload?.comments).toEqual([ expect.objectContaining({ id: sourceComment!.id, issueId, body: "Full external instruction. TRAILING-CLAUSE: keep this exact requirement.", bodyTruncated: false, }), ]); expect(wakePayload?.commentWindow).toEqual({ requestedCount: 2, includedCount: 1, missingCount: 1, }); expect(JSON.stringify(wakePayload)).not.toContain("FOREIGN-ISSUE-SECRET"); }); it("includes bounded attachment descriptors only for requested comments in the same company and issue", async () => { const { companyId, issueId } = await seedIssue(); const otherIssueId = randomUUID(); const otherCompanyId = randomUUID(); const otherCompanyIssueId = randomUUID(); await db.insert(companies).values({ id: otherCompanyId, name: "Other Attachment Co", issuePrefix: "OAT", requireBoardApprovalForNewAgents: false, }); await db.insert(issues).values([ { id: otherIssueId, companyId, identifier: "RED-ATT-2", title: "Other attachment issue", status: "todo", priority: "medium", }, { id: otherCompanyIssueId, companyId: otherCompanyId, identifier: "OAT-1", title: "Other company attachment issue", status: "todo", priority: "medium", }, ]); const [ sourceComment, unrequestedComment, quarantinedComment, otherIssueComment, otherCompanyComment, ] = await db .insert(issueComments) .values([ { companyId, issueId, authorUserId: "board-user-1", body: "Inspect the attached evidence.", }, { companyId, issueId, authorUserId: "board-user-1", body: "Same task, not part of this wake.", }, { companyId, issueId, body: "Quarantined file instructions.", sourceTrust: { preset: LOW_TRUST_REVIEW_PRESET, disposition: "quarantined", sourceIssueId: issueId, }, }, { companyId, issueId: otherIssueId, authorUserId: "board-user-1", body: "Other task.", }, { companyId: otherCompanyId, issueId: otherCompanyIssueId, authorUserId: "other-user", body: "Other company.", }, ]) .returning(); const attachmentFixtures = [ { companyId, issueId, issueCommentId: sourceComment!.id, filename: "evidence.png", }, { companyId, issueId, issueCommentId: unrequestedComment!.id, filename: "same-task-unrequested.txt", }, { companyId, issueId, issueCommentId: quarantinedComment!.id, filename: "quarantined.txt", }, { companyId, issueId: otherIssueId, issueCommentId: otherIssueComment!.id, filename: "other-task.txt", }, { companyId: otherCompanyId, issueId: otherCompanyIssueId, issueCommentId: otherCompanyComment!.id, filename: "other-company.txt", }, ]; for (const [index, fixture] of attachmentFixtures.entries()) { const [asset] = await db .insert(assets) .values({ companyId: fixture.companyId, provider: "local_disk", objectKey: `wake-attachment-${index}`, contentType: index === 0 ? "image/png" : "text/plain", byteSize: index === 0 ? 2048 : 128, sha256: `sha-${index}`, originalFilename: fixture.filename, createdByUserId: "board-user-1", }) .returning(); await db.insert(issueAttachments).values({ companyId: fixture.companyId, issueId: fixture.issueId, issueCommentId: fixture.issueCommentId, assetId: asset!.id, }); } const wakePayload = await buildPaperclipWakePayload({ db, companyId, contextSnapshot: { issueId, wakeCommentIds: [ sourceComment!.id, sourceComment!.id, quarantinedComment!.id, otherIssueComment!.id, otherCompanyComment!.id, ], wakeReason: "issue_commented", }, }); expect(wakePayload?.comments).toEqual( expect.arrayContaining([ expect.objectContaining({ id: sourceComment!.id, attachments: [ expect.objectContaining({ filename: "evidence.png", contentType: "image/png", byteSize: 2048, }), ], }), expect.objectContaining({ id: quarantinedComment!.id }), ]), ); expect( wakePayload?.comments.find( (comment) => comment.id === quarantinedComment!.id, ), ).not.toHaveProperty("attachments"); expect(JSON.stringify(wakePayload)).not.toContain( "same-task-unrequested.txt", ); expect(JSON.stringify(wakePayload)).not.toContain("other-task.txt"); expect(JSON.stringify(wakePayload)).not.toContain("other-company.txt"); expect(JSON.stringify(wakePayload)).not.toContain("quarantined.txt"); const lowTrustWakePayload = await buildPaperclipWakePayload({ db, companyId, contextSnapshot: { issueId, wakeCommentIds: [quarantinedComment!.id], wakeReason: "issue_commented", }, exposeLowTrustRaw: true, }); expect(lowTrustWakePayload?.comments).toEqual([ expect.objectContaining({ id: quarantinedComment!.id, attachments: [ expect.objectContaining({ filename: "quarantined.txt" }), ], }), ]); }); it("serializes comment timestamps as ISO strings through the redacted comments route (PAP-16607)", async () => { const { companyId, issueId } = await seedIssue(); const commentId = randomUUID(); await db.insert(issueComments).values({ id: commentId, companyId, issueId, authorUserId: "board-user-1", body: "ordinary comment", }); const response = await request(createApp(companyId)).get( `/api/issues/${issueId}/comments`, ); expect(response.status, JSON.stringify(response.body)).toBe(200); expect(response.body).toHaveLength(1); // Secret redaction must not collapse Date instances to `{}` — the chat // renderer needs parseable timestamps. expect(typeof response.body[0].createdAt).toBe("string"); expect(Number.isNaN(new Date(response.body[0].createdAt).getTime())).toBe( false, ); expect(typeof response.body[0].updatedAt).toBe("string"); }); it("excludes deleted comment bodies from company search", async () => { const { companyId, issueId } = await seedIssue(); await db.insert(issueComments).values({ companyId, issueId, body: "vanished-search-needle", deletedAt: new Date("2026-06-03T12:00:00.000Z"), deletedByType: "user", deletedByUserId: "board-user-1", }); const result = await companySearchService(db).search( companyId, companySearchQuerySchema.parse({ q: "vanished-search-needle", scope: "comments", }), ); expect(result.results).toEqual([]); }); it("clears issue references sourced from deleted comment bodies", async () => { const companyId = randomUUID(); const sourceIssueId = randomUUID(); const targetIssueId = randomUUID(); const commentId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Reference Redaction Co", issuePrefix: "REF", requireBoardApprovalForNewAgents: false, }); await db.insert(issues).values([ { id: sourceIssueId, companyId, identifier: "REF-1", title: "Source issue", status: "todo", priority: "medium", }, { id: targetIssueId, companyId, identifier: "REF-2", title: "Target issue", status: "todo", priority: "medium", }, ]); await db.insert(issueComments).values({ id: commentId, companyId, issueId: sourceIssueId, body: "Follow up in REF-2", }); const refs = issueReferenceService(db); await refs.syncComment(commentId); expect( (await refs.listIssueReferenceSummary(sourceIssueId)).outbound.map( (item) => item.issue.id, ), ).toEqual([targetIssueId]); await db.update(issueComments).set({ deletedAt: new Date("2026-06-03T12:00:00.000Z"), deletedByType: "user", deletedByUserId: "board-user-1", }); await refs.syncComment(commentId); expect( (await refs.listIssueReferenceSummary(sourceIssueId)).outbound, ).toEqual([]); }); });