mirror of
https://github.com/documenso/documenso.git
synced 2026-08-24 15:22:26 +10:00
Recipients can download a PDF that contains the inserted fields while the envelope is in the PENDING state. The token file route accepts the `pending` version. The PDF contains only the fields from recipients who signed and the fields of the recipient who makes the request. This is the same set of fields that the signing page shows to the recipient. The signing page and the completion page show the download dialog for pending envelopes. The dialog does not show the "Partial" button for legacy envelopes.
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
|
import { prisma } from '@documenso/prisma';
|
|
import { PDF } from '@libpdf/core';
|
|
import type { APIRequestContext } from '@playwright/test';
|
|
import { expect, test } from '@playwright/test';
|
|
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
|
|
|
|
import { apiSeedDraftDocument, apiSeedPendingDocument } from '../../fixtures/api-seeds';
|
|
|
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
|
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
|
|
|
const trpcMutation = async (request: APIRequestContext, procedure: string, input: Record<string, unknown>) => {
|
|
const res = await request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
},
|
|
data: JSON.stringify({ json: input }),
|
|
});
|
|
|
|
expect(res.ok(), `${procedure} failed: ${await res.text()}`).toBeTruthy();
|
|
};
|
|
|
|
const getPdfBytes = async (response: Awaited<ReturnType<APIRequestContext['get']>>) => {
|
|
const body = await response.body();
|
|
|
|
expect(body.subarray(0, 5).toString()).toBe('%PDF-');
|
|
|
|
return new Uint8Array(body);
|
|
};
|
|
|
|
const signAndCompleteRecipient = async ({
|
|
request,
|
|
token,
|
|
documentId,
|
|
fieldId,
|
|
}: {
|
|
request: APIRequestContext;
|
|
token: string;
|
|
documentId: number;
|
|
fieldId: number;
|
|
}) => {
|
|
await trpcMutation(request, 'envelope.field.sign', {
|
|
token,
|
|
fieldId,
|
|
fieldValue: {
|
|
type: FieldType.SIGNATURE,
|
|
value: 'Signature',
|
|
},
|
|
});
|
|
|
|
await trpcMutation(request, 'recipient.completeDocumentWithToken', {
|
|
token,
|
|
documentId,
|
|
});
|
|
};
|
|
|
|
test.describe('API V2 partial signed PDF downloads', () => {
|
|
test('returns a PDF with inserted fields, supports ETag, and rejects after completion', async ({ request }) => {
|
|
const { envelope, token, distributeResult } = await apiSeedPendingDocument(request, {
|
|
recipients: [
|
|
{ email: 'partial-signer-1@test.documenso.com', name: 'Partial Signer 1' },
|
|
{ email: 'partial-signer-2@test.documenso.com', name: 'Partial Signer 2' },
|
|
],
|
|
fieldsPerRecipient: [
|
|
[{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 15, height: 5 }],
|
|
[
|
|
{
|
|
type: FieldType.SIGNATURE,
|
|
page: 1,
|
|
positionX: 5,
|
|
positionY: 15,
|
|
width: 15,
|
|
height: 5,
|
|
},
|
|
],
|
|
],
|
|
});
|
|
|
|
const [recipientOne, recipientTwo] = distributeResult.recipients;
|
|
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
|
const envelopeItem = envelope.envelopeItems[0];
|
|
const recipientOneField = envelope.fields.find(
|
|
(field) => field.recipientId === recipientOne.id && field.type === FieldType.SIGNATURE,
|
|
);
|
|
const recipientTwoField = envelope.fields.find(
|
|
(field) => field.recipientId === recipientTwo.id && field.type === FieldType.SIGNATURE,
|
|
);
|
|
|
|
if (!recipientOneField || !recipientTwoField) {
|
|
throw new Error('Expected signature fields not found');
|
|
}
|
|
|
|
await signAndCompleteRecipient({
|
|
request,
|
|
token: recipientOne.token,
|
|
documentId,
|
|
fieldId: recipientOneField.id,
|
|
});
|
|
|
|
await expect(async () => {
|
|
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
|
|
where: {
|
|
id: envelope.id,
|
|
},
|
|
include: {
|
|
recipients: true,
|
|
},
|
|
});
|
|
|
|
expect(dbEnvelope.status).toBe(DocumentStatus.PENDING);
|
|
expect(dbEnvelope.recipients.find((recipient) => recipient.id === recipientOne.id)?.signingStatus).toBe(
|
|
SigningStatus.SIGNED,
|
|
);
|
|
}).toPass();
|
|
|
|
const downloadUrl = `${API_BASE_URL}/envelope/item/${envelopeItem.id}/download?version=pending`;
|
|
const pendingResponse = await request.get(downloadUrl, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
|
|
expect(pendingResponse.status()).toBe(200);
|
|
expect(pendingResponse.headers()['content-type']).toContain('application/pdf');
|
|
expect(pendingResponse.headers()['cache-control']).toBe('no-store, private');
|
|
expect(pendingResponse.headers()['content-disposition']).toContain('_pending.pdf');
|
|
|
|
const etag = pendingResponse.headers().etag;
|
|
expect(etag).toBeTruthy();
|
|
|
|
const pendingPdfBytes = await getPdfBytes(pendingResponse);
|
|
const pendingPdf = await PDF.load(pendingPdfBytes);
|
|
|
|
const originalEnvelopeItem = await prisma.envelopeItem.findUniqueOrThrow({
|
|
where: {
|
|
id: envelopeItem.id,
|
|
},
|
|
include: {
|
|
documentData: true,
|
|
},
|
|
});
|
|
const originalPdfBytes = await getFileServerSide({
|
|
type: originalEnvelopeItem.documentData.type,
|
|
data: originalEnvelopeItem.documentData.initialData,
|
|
});
|
|
const originalPdf = await PDF.load(new Uint8Array(originalPdfBytes));
|
|
|
|
// Pending PDF should have the same page count as the original (no cert/audit pages).
|
|
expect(pendingPdf.getPageCount()).toBe(originalPdf.getPageCount());
|
|
|
|
const cachedResponse = await request.get(downloadUrl, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'If-None-Match': etag,
|
|
},
|
|
});
|
|
|
|
expect(cachedResponse.status()).toBe(304);
|
|
|
|
await signAndCompleteRecipient({
|
|
request,
|
|
token: recipientTwo.token,
|
|
documentId,
|
|
fieldId: recipientTwoField.id,
|
|
});
|
|
|
|
await expect(async () => {
|
|
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
|
|
where: {
|
|
id: envelope.id,
|
|
},
|
|
});
|
|
|
|
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
|
|
}).toPass({ timeout: 15_000 });
|
|
|
|
const completedResponse = await request.get(downloadUrl, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
const completedError = await completedResponse.json();
|
|
|
|
expect(completedResponse.status()).toBe(400);
|
|
expect(completedError.code).toBe('ENVELOPE_COMPLETED');
|
|
|
|
const signedResponse = await request.get(
|
|
`${API_BASE_URL}/envelope/item/${envelopeItem.id}/download?version=signed`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(signedResponse.status()).toBe(200);
|
|
await getPdfBytes(signedResponse);
|
|
});
|
|
|
|
test('rejects draft and legacy pending envelopes', async ({ request }) => {
|
|
const draft = await apiSeedDraftDocument(request, {
|
|
recipients: [{ email: 'partial-draft@test.documenso.com', name: 'Draft Signer' }],
|
|
});
|
|
const draftResponse = await request.get(
|
|
`${API_BASE_URL}/envelope/item/${draft.envelope.envelopeItems[0].id}/download?version=pending`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${draft.token}`,
|
|
},
|
|
},
|
|
);
|
|
const draftError = await draftResponse.json();
|
|
|
|
expect(draftResponse.status()).toBe(400);
|
|
expect(draftError.code).toBe('ENVELOPE_DRAFT');
|
|
|
|
const legacy = await apiSeedPendingDocument(request);
|
|
|
|
await prisma.envelope.update({
|
|
where: {
|
|
id: legacy.envelope.id,
|
|
},
|
|
data: {
|
|
internalVersion: 1,
|
|
},
|
|
});
|
|
|
|
const legacyResponse = await request.get(
|
|
`${API_BASE_URL}/envelope/item/${legacy.envelope.envelopeItems[0].id}/download?version=pending`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${legacy.token}`,
|
|
},
|
|
},
|
|
);
|
|
const legacyError = await legacyResponse.json();
|
|
|
|
expect(legacyResponse.status()).toBe(400);
|
|
expect(legacyError.code).toBe('ENVELOPE_LEGACY');
|
|
});
|
|
|
|
test('allows recipients to download the partial PDF via their token', async ({ request }) => {
|
|
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
|
|
recipients: [
|
|
{ email: 'partial-token-1@test.documenso.com', name: 'Partial Token 1' },
|
|
{ email: 'partial-token-2@test.documenso.com', name: 'Partial Token 2' },
|
|
],
|
|
fieldsPerRecipient: [
|
|
[{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 15, height: 5 }],
|
|
[
|
|
{
|
|
type: FieldType.SIGNATURE,
|
|
page: 1,
|
|
positionX: 5,
|
|
positionY: 15,
|
|
width: 15,
|
|
height: 5,
|
|
},
|
|
],
|
|
],
|
|
});
|
|
|
|
const [recipientOne, recipientTwo] = distributeResult.recipients;
|
|
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
|
const envelopeItem = envelope.envelopeItems[0];
|
|
const recipientOneField = envelope.fields.find((field) => field.recipientId === recipientOne.id);
|
|
|
|
if (!recipientOneField) {
|
|
throw new Error('Expected signature field not found');
|
|
}
|
|
|
|
const tokenDownloadUrl = (token: string) =>
|
|
`${WEBAPP_BASE_URL}/api/files/token/${token}/envelopeItem/${envelopeItem.id}/download/pending`;
|
|
|
|
// Recipient one inserts their field without completing the document.
|
|
await trpcMutation(request, 'envelope.field.sign', {
|
|
token: recipientOne.token,
|
|
fieldId: recipientOneField.id,
|
|
fieldValue: {
|
|
type: FieldType.SIGNATURE,
|
|
value: 'Signature',
|
|
},
|
|
});
|
|
|
|
const recipientOneResponse = await request.get(tokenDownloadUrl(recipientOne.token));
|
|
|
|
expect(recipientOneResponse.status()).toBe(200);
|
|
expect(recipientOneResponse.headers()['content-type']).toContain('application/pdf');
|
|
expect(recipientOneResponse.headers()['cache-control']).toBe('no-store, private');
|
|
expect(recipientOneResponse.headers()['content-disposition']).toContain('_pending.pdf');
|
|
await getPdfBytes(recipientOneResponse);
|
|
|
|
// Recipient two must not see recipient one's in-progress field. The ETag is
|
|
// derived from the included fields, so the two downloads must differ.
|
|
const recipientTwoResponse = await request.get(tokenDownloadUrl(recipientTwo.token));
|
|
|
|
expect(recipientTwoResponse.status()).toBe(200);
|
|
await getPdfBytes(recipientTwoResponse);
|
|
|
|
expect(recipientOneResponse.headers().etag).not.toBe(recipientTwoResponse.headers().etag);
|
|
|
|
// Once recipient one completes, their field becomes visible to recipient two.
|
|
await trpcMutation(request, 'recipient.completeDocumentWithToken', {
|
|
token: recipientOne.token,
|
|
documentId,
|
|
});
|
|
|
|
await expect(async () => {
|
|
const dbRecipient = await prisma.recipient.findFirstOrThrow({
|
|
where: {
|
|
id: recipientOne.id,
|
|
},
|
|
});
|
|
|
|
expect(dbRecipient.signingStatus).toBe(SigningStatus.SIGNED);
|
|
}).toPass();
|
|
|
|
const afterCompletionResponse = await request.get(tokenDownloadUrl(recipientTwo.token));
|
|
|
|
expect(afterCompletionResponse.status()).toBe(200);
|
|
expect(afterCompletionResponse.headers().etag).toBe(recipientOneResponse.headers().etag);
|
|
});
|
|
|
|
test('rejects a recipient token pending download once the envelope is completed', async ({ request }) => {
|
|
const { envelope, distributeResult } = await apiSeedPendingDocument(request);
|
|
|
|
const [recipient] = distributeResult.recipients;
|
|
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
|
const recipientField = envelope.fields.find((field) => field.recipientId === recipient.id);
|
|
|
|
if (!recipientField) {
|
|
throw new Error('Expected signature field not found');
|
|
}
|
|
|
|
await signAndCompleteRecipient({
|
|
request,
|
|
token: recipient.token,
|
|
documentId,
|
|
fieldId: recipientField.id,
|
|
});
|
|
|
|
await expect(async () => {
|
|
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
|
|
where: {
|
|
id: envelope.id,
|
|
},
|
|
});
|
|
|
|
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
|
|
}).toPass({ timeout: 15_000 });
|
|
|
|
const completedResponse = await request.get(
|
|
`${WEBAPP_BASE_URL}/api/files/token/${recipient.token}/envelopeItem/${envelope.envelopeItems[0].id}/download/pending`,
|
|
);
|
|
const completedError = await completedResponse.json();
|
|
|
|
expect(completedResponse.status()).toBe(400);
|
|
expect(completedError.code).toBe('ENVELOPE_COMPLETED');
|
|
});
|
|
});
|