diff --git a/packages/app-tests/e2e/scenarios/acroform-import.spec.ts b/packages/app-tests/e2e/scenarios/acroform-import.spec.ts index 22be28540..b25486ce7 100644 --- a/packages/app-tests/e2e/scenarios/acroform-import.spec.ts +++ b/packages/app-tests/e2e/scenarios/acroform-import.spec.ts @@ -13,8 +13,10 @@ import type { TCreateEnvelopePayload, TCreateEnvelopeResponse, } from '@documenso/trpc/server/envelope-router/create-envelope.types'; -import { PDF } from '@libpdf/core'; -import { type APIRequestContext, expect, test } from '@playwright/test'; +import { PDF, PdfString } from '@libpdf/core'; +import { type APIRequestContext, expect, type Page, test } from '@playwright/test'; + +import { apiSignin } from '../fixtures/authentication'; const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`; @@ -40,8 +42,9 @@ const API_REQUEST_METADATA: ApiRequestMetadata = { }; type TestUser = Awaited>['user']; +type TestTeam = Awaited>['team']; -const seedUserWithApiToken = async (): Promise<{ token: string; user: TestUser }> => { +const seedUserWithApiToken = async (): Promise<{ token: string; user: TestUser; team: TestTeam }> => { const { user, team } = await seedUser(); const { token } = await createApiToken({ userId: user.id, @@ -50,7 +53,7 @@ const seedUserWithApiToken = async (): Promise<{ token: string; user: TestUser } expiresIn: null, }); - return { token, user }; + return { token, user, team }; }; const pdfHasFormFields = async (pdf: Uint8Array): Promise => { @@ -60,19 +63,36 @@ const pdfHasFormFields = async (pdf: Uint8Array): Promise => { return (form?.fieldCount ?? 0) > 0; }; +const createSignedSignatureAcroFormPdf = (): Promise => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: 'letter' }); + const form = pdf.getOrCreateForm(); + const textField = form.createTextField('full_name'); + const signatureField = form.createSignatureField('signed_signature'); + + page.drawField(textField, { x: 100, y: 700, width: 200, height: 24 }); + signatureField.getDict().set('V', PdfString.fromString('fake-signature')); + + return pdf.save(); +}; + const uploadAcroFormEnvelope = async ({ request, token, payload = ACROFORM_DOCUMENT_PAYLOAD, + file = ACROFORM_FIXTURE, + fileName = 'acroform-import-test.pdf', }: { request: APIRequestContext; token: string; payload?: TCreateEnvelopePayload; + file?: Uint8Array; + fileName?: string; }): Promise => { const formData = new FormData(); formData.append('payload', JSON.stringify(payload)); - formData.append('files', new File([ACROFORM_FIXTURE], 'acroform-import-test.pdf', { type: 'application/pdf' })); + formData.append('files', new File([file], fileName, { type: 'application/pdf' })); const res = await request.post(`${baseUrl}/envelope/create`, { headers: { Authorization: `Bearer ${token}` }, @@ -84,6 +104,23 @@ const uploadAcroFormEnvelope = async ({ return (await res.json()) as TCreateEnvelopeResponse; }; +const importAcroFormFieldsWithSession = ({ + page, + teamId, + envelopeId, +}: { + page: Page; + teamId: number; + envelopeId: string; +}) => + page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.importFromPdf`, { + headers: { + 'content-type': 'application/json', + 'x-team-id': String(teamId), + }, + data: JSON.stringify({ json: { envelopeId } }), + }); + const loadEnvelopeForImport = async (envelopeId: string) => prisma.envelope.findUniqueOrThrow({ where: { id: envelopeId }, @@ -237,4 +274,95 @@ test.describe('AcroForm Import', () => { expect(after.fields.length).toBeGreaterThanOrEqual(8); expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true); }); + + test('import endpoint rejects template envelopes without mutating stored widgets', async ({ page, request }) => { + const { token, user, team } = await seedUserWithApiToken(); + + const response = await uploadAcroFormEnvelope({ + request, + token, + payload: { + type: EnvelopeType.TEMPLATE, + title: 'AcroForm template', + }, + }); + + await apiSignin({ page, email: user.email }); + + const res = await importAcroFormFieldsWithSession({ + page, + teamId: team.id, + envelopeId: response.id, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + + const after = await prisma.envelope.findUniqueOrThrow({ + where: { id: response.id }, + include: { + envelopeItems: { include: { documentData: true } }, + fields: true, + }, + }); + + expect(after.fields).toHaveLength(0); + + const pdfBuffer = await getFileServerSide(after.envelopeItems[0].documentData); + + expect(await pdfHasFormFields(pdfBuffer)).toBe(true); + }); + + test('import does not duplicate fields when signed signatures prevent flattening', async ({ request }) => { + const { token } = await seedUserWithApiToken(); + const signedPdf = await createSignedSignatureAcroFormPdf(); + + const response = await uploadAcroFormEnvelope({ + request, + token, + file: signedPdf, + fileName: 'signed-acroform.pdf', + }); + + const envelope = await loadEnvelopeForImport(response.id); + + const firstResult = await UNSAFE_importAcroFormFieldsFromEnvelope({ + envelope, + apiRequestMetadata: API_REQUEST_METADATA, + }); + + expect(firstResult.fieldsCreated).toBeGreaterThan(0); + expect(firstResult.itemsProcessed).toBe(1); + expect(firstResult.signedSignatureCount).toBe(1); + + const afterFirst = await prisma.envelope.findUniqueOrThrow({ + where: { id: response.id }, + include: { + envelopeItems: { include: { documentData: true } }, + fields: true, + }, + }); + const firstFieldCount = afterFirst.fields.length; + + const preservedPdf = await getFileServerSide(afterFirst.envelopeItems[0].documentData); + + expect(await pdfHasFormFields(preservedPdf)).toBe(true); + + const secondEnvelope = await loadEnvelopeForImport(response.id); + + const secondResult = await UNSAFE_importAcroFormFieldsFromEnvelope({ + envelope: secondEnvelope, + apiRequestMetadata: API_REQUEST_METADATA, + }); + + expect(secondResult.fieldsCreated).toBe(0); + expect(secondResult.itemsProcessed).toBe(0); + + const afterSecond = await prisma.envelope.findUniqueOrThrow({ + where: { id: response.id }, + include: { fields: true }, + }); + + expect(afterSecond.fields).toHaveLength(firstFieldCount); + }); }); diff --git a/packages/lib/server-only/envelope-item/import-acroform-fields.ts b/packages/lib/server-only/envelope-item/import-acroform-fields.ts index 9a38825cb..1aa9ab9f6 100644 --- a/packages/lib/server-only/envelope-item/import-acroform-fields.ts +++ b/packages/lib/server-only/envelope-item/import-acroform-fields.ts @@ -1,6 +1,7 @@ import { prisma } from '@documenso/prisma'; import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client'; import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client'; +import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; import { nanoid } from '../../universal/id'; @@ -53,6 +54,12 @@ export const UNSAFE_importAcroFormFieldsFromEnvelope = async ({ envelope, apiRequestMetadata, }: UnsafeImportAcroFormFieldsOptions): Promise => { + if (envelope.type !== EnvelopeType.DOCUMENT) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'AcroForm import is only supported for document envelopes', + }); + } + const prepared: PreparedItem[] = await Promise.all( envelope.envelopeItems.map(async (item): Promise => { const buffer = await getFileServerSide(item.documentData); @@ -182,6 +189,54 @@ export const UNSAFE_importAcroFormFieldsFromEnvelope = async ({ })[0]; }; + const signedItemIds = prepared + .filter((item) => item.extraction.hasSignedSignature && item.extraction.fields.length > 0) + .map((item) => item.envelopeItemId); + + const alreadyImportedSignedItemIds = new Set(); + + if (signedItemIds.length > 0) { + const existingImportedFields = await tx.field.findMany({ + where: { + envelopeId: envelope.id, + envelopeItemId: { + in: signedItemIds, + }, + }, + select: { + envelopeItemId: true, + fieldMeta: true, + }, + }); + + for (const field of existingImportedFields) { + const fieldMeta = field.fieldMeta; + + if ( + fieldMeta && + typeof fieldMeta === 'object' && + !Array.isArray(fieldMeta) && + (fieldMeta as { source?: unknown }).source === 'acroform' + ) { + alreadyImportedSignedItemIds.add(field.envelopeItemId); + } + } + } + + const itemsToImport = prepared.filter((item) => { + if (item.extraction.fields.length === 0) { + return false; + } + + return !(item.extraction.hasSignedSignature && alreadyImportedSignedItemIds.has(item.envelopeItemId)); + }); + + const createdFields: Field[] = []; + + if (itemsToImport.length === 0) { + return { createdFields, importedItemsCount: 0 }; + } + let recipient = pickFirstSignableRecipient( await tx.recipient.findMany({ where: { envelopeId: envelope.id }, @@ -207,14 +262,9 @@ export const UNSAFE_importAcroFormFieldsFromEnvelope = async ({ }); } - const createdFields: Field[] = []; let importedItemsCount = 0; - for (const item of prepared) { - if (item.extraction.fields.length === 0) { - continue; - } - + for (const item of itemsToImport) { if (item.newDocumentData) { await tx.envelopeItem.update({ where: { id: item.envelopeItemId }, diff --git a/packages/trpc/server/envelope-router/import-acroform-fields.ts b/packages/trpc/server/envelope-router/import-acroform-fields.ts index 5f0f6d16e..09119f927 100644 --- a/packages/trpc/server/envelope-router/import-acroform-fields.ts +++ b/packages/trpc/server/envelope-router/import-acroform-fields.ts @@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields'; import { prisma } from '@documenso/prisma'; -import { DocumentStatus } from '@prisma/client'; +import { DocumentStatus, EnvelopeType } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; import { @@ -21,7 +21,7 @@ export const importAcroFormFieldsRoute = authenticatedProcedure const { envelopeWhereInput } = await getEnvelopeWhereInput({ id: { type: 'envelopeId', id: envelopeId }, - type: null, + type: EnvelopeType.DOCUMENT, userId: user.id, teamId, });