chore: merge main, resolve biome formatting conflicts

Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
This commit is contained in:
ephraimduncan
2026-05-12 11:46:11 +00:00
parent 9194884fbe
commit 138d663c25
1959 changed files with 93488 additions and 47038 deletions
@@ -1,8 +1,5 @@
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
@@ -13,6 +10,8 @@ import type {
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
@@ -138,9 +137,7 @@ test.describe('Envelope distribute validation', () => {
expect(errorResponse.message).toContain('Signers must have at least one signature field');
});
test('should fail to distribute when signer has non-signature fields only', async ({
request,
}) => {
test('should fail to distribute when signer has non-signature fields only', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
@@ -321,9 +318,7 @@ test.describe('Envelope distribute validation', () => {
expect(distributeRes.status()).toBe(200);
});
test('should fail when one of multiple signers is missing signature field', async ({
request,
}) => {
test('should fail when one of multiple signers is missing signature field', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
@@ -1,16 +1,13 @@
import { expect, test } from '@playwright/test';
import type { APIRequestContext } from 'playwright-core';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { CreateEmbeddingPresignTokenOptions } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
import type { VerifyEmbeddingPresignTokenOptions } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import type { APIRequestContext } from 'playwright-core';
test.describe('Embedding Presign API', () => {
test('createEmbeddingPresignToken: should create a token with default expiration', async ({
request,
}) => {
test('createEmbeddingPresignToken: should create a token with default expiration', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
@@ -34,9 +31,7 @@ test.describe('Embedding Presign API', () => {
expect(responseData.expiresIn).toBe(3600); // Default 1 hour in seconds
});
test('createEmbeddingPresignToken: should create a token with custom expiration', async ({
request,
}) => {
test('createEmbeddingPresignToken: should create a token with custom expiration', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
@@ -187,9 +182,7 @@ test.describe('Embedding Presign API', () => {
expect(verifyResponseData.success).toBe(true);
});
test('verifyEmbeddingPresignToken: should reject a scope mismatched token', async ({
request,
}) => {
test('verifyEmbeddingPresignToken: should reject a scope mismatched token', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
@@ -233,19 +226,16 @@ const createPresignToken = async (
apiToken: string,
data?: Partial<CreateEmbeddingPresignTokenOptions>,
) => {
return await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
data: {
apiToken,
...data,
},
return await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`, {
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
);
data: {
apiToken,
...data,
},
});
};
const verifyPresignToken = async (
@@ -253,14 +243,11 @@ const verifyPresignToken = async (
apiToken: string,
data: VerifyEmbeddingPresignTokenOptions,
) => {
return await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
data,
return await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`, {
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
);
data,
});
};
@@ -1,9 +1,5 @@
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { pick } from 'remeda';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
@@ -19,17 +15,20 @@ import {
RecipientRole,
} from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TUpdateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types';
import type { TFindEnvelopesResponse } from '@documenso/trpc/server/envelope-router/find-envelopes.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import { pick } from 'remeda';
import { ALIGNMENT_TEST_FIELDS } from '../../../constants/field-alignment-pdf';
import { FIELD_META_TEST_FIELDS } from '../../../constants/field-meta-pdf';
@@ -94,9 +93,7 @@ test.describe('API V2 Envelopes', () => {
const files = [
{
name: 'field-font-alignment.pdf',
data: fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
),
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf')),
},
];
@@ -171,6 +168,7 @@ test.describe('API V2 Envelopes', () => {
positionY: 0,
width: 0,
height: 0,
fieldMeta: { type: 'signature' },
},
{
type: FieldType.SIGNATURE,
@@ -180,6 +178,7 @@ test.describe('API V2 Envelopes', () => {
positionY: 0,
width: 0,
height: 0,
fieldMeta: { type: 'signature' },
},
],
},
@@ -205,7 +204,9 @@ test.describe('API V2 Envelopes', () => {
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerRecipientExpired: true,
ownerDocumentCompleted: true,
ownerDocumentCreated: true,
},
},
attachments: [
@@ -228,9 +229,7 @@ test.describe('API V2 Envelopes', () => {
},
{
name: 'field-font-alignment.pdf',
data: fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
),
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf')),
},
];
@@ -290,15 +289,11 @@ test.describe('API V2 Envelopes', () => {
expect(envelope.documentMeta.dateFormat).toBe(payload.meta.dateFormat);
expect(envelope.documentMeta.distributionMethod).toBe(payload.meta.distributionMethod);
expect(envelope.documentMeta.signingOrder).toBe(payload.meta.signingOrder);
expect(envelope.documentMeta.allowDictateNextSigner).toBe(
payload.meta.allowDictateNextSigner,
);
expect(envelope.documentMeta.allowDictateNextSigner).toBe(payload.meta.allowDictateNextSigner);
expect(envelope.documentMeta.redirectUrl).toBe(payload.meta.redirectUrl);
expect(envelope.documentMeta.language).toBe(payload.meta.language);
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(
payload.meta.uploadSignatureEnabled,
);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(payload.meta.uploadSignatureEnabled);
expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo);
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
@@ -320,9 +315,7 @@ test.describe('API V2 Envelopes', () => {
role: recipient.role,
signingOrder: recipient.signingOrder,
accessAuth: recipient.authOptions?.accessAuth,
}).toEqual(
pick(payload.recipients[0], ['email', 'name', 'role', 'signingOrder', 'accessAuth']),
);
}).toEqual(pick(payload.recipients[0], ['email', 'name', 'role', 'signingOrder', 'accessAuth']));
expect({
type: field.type,
@@ -331,16 +324,7 @@ test.describe('API V2 Envelopes', () => {
positionY: field.positionY.toNumber(),
width: field.width.toNumber(),
height: field.height.toNumber(),
}).toEqual(
pick(payload.recipients[0].fields[0], [
'type',
'page',
'positionX',
'positionY',
'width',
'height',
]),
);
}).toEqual(pick(payload.recipients[0].fields[0], ['type', 'page', 'positionX', 'positionY', 'width', 'height']));
// Expect string based ID to work.
expect(field.envelopeItemId).toBe(
@@ -359,13 +343,9 @@ test.describe('API V2 Envelopes', () => {
*/
test('Envelope full test', async ({ request }) => {
// Step 1: Create initial envelope with Prisma (with first envelope item)
const alignmentPdf = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
);
const alignmentPdf = fs.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'));
const fieldMetaPdf = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-meta.pdf'),
);
const fieldMetaPdf = fs.readFileSync(path.join(__dirname, '../../../../../assets/field-meta.pdf'));
const formData = new FormData();
@@ -378,10 +358,7 @@ test.describe('API V2 Envelopes', () => {
);
// Only add one file for now.
formData.append(
'files',
new File([alignmentPdf], 'field-font-alignment.pdf', { type: 'application/pdf' }),
);
formData.append('files', new File([alignmentPdf], 'field-font-alignment.pdf', { type: 'application/pdf' }));
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
@@ -414,10 +391,7 @@ test.describe('API V2 Envelopes', () => {
const createEnvelopeItemFormData = new FormData();
createEnvelopeItemFormData.append('payload', JSON.stringify(createEnvelopeItemsPayload));
createEnvelopeItemFormData.append(
'files',
new File([fieldMetaPdf], 'field-meta.pdf', { type: 'application/pdf' }),
);
createEnvelopeItemFormData.append('files', new File([fieldMetaPdf], 'field-meta.pdf', { type: 'application/pdf' }));
const createItemsRes = await request.post(`${baseUrl}/envelope/item/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
@@ -477,12 +451,8 @@ test.describe('API V2 Envelopes', () => {
const envelopeResponse = (await getRes.json()) as TGetEnvelopeResponse;
const recipientId = envelopeResponse.recipients[0].id;
const alignmentItem = envelopeResponse.envelopeItems.find(
(item: { order: number }) => item.order === 1,
);
const fieldMetaItem = envelopeResponse.envelopeItems.find(
(item: { order: number }) => item.order === 2,
);
const alignmentItem = envelopeResponse.envelopeItems.find((item: { order: number }) => item.order === 1);
const fieldMetaItem = envelopeResponse.envelopeItems.find((item: { order: number }) => item.order === 2);
expect(recipientId).toBeDefined();
expect(alignmentItem).toBeDefined();
@@ -551,9 +521,7 @@ test.describe('API V2 Envelopes', () => {
// Verify structure
expect(finalEnvelope.envelopeItems.length).toBe(2);
expect(finalEnvelope.recipients.length).toBe(1);
expect(finalEnvelope.fields.length).toBe(
ALIGNMENT_TEST_FIELDS.length + FIELD_META_TEST_FIELDS.length,
);
expect(finalEnvelope.fields.length).toBe(ALIGNMENT_TEST_FIELDS.length + FIELD_META_TEST_FIELDS.length);
expect(finalEnvelope.title).toBe('Envelope Full Field Test');
expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT);
@@ -564,17 +532,11 @@ test.describe('API V2 Envelopes', () => {
});
test.describe('Envelope find endpoint', () => {
const createEnvelope = async (
request: APIRequestContext,
token: string,
payload: TCreateEnvelopePayload,
) => {
const createEnvelope = async (request: APIRequestContext, token: string, payload: TCreateEnvelopePayload) => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const pdfData = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
);
const pdfData = fs.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'));
formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
@@ -1195,9 +1157,7 @@ test.describe('API V2 Envelopes', () => {
expect(distributeResponse.recipients[1].signingUrl).toBeTruthy();
});
test('Distribute envelope with empty email recipient and auth requirements fails', async ({
request,
}) => {
test('Distribute envelope with empty email recipient and auth requirements fails', async ({ request }) => {
const payload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Auth Requirements',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,244 @@
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');
});
});
@@ -1,10 +1,5 @@
import { PDF, StandardFonts } from '@libpdf/core';
import type { APIRequestContext } from '@playwright/test';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
@@ -16,6 +11,10 @@ import type {
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import { PDF, StandardFonts } from '@libpdf/core';
import type { APIRequestContext } from '@playwright/test';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
@@ -66,11 +65,7 @@ test.describe('Placeholder-based field creation', () => {
return res.json();
};
const createEnvelopeItemsWithPdf = async (
request: APIRequestContext,
envelopeId: string,
pdfFilename: string,
) => {
const createEnvelopeItemsWithPdf = async (request: APIRequestContext, envelopeId: string, pdfFilename: string) => {
const pdfPath = path.join(FIXTURES_DIR, pdfFilename);
const pdfData = fs.readFileSync(pdfPath);
@@ -129,10 +124,7 @@ test.describe('Placeholder-based field creation', () => {
expect(res.ok()).toBeTruthy();
};
const getEnvelope = async (
request: APIRequestContext,
envelopeId: string,
): Promise<TGetEnvelopeResponse> => {
const getEnvelope = async (request: APIRequestContext, envelopeId: string): Promise<TGetEnvelopeResponse> => {
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
headers: { Authorization: `Bearer ${token}` },
});
@@ -281,9 +273,7 @@ test.describe('Placeholder-based field creation', () => {
expect(createFieldsRes.ok()).toBeFalsy();
});
test('should create fields using a mix of coordinate and placeholder positioning', async ({
request,
}) => {
test('should create fields using a mix of coordinate and placeholder positioning', async ({ request }) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipient(request, envelope.id);
@@ -409,9 +399,7 @@ test.describe('Placeholder-based field creation', () => {
expect(uniqueYPositions.size).toBe(3);
});
test('should map placeholder recipients by signing order when adding items', async ({
request,
}) => {
test('should map placeholder recipients by signing order when adding items', async ({ request }) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipients(request, envelope.id, [
@@ -1,5 +1,3 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
@@ -12,16 +10,14 @@ import { prisma } from '@documenso/prisma';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe('Template Field Prefill API v2', () => {
test('should create a document from template with prefilled fields', async ({
page,
request,
}) => {
test('should create a document from template with prefilled fields', async ({ page, request }) => {
// 1. Create a user
const { user, team } = await seedUser();
@@ -196,9 +192,7 @@ test.describe('Template Field Prefill API v2', () => {
});
// 7. Navigate to the template
await page.goto(
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
);
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`);
// 8. Create a document from the template with prefilled fields using v2 API
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
@@ -378,10 +372,7 @@ test.describe('Template Field Prefill API v2', () => {
await expect(page.getByText('Select B')).toBeVisible();
});
test('should create a document from template without prefilled fields', async ({
page,
request,
}) => {
test('should create a document from template without prefilled fields', async ({ page, request }) => {
// 1. Create a user
const { user, team } = await seedUser();
@@ -484,9 +475,7 @@ test.describe('Template Field Prefill API v2', () => {
});
// 7. Navigate to the template
await page.goto(
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
);
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`);
// 8. Create a document from the template without prefilled fields using v2 API
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,10 @@
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
@@ -35,33 +34,23 @@ test.describe('Envelope Attachments API V2', () => {
});
test.describe('Envelope attachment find endpoint', () => {
test('should block unauthorized access to envelope attachment find endpoint', async ({
request,
}) => {
test('should block unauthorized access to envelope attachment find endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.get(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
{
headers: { Authorization: `Bearer ${tokenB}` },
},
);
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment find endpoint', async ({
request,
}) => {
test('should allow authorized access to envelope attachment find endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.get(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
{
headers: { Authorization: `Bearer ${tokenA}` },
},
);
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
@@ -69,9 +58,7 @@ test.describe('Envelope Attachments API V2', () => {
});
test.describe('Envelope attachment create endpoint', () => {
test('should block unauthorized access to envelope attachment create endpoint', async ({
request,
}) => {
test('should block unauthorized access to envelope attachment create endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, {
@@ -89,9 +76,7 @@ test.describe('Envelope Attachments API V2', () => {
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment create endpoint', async ({
request,
}) => {
test('should allow authorized access to envelope attachment create endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, {
@@ -111,9 +96,7 @@ test.describe('Envelope Attachments API V2', () => {
});
test.describe('Envelope attachment update endpoint', () => {
test('should block unauthorized access to envelope attachment update endpoint', async ({
request,
}) => {
test('should block unauthorized access to envelope attachment update endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
@@ -140,9 +123,7 @@ test.describe('Envelope Attachments API V2', () => {
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment update endpoint', async ({
request,
}) => {
test('should allow authorized access to envelope attachment update endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
@@ -171,9 +152,7 @@ test.describe('Envelope Attachments API V2', () => {
});
test.describe('Envelope attachment delete endpoint', () => {
test('should block unauthorized access to envelope attachment delete endpoint', async ({
request,
}) => {
test('should block unauthorized access to envelope attachment delete endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
@@ -194,9 +173,7 @@ test.describe('Envelope Attachments API V2', () => {
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment delete endpoint', async ({
request,
}) => {
test('should allow authorized access to envelope attachment delete endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
@@ -1,13 +1,12 @@
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import { EnvelopeType, FolderType } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import { EnvelopeType, FolderType } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
@@ -16,375 +15,366 @@ test.describe.configure({
});
// Todo: Remove skip once the API endpoints are released.
test.describe.skip('Envelope Bulk API V2', () => {
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
test.describe
.skip('Envelope Bulk API V2', () => {
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
test.beforeEach(async () => {
({ user: userA, team: teamA } = await seedUser());
({ token: tokenA } = await createApiToken({
userId: userA.id,
teamId: teamA.id,
tokenName: 'userA',
expiresIn: null,
}));
test.beforeEach(async () => {
({ user: userA, team: teamA } = await seedUser());
({ token: tokenA } = await createApiToken({
userId: userA.id,
teamId: teamA.id,
tokenName: 'userA',
expiresIn: null,
}));
({ user: userB, team: teamB } = await seedUser());
({ token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
}));
});
test.describe('Envelope bulk move endpoint', () => {
test('should block unauthorized access to envelope bulk move endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// UserB tries to move userA's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: null,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.movedCount).toBe(0);
// Verify in database that the document was not modified
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBeNull();
({ user: userB, team: teamB } = await seedUser());
({ token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
}));
});
test('should block moving envelopes to unauthorized folder', async ({ request }) => {
// Create a document owned by userB
const doc = await seedBlankDocument(userB, teamB.id);
test.describe('Envelope bulk move endpoint', () => {
test('should block unauthorized access to envelope bulk move endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
// UserB tries to move userA's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: null,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.movedCount).toBe(0);
// Verify in database that the document was not modified
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBeNull();
});
// UserB tries to move their document to userA's folder
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
test('should block moving envelopes to unauthorized folder', async ({ request }) => {
// Create a document owned by userB
const doc = await seedBlankDocument(userB, teamB.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
});
// UserB tries to move their document to userA's folder
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// Verify in database that the document was not modified
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBeNull();
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
test('should allow authorized access to envelope bulk move endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// Verify in database that the document was not modified
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
});
// UserA moves their own document to their own folder
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.movedCount).toBe(1);
// Verify in database that the document was moved to the folder
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBe(folderA.id);
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBeNull();
test('should only move authorized envelopes when given mixed array of envelope IDs', async ({ request }) => {
// Create documents owned by userA
const docA1 = await seedBlankDocument(userA, teamA.id);
const docA2 = await seedBlankDocument(userA, teamA.id);
// Create a document owned by userB
const docB = await seedBlankDocument(userB, teamB.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
});
// UserA tries to move a mix of their own documents and userB's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docA1.id, docB.id, docA2.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
// Only userA's documents should be moved
expect(body.movedCount).toBe(2);
// Verify userA's documents were moved
const docA1InDb = await prisma.envelope.findFirst({
where: { id: docA1.id },
});
expect(docA1InDb).not.toBeNull();
expect(docA1InDb?.folderId).toBe(folderA.id);
const docA2InDb = await prisma.envelope.findFirst({
where: { id: docA2.id },
});
expect(docA2InDb).not.toBeNull();
expect(docA2InDb?.folderId).toBe(folderA.id);
// Verify userB's document was NOT moved
const docBInDb = await prisma.envelope.findFirst({
where: { id: docB.id },
});
expect(docBInDb).not.toBeNull();
expect(docBInDb?.folderId).toBeNull();
});
test('should move zero envelopes when all envelope IDs in array are unauthorized', async ({ request }) => {
// Create documents owned by userB
const docB1 = await seedBlankDocument(userB, teamB.id);
const docB2 = await seedBlankDocument(userB, teamB.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
});
// UserA tries to move userB's documents
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docB1.id, docB2.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.movedCount).toBe(0);
// Verify userB's documents were NOT moved
const docB1InDb = await prisma.envelope.findFirst({
where: { id: docB1.id },
});
expect(docB1InDb).not.toBeNull();
expect(docB1InDb?.folderId).toBeNull();
const docB2InDb = await prisma.envelope.findFirst({
where: { id: docB2.id },
});
expect(docB2InDb).not.toBeNull();
expect(docB2InDb?.folderId).toBeNull();
});
});
test('should allow authorized access to envelope bulk move endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
test.describe('Envelope bulk delete endpoint', () => {
test('should block unauthorized access to envelope bulk delete endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
// UserB tries to delete userA's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.deletedCount).toBe(0);
// Unauthorized envelope ID should be in failedIds
expect(body.failedIds).toEqual([doc.id]);
// Verify in database that the document still exists
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.id).toBe(doc.id);
expect(docInDb?.deletedAt).toBeNull();
});
// UserA moves their own document to their own folder
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
test('should allow authorized access to envelope bulk delete endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// UserA deletes their own document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [doc.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.deletedCount).toBe(1);
expect(body.failedIds).toEqual([]);
// Verify in database that the document no longer exists
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).toBeNull();
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
test('should only delete authorized envelopes when given mixed array of envelope IDs', async ({ request }) => {
// Create documents owned by userA
const docA1 = await seedBlankDocument(userA, teamA.id);
const docA2 = await seedBlankDocument(userA, teamA.id);
const body = await res.json();
expect(body.movedCount).toBe(1);
// Create a document owned by userB
const docB = await seedBlankDocument(userB, teamB.id);
// Verify in database that the document was moved to the folder
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
// UserA tries to delete a mix of their own documents and userB's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docA1.id, docB.id, docA2.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
// Only userA's documents should be deleted
expect(body.deletedCount).toBe(2);
// Unauthorized envelope ID (docB) should be in failedIds
expect(body.failedIds).toEqual([docB.id]);
// Verify userA's documents were deleted
const docA1InDb = await prisma.envelope.findFirst({
where: { id: docA1.id },
});
expect(docA1InDb).toBeNull();
const docA2InDb = await prisma.envelope.findFirst({
where: { id: docA2.id },
});
expect(docA2InDb).toBeNull();
// Verify userB's document was NOT deleted
const docBInDb = await prisma.envelope.findFirst({
where: { id: docB.id },
});
expect(docBInDb).not.toBeNull();
expect(docBInDb?.id).toBe(docB.id);
expect(docBInDb?.deletedAt).toBeNull();
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBe(folderA.id);
});
test('should delete zero envelopes when all envelope IDs in array are unauthorized', async ({ request }) => {
// Create documents owned by userB
const docB1 = await seedBlankDocument(userB, teamB.id);
const docB2 = await seedBlankDocument(userB, teamB.id);
test('should only move authorized envelopes when given mixed array of envelope IDs', async ({
request,
}) => {
// Create documents owned by userA
const docA1 = await seedBlankDocument(userA, teamA.id);
const docA2 = await seedBlankDocument(userA, teamA.id);
// UserA tries to delete userB's documents
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docB1.id, docB2.id],
},
});
// Create a document owned by userB
const docB = await seedBlankDocument(userB, teamB.id);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
const body = await res.json();
expect(body.deletedCount).toBe(0);
// All unauthorized envelope IDs should be in failedIds
expect(body.failedIds).toEqual(expect.arrayContaining([docB1.id, docB2.id]));
expect(body.failedIds).toHaveLength(2);
// Verify userB's documents were NOT deleted
const docB1InDb = await prisma.envelope.findFirst({
where: { id: docB1.id },
});
expect(docB1InDb).not.toBeNull();
expect(docB1InDb?.id).toBe(docB1.id);
expect(docB1InDb?.deletedAt).toBeNull();
const docB2InDb = await prisma.envelope.findFirst({
where: { id: docB2.id },
});
expect(docB2InDb).not.toBeNull();
expect(docB2InDb?.id).toBe(docB2.id);
expect(docB2InDb?.deletedAt).toBeNull();
});
// UserA tries to move a mix of their own documents and userB's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docA1.id, docB.id, docA2.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
// Only userA's documents should be moved
expect(body.movedCount).toBe(2);
// Verify userA's documents were moved
const docA1InDb = await prisma.envelope.findFirst({
where: { id: docA1.id },
});
expect(docA1InDb).not.toBeNull();
expect(docA1InDb?.folderId).toBe(folderA.id);
const docA2InDb = await prisma.envelope.findFirst({
where: { id: docA2.id },
});
expect(docA2InDb).not.toBeNull();
expect(docA2InDb?.folderId).toBe(folderA.id);
// Verify userB's document was NOT moved
const docBInDb = await prisma.envelope.findFirst({
where: { id: docB.id },
});
expect(docBInDb).not.toBeNull();
expect(docBInDb?.folderId).toBeNull();
});
test('should move zero envelopes when all envelope IDs in array are unauthorized', async ({
request,
}) => {
// Create documents owned by userB
const docB1 = await seedBlankDocument(userB, teamB.id);
const docB2 = await seedBlankDocument(userB, teamB.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
});
// UserA tries to move userB's documents
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docB1.id, docB2.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.movedCount).toBe(0);
// Verify userB's documents were NOT moved
const docB1InDb = await prisma.envelope.findFirst({
where: { id: docB1.id },
});
expect(docB1InDb).not.toBeNull();
expect(docB1InDb?.folderId).toBeNull();
const docB2InDb = await prisma.envelope.findFirst({
where: { id: docB2.id },
});
expect(docB2InDb).not.toBeNull();
expect(docB2InDb?.folderId).toBeNull();
});
});
test.describe('Envelope bulk delete endpoint', () => {
test('should block unauthorized access to envelope bulk delete endpoint', async ({
request,
}) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// UserB tries to delete userA's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.deletedCount).toBe(0);
// Unauthorized envelope ID should be in failedIds
expect(body.failedIds).toEqual([doc.id]);
// Verify in database that the document still exists
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.id).toBe(doc.id);
expect(docInDb?.deletedAt).toBeNull();
});
test('should allow authorized access to envelope bulk delete endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// UserA deletes their own document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [doc.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.deletedCount).toBe(1);
expect(body.failedIds).toEqual([]);
// Verify in database that the document no longer exists
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).toBeNull();
});
test('should only delete authorized envelopes when given mixed array of envelope IDs', async ({
request,
}) => {
// Create documents owned by userA
const docA1 = await seedBlankDocument(userA, teamA.id);
const docA2 = await seedBlankDocument(userA, teamA.id);
// Create a document owned by userB
const docB = await seedBlankDocument(userB, teamB.id);
// UserA tries to delete a mix of their own documents and userB's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docA1.id, docB.id, docA2.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
// Only userA's documents should be deleted
expect(body.deletedCount).toBe(2);
// Unauthorized envelope ID (docB) should be in failedIds
expect(body.failedIds).toEqual([docB.id]);
// Verify userA's documents were deleted
const docA1InDb = await prisma.envelope.findFirst({
where: { id: docA1.id },
});
expect(docA1InDb).toBeNull();
const docA2InDb = await prisma.envelope.findFirst({
where: { id: docA2.id },
});
expect(docA2InDb).toBeNull();
// Verify userB's document was NOT deleted
const docBInDb = await prisma.envelope.findFirst({
where: { id: docB.id },
});
expect(docBInDb).not.toBeNull();
expect(docBInDb?.id).toBe(docB.id);
expect(docBInDb?.deletedAt).toBeNull();
});
test('should delete zero envelopes when all envelope IDs in array are unauthorized', async ({
request,
}) => {
// Create documents owned by userB
const docB1 = await seedBlankDocument(userB, teamB.id);
const docB2 = await seedBlankDocument(userB, teamB.id);
// UserA tries to delete userB's documents
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docB1.id, docB2.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.deletedCount).toBe(0);
// All unauthorized envelope IDs should be in failedIds
expect(body.failedIds).toEqual(expect.arrayContaining([docB1.id, docB2.id]));
expect(body.failedIds).toHaveLength(2);
// Verify userB's documents were NOT deleted
const docB1InDb = await prisma.envelope.findFirst({
where: { id: docB1.id },
});
expect(docB1InDb).not.toBeNull();
expect(docB1InDb?.id).toBe(docB1.id);
expect(docB1InDb?.deletedAt).toBeNull();
const docB2InDb = await prisma.envelope.findFirst({
where: { id: docB2.id },
});
expect(docB2InDb).not.toBeNull();
expect(docB2InDb?.id).toBe(docB2.id);
expect(docB2InDb?.deletedAt).toBeNull();
});
});
});
@@ -0,0 +1,508 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import type { TUpdateEnvelopeItemsRequest } from '@documenso/trpc/server/envelope-router/update-envelope-items.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Update Items Test',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const pdfData = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${authToken}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
const getEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => {
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TGetEnvelopeResponse;
};
/**
* Transition an envelope from DRAFT to PENDING by adding a recipient with a
* signature field and distributing.
*/
const distributeEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => {
const recipientEmail = `signer-${Date.now()}@test.documenso.com`;
// Create a SIGNER recipient.
const recipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
data: [
{
email: recipientEmail,
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
} satisfies TCreateEnvelopeRecipientsRequest,
});
expect(recipientsRes.ok()).toBeTruthy();
const recipients = (await recipientsRes.json()).data;
// Resolve the envelope item ID.
const envelope = await getEnvelope(request, authToken, envelopeId);
const envelopeItemId = envelope.envelopeItems[0].id;
// Create a SIGNATURE field.
const fieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
data: [
{
recipientId: recipients[0].id,
envelopeItemId,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
},
],
},
});
expect(fieldsRes.ok()).toBeTruthy();
// Distribute.
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
} satisfies TDistributeEnvelopeRequest,
});
expect(distributeRes.ok()).toBeTruthy();
};
const updateEnvelopeItems = async (
request: APIRequestContext,
authToken: string,
payload: TUpdateEnvelopeItemsRequest,
) => {
return request.post(`${baseUrl}/envelope/item/update-many`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: payload,
});
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe('Update envelope items', () => {
let user: User;
let team: Team;
let token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-update-items',
expiresIn: null,
}));
});
// -----------------------------------------------------------------------
// DRAFT envelope — full edit allowed
// -----------------------------------------------------------------------
test('should allow updating item title on a DRAFT envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, title: 'New Draft Title' }],
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
where: { id: envelopeItem.id },
});
expect(dbItem.title).toBe('New Draft Title');
});
test('should allow updating item order on a DRAFT envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, order: 5 }],
});
expect(res.ok()).toBeTruthy();
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
where: { id: envelopeItem.id },
});
expect(dbItem.order).toBe(5);
});
// -----------------------------------------------------------------------
// PENDING envelope — title-only edit allowed
// -----------------------------------------------------------------------
test('should allow updating item title on a PENDING envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, title: 'Updated Pending Title' }],
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
where: { id: envelopeItem.id },
});
expect(dbItem.title).toBe('Updated Pending Title');
});
test('should allow title-only update when order matches existing on a PENDING envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
// Send the same order value that already exists — this should be treated as title-only.
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [
{
envelopeItemId: envelopeItem.id,
title: 'Title With Same Order',
order: envelopeItem.order,
},
],
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
where: { id: envelopeItem.id },
});
expect(dbItem.title).toBe('Title With Same Order');
});
test('should create an ENVELOPE_ITEM_UPDATED audit log when updating item title on PENDING envelope', async ({
request,
}) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const originalTitle = envelopeItem.title;
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, title: 'Audited Title' }],
});
expect(res.ok()).toBeTruthy();
const auditLog = await prisma.documentAuditLog.findFirst({
where: {
envelopeId: envelope.id,
type: 'ENVELOPE_ITEM_UPDATED',
},
orderBy: { createdAt: 'desc' },
});
expect(auditLog).not.toBeNull();
const auditData = auditLog!.data as Record<string, unknown>;
expect(auditData.envelopeItemId).toBe(envelopeItem.id);
const changes = auditData.changes as Array<{ field: string; from: string; to: string }>;
expect(changes).toHaveLength(1);
expect(changes[0].field).toBe('title');
expect(changes[0].from).toBe(originalTitle);
expect(changes[0].to).toBe('Audited Title');
});
// -----------------------------------------------------------------------
// PENDING envelope — order change blocked
// -----------------------------------------------------------------------
test('should reject order change on a PENDING envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, order: 99 }],
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('should reject combined title and order change on a PENDING envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [
{
envelopeItemId: envelopeItem.id,
title: 'Should Not Save',
order: 99,
},
],
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
// Verify title was NOT changed.
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
where: { id: envelopeItem.id },
});
expect(dbItem.title).not.toBe('Should Not Save');
});
// -----------------------------------------------------------------------
// COMPLETED envelope — all edits blocked
// -----------------------------------------------------------------------
test('should reject title update on a COMPLETED envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
// Transition to COMPLETED directly via database.
await prisma.envelope.update({
where: { id: envelope.id },
data: { status: DocumentStatus.COMPLETED, completedAt: new Date() },
});
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, title: 'Should Not Save' }],
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
// Verify title was NOT changed.
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
where: { id: envelopeItem.id },
});
expect(dbItem.title).not.toBe('Should Not Save');
});
test('should reject order update on a COMPLETED envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
await prisma.envelope.update({
where: { id: envelope.id },
data: { status: DocumentStatus.COMPLETED, completedAt: new Date() },
});
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, order: 99 }],
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
// -----------------------------------------------------------------------
// REJECTED envelope — all edits blocked
// -----------------------------------------------------------------------
test('should reject title update on a REJECTED envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
// Transition to REJECTED directly via database.
await prisma.envelope.update({
where: { id: envelope.id },
data: { status: DocumentStatus.REJECTED },
});
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, title: 'Should Not Save' }],
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
// Verify title was NOT changed.
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
where: { id: envelopeItem.id },
});
expect(dbItem.title).not.toBe('Should Not Save');
});
test('should reject order update on a REJECTED envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
await prisma.envelope.update({
where: { id: envelope.id },
data: { status: DocumentStatus.REJECTED },
});
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, order: 99 }],
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
// -----------------------------------------------------------------------
// Deleted envelope — all edits blocked
// -----------------------------------------------------------------------
test('should reject title update on a deleted envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
// Soft-delete the envelope.
await prisma.envelope.update({
where: { id: envelope.id },
data: { deletedAt: new Date() },
});
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, title: 'Should Not Save' }],
});
expect(res.ok()).toBeFalsy();
});
// -----------------------------------------------------------------------
// Validation edge cases
// -----------------------------------------------------------------------
test('should reject an empty title on any envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, title: '' }],
});
expect(res.ok()).toBeFalsy();
});
test('should reject update for an envelope item that does not belong to the envelope', async ({ request }) => {
const envelopeA = await createEnvelope(request, token);
const envelopeB = await createEnvelope(request, token);
const envelopeBData = await getEnvelope(request, token, envelopeB.id);
const envelopeBItem = envelopeBData.envelopeItems[0];
// Try to update envelopeB's item via envelopeA's ID.
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelopeA.id,
data: [{ envelopeItemId: envelopeBItem.id, title: 'Cross Envelope' }],
});
expect(res.ok()).toBeFalsy();
});
});