test(audit-tier-5): webhook + cross-port test coverage
Closes the highest-priority gaps from audit HIGH §19 + MED §§20–21: * New tests/integration/documenso-webhook-route.test.ts exercises the receiver route end-to-end: bad-secret rejection, valid-secret + DOCUMENT_SIGNED writes a documentEvents row, dedup via signatureHash refuses replays of the same body. * tests/integration/documents-expired-webhook.test.ts gains a cross-port assertion: two ports holding the same documenso_id, port A receives the expired event, port B's document must NOT flip. Made passing today by extending handleDocumentExpired to accept an optional `portId` and refuse to mutate when the lookup is ambiguous across multiple ports without one. * tests/integration/custom-fields.test.ts gains a Cross-port Isolation describe: definitions in port A invisible from port B, setValues from port B with a port-A fieldId is rejected, getValues for a port-A entity from port B is empty. Deferred: Tier 5.1 (new test suites for portal-auth / users / email-accounts / document-sends / sales-email-config) is a multi-hour test-writing task best handled in a dedicated PR. Each service is already covered indirectly via route + integration tests; the audit's ask is direct service tests with cross-port negative paths, which this commit doesn't address. Test status: 1175/1175 vitest (was 1168), tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §19 (auditor-J Issue 2) + MED §§20–21 (auditor-J Issues 3–4). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -979,25 +979,50 @@ export async function handleDocumentCompleted(eventData: { documentId: string })
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDocumentExpired(eventData: { documentId: string }) {
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: eq(documents.documensoId, eventData.documentId),
|
||||
export async function handleDocumentExpired(eventData: { documentId: string; portId?: string }) {
|
||||
// Port-scoped lookup when the caller resolved a portId from the
|
||||
// webhook signature. Two ports holding the same Documenso instance
|
||||
// (or migrating between instances with id reuse) would otherwise
|
||||
// share a documensoId across tenants, and findFirst would return
|
||||
// whichever row sorted first — flipping a foreign-port document.
|
||||
const matches = await db.query.documents.findMany({
|
||||
where: eventData.portId
|
||||
? and(eq(documents.documensoId, eventData.documentId), eq(documents.portId, eventData.portId))
|
||||
: eq(documents.documensoId, eventData.documentId),
|
||||
});
|
||||
if (!doc) {
|
||||
|
||||
if (matches.length === 0) {
|
||||
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
||||
return;
|
||||
}
|
||||
|
||||
if (matches.length > 1 && !eventData.portId) {
|
||||
// Cross-tenant ambiguity. Refuse to mutate without a resolved port —
|
||||
// safer to drop the event (the cron expiry sweep will catch up) than
|
||||
// flip the wrong document.
|
||||
logger.error(
|
||||
{
|
||||
documensoId: eventData.documentId,
|
||||
matchCount: matches.length,
|
||||
ports: matches.map((m) => m.portId),
|
||||
},
|
||||
'Document expired webhook ambiguous across multiple ports — refusing to mutate',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = matches[0]!;
|
||||
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'expired', updatedAt: new Date() })
|
||||
.where(eq(documents.id, doc.id));
|
||||
.where(and(eq(documents.id, doc.id), eq(documents.portId, doc.portId)));
|
||||
|
||||
if (doc.interestId && doc.documentType === 'eoi') {
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ eoiStatus: 'expired', updatedAt: new Date() })
|
||||
.where(eq(interests.id, doc.interestId));
|
||||
.where(and(eq(interests.id, doc.interestId), eq(interests.portId, doc.portId)));
|
||||
}
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
|
||||
Reference in New Issue
Block a user