Merge branch 'main' into feat/document-file-conversion

Resolved conflicts:
- create-document-data.ts: merged initialData + originalData/originalMimeType
- files.helpers.ts: kept both imports
- envelope-drop-zone-wrapper.tsx: adopted buildDropzoneRejectionDescription
- create-envelope-items.ts: adopted UNSAFE_createEnvelopeItems
- create-envelope.ts: integrated convertToPdfIfNeeded into createEnvelopeRouteCaller

Extended putPdfFileServerSide to accept { initialData, originalData,
originalMimeType } options; updated seal-document.handler call site.
This commit is contained in:
ephraimduncan
2026-04-20 10:11:35 +00:00
1127 changed files with 107456 additions and 22991 deletions
+40 -6
View File
@@ -43,6 +43,9 @@ import {
const c = initContract();
const deprecatedDescription =
'This endpoint is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api.';
export const ApiContractV1 = c.router(
{
getDocuments: {
@@ -55,6 +58,8 @@ export const ApiContractV1 = c.router(
404: ZUnsuccessfulResponseSchema,
},
summary: 'Get all documents',
deprecated: true,
description: deprecatedDescription,
},
getDocument: {
@@ -66,6 +71,8 @@ export const ApiContractV1 = c.router(
404: ZUnsuccessfulResponseSchema,
},
summary: 'Get a single document',
deprecated: true,
description: deprecatedDescription,
},
downloadSignedDocument: {
@@ -78,6 +85,8 @@ export const ApiContractV1 = c.router(
404: ZUnsuccessfulResponseSchema,
},
summary: 'Download a signed document when the storage transport is S3',
deprecated: true,
description: deprecatedDescription,
},
createDocument: {
@@ -90,6 +99,8 @@ export const ApiContractV1 = c.router(
404: ZUnsuccessfulResponseSchema,
},
summary: 'Upload a new document and get a presigned URL',
deprecated: true,
description: deprecatedDescription,
},
createTemplate: {
@@ -102,6 +113,8 @@ export const ApiContractV1 = c.router(
404: ZUnsuccessfulResponseSchema,
},
summary: 'Create a new template and get a presigned URL',
deprecated: true,
description: deprecatedDescription,
},
deleteTemplate: {
@@ -114,6 +127,8 @@ export const ApiContractV1 = c.router(
404: ZUnsuccessfulResponseSchema,
},
summary: 'Delete a template',
deprecated: true,
description: deprecatedDescription,
},
getTemplate: {
@@ -125,6 +140,8 @@ export const ApiContractV1 = c.router(
404: ZUnsuccessfulResponseSchema,
},
summary: 'Get a single template',
deprecated: true,
description: deprecatedDescription,
},
getTemplates: {
@@ -137,6 +154,8 @@ export const ApiContractV1 = c.router(
404: ZUnsuccessfulResponseSchema,
},
summary: 'Get all templates',
deprecated: true,
description: deprecatedDescription,
},
createDocumentFromTemplate: {
@@ -150,7 +169,7 @@ export const ApiContractV1 = c.router(
},
summary: 'Create a new document from an existing template',
deprecated: true,
description: `This has been deprecated in favour of "/api/v1/templates/:templateId/generate-document". You may face unpredictable behavior using this endpoint as it is no longer maintained.`,
description: `${deprecatedDescription} \n\nIf you must use the V1 API, use "/api/v1/templates/:templateId/generate-document" instead.`,
},
generateDocumentFromTemplate: {
@@ -165,8 +184,8 @@ export const ApiContractV1 = c.router(
500: ZUnsuccessfulResponseSchema,
},
summary: 'Create a new document from an existing template',
description:
'Create a new document from an existing template. Passing in values for title and meta will override the original values defined in the template. If you do not pass in values for recipients, it will use the values defined in the template.',
deprecated: true,
description: `${deprecatedDescription} \n\nCreate a new document from an existing template. Passing in values for title and meta will override the original values defined in the template. If you do not pass in values for recipients, it will use the values defined in the template.`,
},
sendDocument: {
@@ -181,9 +200,8 @@ export const ApiContractV1 = c.router(
500: ZUnsuccessfulResponseSchema,
},
summary: 'Send a document for signing',
// I'm aware this should be in the variable itself, which it is, however it's difficult for users to find in our current UI.
description:
'Notes\n\n`sendEmail` - Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links. Defaults to true',
deprecated: true,
description: `${deprecatedDescription} \n\nNotes\n\nsendEmail - Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links. Defaults to true`,
},
resendDocument: {
@@ -198,6 +216,8 @@ export const ApiContractV1 = c.router(
500: ZUnsuccessfulResponseSchema,
},
summary: 'Re-send a document for signing',
deprecated: true,
description: deprecatedDescription,
},
deleteDocument: {
@@ -210,6 +230,8 @@ export const ApiContractV1 = c.router(
404: ZUnsuccessfulResponseSchema,
},
summary: 'Delete a document',
deprecated: true,
description: deprecatedDescription,
},
createRecipient: {
@@ -224,6 +246,8 @@ export const ApiContractV1 = c.router(
500: ZUnsuccessfulResponseSchema,
},
summary: 'Create a recipient for a document',
deprecated: true,
description: deprecatedDescription,
},
updateRecipient: {
@@ -238,6 +262,8 @@ export const ApiContractV1 = c.router(
500: ZUnsuccessfulResponseSchema,
},
summary: 'Update a recipient for a document',
deprecated: true,
description: deprecatedDescription,
},
deleteRecipient: {
@@ -252,6 +278,8 @@ export const ApiContractV1 = c.router(
500: ZUnsuccessfulResponseSchema,
},
summary: 'Delete a recipient from a document',
deprecated: true,
description: deprecatedDescription,
},
createField: {
@@ -266,6 +294,8 @@ export const ApiContractV1 = c.router(
500: ZUnsuccessfulResponseSchema,
},
summary: 'Create a field for a document',
deprecated: true,
description: deprecatedDescription,
},
updateField: {
@@ -280,6 +310,8 @@ export const ApiContractV1 = c.router(
500: ZUnsuccessfulResponseSchema,
},
summary: 'Update a field for a document',
deprecated: true,
description: deprecatedDescription,
},
deleteField: {
@@ -294,6 +326,8 @@ export const ApiContractV1 = c.router(
500: ZUnsuccessfulResponseSchema,
},
summary: 'Delete a field from a document',
deprecated: true,
description: deprecatedDescription,
},
},
{
+6 -7
View File
@@ -67,6 +67,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
perPage,
userId: user.id,
teamId: team.id,
folderId: args.query.folderId,
});
return {
@@ -77,6 +78,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
externalId: document.externalId,
userId: document.userId,
teamId: document.teamId,
folderId: document.folderId,
title: document.title,
status: document.status,
createdAt: document.createdAt,
@@ -164,6 +166,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
externalId: envelope.externalId,
userId: envelope.userId,
teamId: envelope.teamId,
folderId: envelope.folderId,
title: envelope.title,
status: envelope.status,
createdAt: envelope.createdAt,
@@ -796,6 +799,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
title: body.title,
},
attachments: body.attachments,
formValues: body.formValues,
requestMetadata: metadata,
});
@@ -1043,12 +1047,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
},
};
} catch (err) {
return {
status: 500,
body: {
message: 'An error has occured while sending the document for signing',
},
};
return AppError.toRestAPIError(err);
}
}),
@@ -1392,7 +1391,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
throw new Error('Invalid page number');
}
const recipient = await prisma.recipient.findFirst({
const recipient = await tx.recipient.findFirst({
where: {
id: Number(recipientId),
envelopeId: envelope.id,
+2 -1
View File
@@ -11,7 +11,8 @@ export const OpenAPIV1 = Object.assign(
info: {
title: 'Documenso API',
version: '1.0.0',
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
description:
'API V1 is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.',
},
servers: [
{
+19 -12
View File
@@ -24,6 +24,7 @@ import {
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { zEmail } from '@documenso/lib/utils/zod';
extendZodWithOpenApi(z);
@@ -35,6 +36,10 @@ export const ZNoBodyMutationSchema = null;
export const ZGetDocumentsQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(10),
folderId: z
.string()
.describe('Filter documents by folder ID. When omitted, returns root documents.')
.optional(),
});
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
@@ -48,6 +53,7 @@ export const ZSuccessfulDocumentResponseSchema = z.object({
externalId: z.string().nullish(),
userId: z.number(),
teamId: z.number().nullish(),
folderId: z.string().nullish(),
title: z.string(),
status: z.string(),
createdAt: z.date(),
@@ -145,7 +151,7 @@ export const ZCreateDocumentMutationSchema = z.object({
recipients: z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
}),
@@ -219,7 +225,7 @@ export const ZCreateDocumentMutationResponseSchema = z.object({
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
email: zEmail().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().nullish(),
@@ -239,7 +245,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
recipients: z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
}),
@@ -294,7 +300,7 @@ export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
email: zEmail().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
@@ -321,7 +327,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
.array(
z.object({
id: z.number(),
email: z.string().email(),
email: zEmail(),
name: z.string().optional(),
signingOrder: z.number().optional(),
}),
@@ -381,7 +387,7 @@ export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
email: zEmail().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().nullish(),
@@ -397,7 +403,7 @@ export type TGenerateDocumentFromTemplateMutationResponseSchema = z.infer<
export const ZCreateRecipientMutationSchema = z.object({
name: z.string().min(1),
email: z.string().email().min(1),
email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
authOptions: z
@@ -432,13 +438,13 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
// !: This handles the fact that we have null documentId's for templates
// !: while we won't need the default we must add it to satisfy typescript
documentId: z.number().nullish().default(-1),
email: z.string().email().min(1),
email: zEmail().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().nullish(),
token: z.string(),
// !: Not used for now
// expired: z.string(),
expiresAt: z.date().nullish(),
expirationNotifiedAt: z.date().nullish(),
signedAt: z.date().nullable(),
readStatus: z.nativeEnum(ReadStatus),
signingStatus: z.nativeEnum(SigningStatus),
@@ -571,12 +577,13 @@ export const ZRecipientSchema = z.object({
id: z.number(),
documentId: z.number().nullish(),
templateId: z.number().nullish(),
email: z.string().email().min(1),
email: zEmail().min(1),
name: z.string(),
token: z.string(),
signingOrder: z.number().nullish(),
documentDeletedAt: z.date().nullish(),
expired: z.date().nullish(),
expiresAt: z.date().nullish(),
expirationNotifiedAt: z.date().nullish(),
signedAt: z.date().nullish(),
authOptions: z.unknown(),
role: z.nativeEnum(RecipientRole),
@@ -0,0 +1,468 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import {
seedCompletedDocument,
seedDocuments,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
const trpcDocumentSearch = async (page: Page, query: string) => {
const inputParam = encodeURIComponent(JSON.stringify({ json: { query } }));
const url = `${WEBAPP_BASE_URL}/api/trpc/document.search?input=${inputParam}`;
const res = await page.context().request.get(url);
return {
res,
data: res.ok()
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
((await res.json()).result.data.json as Array<{
title: string;
path: string;
value: string;
}>)
: null,
};
};
// ─── Visibility ──────────────────────────────────────────────────────────────
test.describe('Document Search - Visibility', () => {
test('should respect team document visibility per role', async ({ page }) => {
const { user: owner, organisation, team } = await seedUser();
const [adminUser, managerUser, memberUser] = await seedOrganisationMembers({
organisationId: organisation.id,
members: [
{ organisationRole: OrganisationMemberRole.ADMIN },
{ organisationRole: OrganisationMemberRole.MEMBER },
{ organisationRole: OrganisationMemberRole.MEMBER },
],
});
const managerTeamGroup = await prisma.teamGroup.findFirstOrThrow({
where: { teamId: team.id, teamRole: TeamMemberRole.MANAGER },
include: { organisationGroup: true },
});
const managerOrganisationMember = await prisma.organisationMember.findFirstOrThrow({
where: { organisationId: organisation.id, userId: managerUser.id },
});
await prisma.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
groupId: managerTeamGroup.organisationGroupId,
organisationMemberId: managerOrganisationMember.id,
},
});
await seedDocuments([
{
sender: owner,
teamId: team.id,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
visibility: 'EVERYONE',
title: 'Searchable Document for Everyone',
},
},
{
sender: owner,
teamId: team.id,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
visibility: 'MANAGER_AND_ABOVE',
title: 'Searchable Document for Managers',
},
},
{
sender: owner,
teamId: team.id,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
visibility: 'ADMIN',
title: 'Searchable Document for Admins',
},
},
]);
const testCases = [
{ user: adminUser, visibleDocs: 3 },
{ user: managerUser, visibleDocs: 2 },
{ user: memberUser, visibleDocs: 1 },
];
for (const { user, visibleDocs } of testCases) {
await apiSignin({ page, email: user.email });
const { data } = await trpcDocumentSearch(page, 'Searchable Document');
expect(data).not.toBeNull();
expect(data).toHaveLength(visibleDocs);
await apiSignout({ page });
}
});
test('should respect visibility when searching by recipient email', async ({ page }) => {
const { team, owner } = await seedTeam();
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { user: uniqueRecipient } = await seedUser();
await seedDocuments([
{
sender: owner,
recipients: [uniqueRecipient],
type: DocumentStatus.COMPLETED,
teamId: team.id,
documentOptions: {
visibility: 'ADMIN',
title: 'Admin Document for Unique Recipient',
},
},
]);
// Admin can find the ADMIN-visibility document by recipient email.
await apiSignin({ page, email: adminUser.email });
const { data: adminData } = await trpcDocumentSearch(page, uniqueRecipient.email);
expect(adminData).not.toBeNull();
expect(adminData).toHaveLength(1);
expect(adminData![0].title).toBe('Admin Document for Unique Recipient');
await apiSignout({ page });
// Member cannot find the ADMIN-visibility document by recipient email.
await apiSignin({ page, email: memberUser.email });
const { data: memberData } = await trpcDocumentSearch(page, uniqueRecipient.email);
expect(memberData).not.toBeNull();
expect(memberData).toHaveLength(0);
await apiSignout({ page });
});
});
// ─── Cross-Team Isolation ────────────────────────────────────────────────────
test.describe('Document Search - Cross-Team Isolation', () => {
test('should not reveal documents from other teams', async ({ page }) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const { team: teamB, owner: ownerB } = await seedTeam();
const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER });
await seedDocuments([
{
sender: ownerA,
teamId: teamA.id,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
visibility: 'EVERYONE',
title: 'Unique Team A Document',
},
},
{
sender: ownerB,
teamId: teamB.id,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
visibility: 'EVERYONE',
title: 'Unique Team B Document',
},
},
]);
await apiSignin({ page, email: memberA.email });
const { data } = await trpcDocumentSearch(page, 'Unique');
expect(data).not.toBeNull();
const titles = data!.map((d) => d.title);
expect(titles).toContain('Unique Team A Document');
expect(titles).not.toContain('Unique Team B Document');
await apiSignout({ page });
});
test('should not cross team boundaries when searching SQL wildcard "%"', async ({ page }) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const { team: teamB, owner: ownerB } = await seedTeam();
await seedDocuments([
{
sender: ownerA,
teamId: teamA.id,
recipients: [],
type: DocumentStatus.DRAFT,
documentOptions: { title: 'Wildcard Doc A' },
},
]);
await seedDocuments([
{
sender: ownerB,
teamId: teamB.id,
recipients: [],
type: DocumentStatus.DRAFT,
documentOptions: { title: 'Wildcard Doc B' },
},
]);
await apiSignin({ page, email: ownerA.email });
const { data } = await trpcDocumentSearch(page, '%');
expect(data).not.toBeNull();
expect(data!.map((d) => d.title)).not.toContain('Wildcard Doc B');
await apiSignout({ page });
});
test('should not cross team boundaries when searching SQL wildcard "_"', async ({ page }) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const { team: teamB, owner: ownerB } = await seedTeam();
await seedDocuments([
{
sender: ownerA,
teamId: teamA.id,
recipients: [],
type: DocumentStatus.DRAFT,
documentOptions: { title: 'Underscore A' },
},
]);
await seedDocuments([
{
sender: ownerB,
teamId: teamB.id,
recipients: [],
type: DocumentStatus.DRAFT,
documentOptions: { title: 'Underscore B' },
},
]);
await apiSignin({ page, email: ownerA.email });
const { data } = await trpcDocumentSearch(page, '_');
expect(data).not.toBeNull();
expect(data!.map((d) => d.title)).not.toContain('Underscore B');
await apiSignout({ page });
});
});
// ─── Recipient Search ────────────────────────────────────────────────────────
test.describe('Document Search - Recipient', () => {
test('should find documents where user is a recipient', async ({ page }) => {
const { team: senderTeam, owner: sender } = await seedTeam();
const { user: recipient } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Recipient Search Test Doc' },
});
await apiSignin({ page, email: recipient.email });
const { data } = await trpcDocumentSearch(page, 'Recipient Search Test');
expect(data).not.toBeNull();
expect(data!.length).toBeGreaterThanOrEqual(1);
const result = data!.find((d) => d.title === 'Recipient Search Test Doc');
expect(result).toBeDefined();
expect(result!.path).toMatch(/^\/sign\/.+/);
await apiSignout({ page });
});
});
// ─── Token Masking ───────────────────────────────────────────────────────────
test.describe('Document Search - Token Masking', () => {
test('should not expose signing token in value field', async ({ page }) => {
const { team, owner } = await seedTeam();
const { user: recipient } = await seedUser();
const doc = await seedPendingDocument(owner, team.id, [recipient], {
createDocumentOptions: { title: 'Token Leakage Test Document' },
});
const recipientRecord = await prisma.recipient.findFirstOrThrow({
where: { envelopeId: doc.id, email: recipient.email },
select: { token: true },
});
const signingToken = recipientRecord.token;
// Owner should see the doc but not any signing token.
await apiSignin({ page, email: owner.email });
const { data } = await trpcDocumentSearch(page, 'Token Leakage Test');
expect(data).not.toBeNull();
expect(data!.length).toBeGreaterThanOrEqual(1);
const result = data!.find((d) => d.title === 'Token Leakage Test Document');
expect(result).toBeDefined();
expect(result!.value).not.toContain(signingToken);
expect(result!.path).not.toContain('/sign/');
await apiSignout({ page });
// Recipient should get their own /sign/ path but token still hidden from value.
await apiSignin({ page, email: recipient.email });
const { data: recipientData } = await trpcDocumentSearch(page, 'Token Leakage Test');
expect(recipientData).not.toBeNull();
expect(recipientData!.length).toBeGreaterThanOrEqual(1);
const recipientResult = recipientData!.find((d) => d.title === 'Token Leakage Test Document');
expect(recipientResult).toBeDefined();
expect(recipientResult!.path).toBe(`/sign/${signingToken}`);
expect(recipientResult!.value).not.toContain(signingToken);
await apiSignout({ page });
});
test('should not expose other recipients signing tokens to the owner', async ({ page }) => {
const { team, owner } = await seedTeam();
const { user: recipientA } = await seedUser();
const { user: recipientB } = await seedUser();
const doc = await seedPendingDocument(owner, team.id, [recipientA, recipientB], {
createDocumentOptions: { title: 'Multi Recipient Token Test' },
});
const recipients = await prisma.recipient.findMany({
where: { envelopeId: doc.id },
select: { token: true, email: true },
});
await apiSignin({ page, email: owner.email });
const { data } = await trpcDocumentSearch(page, 'Multi Recipient Token');
expect(data).not.toBeNull();
const result = data!.find((d) => d.title === 'Multi Recipient Token Test');
expect(result).toBeDefined();
for (const r of recipients) {
expect(result!.value).not.toContain(r.token);
expect(result!.path).not.toContain(r.token);
}
await apiSignout({ page });
});
});
// ─── Filtering ───────────────────────────────────────────────────────────────
test.describe('Document Search - Filtering', () => {
test('should exclude soft-deleted documents', async ({ page }) => {
const { team, owner } = await seedTeam();
await seedCompletedDocument(owner, team.id, [], {
createDocumentOptions: { title: 'Active Deletable Document' },
});
const deletedDoc = await seedCompletedDocument(owner, team.id, [], {
createDocumentOptions: { title: 'Deleted Deletable Document' },
});
await prisma.envelope.update({
where: { id: deletedDoc.id },
data: { deletedAt: new Date() },
});
await apiSignin({ page, email: owner.email });
const { data } = await trpcDocumentSearch(page, 'Deletable Document');
expect(data).not.toBeNull();
expect(data).toHaveLength(1);
expect(data![0].title).toBe('Active Deletable Document');
await apiSignout({ page });
});
test('should find documents by externalId', async ({ page }) => {
const { team, owner } = await seedTeam();
await seedDocuments([
{
sender: owner,
teamId: team.id,
recipients: [],
type: DocumentStatus.DRAFT,
documentOptions: {
title: 'ExternalId Test Doc',
externalId: 'ext-unique-abc-123',
},
},
{
sender: owner,
teamId: team.id,
recipients: [],
type: DocumentStatus.DRAFT,
documentOptions: {
title: 'Other Test Doc',
externalId: 'ext-other-xyz',
},
},
]);
await apiSignin({ page, email: owner.email });
const { data } = await trpcDocumentSearch(page, 'ext-unique-abc');
expect(data).not.toBeNull();
expect(data).toHaveLength(1);
expect(data![0].title).toBe('ExternalId Test Doc');
await apiSignout({ page });
});
});
// ─── Authentication ──────────────────────────────────────────────────────────
test.describe('Document Search - Authentication', () => {
test('should reject unauthenticated requests', async ({ page }) => {
const { res } = await trpcDocumentSearch(page, 'anything');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
});
@@ -0,0 +1,259 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
const trpcTemplateSearch = async (page: Page, query: string) => {
const inputParam = encodeURIComponent(JSON.stringify({ json: { query } }));
const url = `${WEBAPP_BASE_URL}/api/trpc/template.search?input=${inputParam}`;
const res = await page.context().request.get(url);
return {
res,
data: res.ok()
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
((await res.json()).result.data.json as Array<{
title: string;
path: string;
value: string;
}>)
: null,
};
};
// ─── Visibility ──────────────────────────────────────────────────────────────
test.describe('Template Search - Visibility', () => {
test('should respect team template visibility per role', async ({ page }) => {
const { user: owner, organisation, team } = await seedUser();
const [adminUser, managerUser, memberUser] = await seedOrganisationMembers({
organisationId: organisation.id,
members: [
{ organisationRole: OrganisationMemberRole.ADMIN },
{ organisationRole: OrganisationMemberRole.MEMBER },
{ organisationRole: OrganisationMemberRole.MEMBER },
],
});
const managerTeamGroup = await prisma.teamGroup.findFirstOrThrow({
where: { teamId: team.id, teamRole: TeamMemberRole.MANAGER },
include: { organisationGroup: true },
});
const managerOrganisationMember = await prisma.organisationMember.findFirstOrThrow({
where: { organisationId: organisation.id, userId: managerUser.id },
});
await prisma.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
groupId: managerTeamGroup.organisationGroupId,
organisationMemberId: managerOrganisationMember.id,
},
});
await seedBlankTemplate(owner, team.id, {
createTemplateOptions: {
visibility: 'EVERYONE',
title: 'Searchable Template for Everyone',
},
});
await seedBlankTemplate(owner, team.id, {
createTemplateOptions: {
visibility: 'MANAGER_AND_ABOVE',
title: 'Searchable Template for Managers',
},
});
await seedBlankTemplate(owner, team.id, {
createTemplateOptions: {
visibility: 'ADMIN',
title: 'Searchable Template for Admins',
},
});
const testCases = [
{ user: adminUser, visibleTemplates: 3 },
{ user: managerUser, visibleTemplates: 2 },
{ user: memberUser, visibleTemplates: 1 },
];
for (const { user, visibleTemplates } of testCases) {
await apiSignin({ page, email: user.email });
const { data } = await trpcTemplateSearch(page, 'Searchable Template');
expect(data).not.toBeNull();
expect(data).toHaveLength(visibleTemplates);
await apiSignout({ page });
}
});
});
// ─── Cross-Team Isolation ────────────────────────────────────────────────────
test.describe('Template Search - Cross-Team Isolation', () => {
test('should not reveal templates from other teams', async ({ page }) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const { team: teamB, owner: ownerB } = await seedTeam();
const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER });
await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
visibility: 'EVERYONE',
title: 'Unique Team A Template',
},
});
await seedBlankTemplate(ownerB, teamB.id, {
createTemplateOptions: {
visibility: 'EVERYONE',
title: 'Unique Team B Template',
},
});
await apiSignin({ page, email: memberA.email });
const { data } = await trpcTemplateSearch(page, 'Unique');
expect(data).not.toBeNull();
const titles = data!.map((d) => d.title);
expect(titles).toContain('Unique Team A Template');
expect(titles).not.toContain('Unique Team B Template');
await apiSignout({ page });
});
test('should not cross team boundaries when searching SQL wildcard "%"', async ({ page }) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const { team: teamB, owner: ownerB } = await seedTeam();
await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: { title: 'Wildcard Tpl A' },
});
await seedBlankTemplate(ownerB, teamB.id, {
createTemplateOptions: { title: 'Wildcard Tpl B' },
});
await apiSignin({ page, email: ownerA.email });
const { data } = await trpcTemplateSearch(page, '%');
expect(data).not.toBeNull();
expect(data!.map((d) => d.title)).not.toContain('Wildcard Tpl B');
await apiSignout({ page });
});
});
// ─── Recipient Email Search ──────────────────────────────────────────────────
test.describe('Template Search - Recipient Email', () => {
test('should find templates by recipient email within team but not cross-team', async ({
page,
}) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const adminUserA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.ADMIN });
const { team: teamB, owner: ownerB } = await seedTeam();
const { user: uniqueRecipient } = await seedUser();
const template = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: { title: 'Template with Unique Recipient' },
});
await prisma.recipient.create({
data: {
email: uniqueRecipient.email,
name: uniqueRecipient.name ?? '',
token: Math.random().toString().slice(2, 7),
envelopeId: template.id,
},
});
// Team admin can find the template by recipient email.
await apiSignin({ page, email: adminUserA.email });
const { data: adminData } = await trpcTemplateSearch(page, uniqueRecipient.email);
expect(adminData).not.toBeNull();
expect(adminData).toHaveLength(1);
expect(adminData![0].title).toBe('Template with Unique Recipient');
await apiSignout({ page });
// Owner of a different team cannot find it.
await apiSignin({ page, email: ownerB.email });
const { data: otherTeamData } = await trpcTemplateSearch(page, uniqueRecipient.email);
expect(otherTeamData).not.toBeNull();
expect(otherTeamData).toHaveLength(0);
await apiSignout({ page });
});
});
// ─── Filtering ───────────────────────────────────────────────────────────────
test.describe('Template Search - Filtering', () => {
test('should exclude soft-deleted templates', async ({ page }) => {
const { team, owner } = await seedTeam();
await seedBlankTemplate(owner, team.id, {
createTemplateOptions: { title: 'Active Findable Template' },
});
const deletedTemplate = await seedBlankTemplate(owner, team.id, {
createTemplateOptions: { title: 'Deleted Findable Template' },
});
await prisma.envelope.update({
where: { id: deletedTemplate.id },
data: { deletedAt: new Date() },
});
await apiSignin({ page, email: owner.email });
const { data } = await trpcTemplateSearch(page, 'Findable Template');
expect(data).not.toBeNull();
expect(data).toHaveLength(1);
expect(data![0].title).toBe('Active Findable Template');
await apiSignout({ page });
});
});
// ─── Authentication ──────────────────────────────────────────────────────────
test.describe('Template Search - Authentication', () => {
test('should reject unauthenticated requests', async ({ page }) => {
const { res } = await trpcTemplateSearch(page, 'anything');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
});
@@ -4,7 +4,11 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import {
seedBlankDocument,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
test.describe('Document API', () => {
@@ -145,4 +149,293 @@ test.describe('Document API', () => {
ownerDocumentCompleted: false,
});
});
test('sendDocument: should fail when signer has no signature field', async ({ request }) => {
const { user, team } = await seedUser();
// Create a blank document and get it with envelope items
const blankDocument = await seedBlankDocument(user, team.id);
const document = await prisma.envelope.findUniqueOrThrow({
where: { id: blankDocument.id },
include: { envelopeItems: true },
});
// Add a signer recipient without any fields
await prisma.recipient.create({
data: {
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
token: 'test-token-1',
envelopeId: document.id,
},
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeFalsy();
expect(response.status()).toBe(400);
});
test('sendDocument: should fail when signer has only non-signature fields', async ({
request,
}) => {
const { user, team } = await seedUser();
// Create a blank document and get it with envelope items
const blankDocument = await seedBlankDocument(user, team.id);
const document = await prisma.envelope.findUniqueOrThrow({
where: { id: blankDocument.id },
include: { envelopeItems: true },
});
// Add a signer recipient with only a TEXT field (not signature)
const recipient = await prisma.recipient.create({
data: {
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
token: 'test-token-2',
envelopeId: document.id,
},
});
// Add a TEXT field (not a signature field)
await prisma.field.create({
data: {
type: FieldType.TEXT,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
customText: '',
inserted: false,
recipientId: recipient.id,
envelopeId: document.id,
envelopeItemId: document.envelopeItems[0].id,
},
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeFalsy();
expect(response.status()).toBe(400);
});
test('sendDocument: should succeed when signer has signature field', async ({ request }) => {
const { user, team } = await seedUser();
const { document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['signer@example.com'],
teamId: team.id,
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
});
test('sendDocument: should succeed when signer has FREE_SIGNATURE field', async ({ request }) => {
const { user, team } = await seedUser();
// Create a blank document and get it with envelope items
const blankDocument = await seedBlankDocument(user, team.id);
const document = await prisma.envelope.findUniqueOrThrow({
where: { id: blankDocument.id },
include: { envelopeItems: true },
});
// Add a signer recipient
const recipient = await prisma.recipient.create({
data: {
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
token: 'test-token-3',
envelopeId: document.id,
},
});
// Add a FREE_SIGNATURE field
await prisma.field.create({
data: {
type: FieldType.FREE_SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
customText: '',
inserted: false,
recipientId: recipient.id,
envelopeId: document.id,
envelopeItemId: document.envelopeItems[0].id,
},
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
});
test('sendDocument: should succeed when non-signer roles have no fields', async ({ request }) => {
const { user, team } = await seedUser();
// Create a blank document and get it with envelope items
const blankDocument = await seedBlankDocument(user, team.id);
const document = await prisma.envelope.findUniqueOrThrow({
where: { id: blankDocument.id },
include: { envelopeItems: true },
});
// Add a signer with signature field
const signer = await prisma.recipient.create({
data: {
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
token: 'test-token-4',
envelopeId: document.id,
},
});
await prisma.field.create({
data: {
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
recipientId: signer.id,
envelopeId: document.id,
envelopeItemId: document.envelopeItems[0].id,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// Add a viewer without any fields
await prisma.recipient.create({
data: {
email: 'viewer@example.com',
name: 'Test Viewer',
role: RecipientRole.VIEWER,
token: 'test-token-5',
envelopeId: document.id,
},
});
// Add an approver without any fields
await prisma.recipient.create({
data: {
email: 'approver@example.com',
name: 'Test Approver',
role: RecipientRole.APPROVER,
token: 'test-token-6',
envelopeId: document.id,
},
});
// Add a CC without any fields
await prisma.recipient.create({
data: {
email: 'cc@example.com',
name: 'Test CC',
role: RecipientRole.CC,
token: 'test-token-7',
envelopeId: document.id,
},
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
});
});
@@ -171,6 +171,24 @@ test.describe('Template Field Prefill API v1', () => {
},
});
// Add SIGNATURE field (required for distribution)
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: firstEnvelopeItem.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// 6. Sign in as the user
await apiSignin({
page,
@@ -179,7 +197,7 @@ test.describe('Template Field Prefill API v1', () => {
// 7. Navigate to the template
await page.goto(
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
);
// 8. Create a document from the template with prefilled fields
@@ -444,6 +462,24 @@ test.describe('Template Field Prefill API v1', () => {
},
});
// Add SIGNATURE field (required for distribution)
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: firstEnvelopeItem.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// 6. Sign in as the user
await apiSignin({
page,
@@ -452,7 +488,7 @@ test.describe('Template Field Prefill API v1', () => {
// 7. Navigate to the template
await page.goto(
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
);
// 8. Create a document from the template without prefilled fields
@@ -221,7 +221,7 @@ test.describe('Document Access API V1', () => {
);
expect(resB.ok()).toBeFalsy();
expect(resB.status()).toBe(500);
expect(resB.status()).toBe(404);
});
test('should allow authorized access to document send endpoint', async ({ request }) => {
@@ -0,0 +1,403 @@
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';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} 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';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
test.describe('Envelope distribute validation', () => {
let user: User, team: Team, token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
}));
});
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Test Document',
};
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;
};
const createRecipients = async (
request: APIRequestContext,
authToken: string,
envelopeId: string,
recipients: TCreateEnvelopeRecipientsRequest['data'],
) => {
const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${authToken}` },
data: {
envelopeId,
data: recipients,
} satisfies TCreateEnvelopeRecipientsRequest,
});
expect(res.ok()).toBeTruthy();
return (await res.json()).data;
};
const createFields = async (
request: APIRequestContext,
authToken: string,
envelopeId: string,
envelopeItemId: string,
fields: Array<{ recipientId: number; type: FieldType }>,
) => {
const res = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${authToken}` },
data: {
envelopeId,
data: fields.map((field, index) => ({
recipientId: field.recipientId,
envelopeItemId,
type: field.type,
page: 1,
positionX: 10,
positionY: 10 + index * 10,
width: 10,
height: 10,
})),
},
});
expect(res.ok()).toBeTruthy();
return (await res.json()).data;
};
test('should fail to distribute when signer has no fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
// Create a signer without any fields
await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Try to distribute without adding any fields
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeFalsy();
expect(distributeRes.status()).toBe(400);
const errorResponse = await distributeRes.json();
expect(errorResponse.message).toContain('missing required fields');
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,
}) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Add only a TEXT field (not a signature field)
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.TEXT },
]);
// Try to distribute
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeFalsy();
expect(distributeRes.status()).toBe(400);
const errorResponse = await distributeRes.json();
expect(errorResponse.message).toContain('missing required fields');
});
test('should succeed when signer has SIGNATURE field', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Add a SIGNATURE field
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
const response = await distributeRes.json();
expect(response.success).toBe(true);
});
// Note: FREE_SIGNATURE field type is not supported via the v2 API for field creation,
// so we only test with SIGNATURE fields here. The v1 tests cover FREE_SIGNATURE
// using direct Prisma creation.
test('should succeed when VIEWER has no fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer and a viewer
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'viewer@example.com',
name: 'Test Viewer',
role: RecipientRole.VIEWER,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature field only for the signer (viewer has no fields)
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
});
test('should succeed when CC has no fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer and a CC recipient
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'cc@example.com',
name: 'Test CC',
role: RecipientRole.CC,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature field only for the signer (CC has no fields)
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
});
test('should succeed when APPROVER has no fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer and an approver
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'approver@example.com',
name: 'Test Approver',
role: RecipientRole.APPROVER,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature field only for the signer (approver has no fields)
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
});
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);
// Create two signers
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer1@example.com',
name: 'Test Signer 1',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'signer2@example.com',
name: 'Test Signer 2',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature field only for the first signer
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should fail because second signer has no signature field
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeFalsy();
expect(distributeRes.status()).toBe(400);
const errorResponse = await distributeRes.json();
expect(errorResponse.message).toContain('missing required fields');
});
test('should succeed when all signers have signature fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create two signers
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer1@example.com',
name: 'Test Signer 1',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'signer2@example.com',
name: 'Test Signer 2',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature fields for both signers
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
{ recipientId: recipients[1].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
});
});
@@ -171,6 +171,7 @@ test.describe('API V2 Envelopes', () => {
positionY: 0,
width: 0,
height: 0,
fieldMeta: { type: 'signature' },
},
{
type: FieldType.SIGNATURE,
@@ -180,6 +181,7 @@ test.describe('API V2 Envelopes', () => {
positionY: 0,
width: 0,
height: 0,
fieldMeta: { type: 'signature' },
},
],
},
@@ -205,7 +207,9 @@ test.describe('API V2 Envelopes', () => {
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerRecipientExpired: true,
ownerDocumentCompleted: true,
ownerDocumentCreated: true,
},
},
attachments: [
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,453 @@
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';
import { 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 { 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';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
const FIXTURES_DIR = path.join(__dirname, '../../../../assets/fixtures/auto-placement');
test.describe.configure({ mode: 'parallel' });
test.describe('Placeholder-based field creation', () => {
let user: User, team: Team, token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
}));
});
const createEnvelopeWithPdf = async (
request: APIRequestContext,
pdfFilename: string,
): Promise<TCreateEnvelopeResponse> => {
const pdfPath = path.join(FIXTURES_DIR, pdfFilename);
const pdfData = fs.readFileSync(pdfPath);
const formData = new FormData();
formData.append(
'payload',
JSON.stringify({
type: EnvelopeType.DOCUMENT,
title: 'Placeholder Fields Test',
} satisfies TCreateEnvelopePayload),
);
formData.append('files', new File([pdfData], pdfFilename, { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return res.json();
};
const createEnvelopeItemsWithPdf = async (
request: APIRequestContext,
envelopeId: string,
pdfFilename: string,
) => {
const pdfPath = path.join(FIXTURES_DIR, pdfFilename);
const pdfData = fs.readFileSync(pdfPath);
const formData = new FormData();
formData.append('payload', JSON.stringify({ envelopeId }));
formData.append('files', new File([pdfData], pdfFilename, { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/item/create-many`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return res.json();
};
const addRecipient = async (request: APIRequestContext, envelopeId: string) => {
const payload: TCreateEnvelopeRecipientsRequest = {
envelopeId,
data: [
{
email: user.email,
name: user.name || '',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: payload,
});
expect(res.ok()).toBeTruthy();
};
const addRecipients = async (
request: APIRequestContext,
envelopeId: string,
recipients: TCreateEnvelopeRecipientsRequest['data'],
) => {
const payload: TCreateEnvelopeRecipientsRequest = {
envelopeId,
data: recipients,
};
const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: payload,
});
expect(res.ok()).toBeTruthy();
};
const getEnvelope = async (
request: APIRequestContext,
envelopeId: string,
): Promise<TGetEnvelopeResponse> => {
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.ok()).toBeTruthy();
return res.json();
};
/**
* Create a PDF with the same placeholder appearing multiple times at different locations.
*/
const createPdfWithDuplicatePlaceholders = async (): Promise<Buffer> => {
const pdf = PDF.create();
const page = pdf.addPage({ size: 'letter' });
// Draw the same placeholder text at three different Y positions.
page.drawText('{{initials}}', { x: 50, y: 700, font: StandardFonts.Helvetica, size: 12 });
page.drawText('{{initials}}', { x: 50, y: 500, font: StandardFonts.Helvetica, size: 12 });
page.drawText('{{initials}}', { x: 50, y: 300, font: StandardFonts.Helvetica, size: 12 });
const bytes = await pdf.save();
return Buffer.from(bytes);
};
const createEnvelopeWithPdfBuffer = async (
request: APIRequestContext,
pdfBuffer: Buffer,
filename: string,
): Promise<TCreateEnvelopeResponse> => {
const formData = new FormData();
formData.append(
'payload',
JSON.stringify({
type: EnvelopeType.DOCUMENT,
title: 'Placeholder Fields Test',
} satisfies TCreateEnvelopePayload),
);
formData.append('files', new File([pdfBuffer], filename, { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return res.json();
};
test('should create a field at a placeholder location', async ({ request }) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.SIGNATURE,
placeholder: '{{signature}}',
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
expect(fields).toHaveLength(1);
expect(fields[0].type).toBe(FieldType.SIGNATURE);
// Verify the field has non-zero position/dimensions resolved from the placeholder.
expect(fields[0].positionX.toNumber()).toBeGreaterThan(0);
expect(fields[0].positionY.toNumber()).toBeGreaterThan(0);
expect(fields[0].width.toNumber()).toBeGreaterThan(0);
expect(fields[0].height.toNumber()).toBeGreaterThan(0);
});
test('should override width and height when provided', async ({ request }) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.NAME,
placeholder: '{{name}}',
width: 30,
height: 5,
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
expect(fields).toHaveLength(1);
expect(fields[0].width.toNumber()).toBeCloseTo(30, 1);
expect(fields[0].height.toNumber()).toBeCloseTo(5, 1);
});
test('should fail when placeholder text is not found in the PDF', async ({ request }) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.TEXT,
placeholder: '{{nonexistent}}',
},
],
},
});
expect(createFieldsRes.ok()).toBeFalsy();
});
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);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.SIGNATURE,
placeholder: '{{signature}}',
},
{
recipientId,
type: FieldType.DATE,
page: 1,
positionX: 10,
positionY: 20,
width: 15,
height: 3,
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
orderBy: { type: 'asc' },
});
expect(fields).toHaveLength(2);
const dateField = fields.find((f) => f.type === FieldType.DATE);
const signatureField = fields.find((f) => f.type === FieldType.SIGNATURE);
expect(dateField).toBeDefined();
expect(dateField!.positionX.toNumber()).toBeCloseTo(10, 1);
expect(dateField!.positionY.toNumber()).toBeCloseTo(20, 1);
expect(signatureField).toBeDefined();
expect(signatureField!.positionX.toNumber()).toBeGreaterThan(0);
});
test('should create a field only at first occurrence by default', async ({ request }) => {
const pdfBuffer = await createPdfWithDuplicatePlaceholders();
const envelope = await createEnvelopeWithPdfBuffer(request, pdfBuffer, 'duplicates.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.INITIALS,
placeholder: '{{initials}}',
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
// Should only create one field (first occurrence).
expect(fields).toHaveLength(1);
expect(fields[0].type).toBe(FieldType.INITIALS);
});
test('should create fields at all occurrences when matchAll is true', async ({ request }) => {
const pdfBuffer = await createPdfWithDuplicatePlaceholders();
const envelope = await createEnvelopeWithPdfBuffer(request, pdfBuffer, 'duplicates.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.INITIALS,
placeholder: '{{initials}}',
matchAll: true,
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
orderBy: { positionY: 'asc' },
});
// Should create three fields (one for each occurrence).
expect(fields).toHaveLength(3);
// All should be INITIALS type.
expect(fields.every((f) => f.type === FieldType.INITIALS)).toBe(true);
// Verify they're at different Y positions.
const yPositions = fields.map((f) => f.positionY.toNumber());
const uniqueYPositions = new Set(yPositions);
expect(uniqueYPositions.size).toBe(3);
});
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, [
{
email: 'second.recipient@documenso.com',
name: 'Second Recipient',
role: RecipientRole.SIGNER,
signingOrder: 2,
accessAuth: [],
actionAuth: [],
},
{
email: 'first.recipient@documenso.com',
name: 'First Recipient',
role: RecipientRole.SIGNER,
signingOrder: 1,
accessAuth: [],
actionAuth: [],
},
]);
await createEnvelopeItemsWithPdf(request, envelope.id, 'project-proposal-single-recipient.pdf');
const recipients = await prisma.recipient.findMany({
where: { envelopeId: envelope.id },
});
const firstRecipient = recipients.find((recipient) => recipient.signingOrder === 1);
expect(firstRecipient).toBeDefined();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
expect(fields.length).toBeGreaterThan(0);
expect(fields.every((field) => field.recipientId === firstRecipient!.id)).toBe(true);
});
});
@@ -171,6 +171,24 @@ test.describe('Template Field Prefill API v2', () => {
},
});
// Add SIGNATURE field (required for distribution)
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: firstEnvelopeItem.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// 6. Sign in as the user
await apiSignin({
page,
@@ -179,7 +197,7 @@ test.describe('Template Field Prefill API v2', () => {
// 7. Navigate to the template
await page.goto(
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
);
// 8. Create a document from the template with prefilled fields using v2 API
@@ -441,6 +459,24 @@ test.describe('Template Field Prefill API v2', () => {
},
});
// Add SIGNATURE field (required for distribution)
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: firstEnvelopeItem.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// 6. Sign in as the user
await apiSignin({
page,
@@ -449,7 +485,7 @@ test.describe('Template Field Prefill API v2', () => {
// 7. Navigate to the template
await page.goto(
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
);
// 8. Create a document from the template without prefilled fields using v2 API
@@ -195,6 +195,31 @@ test.describe('Document API V2', () => {
}) => {
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
// Get the recipient created during seeding.
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
envelopeId: doc.id,
},
});
// Create a signature field for the recipient so distribution validation can run.
await prisma.field.create({
data: {
envelopeId: doc.id,
envelopeItemId: doc.envelopeItems[0].id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
@@ -207,6 +232,31 @@ test.describe('Document API V2', () => {
test('should allow authorized access to document distribute endpoint', async ({ request }) => {
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
// Get the recipient created during seeding.
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
envelopeId: doc.id,
},
});
// Create a signature field for the recipient so distribution validation can run.
await prisma.field.create({
data: {
envelopeId: doc.id,
envelopeItemId: doc.envelopeItems[0].id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
@@ -3678,6 +3728,26 @@ test.describe('Document API V2', () => {
internalVersion: 2,
});
const [recipient] = doc.recipients;
// add signing field for recipient (fieldMeta required for v2 envelopes)
await prisma.field.create({
data: {
page: 1,
type: FieldType.SIGNATURE,
inserted: false,
customText: '',
positionX: 1,
positionY: 1,
width: 1,
height: 1,
envelopeId: doc.id,
envelopeItemId: doc.envelopeItems[0].id,
recipientId: recipient.id,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
const payload: TUseEnvelopePayload = {
envelopeId: doc.id,
distributeDocument: true,
@@ -3741,6 +3811,31 @@ test.describe('Document API V2', () => {
}) => {
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
// Get the recipient created during seeding.
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
envelopeId: doc.id,
},
});
// Create a signature field for the recipient so distribution validation can pass.
await prisma.field.create({
data: {
envelopeId: doc.id,
envelopeItemId: doc.envelopeItems[0].id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/distribute`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: { envelopeId: doc.id },
@@ -4635,252 +4730,6 @@ test.describe('Document API V2', () => {
});
});
test.describe('Envelope item delete endpoint', () => {
test('should block unauthorized access to envelope item delete endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId: doc.id },
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/delete`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeId: doc.id,
envelopeItemId: envelopeItem.id,
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope item delete endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId: doc.id },
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeId: doc.id,
envelopeItemId: envelopeItem.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope attachment find endpoint', () => {
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}` },
},
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
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}` },
},
);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope attachment create endpoint', () => {
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`,
{
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeId: doc.id,
data: {
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
},
},
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
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`,
{
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeId: doc.id,
data: {
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
},
},
);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope attachment update endpoint', () => {
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({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Original Label',
data: 'https://example.com/original.pdf',
},
});
const res = await request.post(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`,
{
headers: { Authorization: `Bearer ${tokenB}` },
data: {
id: attachment.id,
data: {
label: 'Updated Label',
data: 'https://example.com/updated.pdf',
},
},
},
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
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({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Original Label',
data: 'https://example.com/original.pdf',
},
});
const res = await request.post(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`,
{
headers: { Authorization: `Bearer ${tokenA}` },
data: {
id: attachment.id,
data: {
label: 'Updated Label',
data: 'https://example.com/updated.pdf',
},
},
},
);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope attachment delete endpoint', () => {
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({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
});
const res = await request.post(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`,
{
headers: { Authorization: `Bearer ${tokenB}` },
data: { id: attachment.id },
},
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
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({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
});
const res = await request.post(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`,
{
headers: { Authorization: `Bearer ${tokenA}` },
data: { id: attachment.id },
},
);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope audit logs endpoint', () => {
test('should block unauthorized access to envelope audit logs endpoint', async ({
request,
@@ -0,0 +1,220 @@
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';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
test.describe('Envelope Attachments 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,
}));
({ user: userB, team: teamB } = await seedUser());
({ token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
}));
});
test.describe('Envelope attachment find endpoint', () => {
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}` },
},
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
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}` },
},
);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope attachment create endpoint', () => {
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`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeId: doc.id,
data: {
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
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`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeId: doc.id,
data: {
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope attachment update endpoint', () => {
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({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Original Label',
data: 'https://example.com/original.pdf',
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
id: attachment.id,
data: {
label: 'Updated Label',
data: 'https://example.com/updated.pdf',
},
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
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({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Original Label',
data: 'https://example.com/original.pdf',
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
id: attachment.id,
data: {
label: 'Updated Label',
data: 'https://example.com/updated.pdf',
},
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope attachment delete endpoint', () => {
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({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: { id: attachment.id },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
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({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: { id: attachment.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
});
@@ -0,0 +1,390 @@
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';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
// 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.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();
});
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();
});
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);
// 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);
});
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.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,519 @@
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 { 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';
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();
});
});
@@ -0,0 +1,307 @@
import { type Page, expect, test } from '@playwright/test';
import path from 'path';
import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const FIXTURES_DIR = path.join(__dirname, '../../../assets/fixtures/auto-placement');
const SINGLE_PLACEHOLDER_PDF_PATH = path.join(
FIXTURES_DIR,
'project-proposal-single-recipient.pdf',
);
const MULTIPLE_PLACEHOLDER_PDF_PATH = path.join(
FIXTURES_DIR,
'project-proposal-multiple-fields-and-recipients.pdf',
);
const NO_RECIPIENT_PDF_PATH = path.join(FIXTURES_DIR, 'no-recipient-placeholders.pdf');
const INVALID_FIELD_TYPE_PDF_PATH = path.join(FIXTURES_DIR, 'invalid-field-type.pdf');
const FIELD_TYPE_ONLY_PDF_PATH = path.join(FIXTURES_DIR, 'field-type-only.pdf');
const setTeamDefaultRecipients = async (
teamId: number,
defaultRecipients: Array<{ email: string; name: string; role: RecipientRole }>,
) => {
const teamSettings = await prisma.teamGlobalSettings.findFirstOrThrow({
where: {
team: {
id: teamId,
},
},
});
await prisma.teamGlobalSettings.update({
where: {
id: teamSettings.id,
},
data: {
defaultRecipients,
},
});
};
const setupUserAndSignIn = async (page: Page) => {
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
return { user, team };
};
const uploadPdf = async (page: Page, team: { url: string }, pdfPath: string) => {
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page
.locator('input[type=file]')
.nth(1)
.evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(pdfPath);
// Wait for redirect to v2 envelope editor.
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
// Extract envelope ID from URL.
const urlParts = page.url().split('/');
const envelopeId = urlParts.find((part) => part.startsWith('envelope_'));
if (!envelopeId) {
throw new Error('Could not extract envelope ID from URL');
}
return envelopeId;
};
test.describe('PDF Placeholders with single recipient', () => {
test('[AUTO_PLACING_FIELDS]: should create placeholder recipients even with default recipients', async ({
page,
}) => {
const { user, team } = await seedUser();
await setTeamDefaultRecipients(team.id, [
{
email: user.email,
name: user.name || user.email,
role: RecipientRole.CC,
},
]);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
await expect(async () => {
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
});
const placeholderRecipient = recipients.find(
(recipient) => recipient.email === 'recipient.1@documenso.com',
);
const defaultRecipient = recipients.find((recipient) => recipient.email === user.email);
expect(placeholderRecipient).toBeDefined();
expect(defaultRecipient).toBeDefined();
const fields = await prisma.field.findMany({
where: { envelopeId },
});
expect(fields.length).toBeGreaterThan(0);
expect(fields.every((field) => field.recipientId === placeholderRecipient!.id)).toBe(true);
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
// V2 editor shows recipients on the upload page under "Recipients" heading.
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
'recipient.1@documenso.com',
);
await expect(page.getByLabel('Name').first()).toHaveValue('Recipient 1');
});
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
// V2 editor renders fields on a Konva canvas, so we verify via the database.
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
const fieldTypes = fields.map((f) => f.type).sort();
expect(fieldTypes).toEqual(['EMAIL', 'NAME', 'SIGNATURE', 'TEXT'].sort());
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
// Verify field metadata was correctly parsed from the placeholder.
await expect(async () => {
const textField = await prisma.field.findFirst({
where: { envelopeId, type: 'TEXT' },
});
expect(textField).toBeDefined();
expect(textField!.fieldMeta).toBeDefined();
const meta = textField!.fieldMeta as Record<string, unknown>;
expect(meta.required).toBe(true);
expect(meta.textAlign).toBe('right');
}).toPass();
});
});
test.describe('PDF Placeholders with multiple recipients', () => {
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, MULTIPLE_PLACEHOLDER_PDF_PATH);
// V2 editor shows recipients on the upload page.
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
'recipient.1@documenso.com',
);
await expect(page.getByTestId('signer-email-input').nth(1)).toHaveValue(
'recipient.2@documenso.com',
);
await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue(
'recipient.3@documenso.com',
);
// Verify recipients via the database for name validation since the v2 editor
// only shows the "Name" label on the first recipient row.
await expect(async () => {
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
orderBy: { signingOrder: 'asc' },
});
expect(recipients).toHaveLength(3);
expect(recipients[0].name).toBe('Recipient 1');
expect(recipients[1].name).toBe('Recipient 2');
expect(recipients[2].name).toBe('Recipient 3');
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, MULTIPLE_PLACEHOLDER_PDF_PATH);
// V2 editor renders fields on a Konva canvas, so we verify via the database.
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
const fieldTypes = fields.map((f) => f.type).sort();
expect(fieldTypes).toEqual(
['SIGNATURE', 'SIGNATURE', 'SIGNATURE', 'EMAIL', 'EMAIL', 'NAME', 'TEXT', 'NUMBER'].sort(),
);
}).toPass();
});
});
test.describe('PDF Placeholders without recipient identifier', () => {
test('[AUTO_PLACING_FIELDS]: should skip placeholders without a recipient identifier', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, NO_RECIPIENT_PDF_PATH);
// Placeholders like {{signature}}, {{name}}, {{email}} have no recipient
// identifier and should be skipped entirely. No fields or auto-created
// recipients should exist.
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
expect(fields).toHaveLength(0);
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should skip a bare field type placeholder', async ({ page }) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, FIELD_TYPE_ONLY_PDF_PATH);
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
expect(fields).toHaveLength(0);
}).toPass();
});
});
test.describe('PDF Placeholders with invalid field types', () => {
test('[AUTO_PLACING_FIELDS]: should skip invalid field types and process valid ones', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, INVALID_FIELD_TYPE_PDF_PATH);
// Only the valid placeholders (signature,r1 and email,r2) should create fields.
// The invalid ones (bogus,r1 and foobar,r2) should be skipped.
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
const fieldTypes = fields.map((f) => f.type).sort();
expect(fieldTypes).toEqual(['EMAIL', 'SIGNATURE'].sort());
}).toPass();
// Both valid recipients should still be created.
await expect(async () => {
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
orderBy: { signingOrder: 'asc' },
});
expect(recipients).toHaveLength(2);
}).toPass();
});
});
@@ -44,7 +44,7 @@ test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) =
await apiSignin({
page,
email: recipient.email,
email: user.email,
});
await page.keyboard.press('Meta+K');
@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -46,7 +47,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -54,7 +55,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -74,7 +75,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -100,7 +101,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -108,7 +109,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -128,7 +129,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -162,7 +163,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -170,7 +171,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -190,7 +191,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -224,7 +225,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -232,7 +233,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -28,7 +29,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -106,10 +107,12 @@ test.describe('AutoSave Subject Step', () => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
await page.getByText('Send recipient signed email').click();
await page.getByText('Send recipient removed email').click();
await page.getByText('Send document completed email', { exact: true }).click();
await page.getByText('Send document deleted email').click();
await page.getByText('Email the owner when a recipient signs').click();
await page.getByText("Email recipients when they're removed from a pending document").click();
await page
.getByText('Email recipients when the document is completed', { exact: true })
.click();
await page.getByText('Email recipients when a pending document is deleted').click();
await triggerAutosave(page);
@@ -126,26 +129,34 @@ test.describe('AutoSave Subject Step', () => {
const emailSettings = retrievedDocumentData.documentMeta?.emailSettings;
await expect(page.getByText('Send recipient signed email')).toBeChecked({
await expect(page.getByText('Email the owner when a recipient signs')).toBeChecked({
checked: emailSettings?.recipientSigned,
});
await expect(page.getByText('Send recipient removed email')).toBeChecked({
await expect(
page.getByText("Email recipients when they're removed from a pending document"),
).toBeChecked({
checked: emailSettings?.recipientRemoved,
});
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
await expect(
page.getByText('Email recipients when the document is completed', { exact: true }),
).toBeChecked({
checked: emailSettings?.documentCompleted,
});
await expect(page.getByText('Send document deleted email')).toBeChecked({
await expect(
page.getByText('Email recipients when a pending document is deleted'),
).toBeChecked({
checked: emailSettings?.documentDeleted,
});
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
checked: emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Send document pending email')).toBeChecked({
checked: emailSettings?.documentPending,
});
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
{
checked: emailSettings?.documentPending,
},
);
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
checked: emailSettings?.ownerDocumentCompleted,
});
}).toPass();
@@ -161,10 +172,12 @@ test.describe('AutoSave Subject Step', () => {
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
await page.getByText('Send recipient signed email').click();
await page.getByText('Send recipient removed email').click();
await page.getByText('Send document completed email', { exact: true }).click();
await page.getByText('Send document deleted email').click();
await page.getByText('Email the owner when a recipient signs').click();
await page.getByText("Email recipients when they're removed from a pending document").click();
await page
.getByText('Email recipients when the document is completed', { exact: true })
.click();
await page.getByText('Email recipients when a pending document is deleted').click();
await triggerAutosave(page);
@@ -190,26 +203,34 @@ test.describe('AutoSave Subject Step', () => {
retrievedDocumentData.documentMeta?.message ?? '',
);
await expect(page.getByText('Send recipient signed email')).toBeChecked({
await expect(page.getByText('Email the owner when a recipient signs')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigned,
});
await expect(page.getByText('Send recipient removed email')).toBeChecked({
await expect(
page.getByText("Email recipients when they're removed from a pending document"),
).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientRemoved,
});
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
await expect(
page.getByText('Email recipients when the document is completed', { exact: true }),
).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
});
await expect(page.getByText('Send document deleted email')).toBeChecked({
await expect(
page.getByText('Email recipients when a pending document is deleted'),
).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
});
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Send document pending email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
});
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
{
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
},
);
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
});
}).toPass();
@@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -33,14 +34,14 @@ test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
// Step 3: Add fields
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.getByRole('combobox').first().click();
// Switch to second duplicate and add field
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Continue to send
await page.getByRole('button', { name: 'Continue' }).click();
@@ -44,21 +44,21 @@ const completeDocumentFlowWithDuplicateRecipients = async (options: {
// Step 3: Add fields for each recipient
// Add signature field for first duplicate recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
// Switch to second duplicate recipient and add their field
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
// Switch to unique recipient and add their field
await page.getByText('Unique Recipient (unique@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
// Continue to subject
await page.getByRole('button', { name: 'Continue' }).click();
@@ -122,7 +122,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
// Save the document by going to subject
await page.getByRole('button', { name: 'Continue' }).click();
@@ -149,7 +149,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Complete the flow
await page.getByRole('button', { name: 'Continue' }).click();
@@ -257,10 +257,12 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Change second recipient role if role selector is available
const roleDropdown = page.getByLabel('Role').nth(1);
let secondRecipientIsApprover = false;
if (await roleDropdown.isVisible()) {
await roleDropdown.click();
await page.getByText('Approver').click();
secondRecipientIsApprover = true;
}
// Step 3: Add different field types for each duplicate
@@ -268,18 +270,25 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Add signature for first recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
// Add name field for second recipient
await page.getByRole('combobox').first().click();
await page.getByText('Approver Role (signer@example.com)').first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Add date field for second recipient
await page.getByRole('button', { name: 'Date' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 150 } });
// If second recipient is still a SIGNER (role change wasn't available),
// add a signature field for them to pass validation
if (!secondRecipientIsApprover) {
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 200 } });
}
// Complete the document
await page.getByRole('button', { name: 'Continue' }).click();
@@ -340,7 +349,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Add another field to the second duplicate
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 250, y: 150 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 250, y: 150 } });
// Save changes
await page.getByRole('button', { name: 'Continue' }).click();
@@ -9,6 +9,7 @@ import {
import { DateTime } from 'luxon';
import path from 'node:path';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import {
seedBlankDocument,
@@ -92,7 +93,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -100,7 +101,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -158,7 +159,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -166,7 +167,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -177,7 +178,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByText('User 2 (user2@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 500,
y: 100,
@@ -185,7 +186,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 500,
y: 200,
@@ -256,7 +257,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -264,7 +265,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -275,7 +276,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 500,
y: 100,
@@ -283,7 +284,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 500,
y: 200,
@@ -576,7 +577,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
}
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100 * i,
@@ -0,0 +1,253 @@
import { expect, test } from '@playwright/test';
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
test.describe.configure({ mode: 'parallel' });
const seedBulkActionsTestRequirements = async () => {
const sender = await seedUser({ setTeamEmailAsOwner: true });
const [doc1, doc2, doc3] = await Promise.all([
seedDraftDocument(sender.user, sender.team.id, [], {
createDocumentOptions: { title: 'Bulk Test Doc 1' },
}),
seedDraftDocument(sender.user, sender.team.id, [], {
createDocumentOptions: { title: 'Bulk Test Doc 2' },
}),
seedDraftDocument(sender.user, sender.team.id, [], {
createDocumentOptions: { title: 'Bulk Test Doc 3' },
}),
]);
const folder = await seedBlankFolder(sender.user, sender.team.id, {
createFolderOptions: {
name: 'Target Folder',
teamId: sender.team.id,
},
});
return {
sender,
documents: [doc1, doc2, doc3],
folder,
};
};
test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
});
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
const { sender, documents } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await page.getByLabel('Clear selection').click();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
const { sender, folder } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
});
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Delete Documents')).toBeVisible();
await expect(page.getByText('You are about to delete 2 documents')).toBeVisible();
await expect(page.getByText('irreversible')).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Documents deleted');
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Bulk Test Doc 3' })).toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful move', async ({ page }) => {
const { sender, folder } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Documents deleted');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
const { sender, folder } = await seedBulkActionsTestRequirements();
const otherFolder = await seedBlankFolder(sender.user, sender.team.id, {
createFolderOptions: {
name: 'Other Folder',
teamId: sender.team.id,
},
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
await page.getByPlaceholder('Search folders...').fill('Target');
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
await expect(page.getByRole('button', { name: otherFolder.name })).not.toBeVisible();
await page.getByPlaceholder('Search folders...').fill('Other');
await expect(page.getByRole('button', { name: folder.name })).not.toBeVisible();
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
await page.getByPlaceholder('Search folders...').fill('NonExistent');
await expect(page.getByText('No folders found')).toBeVisible();
});
test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ page }) => {
const { sender, documents, folder } = await seedBulkActionsTestRequirements();
const { prisma } = await import('@documenso/prisma');
await prisma.envelope.updateMany({
where: { id: documents[0].id },
data: { folderId: folder.id },
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents/f/${folder.id}`,
});
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await page.goto(`/t/${sender.team.url}/documents`);
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,442 @@
import { type Page, expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType, FieldType, RecipientRole } 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';
import { seedTemplate } from '@documenso/prisma/seed/templates';
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 { apiSignin } from '../fixtures/authentication';
import {
clickAddMyselfButton,
clickEnvelopeEditorStep,
getRecipientEmailInputs,
openDocumentEnvelopeEditor,
openTemplateEnvelopeEditor,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const V2_API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
/**
* Place a field on the PDF canvas in the envelope editor.
*/
const placeFieldOnPdf = async (
root: Page,
fieldName: 'Signature' | 'Text',
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
/**
* Create a PENDING envelope via the V2 API with a single SIGNER recipient
* and a SIGNATURE field, then distribute it.
*
* Returns the envelope ID, recipient email, team URL, and user info for navigation.
*/
const createPendingEnvelopeViaApi = async () => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'e2e-resend-test',
expiresIn: null,
});
const recipientEmail = `resend-${Date.now()}@test.documenso.com`;
// 1. Create envelope with a PDF.
const payload = {
type: EnvelopeType.DOCUMENT,
title: `E2E Resend Test ${Date.now()}`,
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }),
);
const createRes = await fetch(`${V2_API_BASE_URL}/envelope/create`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
expect(createRes.ok).toBeTruthy();
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
// 2. Create a SIGNER recipient.
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: createResponse.id,
data: [
{
email: recipientEmail,
name: 'Resend Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const recipientsRes = await fetch(`${V2_API_BASE_URL}/envelope/recipient/create-many`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(createRecipientsRequest),
});
expect(recipientsRes.ok).toBeTruthy();
const recipientsResponse = await recipientsRes.json();
const recipients = recipientsResponse.data;
// 3. Get envelope to find the envelope item ID.
const getRes = await fetch(`${V2_API_BASE_URL}/envelope/${createResponse.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
const envelope = (await getRes.json()) as TGetEnvelopeResponse;
const envelopeItem = envelope.envelopeItems[0];
// 4. Create a SIGNATURE field for the recipient.
const createFieldsRequest = {
envelopeId: createResponse.id,
data: [
{
recipientId: recipients[0].id,
envelopeItemId: envelopeItem.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
},
],
};
const fieldsRes = await fetch(`${V2_API_BASE_URL}/envelope/field/create-many`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(createFieldsRequest),
});
expect(fieldsRes.ok).toBeTruthy();
// 5. Distribute the envelope.
const distributeRes = await fetch(`${V2_API_BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: createResponse.id,
} satisfies TDistributeEnvelopeRequest),
});
expect(distributeRes.ok).toBeTruthy();
return {
user,
team,
envelopeId: createResponse.id,
recipientEmail,
};
};
test.describe('document editor', () => {
test('send document via email', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
// Add the current user as a recipient via the UI.
await clickAddMyselfButton(surface.root);
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
// Navigate to the add fields step and place a signature field.
await clickEnvelopeEditorStep(surface.root, 'addFields');
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
await expect(surface.root.getByText('1 Field')).toBeVisible();
// Navigate back to the recipients step so the sidebar actions are available.
await clickEnvelopeEditorStep(surface.root, 'upload');
// Click the "Send Document" sidebar action.
await page.locator('button[title="Send Envelope"]').click();
// The distribute dialog should appear.
await expect(page.getByRole('heading', { name: 'Send Document' })).toBeVisible();
// Click Send.
await page.getByRole('button', { name: 'Send' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Envelope distributed');
// Assert the document status was changed in the database.
const updatedEnvelope = await prisma.envelope.findUniqueOrThrow({
where: { id: surface.envelopeId },
});
expect(updatedEnvelope.status).toBe(DocumentStatus.PENDING);
});
test('send document shows validation when signers lack signature fields', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
// Add the current user as a SIGNER recipient via the UI — but do NOT place any fields.
await clickAddMyselfButton(surface.root);
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
// Click the "Send Document" sidebar action without placing signature fields.
await page.locator('button[title="Send Envelope"]').click();
// The distribute dialog should appear.
await expect(page.getByRole('heading', { name: 'Send Document' })).toBeVisible();
// The validation warning should be shown instead of the send form.
await expect(
page.getByText('The following signers are missing signature fields'),
).toBeVisible();
// The "Send" button should not be visible since we're in validation mode.
await expect(page.getByRole('button', { name: 'Send', exact: true })).not.toBeVisible();
// Close the dialog.
await page.getByRole('button', { name: 'Close' }).click();
// The dialog should be closed.
await expect(
page.getByText('The following signers are missing signature fields'),
).not.toBeVisible();
});
test('resend document sends reminder', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createPendingEnvelopeViaApi();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelopeId}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the "Resend Document" sidebar action.
await page.locator('button[title="Resend Envelope"]').click();
// The redistribute dialog should appear.
await expect(page.getByRole('heading', { name: 'Resend Document' })).toBeVisible();
// The unsigned recipient should be listed.
await expect(page.getByText(recipientEmail)).toBeVisible();
// Select the recipient checkbox.
await page.getByRole('checkbox').first().click();
// Click "Send reminder".
await page.getByRole('button', { name: 'Send reminder' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Envelope resent');
// Verify a resend audit log entry was created in the database.
const auditLog = await prisma.documentAuditLog.findFirst({
where: {
envelopeId,
type: 'EMAIL_SENT',
},
orderBy: { createdAt: 'desc' },
});
expect(auditLog).not.toBeNull();
expect((auditLog!.data as Record<string, unknown>).isResending).toBe(true);
expect((auditLog!.data as Record<string, unknown>).recipientEmail).toBe(recipientEmail);
});
test('duplicate document', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
// Click the "Duplicate Document" sidebar action.
await page.locator('button[title="Duplicate Envelope"]').click();
// The duplicate dialog should appear.
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
// Click "Duplicate".
await page.getByRole('button', { name: 'Duplicate' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Document Duplicated');
// The page should have navigated to the new document's edit page.
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
// Verify a new envelope was created in the database.
const envelopes = await prisma.envelope.findMany({
where: {
userId: surface.userId,
teamId: surface.teamId,
type: 'DOCUMENT',
},
});
// Should have original + duplicate.
expect(envelopes.length).toBeGreaterThanOrEqual(2);
});
test('download PDF dialog shows envelope items', async ({ page }) => {
await openDocumentEnvelopeEditor(page);
// Click the "Download PDF" sidebar action.
await page.locator('button[title="Download PDF"]').click();
// The download dialog should appear.
await expect(page.getByRole('heading', { name: 'Download Files' })).toBeVisible();
await expect(page.getByText('Select the files you would like to download.')).toBeVisible();
// At least one envelope item with an "Original" download button should be listed.
await expect(page.getByRole('button', { name: 'Original' }).first()).toBeVisible();
});
test('delete draft document', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const documentId = surface.envelopeId;
// Click the "Delete Document" sidebar action.
await page.locator('button[title="Delete Envelope"]').click();
// The delete dialog should appear.
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
// For DRAFT documents no confirmation input is needed, just click "Delete".
await page.getByRole('button', { name: 'Delete' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Document deleted');
// The page should navigate back to the documents list.
await expect(page).toHaveURL(/\/documents$/);
// Verify the document was deleted from the database.
const deletedDocument = await prisma.envelope.findUnique({
where: { id: documentId },
});
expect(deletedDocument).toBeNull();
});
});
test.describe('template editor', () => {
test('create and manage direct link', async ({ page }) => {
const { user, team } = await seedUser();
// seedTemplate creates a template with a SIGNER recipient.
const template = await seedTemplate({
title: `E2E Direct Link Template ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the "Direct Link" sidebar action.
await page.locator('button[title="Direct Link"]').click();
// The onboarding dialog should appear.
await expect(page.getByRole('heading', { name: 'Create Direct Signing Link' })).toBeVisible();
// Click "Enable direct link signing" to proceed.
await page.getByRole('button', { name: 'Enable direct link signing' }).click();
// Select recipient step: click "Create one automatically".
await page.getByRole('button', { name: 'Create one automatically' }).click();
// Manage step should appear.
await expect(page.getByRole('heading', { name: 'Direct Link Signing' })).toBeVisible();
// The shareable link input should be present.
await expect(page.locator('#copy-direct-link')).toBeVisible();
// Click "Save" to persist the toggle state.
await page.getByRole('button', { name: 'Save' }).click();
// Assert success toast.
await expectToastTextToBeVisible(page, 'Success');
// Verify a TemplateDirectLink was created and enabled in the database.
const directLink = await prisma.templateDirectLink.findFirst({
where: { envelopeId: template.id },
});
expect(directLink).not.toBeNull();
expect(directLink!.enabled).toBe(true);
});
test('duplicate template', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
// Click the "Duplicate Template" sidebar action.
await page.locator('button[title="Duplicate Envelope"]').click();
// The duplicate dialog should appear.
await expect(page.getByRole('heading', { name: 'Duplicate Template' })).toBeVisible();
// Click "Duplicate".
await page.getByRole('button', { name: 'Duplicate' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Duplicated');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify a new envelope was created in the database.
const envelopes = await prisma.envelope.findMany({
where: {
userId: surface.userId,
teamId: surface.teamId,
type: 'TEMPLATE',
},
});
// Should have original + duplicate.
expect(envelopes.length).toBeGreaterThanOrEqual(2);
});
});
@@ -0,0 +1,329 @@
import { type Page, expect, test } from '@playwright/test';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
const TEST_ATTACHMENTS = {
first: {
label: 'E2E First Attachment',
url: 'https://example.com/first-attachment',
},
second: {
label: 'E2E Second Attachment',
url: 'https://example.com/second-attachment',
},
third: {
label: 'E2E Third Attachment',
url: 'https://example.com/third-attachment',
},
};
type AttachmentFlowResult = {
externalId: string;
expectedAttachments: Array<{ label: string; url: string }>;
deletedAttachment: { label: string; url: string };
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const openAttachmentsPopover = async (root: Page) => {
await root.getByRole('button', { name: 'Attachments' }).click();
await expect(root.getByRole('heading', { name: 'Attachments', level: 4 })).toBeVisible();
};
const addAttachment = async (root: Page, { label, url }: { label: string; url: string }) => {
await root.getByRole('button', { name: 'Add Attachment' }).click();
await root.getByPlaceholder('Label').fill(label);
await root.getByPlaceholder('URL').fill(url);
await root.getByRole('button', { name: 'Add', exact: true }).click();
};
const getAttachmentItems = (root: Page) => root.locator('.rounded-md.border.border-border.p-2');
const getAttachmentDeleteButtons = (root: Page) => getAttachmentItems(root).locator('button');
const assertAttachmentVisibleInPopover = async (
root: Page,
{ label, url }: { label: string; url: string },
) => {
const items = getAttachmentItems(root);
const matchingItem = items.filter({ hasText: label });
await expect(matchingItem).toBeVisible();
await expect(matchingItem.locator(`a[href="${url}"]`)).toBeVisible();
};
const assertAttachmentNotVisibleInPopover = async (root: Page, label: string) => {
await expect(getAttachmentItems(root).filter({ hasText: label })).toHaveCount(0);
};
const assertAttachmentCount = async (root: Page, count: number) => {
const button = root.getByRole('button', { name: 'Attachments' });
if (count > 0) {
await expect(button).toContainText(`(${count})`);
} else {
await expect(button).not.toContainText('(');
}
};
const assertAttachmentsInDatabase = async ({
externalId,
surface,
expectedAttachments,
deletedLabel,
}: {
externalId: string;
surface: TEnvelopeEditorSurface;
expectedAttachments: Array<{ label: string; url: string }>;
deletedLabel?: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
envelopeAttachments: {
orderBy: {
createdAt: 'asc',
},
},
},
orderBy: {
createdAt: 'desc',
},
});
expect(envelope.envelopeAttachments).toHaveLength(expectedAttachments.length);
for (const expected of expectedAttachments) {
const found = envelope.envelopeAttachments.find((a) => a.label === expected.label);
expect(found).toBeDefined();
expect(found!.data).toBe(expected.url);
expect(found!.type).toBe('link');
}
if (deletedLabel) {
expect(envelope.envelopeAttachments.some((a) => a.label === deletedLabel)).toBe(false);
}
};
const runAttachmentFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<AttachmentFlowResult> => {
const externalId = `e2e-attachments-${nanoid()}`;
await updateExternalId(surface, externalId);
await openAttachmentsPopover(surface.root);
// Create first attachment.
await addAttachment(surface.root, TEST_ATTACHMENTS.first);
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.first);
await assertAttachmentCount(surface.root, 1);
await assertAttachmentsInDatabase({
externalId,
surface,
expectedAttachments: [TEST_ATTACHMENTS.first],
});
// Create second attachment.
await addAttachment(surface.root, TEST_ATTACHMENTS.second);
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
await assertAttachmentCount(surface.root, 2);
await assertAttachmentsInDatabase({
externalId,
surface,
expectedAttachments: [TEST_ATTACHMENTS.first, TEST_ATTACHMENTS.second],
});
// Create third attachment.
await addAttachment(surface.root, TEST_ATTACHMENTS.third);
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
await assertAttachmentCount(surface.root, 3);
await assertAttachmentsInDatabase({
externalId,
surface,
expectedAttachments: [TEST_ATTACHMENTS.first, TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
});
// Delete first attachment.
await getAttachmentDeleteButtons(surface.root).first().click();
await expectToastTextToBeVisible(surface.root, 'Attachment removed successfully.');
await expect(getAttachmentItems(surface.root)).toHaveCount(2);
await assertAttachmentNotVisibleInPopover(surface.root, TEST_ATTACHMENTS.first.label);
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
await assertAttachmentCount(surface.root, 2);
await assertAttachmentsInDatabase({
externalId,
surface,
expectedAttachments: [TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
deletedLabel: TEST_ATTACHMENTS.first.label,
});
return {
externalId,
expectedAttachments: [TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
deletedAttachment: TEST_ATTACHMENTS.first,
};
};
const runEmbeddedAttachmentFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<AttachmentFlowResult> => {
const externalId = `e2e-attachments-${nanoid()}`;
await updateExternalId(surface, externalId);
await openAttachmentsPopover(surface.root);
// Create first attachment.
await addAttachment(surface.root, TEST_ATTACHMENTS.first);
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.first);
await assertAttachmentCount(surface.root, 1);
// Create second attachment.
await addAttachment(surface.root, TEST_ATTACHMENTS.second);
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
await assertAttachmentCount(surface.root, 2);
// Create third attachment.
await addAttachment(surface.root, TEST_ATTACHMENTS.third);
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
await assertAttachmentCount(surface.root, 3);
// Delete first attachment.
await getAttachmentDeleteButtons(surface.root).first().click();
await expectToastTextToBeVisible(surface.root, 'Attachment removed successfully.');
await expect(getAttachmentItems(surface.root)).toHaveCount(2);
await assertAttachmentNotVisibleInPopover(surface.root, TEST_ATTACHMENTS.first.label);
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
await assertAttachmentCount(surface.root, 2);
return {
externalId,
expectedAttachments: [TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
deletedAttachment: TEST_ATTACHMENTS.first,
};
};
const assertAttachmentsPersistedInDatabase = async ({
surface,
externalId,
expectedAttachments,
deletedAttachment,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
expectedAttachments: AttachmentFlowResult['expectedAttachments'];
deletedAttachment: AttachmentFlowResult['deletedAttachment'];
}) => {
await assertAttachmentsInDatabase({
externalId,
surface,
expectedAttachments,
deletedLabel: deletedAttachment.label,
});
};
test.describe('document editor', () => {
test('add, verify and delete attachments', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runAttachmentFlow(surface);
await assertAttachmentsPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('template editor', () => {
test('add, verify and delete attachments', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runAttachmentFlow(surface);
await assertAttachmentsPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('embedded create', () => {
test('add, verify and delete attachments', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-attachments',
});
await addEnvelopeItemPdf(surface.root, 'embedded-document-attachments.pdf');
const result = await runEmbeddedAttachmentFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertAttachmentsPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('embedded edit', () => {
test('add, verify and delete attachments', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-attachments',
});
const result = await runEmbeddedAttachmentFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertAttachmentsPersistedInDatabase({
surface,
...result,
});
});
});
@@ -0,0 +1,436 @@
import type { Page } from '@playwright/test';
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 { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
createEmbeddedEnvelopeCreateHash,
getEnvelopeEditorSettingsTrigger,
openEmbeddedEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
const TEST_CSS_VARS = {
background: '#ff0000',
primary: '#00ff00',
radius: '1rem',
};
/**
* A unique CSS selector used for asserting raw CSS injection.
*/
const TEST_RAW_CSS = '.e2e-css-test-marker { color: red; }';
/**
* Expected HSL values after conversion by `toNativeCssVars`:
* - colord('#ff0000').toHsl() → { h: 0, s: 100, l: 50 }
* - colord('#00ff00').toHsl() → { h: 120, s: 100, l: 50 }
*/
const EXPECTED_CSS_VARS = {
'--background': '0 100 50',
'--primary': '120 100 50',
'--radius': '1rem',
};
const enableEmbedAuthoringWhiteLabel = async (userId: number) => {
const organisation = await prisma.organisation.findFirstOrThrow({
where: { ownerUserId: userId },
include: { organisationClaim: true },
});
await prisma.organisationClaim.update({
where: { id: organisation.organisationClaim.id },
data: {
flags: {
allowLegacyEnvelopes: true,
embedAuthoringWhiteLabel: true,
},
},
});
};
/**
* The default background color from the theme before any CSS injection.
*
* The theme default `--background: 0 0% 100%` resolves to hsl(0, 0%, 100%) which is white.
*/
const DEFAULT_BODY_BG_COLOR = 'rgb(255, 255, 255)';
/**
* When `--background` is set to `0 100 50` (hsl(0, 100%, 50%)) the body background
* resolves to pure red via the Tailwind `bg-background` → `hsl(var(--background))` chain.
*/
const INJECTED_BODY_BG_COLOR = 'rgb(255, 0, 0)';
const assertCssNotInjected = async (surface: TEnvelopeEditorSurface) => {
const { root: page } = surface;
const cssState = await page.evaluate(() => {
const rootStyle = document.documentElement.style;
const bodyBgColor = window.getComputedStyle(document.body).backgroundColor;
return {
background: rootStyle.getPropertyValue('--background'),
primary: rootStyle.getPropertyValue('--primary'),
radius: rootStyle.getPropertyValue('--radius'),
bodyBgColor,
hasInjectedStyle: Array.from(document.head.querySelectorAll('style')).some((el) =>
el.innerHTML.includes('.e2e-css-test-marker'),
),
};
});
// CSS custom properties should not be set on the inline style.
expect(cssState.background).toBe('');
expect(cssState.primary).toBe('');
expect(cssState.radius).toBe('');
// No raw CSS style tag should be injected.
expect(cssState.hasInjectedStyle).toBe(false);
// The body should still use the default theme background color.
expect(cssState.bodyBgColor).toBe(DEFAULT_BODY_BG_COLOR);
};
const assertCssInjected = async (surface: TEnvelopeEditorSurface) => {
const { root: page } = surface;
const cssState = await page.evaluate(() => {
const rootStyle = document.documentElement.style;
const bodyBgColor = window.getComputedStyle(document.body).backgroundColor;
return {
background: rootStyle.getPropertyValue('--background'),
primary: rootStyle.getPropertyValue('--primary'),
radius: rootStyle.getPropertyValue('--radius'),
bodyBgColor,
hasInjectedStyle: Array.from(document.head.querySelectorAll('style')).some((el) =>
el.innerHTML.includes('.e2e-css-test-marker'),
),
};
});
// CSS custom properties should be set to the expected HSL values.
expect(cssState.background).toBe(EXPECTED_CSS_VARS['--background']);
expect(cssState.primary).toBe(EXPECTED_CSS_VARS['--primary']);
expect(cssState.radius).toBe(EXPECTED_CSS_VARS['--radius']);
// Raw CSS style tag should be injected.
expect(cssState.hasInjectedStyle).toBe(true);
// The body background should reflect the injected --background value (red).
expect(cssState.bodyBgColor).toBe(INJECTED_BODY_BG_COLOR);
};
const assertDarkModeDisabled = async (surface: TEnvelopeEditorSurface) => {
const { root: page } = surface;
const hasDarkModeDisabled = await page.evaluate(() =>
document.documentElement.classList.contains('dark-mode-disabled'),
);
expect(hasDarkModeDisabled).toBe(true);
};
/**
* Open an embedded create editor with a pre-seeded user. This is needed for folderId
* tests where the folder must exist before the hash is built.
*/
const openEmbeddedCreateWithUser = async (
page: Page,
user: { id: number; email: string; name: string | null },
team: { id: number },
options: { folderId?: string; tokenNamePrefix?: string },
): Promise<TEnvelopeEditorSurface> => {
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: `${options.tokenNamePrefix ?? 'e2e-embed-folder'}-document`,
expiresIn: null,
});
// Exchange API token for presign token.
const response = await page
.context()
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2/embedding/create-presign-token`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
});
const data: unknown = await response.json();
if (typeof data !== 'object' || data === null || !('token' in data)) {
throw new Error(`Unexpected presign response: ${JSON.stringify(data)}`);
}
const presignToken = data.token;
if (typeof presignToken !== 'string' || presignToken.length === 0) {
throw new Error(`Unexpected presign response: ${JSON.stringify(data)}`);
}
const hash = createEmbeddedEnvelopeCreateHash({
envelopeType: 'DOCUMENT',
folderId: options.folderId,
});
await page.goto(
`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(presignToken)}#${hash}`,
);
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
return {
root: page,
isEmbedded: true,
envelopeType: 'DOCUMENT',
userId: user.id,
userEmail: user.email,
userName: user.name ?? '',
teamId: team.id,
};
};
/**
* Helper to set an external ID on the envelope via the settings dialog so we can
* look it up in the database after creation.
*/
const setExternalIdViaSettings = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await getEnvelopeEditorSettingsTrigger(surface.root).click();
await expect(surface.root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
};
/**
* Minimal setup for an embedded create flow: upload a PDF and add a recipient
* so the "Create Document" button works.
*/
const setupMinimalEnvelope = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await addEnvelopeItemPdf(surface.root);
await setRecipientEmail(surface.root, 0, `${nanoid()}@test.documenso.com`);
await setExternalIdViaSettings(surface, externalId);
};
/**
* Click "Create Document" and expect a failure toast instead of the success heading.
*/
const expectCreateToFail = async (surface: TEnvelopeEditorSurface) => {
const actionButtonName =
surface.envelopeType === 'DOCUMENT' ? 'Create Document' : 'Create Template';
await surface.root.getByRole('button', { name: actionButtonName }).click();
await expectToastTextToBeVisible(surface.root, 'Failed to create document');
};
test.describe('embedded create - folderId', () => {
test('creates envelope in the specified folder when folderId is provided', async ({ page }) => {
const { user, team } = await seedUser();
const folder = await prisma.folder.create({
data: {
name: 'E2E Document Folder',
teamId: team.id,
userId: user.id,
type: 'DOCUMENT',
},
});
const surface = await openEmbeddedCreateWithUser(page, user, team, {
folderId: folder.id,
tokenNamePrefix: 'e2e-embed-folder',
});
const externalId = `e2e-folder-create-${nanoid()}`;
await setupMinimalEnvelope(surface, externalId);
await persistEmbeddedEnvelope(surface);
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
},
});
expect(envelope.folderId).toBe(folder.id);
});
test('creates envelope in root folder when no folderId is provided', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
mode: 'create',
tokenNamePrefix: 'e2e-embed-folder-none',
});
const externalId = `e2e-folder-root-${nanoid()}`;
await setupMinimalEnvelope(surface, externalId);
await persistEmbeddedEnvelope(surface);
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
},
});
expect(envelope.folderId).toBeNull();
});
test('rejects creation when folderId has wrong folder type', async ({ page }) => {
const { user, team } = await seedUser();
// Create a TEMPLATE folder but attempt to create a DOCUMENT envelope in it.
const templateFolder = await prisma.folder.create({
data: {
name: 'E2E Template Folder',
teamId: team.id,
userId: user.id,
type: 'TEMPLATE',
},
});
const surface = await openEmbeddedCreateWithUser(page, user, team, {
folderId: templateFolder.id,
tokenNamePrefix: 'e2e-embed-folder-wrong-type',
});
const externalId = `e2e-folder-wrong-type-${nanoid()}`;
await setupMinimalEnvelope(surface, externalId);
await expectCreateToFail(surface);
// Verify no envelope was created with this externalId.
const count = await prisma.envelope.count({ where: { externalId } });
expect(count).toBe(0);
});
test('rejects creation when folderId belongs to another team', async ({ page }) => {
// Create the user who will use the embedded editor.
const { user, team } = await seedUser();
// Create a second user/team that owns the folder.
const { user: otherUser, team: otherTeam } = await seedUser();
const otherTeamFolder = await prisma.folder.create({
data: {
name: 'E2E Other Team Folder',
teamId: otherTeam.id,
userId: otherUser.id,
type: 'DOCUMENT',
},
});
// Open the embedded editor for the first user but pass the folder from the other team.
const surface = await openEmbeddedCreateWithUser(page, user, team, {
folderId: otherTeamFolder.id,
tokenNamePrefix: 'e2e-embed-folder-no-perm',
});
const externalId = `e2e-folder-no-perm-${nanoid()}`;
await setupMinimalEnvelope(surface, externalId);
await expectCreateToFail(surface);
// Verify no envelope was created with this externalId.
const count = await prisma.envelope.count({ where: { externalId } });
expect(count).toBe(0);
});
test('rejects creation when folderId does not exist', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
mode: 'create',
tokenNamePrefix: 'e2e-embed-folder-nonexistent',
folderId: 'nonexistent-folder-id',
});
const externalId = `e2e-folder-nonexistent-${nanoid()}`;
await setupMinimalEnvelope(surface, externalId);
await expectCreateToFail(surface);
// Verify no envelope was created with this externalId.
const count = await prisma.envelope.count({ where: { externalId } });
expect(count).toBe(0);
});
});
test.describe('embedded create', () => {
test('cssVars and css respect embedAuthoringWhiteLabel flag', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
mode: 'create',
tokenNamePrefix: 'e2e-embed-css',
css: TEST_RAW_CSS,
cssVars: TEST_CSS_VARS,
darkModeDisabled: true,
});
// darkModeDisabled is applied regardless of the flag.
await assertDarkModeDisabled(surface);
// Flag is disabled by default so CSS should NOT be injected.
await assertCssNotInjected(surface);
// Enable the embedAuthoringWhiteLabel flag on the organisation claim.
await enableEmbedAuthoringWhiteLabel(surface.userId);
// Reload the page to re-run the layout loader with the updated claim.
await page.reload();
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// CSS should now be injected.
await assertCssInjected(surface);
// darkModeDisabled should still be applied after reload.
await assertDarkModeDisabled(surface);
});
});
test.describe('embedded edit', () => {
test('cssVars and css respect embedAuthoringWhiteLabel flag', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-css',
css: TEST_RAW_CSS,
cssVars: TEST_CSS_VARS,
darkModeDisabled: true,
});
// darkModeDisabled is applied regardless of the flag.
await assertDarkModeDisabled(surface);
// Flag is disabled by default so CSS should NOT be injected.
await assertCssNotInjected(surface);
// Enable the embedAuthoringWhiteLabel flag on the organisation claim.
await enableEmbedAuthoringWhiteLabel(surface.userId);
// Reload the page to re-run the layout loader with the updated claim.
await page.reload();
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// CSS should now be injected.
await assertCssInjected(surface);
// darkModeDisabled should still be applied after reload.
await assertDarkModeDisabled(surface);
});
});
@@ -0,0 +1,852 @@
import { type Page, expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
clickAddMyselfButton,
clickAddSignerButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getRecipientEmailInputs,
getRecipientRemoveButtons,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { getKonvaElementCountForPage } from '../fixtures/konva';
type TFieldFlowResult = {
externalId: string;
recipientEmail: string;
};
const TEST_FIELD_VALUES = {
embeddedRecipient: {
email: 'embedded-field-recipient@documenso.com',
name: 'Embedded Field Recipient',
},
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const setupRecipientsForFieldPlacement = async (surface: TEnvelopeEditorSurface) => {
if (surface.isEmbedded) {
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
await setRecipientEmail(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
await setRecipientName(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
return TEST_FIELD_VALUES.embeddedRecipient.email;
}
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
await clickAddMyselfButton(surface.root);
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
return surface.userEmail;
};
type FieldButtonName =
| 'Signature'
| 'Email'
| 'Name'
| 'Initials'
| 'Date'
| 'Text'
| 'Number'
| 'Radio'
| 'Checkbox'
| 'Dropdown';
const placeFieldOnPdf = async (
root: Page,
fieldName: FieldButtonName,
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
const selectRecipientInFieldsStep = async (root: Page, recipientIdentifier: string) => {
await root.locator('button[role="combobox"]').click();
await root.getByText(recipientIdentifier).click();
};
const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => {
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await root.waitForTimeout(300);
// Use force:true to bypass any floating action toolbar buttons that may intercept clicks.
await canvas.click({ position, force: true });
};
const runAddAndPersistSignatureTextFields = async (
surface: TEnvelopeEditorSurface,
): Promise<TFieldFlowResult> => {
const externalId = `e2e-fields-${nanoid()}`;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'embedded-fields.pdf');
}
await updateExternalId(surface, externalId);
const recipientEmail = await setupRecipientsForFieldPlacement(surface);
await clickEnvelopeEditorStep(surface.root, 'addFields');
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
let fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
expect(fieldCount).toBe(1);
await placeFieldOnPdf(surface.root, 'Text', { x: 220, y: 240 });
fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
expect(fieldCount).toBe(2);
await clickEnvelopeEditorStep(surface.root, 'upload');
await expect(surface.root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
await clickEnvelopeEditorStep(surface.root, 'addFields');
await surface.root.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
expect(fieldCount).toBe(2);
return {
externalId,
recipientEmail,
};
};
const getFieldMetaType = (fieldMeta: unknown) => {
if (!isRecord(fieldMeta)) {
return null;
}
return typeof fieldMeta.type === 'string' ? fieldMeta.type : null;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const assertFieldsPersistedInDatabase = async ({
surface,
externalId,
recipientEmail,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
recipientEmail: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: {
createdAt: 'desc',
},
include: {
fields: true,
recipients: true,
},
});
const recipient = envelope.recipients.find(
(currentRecipient) => currentRecipient.email === recipientEmail,
);
expect(recipient).toBeDefined();
const fieldTypes = envelope.fields.map((field) => field.type).sort();
const expectedFieldTypes = [FieldType.SIGNATURE, FieldType.TEXT].sort();
expect(envelope.fields).toHaveLength(2);
expect(fieldTypes).toEqual(expectedFieldTypes);
expect(new Set(envelope.fields.map((field) => field.envelopeItemId)).size).toBe(1);
expect(envelope.fields.every((field) => field.recipientId === recipient?.id)).toBe(true);
const signatureField = envelope.fields.find((field) => field.type === FieldType.SIGNATURE);
const textField = envelope.fields.find((field) => field.type === FieldType.TEXT);
expect(getFieldMetaType(signatureField?.fieldMeta)).toBe('signature');
expect(getFieldMetaType(textField?.fieldMeta)).toBe('text');
};
// --- Multi-recipient field flow ---
type TMultiRecipientFlowResult = {
externalId: string;
firstRecipientEmail: string;
secondRecipientEmail: string;
};
const MULTI_RECIPIENT_VALUES = {
secondSigner: {
email: 'second-signer@test.documenso.com',
name: 'Second Signer',
},
};
const runMultiRecipientFieldFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<TMultiRecipientFlowResult> => {
const externalId = `e2e-multi-recip-${nanoid()}`;
const root = surface.root;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
}
await updateExternalId(surface, externalId);
// Add two recipients.
let firstRecipientEmail: string;
if (surface.isEmbedded) {
await setRecipientEmail(root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
await setRecipientName(root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
firstRecipientEmail = TEST_FIELD_VALUES.embeddedRecipient.email;
} else {
await clickAddMyselfButton(root);
firstRecipientEmail = surface.userEmail;
}
await clickAddSignerButton(root);
await setRecipientEmail(root, 1, MULTI_RECIPIENT_VALUES.secondSigner.email);
await setRecipientName(root, 1, MULTI_RECIPIENT_VALUES.secondSigner.name);
// Navigate to fields step.
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.getByText('Selected Recipient')).toBeVisible();
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(0);
// Place Signature for recipient #1 (auto-selected).
await placeFieldOnPdf(root, 'Signature', { x: 120, y: 140 });
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(1);
// Switch recipient and place text field for recipient #2.
await selectRecipientInFieldsStep(root, MULTI_RECIPIENT_VALUES.secondSigner.email);
await placeFieldOnPdf(root, 'Text', { x: 220, y: 240 });
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(2);
// Navigate away and back to ensure fields are persisted in the UI.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(2);
// Phase 2: cascade deletion — go back to recipients and remove the second one.
await clickEnvelopeEditorStep(root, 'upload');
await expect(getRecipientEmailInputs(root)).toHaveCount(2);
await getRecipientRemoveButtons(root).nth(1).click();
await expect(getRecipientEmailInputs(root)).toHaveCount(1);
// Go back to fields and verify cascade removal.
await clickEnvelopeEditorStep(root, 'addFields');
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(1);
return {
externalId,
firstRecipientEmail,
secondRecipientEmail: MULTI_RECIPIENT_VALUES.secondSigner.email,
};
};
const assertMultiRecipientCascadePersistedInDatabase = async ({
surface,
externalId,
firstRecipientEmail,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
firstRecipientEmail: string;
secondRecipientEmail: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: { fields: true, recipients: true },
});
// After cascade deletion, only one recipient and one field should remain.
expect(envelope.recipients).toHaveLength(1);
expect(envelope.recipients[0].email).toBe(firstRecipientEmail);
expect(envelope.fields).toHaveLength(1);
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
expect(envelope.fields[0].recipientId).toBe(envelope.recipients[0].id);
};
// --- All 10 field types flow ---
type TAllFieldTypesFlowResult = {
externalId: string;
};
const runAllFieldTypesFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<TAllFieldTypesFlowResult> => {
const externalId = `e2e-all-fields-${nanoid()}`;
const root = surface.root;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
}
await updateExternalId(surface, externalId);
await setupRecipientsForFieldPlacement(surface);
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
// Place and configure each field type immediately after placement.
// After placeFieldOnPdf, the sidebar shows the field's config form (field is selected in React state).
// 1. Signature: place and set fontSize to 24.
await placeFieldOnPdf(root, 'Signature', { x: 120, y: 50 });
await root.locator('[data-testid="field-form-fontSize"]').fill('24');
// 2. Email: place and set textAlign to center.
await placeFieldOnPdf(root, 'Email', { x: 120, y: 100 });
await root.locator('[data-testid="field-form-textAlign"]').click();
await root.getByRole('option', { name: 'Center' }).click();
// 3. Name: place and set textAlign to right.
await placeFieldOnPdf(root, 'Name', { x: 120, y: 150 });
await root.locator('[data-testid="field-form-textAlign"]').click();
await root.getByRole('option', { name: 'Right' }).click();
// 4. Initials: place and set fontSize to 16.
await placeFieldOnPdf(root, 'Initials', { x: 120, y: 200 });
await root.locator('[data-testid="field-form-fontSize"]').fill('16');
// 5. Date: place and set textAlign to center.
await placeFieldOnPdf(root, 'Date', { x: 120, y: 250 });
await root.locator('[data-testid="field-form-textAlign"]').click();
await root.getByRole('option', { name: 'Center' }).click();
// 6. Text: place and configure label, placeholder, text, characterLimit, required.
await placeFieldOnPdf(root, 'Text', { x: 120, y: 300 });
await root.locator('[data-testid="field-form-label"]').fill('Test Label');
await root.locator('[data-testid="field-form-placeholder"]').fill('Enter text here');
await root.locator('[data-testid="field-form-text"]').fill('Default text value');
await root.locator('[data-testid="field-form-characterLimit"]').fill('100');
await root.locator('[data-testid="field-form-required"]').click();
// 7. Number: place and configure label, placeholder, numberFormat, minValue, maxValue, required.
await placeFieldOnPdf(root, 'Number', { x: 120, y: 350 });
await root.locator('[data-testid="field-form-label"]').fill('Amount');
await root.locator('[data-testid="field-form-placeholder"]').fill('0.00');
await root.locator('[data-testid="field-form-numberFormat"]').click();
await root.getByRole('option', { name: '123,456,789.00' }).click();
await root.locator('[data-testid="field-form-minValue"]').fill('0');
await root.locator('[data-testid="field-form-maxValue"]').fill('1000');
await root.locator('[data-testid="field-form-required"]').click();
// 8. Radio: place and configure two options, pre-select first, set direction to horizontal.
await placeFieldOnPdf(root, 'Radio', { x: 120, y: 400 });
// The first option already exists with default value "Default value". Fill it.
await root.locator('[data-testid="field-form-values-0-value"]').fill('Option A');
// Add a second option.
await root.locator('[data-testid="field-form-values-add"]').click();
await root.locator('[data-testid="field-form-values-1-value"]').fill('Option B');
// Pre-select the first option (click its checkbox).
await root.locator('[data-testid="field-form-values-0-checked"]').click();
// Set direction to horizontal.
await root.locator('[data-testid="field-form-direction"]').click();
await root.getByRole('option', { name: 'Horizontal' }).click();
// 9. Checkbox: place and configure two options, check both, set validation rule.
await placeFieldOnPdf(root, 'Checkbox', { x: 120, y: 450 });
// Fill first option value.
await root.locator('[data-testid="field-form-values-0-value"]').fill('Check A');
// Add a second option.
await root.locator('[data-testid="field-form-values-add"]').click();
await root.locator('[data-testid="field-form-values-1-value"]').fill('Check B');
// Check both options (click their checkboxes).
await root.locator('[data-testid="field-form-values-0-checked"]').click();
await root.locator('[data-testid="field-form-values-1-checked"]').click();
// Set validation: "Select at least" 1.
await root.locator('[data-testid="field-form-validationRule"]').click();
await root.getByRole('option', { name: 'Select at least' }).click();
// Set validation length to 1.
await root.locator('[data-testid="field-form-validationLength"]').click();
await root.getByRole('option', { name: '1', exact: true }).click();
// 10. Dropdown: place and configure two options, set default value.
await placeFieldOnPdf(root, 'Dropdown', { x: 120, y: 500 });
// First option already has "Option 1". Change it to "Red".
await root.locator('[data-testid="field-form-values-0-value"]').fill('Red');
// Add a second option.
await root.locator('[data-testid="field-form-values-add"]').click();
await root.locator('[data-testid="field-form-values-1-value"]').clear();
await root.locator('[data-testid="field-form-values-1-value"]').fill('Blue');
// Set default value to "Red".
await root.locator('[data-testid="field-form-defaultValue"]').click();
await root.getByRole('option', { name: 'Red' }).click();
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(10);
// Wait briefly for auto-save to fire on the last configured field.
await root.waitForTimeout(500);
// Navigate away and back to verify persistence.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(10);
return { externalId };
};
const assertAllFieldTypesPersistedInDatabase = async ({
surface,
externalId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: { fields: true },
});
expect(envelope.fields).toHaveLength(10);
const fieldsByType = new Map(envelope.fields.map((f) => [f.type, f]));
// Helper to safely access fieldMeta as a record.
const meta = (type: FieldType): Record<string, unknown> => {
const field = fieldsByType.get(type);
expect(field).toBeDefined();
const fieldMeta = field!.fieldMeta;
expect(typeof fieldMeta).toBe('object');
expect(fieldMeta).not.toBeNull();
return fieldMeta as Record<string, unknown>;
};
// SIGNATURE
expect(meta(FieldType.SIGNATURE).type).toBe('signature');
expect(meta(FieldType.SIGNATURE).fontSize).toBe(24);
// EMAIL
expect(meta(FieldType.EMAIL).type).toBe('email');
expect(meta(FieldType.EMAIL).textAlign).toBe('center');
// NAME
expect(meta(FieldType.NAME).type).toBe('name');
expect(meta(FieldType.NAME).textAlign).toBe('right');
// INITIALS
expect(meta(FieldType.INITIALS).type).toBe('initials');
expect(meta(FieldType.INITIALS).fontSize).toBe(16);
// DATE
expect(meta(FieldType.DATE).type).toBe('date');
expect(meta(FieldType.DATE).textAlign).toBe('center');
// TEXT
expect(meta(FieldType.TEXT).type).toBe('text');
expect(meta(FieldType.TEXT).label).toBe('Test Label');
expect(meta(FieldType.TEXT).placeholder).toBe('Enter text here');
expect(meta(FieldType.TEXT).text).toBe('Default text value');
expect(meta(FieldType.TEXT).characterLimit).toBe(100);
expect(meta(FieldType.TEXT).required).toBe(true);
// NUMBER
expect(meta(FieldType.NUMBER).type).toBe('number');
expect(meta(FieldType.NUMBER).label).toBe('Amount');
expect(meta(FieldType.NUMBER).placeholder).toBe('0.00');
expect(meta(FieldType.NUMBER).numberFormat).toBe('123,456,789.00');
expect(meta(FieldType.NUMBER).minValue).toBe(0);
expect(meta(FieldType.NUMBER).maxValue).toBe(1000);
expect(meta(FieldType.NUMBER).required).toBe(true);
// RADIO
expect(meta(FieldType.RADIO).type).toBe('radio');
expect(meta(FieldType.RADIO).direction).toBe('horizontal');
const radioValues = meta(FieldType.RADIO).values as Array<{
value: string;
checked: boolean;
}>;
expect(radioValues).toHaveLength(2);
expect(radioValues[0].value).toBe('Option A');
expect(radioValues[0].checked).toBe(true);
expect(radioValues[1].value).toBe('Option B');
expect(radioValues[1].checked).toBe(false);
// CHECKBOX
expect(meta(FieldType.CHECKBOX).type).toBe('checkbox');
expect(meta(FieldType.CHECKBOX).validationRule).toBe('Select at least');
expect(meta(FieldType.CHECKBOX).validationLength).toBe(1);
const checkboxValues = meta(FieldType.CHECKBOX).values as Array<{
value: string;
checked: boolean;
}>;
expect(checkboxValues).toHaveLength(2);
expect(checkboxValues[0].value).toBe('Check A');
expect(checkboxValues[0].checked).toBe(true);
expect(checkboxValues[1].value).toBe('Check B');
expect(checkboxValues[1].checked).toBe(true);
// DROPDOWN
expect(meta(FieldType.DROPDOWN).type).toBe('dropdown');
expect(meta(FieldType.DROPDOWN).defaultValue).toBe('Red');
const dropdownValues = meta(FieldType.DROPDOWN).values as Array<{ value: string }>;
expect(dropdownValues).toHaveLength(2);
expect(dropdownValues[0].value).toBe('Red');
expect(dropdownValues[1].value).toBe('Blue');
};
// --- Duplicate and delete fields flow ---
type TDuplicateDeleteFlowResult = {
externalId: string;
};
const runDuplicateDeleteFieldFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<TDuplicateDeleteFlowResult> => {
const externalId = `e2e-dup-del-${nanoid()}`;
const root = surface.root;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
}
await updateExternalId(surface, externalId);
await setupRecipientsForFieldPlacement(surface);
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
// Place a Signature field.
await placeFieldOnPdf(root, 'Signature', { x: 150, y: 150 });
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(1);
// Select the field on canvas to show the action toolbar.
await selectFieldOnCanvas(root, { x: 150, y: 150 });
await expect(root.locator('button[title="Duplicate"]')).toBeVisible();
// Duplicate the field.
await root.locator('button[title="Duplicate"]').click();
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(2);
// Navigate away and back to persist changes.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(2);
// Select a field and delete it via the Remove button.
await selectFieldOnCanvas(root, { x: 150, y: 150 });
await expect(root.locator('button[title="Remove"]')).toBeVisible();
await root.locator('button[title="Remove"]').click();
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(1);
// Navigate away and back to verify persistence.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(1);
return { externalId };
};
const assertDuplicateDeleteFieldPersistedInDatabase = async ({
surface,
externalId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: { fields: true },
});
// After duplicating (2 fields) then deleting one, exactly 1 SIGNATURE field should remain.
expect(envelope.fields).toHaveLength(1);
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
};
// --- Test describe blocks ---
test.describe('document editor', () => {
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runAddAndPersistSignatureTextFields(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runMultiRecipientFieldFlow(surface);
await assertMultiRecipientCascadePersistedInDatabase({
surface,
...result,
});
});
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runDuplicateDeleteFieldFlow(surface);
await assertDuplicateDeleteFieldPersistedInDatabase({
surface,
...result,
});
});
test('place and configure all 10 field types', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runAllFieldTypesFlow(surface);
await assertAllFieldTypesPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('template editor', () => {
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runAddAndPersistSignatureTextFields(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runMultiRecipientFieldFlow(surface);
await assertMultiRecipientCascadePersistedInDatabase({
surface,
...result,
});
});
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runDuplicateDeleteFieldFlow(surface);
await assertDuplicateDeleteFieldPersistedInDatabase({
surface,
...result,
});
});
test('place and configure all 10 field types', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runAllFieldTypesFlow(surface);
await assertAllFieldTypesPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('embedded create', () => {
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-fields',
});
const result = await runAddAndPersistSignatureTextFields(surface);
await persistEmbeddedEnvelope(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-multi-recip',
});
const result = await runMultiRecipientFieldFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertMultiRecipientCascadePersistedInDatabase({
surface,
...result,
});
});
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-dup-del',
});
const result = await runDuplicateDeleteFieldFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertDuplicateDeleteFieldPersistedInDatabase({
surface,
...result,
});
});
test('place and configure all 10 field types', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-all-fields',
});
const result = await runAllFieldTypesFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertAllFieldTypesPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('embedded edit', () => {
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-fields',
});
const result = await runAddAndPersistSignatureTextFields(surface);
await persistEmbeddedEnvelope(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-multi-recip',
});
const result = await runMultiRecipientFieldFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertMultiRecipientCascadePersistedInDatabase({
surface,
...result,
});
});
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-dup-del',
});
const result = await runDuplicateDeleteFieldFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertDuplicateDeleteFieldPersistedInDatabase({
surface,
...result,
});
});
test('place and configure all 10 field types', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-all-fields',
});
const result = await runAllFieldTypesFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertAllFieldTypesPersistedInDatabase({
surface,
...result,
});
});
});
@@ -0,0 +1,321 @@
import { type Page, expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
clickAddMyselfButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
test.use({
storageState: {
cookies: [],
origins: [],
},
});
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const multiPagePdfBuffer = fs.readFileSync(
path.join(__dirname, '../../../../assets/field-font-alignment.pdf'),
);
// --- Shared helpers ---
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const getEditButton = (root: Page, index: number) =>
root.locator('[data-testid^="envelope-item-edit-button-"]').nth(index);
const getEditDialog = (root: Page) => root.getByRole('dialog');
const getEditDialogTitleInput = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-title-input"]');
const getEditDialogDropzone = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-dropzone"]');
const getEditDialogSelectedFile = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-selected-file"]');
const getEditDialogClearFileButton = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-clear-file"]');
const getEditDialogUpdateButton = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-update-button"]');
const assertPdfPageCount = async (root: Page, expectedCount: number) => {
await expect(root.locator('[data-pdf-content]').first()).toHaveAttribute(
'data-page-count',
String(expectedCount),
{ timeout: 15000 },
);
};
const navigateToFieldsPage = async (surface: TEnvelopeEditorSurface) => {
// Set up a recipient first so the fields page is functional.
if (surface.isEmbedded) {
await setRecipientEmail(surface.root, 0, `test-${nanoid(4)}@example.com`);
await setRecipientName(surface.root, 0, 'Test User');
} else {
await clickAddMyselfButton(surface.root);
}
await clickEnvelopeEditorStep(surface.root, 'addFields');
// Wait for the file selector to be visible on the fields page.
await expect(getEditButton(surface.root, 0)).toBeAttached({ timeout: 10000 });
};
const openEditDialogOnFieldsPage = async (root: Page, index = 0) => {
// Hover to reveal the edit button, then click it.
const editButton = getEditButton(root, index);
await editButton.click({ force: true });
await expect(getEditDialog(root)).toBeVisible();
};
// --- Flows ---
const runRenameFlow = async (surface: TEnvelopeEditorSurface) => {
const externalId = `e2e-edit-rename-${nanoid()}`;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'rename-test.pdf');
}
await updateExternalId(surface, externalId);
await navigateToFieldsPage(surface);
// Open edit dialog and change the title.
await openEditDialogOnFieldsPage(surface.root);
const titleInput = getEditDialogTitleInput(surface.root);
await expect(titleInput).toBeVisible();
await titleInput.clear();
await titleInput.fill('Renamed Document');
// Update button should be disabled without a replacement file.
await expect(getEditDialogUpdateButton(surface.root)).toBeDisabled();
// A replacement file is required for the Update button to be enabled.
const dropzone = getEditDialogDropzone(surface.root);
await expect(dropzone).toBeVisible();
const fileInput = dropzone.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'example.pdf',
mimeType: 'application/pdf',
buffer: examplePdfBuffer,
});
await expect(getEditDialogSelectedFile(surface.root)).toBeVisible();
await getEditDialogUpdateButton(surface.root).click();
// Dialog should close.
await expect(getEditDialog(surface.root)).not.toBeVisible({ timeout: 10000 });
return { externalId };
};
const runReplacePdfFlow = async (surface: TEnvelopeEditorSurface) => {
const externalId = `e2e-edit-replace-${nanoid()}`;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'replace-test.pdf');
}
await updateExternalId(surface, externalId);
await navigateToFieldsPage(surface);
// First, assert the page count is 1 (example.pdf has 1 page).
await assertPdfPageCount(surface.root, 1);
// Open edit dialog.
await openEditDialogOnFieldsPage(surface.root);
// Select a multi-page PDF via the dropzone.
const dropzone = getEditDialogDropzone(surface.root);
await expect(dropzone).toBeVisible();
const fileInput = dropzone.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'field-font-alignment.pdf',
mimeType: 'application/pdf',
buffer: multiPagePdfBuffer,
});
// Verify file is shown in the selected file display.
await expect(getEditDialogSelectedFile(surface.root)).toBeVisible();
// Click Update.
await getEditDialogUpdateButton(surface.root).click();
// Dialog should close.
await expect(getEditDialog(surface.root)).not.toBeVisible({ timeout: 15000 });
// After replacement, the page count should be 3 (field-font-alignment.pdf has 3 pages).
await assertPdfPageCount(surface.root, 3);
return { externalId };
};
const runClearFileSelectionFlow = async (surface: TEnvelopeEditorSurface) => {
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'clear-file-test.pdf');
}
await navigateToFieldsPage(surface);
// Open edit dialog.
await openEditDialogOnFieldsPage(surface.root);
// Select a file.
const dropzone = getEditDialogDropzone(surface.root);
await expect(dropzone).toBeVisible();
const fileInput = dropzone.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'to-be-cleared.pdf',
mimeType: 'application/pdf',
buffer: examplePdfBuffer,
});
// Verify file appears.
await expect(getEditDialogSelectedFile(surface.root)).toBeVisible();
await expect(getEditDialogDropzone(surface.root)).not.toBeVisible();
// Click X to clear.
await getEditDialogClearFileButton(surface.root).click();
// Dropzone should reappear.
await expect(getEditDialogDropzone(surface.root)).toBeVisible();
await expect(getEditDialogSelectedFile(surface.root)).not.toBeVisible();
};
// --- DB assertion ---
const assertRenamePersistedInDatabase = async ({
surface,
externalId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
envelopeItems: {
orderBy: { order: 'asc' },
},
},
orderBy: { createdAt: 'desc' },
});
expect(envelope.envelopeItems.length).toBeGreaterThanOrEqual(1);
expect(envelope.envelopeItems[0].title).toBe('Renamed Document');
};
// --- Tests ---
test.describe('document editor', () => {
test('should rename an envelope item from the fields page', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runRenameFlow(surface);
await assertRenamePersistedInDatabase({ surface, ...result });
});
test('should replace a PDF from the fields page', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
await runReplacePdfFlow(surface);
});
test('should clear a selected file before submitting', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
await runClearFileSelectionFlow(surface);
});
});
test.describe('template editor', () => {
test('should rename an envelope item from the fields page', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runRenameFlow(surface);
await assertRenamePersistedInDatabase({ surface, ...result });
});
});
test.describe('embedded create', () => {
test('should rename an envelope item from the fields page', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-edit-dialog-create',
});
const result = await runRenameFlow(surface);
await clickEnvelopeEditorStep(surface.root, 'upload');
await persistEmbeddedEnvelope(surface);
await assertRenamePersistedInDatabase({ surface, ...result });
});
test('should replace a PDF from the fields page', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-edit-dialog-replace',
});
await runReplacePdfFlow(surface);
});
});
test.describe('embedded edit', () => {
test('should rename an envelope item from the fields page', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-edit-dialog-edit',
});
const result = await runRenameFlow(surface);
await clickEnvelopeEditorStep(surface.root, 'upload');
await persistEmbeddedEnvelope(surface);
await assertRenamePersistedInDatabase({ surface, ...result });
});
});
@@ -0,0 +1,388 @@
import { type Page, expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import {
type TEnvelopeEditorSurface,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getEnvelopeItemDragHandles,
getEnvelopeItemDropzoneInput,
getEnvelopeItemRemoveButtons,
getEnvelopeItemTitleInputs,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
test.use({
storageState: {
cookies: [],
origins: [],
},
});
type TestFilePayload = {
name: string;
mimeType: string;
buffer: Buffer;
};
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const createPdfPayload = (name: string): TestFilePayload => ({
name,
mimeType: 'application/pdf',
buffer: examplePdfBuffer,
});
const getCurrentTitles = async (root: Page) => {
const titleInputs = getEnvelopeItemTitleInputs(root);
const count = await titleInputs.count();
return await Promise.all(
Array.from({ length: count }, async (_, index) => await titleInputs.nth(index).inputValue()),
);
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const navigateToAddFieldsAndBack = async (root: Page) => {
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.getByText('Selected Recipient')).toBeVisible();
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
};
const getEnvelopeItemsFromDatabase = async (
surface: TEnvelopeEditorSurface,
externalId: string,
) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
envelopeItems: {
orderBy: {
order: 'asc',
},
},
},
orderBy: {
createdAt: 'desc',
},
});
return envelope.envelopeItems;
};
type ItemFlowResult = {
externalId: string;
};
const uploadFiles = async (root: Page, files: TestFilePayload[]) => {
const input = getEnvelopeItemDropzoneInput(root);
await input.setInputFiles(files);
};
const dragEnvelopeItemByHandle = async ({
root,
sourceIndex,
targetIndex,
}: {
root: Page;
sourceIndex: number;
targetIndex: number;
}) => {
const sourceHandle = getEnvelopeItemDragHandles(root).nth(sourceIndex);
const targetHandle = getEnvelopeItemDragHandles(root).nth(targetIndex);
await expect(sourceHandle).toBeVisible();
await expect(targetHandle).toBeVisible();
const sourceBox = await sourceHandle.boundingBox();
const targetBox = await targetHandle.boundingBox();
if (!sourceBox || !targetBox) {
throw new Error('Could not resolve drag handle bounding boxes');
}
const sourceX = sourceBox.x + sourceBox.width / 2;
const sourceY = sourceBox.y + sourceBox.height / 2;
const targetX = targetBox.x + targetBox.width / 2;
const targetY = targetBox.y + targetBox.height / 2;
await root.mouse.move(sourceX, sourceY);
await root.mouse.down();
await root.mouse.move(targetX, targetY, { steps: 20 });
await root.mouse.up();
};
const runEnvelopeItemCrudFlow = async ({
surface,
initialCount,
filesToUpload,
}: {
surface: TEnvelopeEditorSurface;
initialCount: number;
filesToUpload: TestFilePayload[];
}): Promise<ItemFlowResult> => {
const { root, isEmbedded } = surface;
const externalId = `e2e-items-${nanoid()}`;
await updateExternalId(surface, externalId);
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(initialCount);
// Upload files.
await uploadFiles(root, filesToUpload);
const expectedCountAfterUpload = initialCount + filesToUpload.length;
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload);
if (!isEmbedded) {
await navigateToAddFieldsAndBack(root);
const itemsAfterUpload = await getEnvelopeItemsFromDatabase(surface, externalId);
expect(itemsAfterUpload).toHaveLength(expectedCountAfterUpload);
}
// Rename items.
await getEnvelopeItemTitleInputs(root).nth(0).fill('Envelope Item A');
await getEnvelopeItemTitleInputs(root).nth(1).fill('Envelope Item B');
await expect(getEnvelopeItemTitleInputs(root).nth(0)).toHaveValue('Envelope Item A');
await expect(getEnvelopeItemTitleInputs(root).nth(1)).toHaveValue('Envelope Item B');
if (!isEmbedded) {
await navigateToAddFieldsAndBack(root);
const itemsAfterRename = await getEnvelopeItemsFromDatabase(surface, externalId);
expect(itemsAfterRename[0].title).toBe('Envelope Item A');
expect(itemsAfterRename[1].title).toBe('Envelope Item B');
}
// Reorder items.
await dragEnvelopeItemByHandle({
root,
sourceIndex: 0,
targetIndex: 1,
});
await expect
.poll(async () => await getCurrentTitles(root))
.toEqual(['Envelope Item B', 'Envelope Item A']);
if (!isEmbedded) {
await navigateToAddFieldsAndBack(root);
const itemsAfterReorder = await getEnvelopeItemsFromDatabase(surface, externalId);
expect(itemsAfterReorder[0].title).toBe('Envelope Item B');
expect(itemsAfterReorder[1].title).toBe('Envelope Item A');
}
// Remove first item.
await getEnvelopeItemRemoveButtons(root).first().click();
if (!isEmbedded) {
await root.getByRole('button', { name: 'Delete' }).click();
}
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload - 1);
if (!isEmbedded) {
await navigateToAddFieldsAndBack(root);
const itemsAfterRemove = await getEnvelopeItemsFromDatabase(surface, externalId);
expect(itemsAfterRemove).toHaveLength(expectedCountAfterUpload - 1);
}
return {
externalId,
};
};
test.describe('document editor', () => {
test('add, remove, reorder and retitle items', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runEnvelopeItemCrudFlow({
surface,
initialCount: 1,
filesToUpload: [createPdfPayload('document-item-added.pdf')],
});
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
expect(items).toHaveLength(1);
expect(items[0].title).toBe('Envelope Item A');
expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items.
});
});
test.describe('template editor', () => {
test('add, remove, reorder and retitle items', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runEnvelopeItemCrudFlow({
surface,
initialCount: 1,
filesToUpload: [createPdfPayload('template-item-added.pdf')],
});
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
expect(items).toHaveLength(1);
expect(items[0].title).toBe('Envelope Item A');
expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items.
});
});
test.describe('embedded create', () => {
test('add, remove, reorder and retitle items', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-items',
});
const result = await runEnvelopeItemCrudFlow({
surface,
initialCount: 0,
filesToUpload: [
createPdfPayload('embedded-document-item-a.pdf'),
createPdfPayload('embedded-document-item-b.pdf'),
],
});
await persistEmbeddedEnvelope(surface);
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
expect(items).toHaveLength(1);
expect(items[0].title).toBe('Envelope Item A');
expect(items[0].order).toBe(1); // Expect order 1 because this is a one shot create via embedding. There are no incremental updates.
});
});
test.describe('embedded edit', () => {
test('add, remove, reorder and retitle items', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-items',
});
const result = await runEnvelopeItemCrudFlow({
surface,
initialCount: 1,
filesToUpload: [createPdfPayload('embedded-template-item-updated.pdf')],
});
await persistEmbeddedEnvelope(surface);
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
expect(items).toHaveLength(1);
expect(items[0].title).toBe('Envelope Item A');
expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items.
});
});
test.describe('pending envelope title editing', () => {
test('edit envelope and item titles on a pending envelope', async ({ page }) => {
const recipientEmail = `recipient-${nanoid()}@test.documenso.com`;
const { user, team } = await seedUser();
const pendingDocument = await seedPendingDocument(user, team.id, [recipientEmail], {
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${pendingDocument.id}/edit`,
});
// Verify the envelope editor loaded with the upload step visible.
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Edit the envelope title in the header.
const envelopeTitleInput = page.locator('[data-testid="envelope-title-input"]');
await expect(envelopeTitleInput).toBeEnabled();
await envelopeTitleInput.fill('Updated Pending Title');
// Edit the envelope item title.
const itemTitleInput = getEnvelopeItemTitleInputs(page).first();
await expect(itemTitleInput).toBeEnabled();
await itemTitleInput.fill('Updated Item Title');
// Wait for debounced auto-save to persist by navigating away and back.
await clickEnvelopeEditorStep(page, 'addFields');
await expect(page.getByText('Selected Recipient')).toBeVisible();
await clickEnvelopeEditorStep(page, 'upload');
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Verify the titles persisted in the database.
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: pendingDocument.id },
include: {
envelopeItems: true,
auditLogs: {
orderBy: { createdAt: 'desc' },
},
},
});
expect(updatedEnvelope.title).toBe('Updated Pending Title');
expect(updatedEnvelope.envelopeItems[0].title).toBe('Updated Item Title');
// Verify audit logs were created for both title changes.
const titleAuditLog = updatedEnvelope.auditLogs.find(
(log) => log.type === 'DOCUMENT_TITLE_UPDATED',
);
expect(titleAuditLog).toBeDefined();
const itemAuditLog = updatedEnvelope.auditLogs.find(
(log) => log.type === 'ENVELOPE_ITEM_UPDATED',
);
expect(itemAuditLog).toBeDefined();
// Verify the upload dropzone is still disabled for pending envelopes.
const dropzoneMessage = page.getByText('Cannot upload items after the document has been sent');
await expect(dropzoneMessage).toBeVisible();
// Drag handles are still rendered but dragging is disabled via isDragDisabled
// when canItemsBeModified is false. Verify at least that the handle is present
// but we cannot programmatically assert isDragDisabled from the DOM.
await expect(getEnvelopeItemDragHandles(page)).toHaveCount(1);
});
});
@@ -0,0 +1,277 @@
import { type Page, expect, test } from '@playwright/test';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
assertRecipientRole,
clickAddMyselfButton,
clickAddSignerButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getRecipientEmailInputs,
getRecipientNameInputs,
getRecipientRemoveButtons,
getSigningOrderInputs,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
setRecipientRole,
setSigningOrderValue,
toggleAllowDictateSigners,
toggleSigningOrder,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
type RecipientFlowResult = {
externalId: string;
expectedRecipientsBySigningOrder: Array<{
email: string;
name: string;
role: RecipientRole;
signingOrder: number;
}>;
removedRecipientEmail: string;
};
const TEST_RECIPIENT_VALUES = {
secondRecipient: {
email: 'recipient-two@example.com',
name: 'Recipient Two',
},
thirdRecipient: {
email: 'recipient-three@example.com',
name: 'Recipient Three',
},
embeddedPrimaryRecipient: {
email: 'embedded-primary@example.com',
name: 'Embedded Primary',
},
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const navigateToAddFieldsAndBack = async (root: Page) => {
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.getByText('Selected Recipient')).toBeVisible();
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
};
const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise<RecipientFlowResult> => {
const externalId = `e2e-recipients-${nanoid()}`;
await updateExternalId(surface, externalId);
let primaryRecipient = TEST_RECIPIENT_VALUES.embeddedPrimaryRecipient;
if (surface.isEmbedded) {
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
await setRecipientEmail(surface.root, 0, primaryRecipient.email);
await setRecipientName(surface.root, 0, primaryRecipient.name);
} else {
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
await clickAddMyselfButton(surface.root);
primaryRecipient = {
email: surface.userEmail,
name: surface.userName,
};
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(surface.userEmail);
}
await clickAddSignerButton(surface.root);
await clickAddSignerButton(surface.root);
await setRecipientEmail(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.email);
await setRecipientName(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.name);
await setRecipientEmail(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.email);
await setRecipientName(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.name);
await setRecipientRole(surface.root, 1, 'Needs to approve');
await setRecipientRole(surface.root, 2, 'Receives copy');
await getRecipientRemoveButtons(surface.root).nth(2).click();
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
await toggleSigningOrder(surface.root, true);
await expect(getSigningOrderInputs(surface.root)).toHaveCount(2);
await setSigningOrderValue(surface.root, 0, 2);
await toggleAllowDictateSigners(surface.root, true);
await navigateToAddFieldsAndBack(surface.root);
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(
TEST_RECIPIENT_VALUES.secondRecipient.email,
);
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.email);
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(
TEST_RECIPIENT_VALUES.secondRecipient.name,
);
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.name);
await assertRecipientRole(surface.root, 0, 'Needs to approve');
await assertRecipientRole(surface.root, 1, 'Needs to sign');
await expect(surface.root.locator('#signingOrder')).toHaveAttribute('aria-checked', 'true');
await expect(surface.root.locator('#allowDictateNextSigner')).toHaveAttribute(
'aria-checked',
'true',
);
await expect(getSigningOrderInputs(surface.root).nth(0)).toHaveValue('1');
await expect(getSigningOrderInputs(surface.root).nth(1)).toHaveValue('2');
return {
externalId,
removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email,
expectedRecipientsBySigningOrder: [
{
email: TEST_RECIPIENT_VALUES.secondRecipient.email,
name: TEST_RECIPIENT_VALUES.secondRecipient.name,
role: RecipientRole.APPROVER,
signingOrder: 1,
},
{
email: primaryRecipient.email,
name: primaryRecipient.name,
role: RecipientRole.SIGNER,
signingOrder: 2,
},
],
};
};
const assertRecipientsPersistedInDatabase = async ({
surface,
externalId,
expectedRecipientsBySigningOrder,
removedRecipientEmail,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
expectedRecipientsBySigningOrder: RecipientFlowResult['expectedRecipientsBySigningOrder'];
removedRecipientEmail: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
documentMeta: true,
recipients: {
orderBy: {
signingOrder: 'asc',
},
},
},
orderBy: {
createdAt: 'desc',
},
});
expect(envelope.recipients).toHaveLength(expectedRecipientsBySigningOrder.length);
expect(envelope.documentMeta.signingOrder).toBe(DocumentSigningOrder.SEQUENTIAL);
expect(envelope.documentMeta.allowDictateNextSigner).toBe(true);
expectedRecipientsBySigningOrder.forEach((expectedRecipient, index) => {
const recipient = envelope.recipients[index];
expect(recipient.email).toBe(expectedRecipient.email);
expect(recipient.name).toBe(expectedRecipient.name);
expect(recipient.role).toBe(expectedRecipient.role);
expect(recipient.signingOrder).toBe(expectedRecipient.signingOrder);
});
expect(envelope.recipients.some((recipient) => recipient.email === removedRecipientEmail)).toBe(
false,
);
};
test.describe('document editor', () => {
test('add myself, CRUD, roles, signing order and dictate signers', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runRecipientFlow(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('template editor', () => {
test('add myself, CRUD, roles, signing order and dictate signers', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runRecipientFlow(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('embedded create', () => {
test('CRUD, roles, signing order and dictate signers', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-recipients',
});
await addEnvelopeItemPdf(surface.root, 'embedded-document-recipients.pdf');
const result = await runRecipientFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('embedded edit', () => {
test('CRUD, roles, signing order and dictate signers', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-recipients',
});
const result = await runRecipientFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
});
@@ -0,0 +1,532 @@
import { type Page, expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
clickAddMyselfButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getEnvelopeItemDropzoneInput,
getEnvelopeItemReplaceButtons,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { getKonvaElementCountForPage } from '../fixtures/konva';
test.use({
storageState: {
cookies: [],
origins: [],
},
});
type TestFilePayload = {
name: string;
mimeType: string;
buffer: Buffer;
};
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const multiPagePdfBuffer = fs.readFileSync(
path.join(__dirname, '../../../../assets/field-font-alignment.pdf'),
);
const createPdfPayload = (name: string, buffer: Buffer = examplePdfBuffer): TestFilePayload => ({
name,
mimeType: 'application/pdf',
buffer,
});
// --- Shared helpers ---
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const replaceEnvelopeItemPdf = async (
root: Page,
index: number,
file: TestFilePayload,
options?: { isEmbedded?: boolean },
) => {
const replaceButton = getEnvelopeItemReplaceButtons(root).nth(index);
await expect(replaceButton).toBeVisible();
// Listen for the file chooser event before clicking so the native dialog
// is intercepted and never actually shown to the user.
const [fileChooser] = await Promise.all([
root.waitForEvent('filechooser'),
replaceButton.click(),
]);
await fileChooser.setFiles(file);
// The button stays in the DOM but becomes disabled while the replace
// mutation is in flight, then re-enables once the mutation completes.
// Wait for both transitions to guarantee the replace has fully finished.
//
// For embedded surfaces the replacement is purely local state with no
// network round-trip, so it can complete before Playwright observes the
// disabled state. Skip the disabled assertion for embedded surfaces.
if (!options?.isEmbedded) {
await expect(replaceButton).toBeDisabled({ timeout: 15000 });
}
await expect(replaceButton).toBeEnabled({ timeout: 15000 });
};
const assertPdfPageCount = async (root: Page, expectedCount: number) => {
await expect(root.locator('[data-pdf-content]').first()).toHaveAttribute(
'data-page-count',
String(expectedCount),
{ timeout: 15000 },
);
};
const placeFieldOnPdf = async (
root: Page,
fieldName: 'Signature' | 'Text',
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
const scrollToPage = async (root: Page, pageNumber: number) => {
const pageImage = root.locator(`img[data-page-number="${pageNumber}"]`);
await pageImage.scrollIntoViewIfNeeded();
await expect(pageImage).toBeVisible({ timeout: 10000 });
// Wait for the Konva stage to initialize after virtualized rendering.
await root.waitForTimeout(1000);
};
const placeFieldOnPage = async (
root: Page,
pageNumber: number,
fieldName: 'Signature' | 'Text',
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
if (pageNumber > 1) {
await scrollToPage(root, pageNumber);
}
// Find the canvas corresponding to this page number via Konva stages.
// Since the virtualized list may have multiple canvases, we target the one
// that belongs to the correct page by using the evaluate approach.
const canvas = root.locator('.konva-container canvas');
if (pageNumber === 1) {
await expect(canvas.first()).toBeVisible();
await canvas.first().click({ position });
} else {
// For multi-page, find the canvas at the page position.
// The canvases are rendered in page order within the viewport.
// After scrolling to page N, it should be visible. We use nth based on
// the page index among currently rendered canvases.
// A more reliable approach: click on the page's img and offset into canvas.
const pageImg = root.locator(`img[data-page-number="${pageNumber}"]`);
const pageBox = await pageImg.boundingBox();
if (!pageBox) {
throw new Error(`Could not find bounding box for page ${pageNumber}`);
}
// Click at the desired position relative to the page image.
// The Konva canvas overlays the image, so clicking at the same coordinates works.
await root.mouse.click(pageBox.x + position.x, pageBox.y + position.y);
}
};
// --- Test 1: Basic replace flow ---
type BasicReplaceFlowResult = {
externalId: string;
originalDocumentDataId: string | null;
};
const runBasicReplaceFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<BasicReplaceFlowResult> => {
const { root, isEmbedded } = surface;
const externalId = `e2e-replace-${nanoid()}`;
// For embedded create, upload a PDF first.
if (isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(root, 'replace-test-original.pdf');
}
await updateExternalId(surface, externalId);
// Record the original documentDataId so we can assert it changed after replace.
// For embedded flows the externalId only lives in client state until persist,
// so query by envelope ID when available, and skip entirely for embedded create.
let originalDocumentDataId: string | null = null;
if (!isEmbedded) {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
envelopeItems: { orderBy: { order: 'asc' } },
},
});
originalDocumentDataId = envelope.envelopeItems[0].documentDataId;
} else if (surface.envelopeId) {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: surface.envelopeId,
},
include: {
envelopeItems: { orderBy: { order: 'asc' } },
},
});
originalDocumentDataId = envelope.envelopeItems[0].documentDataId;
}
// Navigate to addFields step to verify initial page count.
await clickEnvelopeEditorStep(root, 'addFields');
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
await assertPdfPageCount(root, 1);
// Navigate back to upload step.
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Replace the PDF.
await replaceEnvelopeItemPdf(root, 0, createPdfPayload('replace-test-new.pdf'), { isEmbedded });
// Navigate to addFields step to verify the PDF loaded correctly.
await clickEnvelopeEditorStep(root, 'addFields');
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
await assertPdfPageCount(root, 1);
// Navigate back to upload step.
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
return { externalId, originalDocumentDataId };
};
const assertBasicReplaceInDatabase = async ({
surface,
externalId,
originalDocumentDataId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
originalDocumentDataId: string | null;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
envelopeItems: { orderBy: { order: 'asc' } },
},
orderBy: { createdAt: 'desc' },
});
expect(envelope.envelopeItems).toHaveLength(1);
const item = envelope.envelopeItems[0];
// The documentDataId should have changed after replace.
if (originalDocumentDataId) {
expect(item.documentDataId).not.toBe(originalDocumentDataId);
}
// Verify documentDataId is a valid non-empty string.
expect(item.documentDataId).toBeTruthy();
expect(typeof item.documentDataId).toBe('string');
// Title and order should be unchanged.
expect(item.order).toBeGreaterThanOrEqual(1);
};
// --- Test 2: Field cleanup replace flow ---
const TEST_FIELD_VALUES = {
embeddedRecipient: {
email: 'embedded-replace-recipient@documenso.com',
name: 'Embedded Replace Recipient',
},
};
type FieldCleanupFlowResult = {
externalId: string;
recipientEmail: string;
};
const setupRecipient = async (surface: TEnvelopeEditorSurface): Promise<string> => {
if (surface.isEmbedded) {
await setRecipientEmail(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
await setRecipientName(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
return TEST_FIELD_VALUES.embeddedRecipient.email;
}
await clickAddMyselfButton(surface.root);
return surface.userEmail;
};
const uploadMultiPagePdf = async (root: Page) => {
await getEnvelopeItemDropzoneInput(root).setInputFiles({
name: 'multi-page.pdf',
mimeType: 'application/pdf',
buffer: multiPagePdfBuffer,
});
};
const runFieldCleanupReplaceFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<FieldCleanupFlowResult> => {
const { root, isEmbedded } = surface;
const externalId = `e2e-replace-fields-${nanoid()}`;
// Step 1: Get a 3-page PDF loaded.
if (isEmbedded && !surface.envelopeId) {
// Embedded create: upload the multi-page PDF directly.
await uploadMultiPagePdf(root);
} else {
// All other surfaces: replace the existing 1-page PDF with the 3-page one.
await replaceEnvelopeItemPdf(root, 0, createPdfPayload('multi-page.pdf', multiPagePdfBuffer), {
isEmbedded,
});
}
await updateExternalId(surface, externalId);
// Step 2: Add a recipient.
const recipientEmail = await setupRecipient(surface);
// Step 3: Navigate to addFields step and verify 3-page PDF is loaded.
await clickEnvelopeEditorStep(root, 'addFields');
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
await assertPdfPageCount(root, 3);
// Step 4: Place a Signature field on page 1.
await placeFieldOnPdf(root, 'Signature', { x: 120, y: 140 });
let fieldCountPage1 = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCountPage1).toBe(1);
// Step 5: Scroll to page 2 and place a Text field.
await placeFieldOnPage(root, 2, 'Text', { x: 120, y: 140 });
const fieldCountPage2 = await getKonvaElementCountForPage(root, 2, '.field-group');
expect(fieldCountPage2).toBe(1);
// Verify file selector shows "2 Fields".
await expect(root.getByText('2 Fields')).toBeVisible();
// Step 6: Navigate back to upload step.
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Step 7: Replace with 1-page PDF.
await replaceEnvelopeItemPdf(root, 0, createPdfPayload('single-page.pdf'), { isEmbedded });
// Step 8: Navigate to addFields step to verify.
await clickEnvelopeEditorStep(root, 'addFields');
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
// PDF should now be 1 page.
await assertPdfPageCount(root, 1);
// Page 1 field should survive.
fieldCountPage1 = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCountPage1).toBe(1);
// File selector should show "1 Field".
await expect(root.getByText('1 Field')).toBeVisible();
return { externalId, recipientEmail };
};
const assertFieldCleanupInDatabase = async ({
surface,
externalId,
recipientEmail,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
recipientEmail: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
fields: true,
recipients: true,
envelopeItems: { orderBy: { order: 'asc' } },
},
orderBy: { createdAt: 'desc' },
});
const recipient = envelope.recipients.find((r) => r.email === recipientEmail);
expect(recipient).toBeDefined();
// Only the page-1 field should remain.
expect(envelope.fields).toHaveLength(1);
expect(envelope.fields[0].page).toBe(1);
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
expect(envelope.fields[0].recipientId).toBe(recipient?.id);
// The envelope item should have a 1-page PDF (documentDataId should exist).
expect(envelope.envelopeItems).toHaveLength(1);
expect(envelope.envelopeItems[0].documentDataId).toBeTruthy();
};
// --- Test describe blocks ---
test.describe('document editor', () => {
test('replace PDF on an envelope item', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runBasicReplaceFlow(surface);
await assertBasicReplaceInDatabase({
surface,
...result,
});
});
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runFieldCleanupReplaceFlow(surface);
await assertFieldCleanupInDatabase({
surface,
...result,
});
});
});
test.describe('template editor', () => {
test('replace PDF on an envelope item', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runBasicReplaceFlow(surface);
await assertBasicReplaceInDatabase({
surface,
...result,
});
});
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runFieldCleanupReplaceFlow(surface);
await assertFieldCleanupInDatabase({
surface,
...result,
});
});
});
test.describe('embedded create', () => {
test('replace PDF on an envelope item', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-replace',
});
const result = await runBasicReplaceFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertBasicReplaceInDatabase({
surface,
...result,
});
});
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-replace-fields',
});
const result = await runFieldCleanupReplaceFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertFieldCleanupInDatabase({
surface,
...result,
});
});
});
test.describe('embedded edit', () => {
test('replace PDF on an envelope item', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-replace',
});
const result = await runBasicReplaceFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertBasicReplaceInDatabase({
surface,
...result,
});
});
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-replace-fields',
});
const result = await runFieldCleanupReplaceFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertFieldCleanupInDatabase({
surface,
...result,
});
});
});
@@ -0,0 +1,495 @@
import { type Page, expect, test } from '@playwright/test';
import { EnvelopeType, FieldType, RecipientRole } 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';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} 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 { apiSignin } from '../fixtures/authentication';
import {
clickAddMyselfButton,
clickEnvelopeEditorStep,
getRecipientEmailInputs,
openDocumentEnvelopeEditor,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const V2_API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
/**
* Place a field on the PDF canvas in the envelope editor.
*/
const placeFieldOnPdf = async (
root: Page,
fieldName: 'Signature' | 'Text',
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
/**
* Create a V2 document envelope via the API with a single SIGNER recipient
* and a SIGNATURE field, suitable for testing save-as-template with data.
*
* Returns the envelope ID, user, team, and recipient email.
*/
const createDocumentWithRecipientAndField = async () => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: `e2e-save-as-template-${Date.now()}`,
expiresIn: null,
});
const recipientEmail = `save-template-${Date.now()}@test.documenso.com`;
// 1. Create envelope with a PDF.
const payload = {
type: EnvelopeType.DOCUMENT,
title: `E2E Save as Template ${Date.now()}`,
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }),
);
const createRes = await fetch(`${V2_API_BASE_URL}/envelope/create`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
expect(createRes.ok).toBeTruthy();
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
// 2. Create a SIGNER recipient.
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: createResponse.id,
data: [
{
email: recipientEmail,
name: 'Template Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const recipientsRes = await fetch(`${V2_API_BASE_URL}/envelope/recipient/create-many`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(createRecipientsRequest),
});
expect(recipientsRes.ok).toBeTruthy();
const recipientsResponse = await recipientsRes.json();
const recipients = recipientsResponse.data;
// 3. Get envelope to find the envelope item ID.
const getRes = await fetch(`${V2_API_BASE_URL}/envelope/${createResponse.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
const envelope = (await getRes.json()) as TGetEnvelopeResponse;
const envelopeItem = envelope.envelopeItems[0];
// 4. Create a SIGNATURE field for the recipient.
const createFieldsRequest = {
envelopeId: createResponse.id,
data: [
{
recipientId: recipients[0].id,
envelopeItemId: envelopeItem.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
},
],
};
const fieldsRes = await fetch(`${V2_API_BASE_URL}/envelope/field/create-many`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(createFieldsRequest),
});
expect(fieldsRes.ok).toBeTruthy();
return {
user,
team,
envelopeId: createResponse.id,
recipientEmail,
};
};
test.describe('document editor', () => {
test('save document as template from editor sidebar', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
// Add the current user as a recipient via the UI.
await clickAddMyselfButton(surface.root);
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
// Navigate to the add fields step and place a signature field.
await clickEnvelopeEditorStep(surface.root, 'addFields');
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
// Navigate back to the upload step so the sidebar actions are available.
await clickEnvelopeEditorStep(surface.root, 'upload');
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
// The save as template dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Both checkboxes should be checked by default.
await expect(page.locator('#envelopeIncludeRecipients')).toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).toBeChecked();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the new template envelope was created in the database with correct type and secondaryId.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: surface.userId,
teamId: surface.teamId,
type: 'TEMPLATE',
},
include: {
recipients: {
include: {
fields: true,
},
},
},
});
expect(templateEnvelopes.length).toBeGreaterThanOrEqual(1);
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix, not document_.
// This confirms incrementTemplateId was called, not incrementDocumentId.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE);
// Verify recipients were included.
expect(createdTemplate!.recipients.length).toBe(1);
expect(createdTemplate!.recipients[0].email).toBe(surface.userEmail);
// Verify fields were included.
expect(createdTemplate!.recipients[0].fields.length).toBe(1);
expect(createdTemplate!.recipients[0].fields[0].type).toBe(FieldType.SIGNATURE);
});
test('save document as template without recipients', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelopeId}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Uncheck "Include Recipients" - "Include Fields" should auto-disable.
await page.locator('#envelopeIncludeRecipients').click();
await expect(page.locator('#envelopeIncludeRecipients')).not.toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).not.toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).toBeDisabled();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created without recipients.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
include: {
recipients: true,
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
// Verify no recipients were included.
expect(createdTemplate!.recipients.length).toBe(0);
});
test('save document as template without fields but with recipients', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelopeId}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Uncheck "Include Fields" but keep "Include Recipients".
await page.locator('#envelopeIncludeFields').click();
await expect(page.locator('#envelopeIncludeRecipients')).toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).not.toBeChecked();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created with recipients but no fields.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
include: {
recipients: {
include: {
fields: true,
},
},
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
// Verify recipients were included.
expect(createdTemplate!.recipients.length).toBe(1);
expect(createdTemplate!.recipients[0].email).toBe(recipientEmail);
// Verify no fields were included.
expect(createdTemplate!.recipients[0].fields.length).toBe(0);
});
});
test.describe('documents table', () => {
test('save as template from document row dropdown', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Wait for the documents table to load.
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the actions dropdown for the document row.
await page.getByTestId('document-table-action-btn').first().click();
// Click "Save as Template" in the dropdown.
await page.getByRole('menuitem', { name: 'Save as Template' }).click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created with correct type and secondaryId.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
include: {
recipients: {
include: {
fields: true,
},
},
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE);
// Verify recipients and fields were included (defaults are both checked).
expect(createdTemplate!.recipients.length).toBe(1);
expect(createdTemplate!.recipients[0].fields.length).toBe(1);
});
});
test.describe('document index page', () => {
test('save as template from document page dropdown', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelopeId}`,
});
// Wait for the document page to load.
await expect(page.getByRole('heading', { name: /E2E Save as Template/ })).toBeVisible();
// Click the more actions dropdown trigger.
await page.getByTestId('document-page-view-action-btn').click();
// Click "Save as Template" in the dropdown.
await page.getByRole('menuitem', { name: 'Save as Template' }).click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created with correct type and secondaryId.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE);
});
});
test.describe('legacy ID correctness', () => {
test('save as template uses template counter, not document counter', async ({ page }) => {
// Record the current counter values before the operation.
const [documentCounterBefore, templateCounterBefore] = await Promise.all([
prisma.counter.findUnique({ where: { id: 'document' } }),
prisma.counter.findUnique({ where: { id: 'template' } }),
]);
const surface = await openDocumentEnvelopeEditor(page);
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
await expectToastTextToBeVisible(page, 'Template Created');
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Record the counter values after the operation.
const [documentCounterAfter, templateCounterAfter] = await Promise.all([
prisma.counter.findUnique({ where: { id: 'document' } }),
prisma.counter.findUnique({ where: { id: 'template' } }),
]);
// The template counter MUST have incremented (at least once - could be more due to
// the seedBlankDocument call in openDocumentEnvelopeEditor seeding other templates).
expect(templateCounterAfter!.value).toBeGreaterThan(templateCounterBefore!.value);
// Verify the created template's secondaryId matches the template counter.
const createdTemplate = await prisma.envelope.findFirst({
where: {
userId: surface.userId,
teamId: surface.teamId,
type: 'TEMPLATE',
},
orderBy: { createdAt: 'desc' },
});
expect(createdTemplate).not.toBeNull();
expect(createdTemplate!.secondaryId).toBe(`template_${templateCounterAfter!.value}`);
expect(createdTemplate!.secondaryId).not.toMatch(/^document_/);
});
});
@@ -0,0 +1,506 @@
import { type Page, expect, test } from '@playwright/test';
import { DocumentDistributionMethod, DocumentVisibility } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
type SettingsFlowData = {
externalId: string;
isEmbedded: boolean;
};
const TEST_SETTINGS_VALUES = {
replyTo: 'e2e-settings@example.com',
redirectUrl: 'https://example.com/e2e-settings-complete',
subject: 'E2E settings subject',
message: 'E2E settings message',
language: 'French',
dateFormat: 'DD/MM/YYYY',
timezone: 'Europe/London',
distributionMethod: 'None',
expirationMode: 'Custom duration',
expirationAmount: 5,
expirationUnit: 'Weeks',
reminderMode: 'Enabled',
reminderSendAfterAmount: 3,
reminderSendAfterUnit: 'Days',
reminderRepeatMode: 'Custom interval',
reminderRepeatAmount: 7,
reminderRepeatUnit: 'Days',
accessAuth: 'Require account',
actionAuth: 'Require password',
visibility: 'Managers and above',
};
const DB_EXPECTED_VALUES = {
language: 'fr',
dateFormat: 'dd/MM/yyyy',
timezone: 'Europe/London',
distributionMethod: DocumentDistributionMethod.NONE,
envelopeExpirationPeriod: { unit: 'week', amount: 5 },
reminderSettings: {
sendAfter: { unit: 'day', amount: 3 },
repeatEvery: { unit: 'day', amount: 7 },
},
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: ['PASSWORD'],
emailSettings: {
recipientSigned: false,
recipientSigningRequest: false,
recipientRemoved: false,
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerDocumentCompleted: false,
ownerRecipientExpired: false,
ownerDocumentCreated: false,
},
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const clickSettingsDialogHeader = async (root: Page) => {
await root.locator('[data-testid="envelope-editor-settings-dialog-header"]').click();
};
const getComboboxByLabel = (root: Page, label: string) =>
root
.locator(`label:has-text("${label}")`)
.locator('xpath=..')
.locator('[role="combobox"]')
.first();
const selectMultiSelectOption = async (
root: Page,
dataTestId: 'documentAccessSelectValue' | 'documentActionSelectValue',
optionLabel: string,
) => {
const select = root.locator(`[data-testid="${dataTestId}"]`);
await select.click();
await root.locator('[cmdk-item]').filter({ hasText: optionLabel }).first().click();
await clickSettingsDialogHeader(root);
};
const runSettingsFlow = async (
{ root }: TEnvelopeEditorSurface,
{ externalId, isEmbedded }: SettingsFlowData,
) => {
await openSettingsDialog(root);
await getComboboxByLabel(root, 'Language').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.language }).click();
await clickSettingsDialogHeader(root);
const signatureTypesCombobox = getComboboxByLabel(root, 'Allowed Signature Types');
await signatureTypesCombobox.click();
await root.getByRole('option', { name: 'Upload' }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Date Format').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.dateFormat, exact: true }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Time Zone').click();
await root.locator('[cmdk-input]').last().fill(TEST_SETTINGS_VALUES.timezone);
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.timezone }).click();
await clickSettingsDialogHeader(root);
await root.locator('input[name="externalId"]').fill(externalId);
await root.locator('input[name="meta.redirectUrl"]').fill(TEST_SETTINGS_VALUES.redirectUrl);
await root.locator('[data-testid="documentDistributionMethodSelectValue"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.distributionMethod }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Expiration').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.expirationMode }).click();
await root.getByRole('spinbutton').clear();
await root.getByRole('spinbutton').fill(String(TEST_SETTINGS_VALUES.expirationAmount));
const expirationUnitTrigger = root
.locator('button[role="combobox"]')
.filter({ hasText: /Months|Days|Weeks|Years/ })
.first();
await expirationUnitTrigger.click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.expirationUnit }).click();
await clickSettingsDialogHeader(root);
// Configure reminder settings.
await root.getByRole('button', { name: 'Reminders' }).click();
await root.locator('[data-testid="reminder-mode-select"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderMode }).click();
await clickSettingsDialogHeader(root);
await root.locator('[data-testid="reminder-send-after-amount"]').clear();
await root
.locator('[data-testid="reminder-send-after-amount"]')
.fill(String(TEST_SETTINGS_VALUES.reminderSendAfterAmount));
await root.locator('[data-testid="reminder-send-after-unit"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderSendAfterUnit }).click();
await clickSettingsDialogHeader(root);
await root.locator('[data-testid="reminder-repeat-mode-select"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatMode }).click();
await clickSettingsDialogHeader(root);
await root.locator('[data-testid="reminder-repeat-amount"]').clear();
await root
.locator('[data-testid="reminder-repeat-amount"]')
.fill(String(TEST_SETTINGS_VALUES.reminderRepeatAmount));
await root.locator('[data-testid="reminder-repeat-unit"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
await clickSettingsDialogHeader(root);
const spinbuttons = root.getByRole('spinbutton');
await spinbuttons.first().clear();
await spinbuttons.first().fill(String(TEST_SETTINGS_VALUES.reminderSendAfterAmount));
const sendAfterUnitTrigger = root
.locator('button[role="combobox"]')
.filter({ hasText: /Days|Weeks|Months/ })
.first();
await sendAfterUnitTrigger.click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderSendAfterUnit }).click();
await clickSettingsDialogHeader(root);
const repeatModeSelect = root
.locator('button[role="combobox"]')
.filter({ hasText: /Custom interval|Don't repeat/ })
.first();
await repeatModeSelect.click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatMode }).click();
await clickSettingsDialogHeader(root);
await spinbuttons.last().clear();
await spinbuttons.last().fill(String(TEST_SETTINGS_VALUES.reminderRepeatAmount));
const repeatUnitTrigger = root
.locator('button[role="combobox"]')
.filter({ hasText: /Days|Weeks|Months/ })
.last();
await repeatUnitTrigger.click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
await clickSettingsDialogHeader(root);
await root.getByRole('button', { name: 'Email' }).click();
await root.locator('#recipientSigned').click();
await root.locator('#recipientSigningRequest').click();
await root.locator('#recipientRemoved').click();
await root.locator('#documentPending').click();
await root.locator('#documentCompleted').click();
await root.locator('#documentDeleted').click();
await root.locator('#ownerDocumentCompleted').click();
await root.locator('#ownerRecipientExpired').click();
await root.locator('#ownerDocumentCreated').click();
await root.locator('input[name="meta.emailReplyTo"]').fill(TEST_SETTINGS_VALUES.replyTo);
await root.locator('input[name="meta.subject"]').fill(TEST_SETTINGS_VALUES.subject);
await root.locator('textarea[name="meta.message"]').fill(TEST_SETTINGS_VALUES.message);
await root.getByRole('button', { name: 'Security' }).click();
await selectMultiSelectOption(root, 'documentAccessSelectValue', TEST_SETTINGS_VALUES.accessAuth);
const actionAuthSelect = root.locator('[data-testid="documentActionSelectValue"]');
const hasActionAuthSelect = (await actionAuthSelect.count()) > 0;
if (hasActionAuthSelect) {
await selectMultiSelectOption(
root,
'documentActionSelectValue',
TEST_SETTINGS_VALUES.actionAuth,
);
}
if (isEmbedded) {
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toHaveCount(0);
} else {
await root.locator('[data-testid="documentVisibilitySelectValue"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.visibility }).click();
await clickSettingsDialogHeader(root);
}
await root.getByRole('button', { name: 'Update' }).click();
if (!isEmbedded) {
await expectToastTextToBeVisible(root, 'Envelope updated');
}
await openSettingsDialog(root);
await expect(root.locator('input[name="externalId"]')).toHaveValue(externalId);
await expect(root.locator('input[name="meta.redirectUrl"]')).toHaveValue(
TEST_SETTINGS_VALUES.redirectUrl,
);
await expect(getComboboxByLabel(root, 'Language')).toContainText(TEST_SETTINGS_VALUES.language);
await expect(getComboboxByLabel(root, 'Allowed Signature Types')).not.toContainText('Upload');
await expect(getComboboxByLabel(root, 'Date Format')).toContainText(
TEST_SETTINGS_VALUES.dateFormat,
);
await expect(getComboboxByLabel(root, 'Time Zone')).toContainText(TEST_SETTINGS_VALUES.timezone);
await expect(root.locator('[data-testid="documentDistributionMethodSelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.distributionMethod,
);
await expect(getComboboxByLabel(root, 'Expiration')).toContainText(
TEST_SETTINGS_VALUES.expirationMode,
);
await expect(root.getByRole('spinbutton')).toHaveValue(
String(TEST_SETTINGS_VALUES.expirationAmount),
);
await expect(
root
.locator('button[role="combobox"]')
.filter({ hasText: TEST_SETTINGS_VALUES.expirationUnit })
.first(),
).toBeVisible();
// Verify reminder settings persisted in UI.
await root.getByRole('button', { name: 'Reminders' }).click();
await expect(root.locator('[data-testid="reminder-mode-select"]')).toContainText(
TEST_SETTINGS_VALUES.reminderMode,
);
await expect(root.locator('[data-testid="reminder-send-after-amount"]')).toHaveValue(
String(TEST_SETTINGS_VALUES.reminderSendAfterAmount),
);
await expect(root.locator('[data-testid="reminder-send-after-unit"]')).toContainText(
TEST_SETTINGS_VALUES.reminderSendAfterUnit,
);
await expect(root.locator('[data-testid="reminder-repeat-mode-select"]')).toContainText(
TEST_SETTINGS_VALUES.reminderRepeatMode,
);
await expect(root.locator('[data-testid="reminder-repeat-amount"]')).toHaveValue(
String(TEST_SETTINGS_VALUES.reminderRepeatAmount),
);
await expect(root.locator('[data-testid="reminder-repeat-unit"]')).toContainText(
TEST_SETTINGS_VALUES.reminderRepeatUnit,
);
await root.getByRole('button', { name: 'Email' }).click();
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#recipientSigningRequest')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#recipientRemoved')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#documentPending')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#documentCompleted')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#documentDeleted')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#ownerDocumentCompleted')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#ownerRecipientExpired')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#ownerDocumentCreated')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('input[name="meta.emailReplyTo"]')).toHaveValue(
TEST_SETTINGS_VALUES.replyTo,
);
await expect(root.locator('input[name="meta.subject"]')).toHaveValue(
TEST_SETTINGS_VALUES.subject,
);
await expect(root.locator('textarea[name="meta.message"]')).toHaveValue(
TEST_SETTINGS_VALUES.message,
);
await root.getByRole('button', { name: 'Security' }).click();
await expect(root.locator('[data-testid="documentAccessSelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.accessAuth,
);
if (hasActionAuthSelect) {
await expect(root.locator('[data-testid="documentActionSelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.actionAuth,
);
}
if (isEmbedded) {
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toHaveCount(0);
} else {
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.visibility,
);
}
await root.getByRole('button', { name: 'Update' }).click();
if (!isEmbedded) {
await expectToastTextToBeVisible(root, 'Envelope updated');
}
return {
hasActionAuthSelect,
};
};
const assertEnvelopeSettingsPersistedInDatabase = async ({
externalId,
surface,
hasActionAuthSelect,
shouldAssertVisibility,
}: {
externalId: string;
surface: TEnvelopeEditorSurface;
hasActionAuthSelect: boolean;
shouldAssertVisibility: boolean;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: {
documentMeta: true,
},
});
expect(envelope.externalId).toBe(externalId);
if (shouldAssertVisibility) {
expect(envelope.visibility).toBe(DB_EXPECTED_VALUES.visibility);
}
expect(envelope.documentMeta.language).toBe(DB_EXPECTED_VALUES.language);
expect(envelope.documentMeta.dateFormat).toBe(DB_EXPECTED_VALUES.dateFormat);
expect(envelope.documentMeta.timezone).toBe(DB_EXPECTED_VALUES.timezone);
expect(envelope.documentMeta.distributionMethod).toBe(DB_EXPECTED_VALUES.distributionMethod);
expect(envelope.documentMeta.envelopeExpirationPeriod).toEqual(
DB_EXPECTED_VALUES.envelopeExpirationPeriod,
);
expect(envelope.documentMeta.reminderSettings).toEqual(DB_EXPECTED_VALUES.reminderSettings);
expect(envelope.documentMeta.redirectUrl).toBe(TEST_SETTINGS_VALUES.redirectUrl);
expect(envelope.documentMeta.emailReplyTo).toBe(TEST_SETTINGS_VALUES.replyTo);
expect(envelope.documentMeta.subject).toBe(TEST_SETTINGS_VALUES.subject);
expect(envelope.documentMeta.message).toBe(TEST_SETTINGS_VALUES.message);
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
const authOptions = parseAuthOptions(envelope.authOptions);
expect(authOptions.globalAccessAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalAccessAuth);
if (hasActionAuthSelect) {
expect(authOptions.globalActionAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalActionAuth);
}
};
const parseAuthOptions = (
authOptions: unknown,
): { globalAccessAuth: string[]; globalActionAuth: string[] } => {
if (!isRecord(authOptions)) {
return {
globalAccessAuth: [],
globalActionAuth: [],
};
}
return {
globalAccessAuth: Array.isArray(authOptions.globalAccessAuth)
? authOptions.globalAccessAuth.filter((entry): entry is string => typeof entry === 'string')
: [],
globalActionAuth: Array.isArray(authOptions.globalActionAuth)
? authOptions.globalActionAuth.filter((entry): entry is string => typeof entry === 'string')
: [],
};
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
test.describe('document editor', () => {
test('update and persist settings', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: false,
});
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
shouldAssertVisibility: true,
});
});
});
test.describe('template editor', () => {
test('update and persist settings', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: false,
});
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
shouldAssertVisibility: true,
});
});
});
test.describe('embedded create', () => {
test('update and persist settings', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-settings',
});
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: true,
});
await persistEmbeddedEnvelope(surface);
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
shouldAssertVisibility: false,
});
});
});
test.describe('embedded edit', () => {
test('update and persist settings', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-settings',
});
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: true,
});
await persistEmbeddedEnvelope(surface);
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
shouldAssertVisibility: false,
});
});
});
@@ -0,0 +1,218 @@
import { type APIRequestContext, type Page, expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
import { createApiToken } from '../../../lib/server-only/public-api/create-api-token';
import { RecipientRole } from '../../../prisma/generated/types';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '../../../trpc/server/envelope-router/create-envelope.types';
import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const signAndVerifyPageDimensions = async ({
page,
request,
pdfFile,
identifier,
title,
expectedWidth,
expectedHeight,
}: {
page: Page;
request: APIRequestContext;
pdfFile: string;
identifier: string;
title: string;
expectedWidth: number;
expectedHeight: number;
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const pdfBuffer = fs.readFileSync(path.join(__dirname, `../../../../assets/${pdfFile}`));
const formData = new FormData();
const createEnvelopePayload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title,
recipients: [
{
email: user.email,
name: user.name || '',
role: RecipientRole.SIGNER,
fields: [
{
identifier,
type: FieldType.SIGNATURE,
fieldMeta: { type: 'signature' },
page: 1,
positionX: 10,
positionY: 10,
width: 40,
height: 10,
},
],
},
],
};
formData.append('payload', JSON.stringify(createEnvelopePayload));
formData.append('files', new File([pdfBuffer], identifier, { type: 'application/pdf' }));
const createResponse = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(createResponse.ok()).toBeTruthy();
const { id: envelopeId }: TCreateEnvelopeResponse = await createResponse.json();
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelopeId },
include: { recipients: true },
});
const distributeResponse = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id } satisfies TDistributeEnvelopeRequest,
});
expect(distributeResponse.ok()).toBeTruthy();
// Pre-insert all fields via Prisma so we can skip the UI field interaction.
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id, inserted: false },
});
for (const field of fields) {
await prisma.field.update({
where: { id: field.id },
data: {
inserted: true,
signature: {
create: {
recipientId: envelope.recipients[0].id,
typedSignature: 'Test Signature',
},
},
},
});
}
const recipientToken = envelope.recipients[0].token;
const signUrl = `/sign/${recipientToken}`;
await apiSignin({
page,
email: user.email,
redirectPath: signUrl,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
await expect(async () => {
const { status } = await prisma.envelope.findFirstOrThrow({
where: { id: envelope.id },
});
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass({ timeout: 10000 });
const completedEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: envelope.id },
include: {
envelopeItems: {
orderBy: { order: 'asc' },
include: { documentData: true },
},
},
});
for (const item of completedEnvelope.envelopeItems) {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: item,
token: recipientToken,
version: 'signed',
});
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(pdfData) });
const pdf = await loadingTask.promise;
expect(pdf.numPages).toBeGreaterThan(1);
for (let i = 1; i <= pdf.numPages; i++) {
const pdfPage = await pdf.getPage(i);
const viewport = pdfPage.getViewport({ scale: 1 });
expect(Math.round(viewport.width)).toBe(expectedWidth);
expect(Math.round(viewport.height)).toBe(expectedHeight);
}
}
};
test('cert and audit log pages match letter page dimensions', async ({ page, request }) => {
await signAndVerifyPageDimensions({
page,
request,
pdfFile: 'letter-size.pdf',
identifier: 'letter-doc',
title: 'Letter Size Dimension Test',
expectedWidth: 612,
expectedHeight: 792,
});
});
test('cert and audit log pages match A4 page dimensions', async ({ page, request }) => {
await signAndVerifyPageDimensions({
page,
request,
pdfFile: 'a4-size.pdf',
identifier: 'a4-doc',
title: 'A4 Size Dimension Test',
expectedWidth: 595,
expectedHeight: 842,
});
});
test('cert and audit log pages match tabloid landscape page dimensions', async ({
page,
request,
}) => {
await signAndVerifyPageDimensions({
page,
request,
pdfFile: 'tabloid-landscape.pdf',
identifier: 'tabloid-doc',
title: 'Tabloid Landscape Dimension Test',
expectedWidth: 1224,
expectedHeight: 792,
});
});
@@ -1,7 +1,7 @@
import { createCanvas } from '@napi-rs/canvas';
import type { TestInfo } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
@@ -161,7 +161,16 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
const uninsertedFields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
inserted: false,
OR: [
{
inserted: false,
},
{
// Include email fields because they are automatically inserted during envelope distribution.
// We need to extract it to override their values for accurate comparison in tests.
type: FieldType.EMAIL,
},
],
},
include: {
envelopeItem: {
@@ -298,18 +307,34 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
*
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
*/
test.skip('download envelope images', async ({ page }) => {
test.skip('download envelope images', async ({ page, request }) => {
const { user, team } = await seedUser();
const { token: apiToken } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const envelope = await seedAlignmentTestDocument({
userId: user.id,
teamId: team.id,
recipientName: user.name || '',
recipientEmail: user.email,
insertFields: true,
status: DocumentStatus.PENDING,
status: DocumentStatus.DRAFT,
});
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${apiToken}` },
data: {
envelopeId: envelope.id,
} satisfies TDistributeEnvelopeRequest,
});
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
const token = envelope.recipients[0].token;
const signUrl = `/sign/${token}`;
@@ -0,0 +1,291 @@
import { expect, test } from '@playwright/test';
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 { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
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 { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
test.describe.configure({ mode: 'parallel' });
test('[ENVELOPE_EXPIRATION]: sending document sets expiresAt on recipients', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-expiration-send',
expiresIn: null,
});
const createPayload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: '[TEST] Expiration Send Test',
recipients: [
{
email: 'signer-expiry@test.documenso.com',
name: 'Signer Expiry',
role: RecipientRole.SIGNER,
fields: [
{
type: 'SIGNATURE',
page: 1,
positionX: 10,
positionY: 10,
width: 10,
height: 5,
fieldMeta: { type: 'signature' },
},
],
},
],
};
const formData = new FormData();
formData.append('payload', JSON.stringify(createPayload));
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
const createRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(createRes.ok()).toBeTruthy();
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
});
expect(distributeRes.ok()).toBeTruthy();
// Check that recipients now have expiresAt set.
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
});
expect(recipients.length).toBe(1);
expect(recipients[0].expiresAt).not.toBeNull();
// The default expiration period is 3 months. Verify it's roughly correct.
const expiresAt = recipients[0].expiresAt!;
const now = new Date();
const diffMs = expiresAt.getTime() - now.getTime();
const diffDays = diffMs / (1000 * 60 * 60 * 24);
// 3 months is roughly 89-92 days. Allow a generous range.
expect(diffDays).toBeGreaterThan(80);
expect(diffDays).toBeLessThan(100);
});
test('[ENVELOPE_EXPIRATION]: sending document with custom org expiration period', async ({
request,
}) => {
const { user, organisation, team } = await seedUser();
// Set org expiration to 7 days.
await prisma.organisationGlobalSettings.update({
where: { id: organisation.organisationGlobalSettingsId },
data: { envelopeExpirationPeriod: { unit: 'day', amount: 7 } },
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-expiration-custom',
expiresIn: null,
});
const createPayload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: '[TEST] Custom Expiration Send Test',
recipients: [
{
email: 'signer-custom@test.documenso.com',
name: 'Signer Custom',
role: RecipientRole.SIGNER,
fields: [
{
type: 'SIGNATURE',
page: 1,
positionX: 10,
positionY: 10,
width: 10,
height: 5,
fieldMeta: { type: 'signature' },
},
],
},
],
};
const formData = new FormData();
formData.append('payload', JSON.stringify(createPayload));
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
const createRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(createRes.ok()).toBeTruthy();
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
});
expect(distributeRes.ok()).toBeTruthy();
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
});
expect(recipients.length).toBe(1);
expect(recipients[0].expiresAt).not.toBeNull();
// 7 days expiration.
const expiresAt = recipients[0].expiresAt!;
const now = new Date();
const diffMs = expiresAt.getTime() - now.getTime();
const diffDays = diffMs / (1000 * 60 * 60 * 24);
expect(diffDays).toBeGreaterThan(6);
expect(diffDays).toBeLessThan(8);
});
test('[ENVELOPE_EXPIRATION]: sending document with expiration disabled', async ({ request }) => {
const { user, organisation, team } = await seedUser();
// Disable expiration at org level.
await prisma.organisationGlobalSettings.update({
where: { id: organisation.organisationGlobalSettingsId },
data: { envelopeExpirationPeriod: { disabled: true } },
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-expiration-disabled',
expiresIn: null,
});
const createPayload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: '[TEST] Disabled Expiration Send Test',
recipients: [
{
email: 'signer-disabled@test.documenso.com',
name: 'Signer Disabled',
role: RecipientRole.SIGNER,
fields: [
{
type: 'SIGNATURE',
page: 1,
positionX: 10,
positionY: 10,
width: 10,
height: 5,
fieldMeta: { type: 'signature' },
},
],
},
],
};
const formData = new FormData();
formData.append('payload', JSON.stringify(createPayload));
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
const createRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(createRes.ok()).toBeTruthy();
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
});
expect(distributeRes.ok()).toBeTruthy();
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
});
expect(recipients.length).toBe(1);
expect(recipients[0].expiresAt).toBeNull();
});
test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedPendingDocument(user, team.id, ['resend-target@test.documenso.com']);
const recipient = document.recipients[0];
// Set an initial expiresAt that's 1 day from now.
const initialExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await prisma.recipient.update({
where: { id: recipient.id },
data: { expiresAt: initialExpiresAt },
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
// Open the document action menu and click Resend.
const actionBtn = page.getByTestId('document-table-action-btn').first();
await expect(actionBtn).toBeAttached();
await openDropdownMenu(page, actionBtn);
await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Resend' }).click();
// Select the recipient and send.
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({
timeout: 10_000,
});
// Verify expiresAt was refreshed.
await expect(async () => {
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.expiresAt).not.toBeNull();
expect(updatedRecipient.expiresAt!.getTime()).toBeGreaterThan(initialExpiresAt.getTime());
}).toPass({ timeout: 10_000 });
});
@@ -0,0 +1,141 @@
import { expect, test } from '@playwright/test';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level', async ({
page,
}) => {
const { user, organisation } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/document`,
});
// Wait for the form to load.
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// Change the amount to 2.
const amountInput = page.getByTestId('envelope-expiration-amount');
await amountInput.clear();
await amountInput.fill('2');
// Find all triggers, the unit picker is the one showing Months/Days/etc.
// In the duration mode, there's a mode select and a unit select.
// The unit select is inside the duration row, after the number input.
// Let's find the select trigger that contains the unit text.
const unitTrigger = page.getByTestId('envelope-expiration-unit');
await unitTrigger.click();
await page.getByRole('option', { name: 'Weeks' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify via database.
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(orgSettings.envelopeExpirationPeriod).toEqual({ unit: 'week', amount: 2 });
});
test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({ page }) => {
const { user, organisation } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/document`,
});
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// Find the mode select (shows "Custom duration") and change to "Never expires".
const modeTrigger = page.getByTestId('envelope-expiration-mode');
await modeTrigger.click();
await page.getByRole('option', { name: 'Never expires' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify via database.
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(orgSettings.envelopeExpirationPeriod).toEqual({ disabled: true });
});
test('[ENVELOPE_EXPIRATION]: team inherits expiration from organisation', async () => {
const { organisation, team } = await seedUser({
isPersonalOrganisation: false,
});
// Set org expiration to 2 weeks directly.
await prisma.organisationGlobalSettings.update({
where: { id: organisation.organisationGlobalSettingsId },
data: { envelopeExpirationPeriod: { unit: 'week', amount: 2 } },
});
// Verify team settings inherit the org setting.
const teamSettings = await getTeamSettings({ teamId: team.id });
expect(teamSettings.envelopeExpirationPeriod).toEqual({ unit: 'week', amount: 2 });
});
test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ page }) => {
const { user, organisation, team } = await seedUser({
isPersonalOrganisation: false,
});
// Set org expiration to 2 weeks.
await prisma.organisationGlobalSettings.update({
where: { id: organisation.organisationGlobalSettingsId },
data: { envelopeExpirationPeriod: { unit: 'week', amount: 2 } },
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/document`,
});
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// The expiration picker mode select should show "Inherit from organisation" by default.
const modeTrigger = page.getByTestId('envelope-expiration-mode');
await expect(modeTrigger).toBeVisible();
// Switch to custom duration.
await modeTrigger.click();
await page.getByRole('option', { name: 'Custom duration' }).click();
// Set to 5 days.
const amountInput = page.getByTestId('envelope-expiration-amount');
await amountInput.clear();
await amountInput.fill('5');
const unitTrigger = page.getByTestId('envelope-expiration-unit');
await unitTrigger.click();
await page.getByRole('option', { name: 'Days' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify team setting is overridden.
const teamSettings = await getTeamSettings({ teamId: team.id });
expect(teamSettings.envelopeExpirationPeriod).toEqual({ unit: 'day', amount: 5 });
});
@@ -0,0 +1,131 @@
import { expect, test } from '@playwright/test';
import { prisma } from '@documenso/prisma';
import { FieldType } from '@documenso/prisma/client';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
test.describe.configure({ mode: 'parallel' });
test('[ENVELOPE_EXPIRATION]: expired recipient is redirected to expired page', async ({ page }) => {
const { user, team } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['expired-recipient@test.documenso.com'],
teamId: team.id,
});
const recipient = recipients[0];
// Set expiresAt to the past so the recipient is expired.
await prisma.recipient.update({
where: { id: recipient.id },
data: { expiresAt: new Date(Date.now() - 60_000) },
});
await page.goto(`/sign/${recipient.token}`);
await page.waitForURL(`/sign/${recipient.token}/expired`);
await expect(page.getByText('Signing Deadline Expired')).toBeVisible();
await expect(page.getByText('The signing deadline for this document has passed')).toBeVisible();
});
test('[ENVELOPE_EXPIRATION]: non-expired recipient can access signing page', async ({ page }) => {
const { user, team } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['active-recipient@test.documenso.com'],
teamId: team.id,
});
const recipient = recipients[0];
// Set expiresAt to 1 hour in the future.
await prisma.recipient.update({
where: { id: recipient.id },
data: { expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
await page.goto(`/sign/${recipient.token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
});
test('[ENVELOPE_EXPIRATION]: recipient with null expiresAt can sign normally', async ({ page }) => {
const { user, team } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['null-expiry@test.documenso.com'],
teamId: team.id,
});
const recipient = recipients[0];
// Verify expiresAt is null (default from seed).
const dbRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(dbRecipient.expiresAt).toBeNull();
await page.goto(`/sign/${recipient.token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
});
test('[ENVELOPE_EXPIRATION]: expired recipient cannot complete signing', async ({ page }) => {
const { user, team } = await seedUser();
// Use only a SIGNATURE field to simplify the signing flow.
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [user],
teamId: team.id,
fields: [FieldType.SIGNATURE],
});
const recipient = recipients[0];
await apiSignin({
page,
email: user.email,
redirectPath: `/sign/${recipient.token}`,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
// Now expire the recipient while they're on the signing page.
await prisma.recipient.update({
where: { id: recipient.id },
data: { expiresAt: new Date(Date.now() - 1_000) },
});
// Set up signature.
await signSignaturePad(page);
// Click the signature field to attempt to insert it.
// The server will reject because the recipient is now expired.
const signatureField = recipient.fields.find((f) => f.type === FieldType.SIGNATURE);
if (signatureField) {
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
}
// The server should reject the signing attempt because the recipient has expired.
// Verify the field was NOT inserted (stays data-inserted="false").
if (signatureField) {
await expect(async () => {
const field = await prisma.field.findUniqueOrThrow({
where: { id: signatureField.id },
});
expect(field.inserted).toBe(false);
}).toPass({ timeout: 10_000 });
}
});
@@ -0,0 +1,264 @@
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { DateTime } from 'luxon';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { prisma } from '@documenso/prisma';
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
const PDF_PAGE_SELECTOR = 'img[data-page-number]';
test.describe('V2 envelope field insertion during signing', () => {
test('date fields are auto-inserted when completing a V2 envelope', async ({ page, request }) => {
const now = DateTime.now().setZone(DEFAULT_DOCUMENT_TIME_ZONE);
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
recipients: [{ email: 'signer-date@test.documenso.com', name: 'Date Signer' }],
fieldsPerRecipient: [
[
{ type: FieldType.DATE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 },
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
],
],
});
const { token } = distributeResult.recipients[0];
await page.goto(`/sign/${token}`);
// wait for PDF to be visible
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
// Wait for the Konva canvas to be ready.
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
// DATE is auto-filled, but SIGNATURE still needs manual interaction.
await expect(page.getByText('1 Field Remaining').first()).toBeVisible();
// Set up a signature via the sidebar form.
await page.getByTestId('signature-pad-dialog-button').click();
await page.getByRole('tab', { name: 'Type' }).click();
await page.getByTestId('signature-pad-type-input').fill('Signature');
await page.getByRole('button', { name: 'Next' }).click();
// Click the signature field on the canvas.
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
const sigField = envelope.fields.find((f) => f.type === FieldType.SIGNATURE);
if (!sigField) {
throw new Error('Signature field not found');
}
const x =
(Number(sigField.positionX) / 100) * canvasBox.width +
((Number(sigField.width) / 100) * canvasBox.width) / 2;
const y =
(Number(sigField.positionY) / 100) * canvasBox.height +
((Number(sigField.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
await page.waitForTimeout(500);
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
// Verify the date field was inserted in the database with the correct format.
const dateField = await prisma.field.findFirstOrThrow({
where: {
envelopeId: envelope.id,
type: FieldType.DATE,
},
});
expect(dateField.inserted).toBe(true);
expect(dateField.customText).toBeTruthy();
// Verify the inserted date is close to now (within 2 minutes).
const insertedDate = DateTime.fromFormat(dateField.customText, DEFAULT_DOCUMENT_DATE_FORMAT, {
zone: DEFAULT_DOCUMENT_TIME_ZONE,
});
expect(insertedDate.isValid).toBe(true);
expect(Math.abs(insertedDate.diff(now, 'minutes').minutes)).toBeLessThanOrEqual(2);
// Verify the document reached COMPLETED status.
await expect(async () => {
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
});
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
}).toPass();
});
test('date and email fields are inserted when completing a V2 envelope with multiple field types', async ({
page,
request,
}) => {
const now = DateTime.now().setZone(DEFAULT_DOCUMENT_TIME_ZONE);
const recipientEmail = 'signer-multi@test.documenso.com';
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
recipients: [{ email: recipientEmail, name: 'Multi Signer' }],
fieldsPerRecipient: [
[
{ type: FieldType.DATE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 },
{ type: FieldType.EMAIL, page: 1, positionX: 5, positionY: 10, width: 5, height: 5 },
{ type: FieldType.NAME, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
{
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: 20,
width: 5,
height: 5,
},
],
],
});
const { token } = distributeResult.recipients[0];
// Resolve the fields from the envelope for position calculations.
const fields = envelope.fields;
await page.goto(`/sign/${token}`);
// wait for PDF to be visible
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
// Wait for the Konva canvas to be ready.
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
// DATE and EMAIL fields are auto-filled, so only NAME and SIGNATURE remain.
await expect(page.getByText('2 Fields Remaining').first()).toBeVisible();
// Set up a signature via the sidebar form.
await page.getByTestId('signature-pad-dialog-button').click();
await page.getByRole('tab', { name: 'Type' }).click();
await page.getByTestId('signature-pad-type-input').fill('Signature');
await page.getByRole('button', { name: 'Next' }).click();
// Click each non-date field on the Konva canvas to insert it.
// Fields are seeded with positions as percentages of the page.
// We need to convert these percentages to pixel positions on the canvas.
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
// Only NAME and SIGNATURE fields need manual interaction (DATE and EMAIL are auto-filled).
const manualFields = fields.filter(
(f) => f.type !== FieldType.DATE && f.type !== FieldType.EMAIL,
);
for (const field of manualFields) {
const x =
(Number(field.positionX) / 100) * canvasBox.width +
((Number(field.width) / 100) * canvasBox.width) / 2;
const y =
(Number(field.positionY) / 100) * canvasBox.height +
((Number(field.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
if (field.type === FieldType.NAME) {
const nameDialog = page.getByRole('dialog');
const isDialogVisible = await nameDialog.isVisible().catch(() => false);
if (isDialogVisible) {
const nameInput = nameDialog.locator('input[type="text"], input[name="name"]').first();
const isInputVisible = await nameInput.isVisible().catch(() => false);
if (isInputVisible) {
await nameInput.fill('Test Signer');
}
const saveButton = nameDialog.getByRole('button', { name: 'Save' });
const isButtonVisible = await saveButton.isVisible().catch(() => false);
if (isButtonVisible) {
await saveButton.click();
}
}
}
// Small delay to allow the field signing to complete.
await page.waitForTimeout(500);
}
// All fields should now be complete.
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
// Verify the date field was auto-inserted with the correct format.
const dateField = await prisma.field.findFirstOrThrow({
where: {
envelopeId: envelope.id,
type: FieldType.DATE,
},
});
expect(dateField.inserted).toBe(true);
expect(dateField.customText).toBeTruthy();
const insertedDate = DateTime.fromFormat(dateField.customText, DEFAULT_DOCUMENT_DATE_FORMAT, {
zone: DEFAULT_DOCUMENT_TIME_ZONE,
});
expect(insertedDate.isValid).toBe(true);
expect(Math.abs(insertedDate.diff(now, 'minutes').minutes)).toBeLessThanOrEqual(2);
// Verify the email field was inserted with the recipient's email.
const emailField = await prisma.field.findFirstOrThrow({
where: {
envelopeId: envelope.id,
type: FieldType.EMAIL,
},
});
expect(emailField.inserted).toBe(true);
expect(emailField.customText).toBe(recipientEmail);
// Verify all fields are inserted.
const allFields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
for (const field of allFields) {
expect(field.inserted).toBe(true);
}
// Verify the document reached COMPLETED status.
await expect(async () => {
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
});
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
}).toPass();
});
});
@@ -1,4 +1,4 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
@@ -43,7 +43,7 @@ test.describe('Signing Certificate Tests', () => {
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
});
const originalPdf = await PDFDocument.load(documentData);
const originalPdf = await PDF.load(new Uint8Array(documentData));
// Sign the document
await page.goto(`/sign/${recipient.token}`);
@@ -101,7 +101,7 @@ test.describe('Signing Certificate Tests', () => {
const completedDocumentData = new Uint8Array(pdfData);
// Load the PDF and check number of pages
const pdfDoc = await PDFDocument.load(completedDocumentData);
const pdfDoc = await PDF.load(new Uint8Array(completedDocumentData));
expect(pdfDoc.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
});
@@ -153,7 +153,7 @@ test.describe('Signing Certificate Tests', () => {
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
});
const originalPdf = await PDFDocument.load(documentData);
const originalPdf = await PDF.load(new Uint8Array(documentData));
// Sign the document
await page.goto(`/sign/${recipient.token}`);
@@ -206,7 +206,7 @@ test.describe('Signing Certificate Tests', () => {
const completedDocumentData = new Uint8Array(pdfData);
// Load the PDF and check number of pages
const completedPdf = await PDFDocument.load(completedDocumentData);
const completedPdf = await PDF.load(new Uint8Array(completedDocumentData));
expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
});
@@ -258,7 +258,7 @@ test.describe('Signing Certificate Tests', () => {
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
});
const originalPdf = await PDFDocument.load(new Uint8Array(documentData));
const originalPdf = await PDF.load(new Uint8Array(documentData));
// Sign the document
await page.goto(`/sign/${recipient.token}`);
@@ -309,7 +309,7 @@ test.describe('Signing Certificate Tests', () => {
);
// Load the PDF and check number of pages
const completedPdf = await PDFDocument.load(completedDocumentData);
const completedPdf = await PDF.load(new Uint8Array(completedDocumentData));
expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount());
});
@@ -0,0 +1,901 @@
/**
* API V2-based seed fixtures for E2E tests.
*
* These fixtures create documents, templates, envelopes, recipients, fields,
* and folders through the API V2 endpoints instead of direct Prisma calls.
* This ensures all creation-time side effects (PDF normalization, field meta
* defaults, etc.) are exercised the same way a real user would trigger them.
*
* Usage:
* import { apiSeedDraftDocument, apiSeedPendingDocument, ... } from '../fixtures/api-seeds';
*
* test('my test', async ({ request }) => {
* const { envelope, token, user, team } = await apiSeedDraftDocument(request, {
* title: 'My Document',
* recipients: [{ email: 'signer@example.com', name: 'Signer', role: 'SIGNER' }],
* });
* });
*/
import { type APIRequestContext, expect } from '@playwright/test';
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 { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type {
TDistributeEnvelopeRequest,
TDistributeEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
import type { TCreateEnvelopeRecipientsResponse } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ApiRecipient = {
email: string;
name?: string;
role?: 'SIGNER' | 'APPROVER' | 'VIEWER' | 'CC' | 'ASSISTANT';
signingOrder?: number;
accessAuth?: string[];
actionAuth?: string[];
};
export type ApiField = {
recipientId: number;
envelopeItemId?: string;
type: string;
page?: number;
positionX?: number;
positionY?: number;
width?: number;
height?: number;
fieldMeta?: Record<string, unknown>;
placeholder?: string;
matchAll?: boolean;
};
export type ApiSeedContext = {
user: Awaited<ReturnType<typeof seedUser>>['user'];
team: Awaited<ReturnType<typeof seedUser>>['team'];
token: string;
};
export type ApiSeedEnvelopeOptions = {
title?: string;
type?: 'DOCUMENT' | 'TEMPLATE';
externalId?: string;
visibility?: string;
globalAccessAuth?: string[];
globalActionAuth?: string[];
folderId?: string;
pdfFile?: { name: string; data: Buffer };
meta?: TCreateEnvelopePayload['meta'];
recipients?: Array<
ApiRecipient & {
fields?: Array<{
type: string;
identifier?: string | number;
page?: number;
positionX?: number;
positionY?: number;
width?: number;
height?: number;
fieldMeta?: Record<string, unknown>;
}>;
}
>;
};
// ---------------------------------------------------------------------------
// Core API helpers (low-level)
// ---------------------------------------------------------------------------
/**
* Create a fresh user + team + API token for test isolation.
* Every high-level seed function calls this internally, but you can also
* call it directly if you need the context for multiple operations.
*/
export const apiCreateTestContext = async (tokenName = 'e2e-seed'): Promise<ApiSeedContext> => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName,
expiresIn: null,
});
return { user, team, token };
};
const authHeader = (token: string) => ({
Authorization: `Bearer ${token}`,
});
/**
* Create an envelope via API V2 with a PDF file attached.
*
* This is the lowest-level envelope creation function. It creates the
* envelope with optional inline recipients and fields in a single call.
*/
export const apiCreateEnvelope = async (
request: APIRequestContext,
token: string,
options: ApiSeedEnvelopeOptions = {},
): Promise<TCreateEnvelopeResponse> => {
const {
title = '[TEST] API Seeded Envelope',
type = 'DOCUMENT',
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
folderId,
pdfFile,
meta,
recipients,
} = options;
// Build payload as a plain object. The API receives this as a JSON string
// inside multipart form data, so strict TypeScript union narrowing is not
// required - the server validates with Zod at runtime.
const payload: Record<string, unknown> = {
title,
type,
};
if (externalId !== undefined) {
payload.externalId = externalId;
}
if (visibility !== undefined) {
payload.visibility = visibility;
}
if (globalAccessAuth !== undefined) {
payload.globalAccessAuth = globalAccessAuth;
}
if (globalActionAuth !== undefined) {
payload.globalActionAuth = globalActionAuth;
}
if (folderId !== undefined) {
payload.folderId = folderId;
}
if (meta !== undefined) {
payload.meta = meta;
}
if (recipients !== undefined) {
payload.recipients = recipients.map((r) => {
const recipientPayload: Record<string, unknown> = {
email: r.email,
name: r.name ?? r.email,
role: r.role ?? 'SIGNER',
};
if (r.signingOrder !== undefined) {
recipientPayload.signingOrder = r.signingOrder;
}
if (r.accessAuth !== undefined) {
recipientPayload.accessAuth = r.accessAuth;
}
if (r.actionAuth !== undefined) {
recipientPayload.actionAuth = r.actionAuth;
}
if (r.fields !== undefined) {
recipientPayload.fields = r.fields.map((f) => {
const fieldPayload: Record<string, unknown> = {
type: f.type,
page: f.page ?? 1,
positionX: f.positionX ?? 10,
positionY: f.positionY ?? 10,
width: f.width ?? 15,
height: f.height ?? 5,
};
if (f.identifier !== undefined) {
fieldPayload.identifier = f.identifier;
}
if (f.fieldMeta !== undefined) {
fieldPayload.fieldMeta = f.fieldMeta;
}
return fieldPayload;
});
}
return recipientPayload;
});
}
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const pdf = pdfFile ?? { name: 'example.pdf', data: examplePdfBuffer };
formData.append('files', new File([pdf.data], pdf.name, { type: 'application/pdf' }));
const res = await request.post(`${API_BASE_URL}/envelope/create`, {
headers: authHeader(token),
multipart: formData,
});
expect(res.ok(), `envelope/create failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
/**
* Get full envelope data via API V2.
*/
export const apiGetEnvelope = async (
request: APIRequestContext,
token: string,
envelopeId: string,
): Promise<TGetEnvelopeResponse> => {
const res = await request.get(`${API_BASE_URL}/envelope/${envelopeId}`, {
headers: authHeader(token),
});
expect(res.ok(), `envelope/get failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as TGetEnvelopeResponse;
};
/**
* Add recipients to an existing envelope via API V2.
*/
export const apiCreateRecipients = async (
request: APIRequestContext,
token: string,
envelopeId: string,
recipients: ApiRecipient[],
): Promise<TCreateEnvelopeRecipientsResponse> => {
const data = {
envelopeId,
data: recipients.map((r) => {
const recipientPayload: Record<string, unknown> = {
email: r.email,
name: r.name ?? r.email,
role: r.role ?? 'SIGNER',
};
if (r.signingOrder !== undefined) {
recipientPayload.signingOrder = r.signingOrder;
}
if (r.accessAuth !== undefined) {
recipientPayload.accessAuth = r.accessAuth;
}
if (r.actionAuth !== undefined) {
recipientPayload.actionAuth = r.actionAuth;
}
return recipientPayload;
}),
};
const res = await request.post(`${API_BASE_URL}/envelope/recipient/create-many`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data,
});
expect(res.ok(), `recipient/create-many failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as TCreateEnvelopeRecipientsResponse;
};
/**
* Add fields to an existing envelope via API V2.
*
* If `recipientId` is not set on fields, the first recipient is used.
* If `envelopeItemId` is not set, the first envelope item is used.
*/
export const apiCreateFields = async (
request: APIRequestContext,
token: string,
envelopeId: string,
fields: ApiField[],
): Promise<void> => {
// Build as plain object - the deeply discriminated union types for fields
// (type + fieldMeta combinations) are validated by Zod on the server.
const data = {
envelopeId,
data: fields.map((f) => {
const fieldPayload: Record<string, unknown> = {
recipientId: f.recipientId,
type: f.type,
};
if (f.envelopeItemId !== undefined) {
fieldPayload.envelopeItemId = f.envelopeItemId;
}
if (f.fieldMeta !== undefined) {
fieldPayload.fieldMeta = f.fieldMeta;
}
if (f.placeholder) {
fieldPayload.placeholder = f.placeholder;
if (f.width !== undefined) {
fieldPayload.width = f.width;
}
if (f.height !== undefined) {
fieldPayload.height = f.height;
}
if (f.matchAll !== undefined) {
fieldPayload.matchAll = f.matchAll;
}
} else {
fieldPayload.page = f.page ?? 1;
fieldPayload.positionX = f.positionX ?? 10;
fieldPayload.positionY = f.positionY ?? 10;
fieldPayload.width = f.width ?? 15;
fieldPayload.height = f.height ?? 5;
}
return fieldPayload;
}),
};
const res = await request.post(`${API_BASE_URL}/envelope/field/create-many`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data,
});
expect(res.ok(), `field/create-many failed: ${await res.text()}`).toBeTruthy();
};
/**
* Distribute (send) an envelope via API V2.
* Returns the distribute response which includes signing URLs for recipients.
*/
export const apiDistributeEnvelope = async (
request: APIRequestContext,
token: string,
envelopeId: string,
meta?: TDistributeEnvelopeRequest['meta'],
): Promise<TDistributeEnvelopeResponse> => {
const data: TDistributeEnvelopeRequest = {
envelopeId,
...(meta !== undefined && { meta }),
};
const res = await request.post(`${API_BASE_URL}/envelope/distribute`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data,
});
expect(res.ok(), `envelope/distribute failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as TDistributeEnvelopeResponse;
};
/**
* Create a folder via API V2.
*/
export const apiCreateFolder = async (
request: APIRequestContext,
token: string,
options: {
name?: string;
parentId?: string;
type?: 'DOCUMENT' | 'TEMPLATE';
} = {},
): Promise<{ id: string; name: string }> => {
const { name = 'Test Folder', parentId, type } = options;
const res = await request.post(`${API_BASE_URL}/folder/create`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data: {
name,
...(parentId !== undefined && { parentId }),
...(type !== undefined && { type }),
},
});
expect(res.ok(), `folder/create failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as { id: string; name: string };
};
/**
* Create a direct template link via API V2.
*/
export const apiCreateDirectTemplateLink = async (
request: APIRequestContext,
token: string,
templateId: number,
directRecipientId?: number,
): Promise<{ id: number; token: string; enabled: boolean; directTemplateRecipientId: number }> => {
const res = await request.post(`${API_BASE_URL}/template/direct/create`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data: {
templateId,
...(directRecipientId !== undefined && { directRecipientId }),
},
});
expect(res.ok(), `template/direct/create failed: ${await res.text()}`).toBeTruthy();
return await res.json();
};
// ---------------------------------------------------------------------------
// High-level seed functions (composites)
// ---------------------------------------------------------------------------
export type ApiSeedResult = {
/** The created envelope/document/template. */
envelope: TGetEnvelopeResponse;
/** API token for further API calls. */
token: string;
/** The seeded user. */
user: ApiSeedContext['user'];
/** The seeded team. */
team: ApiSeedContext['team'];
};
export type ApiSeedDocumentOptions = {
/** Document title. Default: '[TEST] API Document - Draft' */
title?: string;
/** Recipients to add to the document. */
recipients?: ApiRecipient[];
/** Fields to add per recipient. If provided, must match recipients order. */
fieldsPerRecipient?: Array<
Array<{
type: string;
page?: number;
positionX?: number;
positionY?: number;
width?: number;
height?: number;
fieldMeta?: Record<string, unknown>;
}>
>;
/** External ID for the envelope. */
externalId?: string;
/** Document visibility setting. */
visibility?: string;
/** Global access auth requirements. */
globalAccessAuth?: string[];
/** Global action auth requirements. */
globalActionAuth?: string[];
/** Folder ID to place the document in. */
folderId?: string;
/** Document meta settings. */
meta?: TCreateEnvelopePayload['meta'];
/** Custom PDF file. Default: example.pdf */
pdfFile?: { name: string; data: Buffer };
/** Reuse an existing test context instead of creating a new one. */
context?: ApiSeedContext;
};
/**
* Create a draft document via API V2.
*
* Creates a user, team, API token, and a DRAFT document. Optionally adds
* recipients and fields.
*
* @example
* ```ts
* const { envelope, token, user, team } = await apiSeedDraftDocument(request, {
* title: 'My Document',
* recipients: [{ email: 'signer@test.com', name: 'Test Signer' }],
* });
* ```
*/
export const apiSeedDraftDocument = async (
request: APIRequestContext,
options: ApiSeedDocumentOptions = {},
): Promise<ApiSeedResult> => {
const ctx = options.context ?? (await apiCreateTestContext('e2e-draft-doc'));
// Create the envelope with inline recipients if provided
const createOptions: ApiSeedEnvelopeOptions = {
title: options.title ?? '[TEST] API Document - Draft',
type: 'DOCUMENT',
externalId: options.externalId,
visibility: options.visibility,
globalAccessAuth: options.globalAccessAuth,
globalActionAuth: options.globalActionAuth,
folderId: options.folderId,
meta: options.meta,
pdfFile: options.pdfFile,
};
// If we have recipients but no per-recipient fields, use inline creation
if (options.recipients && !options.fieldsPerRecipient) {
createOptions.recipients = options.recipients.map((r) => ({
...r,
role: r.role ?? 'SIGNER',
}));
}
const { id: envelopeId } = await apiCreateEnvelope(request, ctx.token, createOptions);
// If we need per-recipient fields, add recipients and fields separately
if (options.recipients && options.fieldsPerRecipient) {
const recipientsRes = await apiCreateRecipients(
request,
ctx.token,
envelopeId,
options.recipients,
);
// Get envelope to resolve envelope item IDs
const envelopeData = await apiGetEnvelope(request, ctx.token, envelopeId);
const firstItemId = envelopeData.envelopeItems[0]?.id;
// Create fields for each recipient
for (const [index, recipientFields] of options.fieldsPerRecipient.entries()) {
if (recipientFields.length === 0) {
continue;
}
const recipientId = recipientsRes.data[index].id;
await apiCreateFields(
request,
ctx.token,
envelopeId,
recipientFields.map((f) => ({
...f,
recipientId,
envelopeItemId: firstItemId,
type: f.type,
})),
);
}
}
const envelope = await apiGetEnvelope(request, ctx.token, envelopeId);
return { envelope, token: ctx.token, user: ctx.user, team: ctx.team };
};
export type ApiSeedPendingDocumentOptions = ApiSeedDocumentOptions & {
/** Distribution meta (subject, message, etc.). */
distributeMeta?: TDistributeEnvelopeRequest['meta'];
};
/**
* Seed a pending (distributed) document via API V2.
*
* Creates the document, adds recipients with SIGNATURE fields, then
* distributes (sends) it. The response includes signing URLs for each
* recipient.
*
* Every SIGNER recipient must have at least one SIGNATURE field for
* distribution to succeed. If you don't provide `fieldsPerRecipient`,
* a default SIGNATURE field is added for each SIGNER/APPROVER recipient.
*
* @example
* ```ts
* const { envelope, distributeResult, token } = await apiSeedPendingDocument(request, {
* recipients: [
* { email: 'signer@test.com', name: 'Signer' },
* { email: 'viewer@test.com', name: 'Viewer', role: 'VIEWER' },
* ],
* });
*
* // Access signing URL:
* const signingUrl = distributeResult.recipients[0].signingUrl;
* ```
*/
export const apiSeedPendingDocument = async (
request: APIRequestContext,
options: ApiSeedPendingDocumentOptions = {},
): Promise<
ApiSeedResult & {
distributeResult: TDistributeEnvelopeResponse;
}
> => {
const ctx = options.context ?? (await apiCreateTestContext('e2e-pending-doc'));
const recipients = options.recipients ?? [
{
email: `signer-${Date.now()}@test.documenso.com`,
name: 'Test Signer',
role: 'SIGNER' as const,
},
];
// Create the base envelope
const { id: envelopeId } = await apiCreateEnvelope(request, ctx.token, {
title: options.title ?? '[TEST] API Document - Pending',
type: 'DOCUMENT',
externalId: options.externalId,
visibility: options.visibility,
globalAccessAuth: options.globalAccessAuth,
globalActionAuth: options.globalActionAuth,
folderId: options.folderId,
meta: options.meta,
pdfFile: options.pdfFile,
});
// Add recipients
const recipientsRes = await apiCreateRecipients(request, ctx.token, envelopeId, recipients);
// Get envelope for item IDs
const envelopeData = await apiGetEnvelope(request, ctx.token, envelopeId);
const firstItemId = envelopeData.envelopeItems[0]?.id;
// Add fields
if (options.fieldsPerRecipient) {
for (const [index, recipientFields] of options.fieldsPerRecipient.entries()) {
if (recipientFields.length === 0) {
continue;
}
await apiCreateFields(
request,
ctx.token,
envelopeId,
recipientFields.map((f) => ({
...f,
recipientId: recipientsRes.data[index].id,
envelopeItemId: firstItemId,
type: f.type,
})),
);
}
} else {
// Auto-add a SIGNATURE field for each SIGNER/APPROVER recipient
const signerFields: ApiField[] = [];
for (const [index, r] of recipientsRes.data.entries()) {
const role = recipients[index].role ?? 'SIGNER';
if (role === 'SIGNER' || role === 'APPROVER') {
signerFields.push({
recipientId: r.id,
envelopeItemId: firstItemId,
type: 'SIGNATURE',
page: 1,
positionX: 10,
positionY: 10 + index * 10,
width: 15,
height: 5,
});
}
}
if (signerFields.length > 0) {
await apiCreateFields(request, ctx.token, envelopeId, signerFields);
}
}
// Distribute
const distributeResult = await apiDistributeEnvelope(
request,
ctx.token,
envelopeId,
options.distributeMeta,
);
const envelope = await apiGetEnvelope(request, ctx.token, envelopeId);
return {
envelope,
distributeResult,
token: ctx.token,
user: ctx.user,
team: ctx.team,
};
};
export type ApiSeedTemplateOptions = Omit<ApiSeedDocumentOptions, 'folderId'> & {
/** Folder ID to place the template in. */
folderId?: string;
};
/**
* Seed a template via API V2.
*
* Creates a TEMPLATE envelope with optional recipients and fields.
*
* @example
* ```ts
* const { envelope, token } = await apiSeedTemplate(request, {
* title: 'My Template',
* recipients: [{ email: 'recipient@test.com', name: 'Signer', role: 'SIGNER' }],
* });
* ```
*/
export const apiSeedTemplate = async (
request: APIRequestContext,
options: ApiSeedTemplateOptions = {},
): Promise<ApiSeedResult> => {
const ctx = options.context ?? (await apiCreateTestContext('e2e-template'));
const createOptions: ApiSeedEnvelopeOptions = {
title: options.title ?? '[TEST] API Template',
type: 'TEMPLATE',
externalId: options.externalId,
visibility: options.visibility,
globalAccessAuth: options.globalAccessAuth,
globalActionAuth: options.globalActionAuth,
folderId: options.folderId,
meta: options.meta,
pdfFile: options.pdfFile,
};
if (options.recipients && !options.fieldsPerRecipient) {
createOptions.recipients = options.recipients.map((r) => ({
...r,
role: r.role ?? 'SIGNER',
}));
}
const { id: envelopeId } = await apiCreateEnvelope(request, ctx.token, createOptions);
if (options.recipients && options.fieldsPerRecipient) {
const recipientsRes = await apiCreateRecipients(
request,
ctx.token,
envelopeId,
options.recipients,
);
const envelopeData = await apiGetEnvelope(request, ctx.token, envelopeId);
const firstItemId = envelopeData.envelopeItems[0]?.id;
for (const [index, recipientFields] of options.fieldsPerRecipient.entries()) {
if (recipientFields.length === 0) {
continue;
}
await apiCreateFields(
request,
ctx.token,
envelopeId,
recipientFields.map((f) => ({
...f,
recipientId: recipientsRes.data[index].id,
envelopeItemId: firstItemId,
type: f.type,
})),
);
}
}
const envelope = await apiGetEnvelope(request, ctx.token, envelopeId);
return { envelope, token: ctx.token, user: ctx.user, team: ctx.team };
};
/**
* Seed a template with a direct link via API V2.
*
* Creates a template with a recipient, then creates a direct link for it.
*
* @example
* ```ts
* const { envelope, directLink, token } = await apiSeedDirectTemplate(request, {
* title: 'Direct Template',
* });
*
* // Use directLink.token for the signing URL
* ```
*/
export const apiSeedDirectTemplate = async (
request: APIRequestContext,
options: ApiSeedTemplateOptions & {
/** Custom recipient for the direct link. Default: a SIGNER placeholder. */
directRecipient?: ApiRecipient;
} = {},
): Promise<
ApiSeedResult & {
directLink: { id: number; token: string; enabled: boolean; directTemplateRecipientId: number };
}
> => {
const recipients = options.recipients ?? [
options.directRecipient ?? {
email: 'direct-template-recipient@documenso.com',
name: 'Direct Template Recipient',
role: 'SIGNER' as const,
},
];
const templateResult = await apiSeedTemplate(request, {
...options,
recipients,
});
// Find the recipient ID for the direct link
const directRecipientEmail = options.directRecipient?.email ?? recipients[0].email;
const directRecipient = templateResult.envelope.recipients.find(
(r) => r.email === directRecipientEmail,
);
if (!directRecipient) {
throw new Error(`Direct template recipient not found: ${directRecipientEmail}`);
}
const numericTemplateId = mapSecondaryIdToTemplateId(templateResult.envelope.secondaryId);
const directLink = await apiCreateDirectTemplateLink(
request,
templateResult.token,
numericTemplateId,
directRecipient.id,
);
// Re-fetch envelope to include directLink data
const envelope = await apiGetEnvelope(request, templateResult.token, templateResult.envelope.id);
return {
...templateResult,
envelope,
directLink,
};
};
/**
* Seed multiple draft documents in parallel for a single user context.
*
* Useful for tests that need multiple documents (e.g., bulk actions, find/filter tests).
*
* @example
* ```ts
* const { documents, token, user, team } = await apiSeedMultipleDraftDocuments(request, [
* { title: 'Doc A' },
* { title: 'Doc B' },
* { title: 'Doc C' },
* ]);
* ```
*/
export const apiSeedMultipleDraftDocuments = async (
request: APIRequestContext,
documents: ApiSeedDocumentOptions[],
context?: ApiSeedContext,
): Promise<{
documents: TGetEnvelopeResponse[];
token: string;
user: ApiSeedContext['user'];
team: ApiSeedContext['team'];
}> => {
const ctx = context ?? (await apiCreateTestContext('e2e-multi-doc'));
const results = await Promise.all(
documents.map(async (docOptions) =>
apiSeedDraftDocument(request, { ...docOptions, context: ctx }),
),
);
return {
documents: results.map((r) => r.envelope),
token: ctx.token,
user: ctx.user,
team: ctx.team,
};
};
@@ -0,0 +1,437 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
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 { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-editor';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from './authentication';
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
export type TEnvelopeEditorSurface = {
root: Page;
isEmbedded: boolean;
envelopeId?: string;
envelopeType: TEnvelopeEditorType;
userId: number;
userEmail: string;
userName: string;
teamId: number;
};
export type TEnvelopeEditorType = 'DOCUMENT' | 'TEMPLATE';
type TEmbeddedHashCommonOptions = {
externalId?: string;
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
css?: string;
cssVars?: Record<string, string>;
darkModeDisabled?: boolean;
};
const encodeEmbeddedOptions = (options: Record<string, unknown>) => {
const encodedPayload = encodeURIComponent(JSON.stringify(options));
if (typeof btoa === 'function') {
return btoa(encodedPayload);
}
return Buffer.from(encodedPayload, 'utf8').toString('base64');
};
export const createEmbeddedEnvelopeCreateHash = ({
envelopeType,
externalId,
folderId,
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
css,
cssVars,
darkModeDisabled,
}: { envelopeType: TEnvelopeEditorType; folderId?: string } & TEmbeddedHashCommonOptions) => {
return encodeEmbeddedOptions({
externalId,
type: envelopeType,
folderId,
features,
css,
cssVars,
darkModeDisabled,
});
};
export const createEmbeddedEnvelopeEditHash = ({
externalId,
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
css,
cssVars,
darkModeDisabled,
}: TEmbeddedHashCommonOptions) => {
return encodeEmbeddedOptions({
externalId,
features,
css,
cssVars,
darkModeDisabled,
});
};
export const openDocumentEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, {
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
});
return {
root: page,
isEmbedded: false,
envelopeId: document.id,
envelopeType: 'DOCUMENT',
userId: user.id,
userEmail: user.email,
userName: user.name ?? '',
teamId: team.id,
};
};
export const openTemplateEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id, {
createTemplateOptions: {
title: `E2E Template ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit?step=uploadAndRecipients`,
});
return {
root: page,
isEmbedded: false,
envelopeId: template.id,
envelopeType: 'TEMPLATE',
userId: user.id,
userEmail: user.email,
userName: user.name ?? '',
teamId: team.id,
};
};
type OpenEmbeddedEnvelopeEditorOptions = {
envelopeType: TEnvelopeEditorType;
mode?: 'create' | 'edit';
tokenNamePrefix?: string;
externalId?: string;
folderId?: string;
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
css?: string;
cssVars?: Record<string, string>;
darkModeDisabled?: boolean;
};
export const openEmbeddedEnvelopeEditor = async (
page: Page,
{
envelopeType,
mode = 'create',
tokenNamePrefix = 'e2e-embed',
externalId,
folderId,
features,
css,
cssVars,
darkModeDisabled,
}: OpenEmbeddedEnvelopeEditorOptions,
): Promise<TEnvelopeEditorSurface> => {
const { user, team } = await seedUser();
const envelopeToEdit =
mode === 'edit'
? envelopeType === 'DOCUMENT'
? await seedBlankDocument(user, team.id, {
internalVersion: 2,
})
: await seedBlankTemplate(user, team.id, {
createTemplateOptions: {
title: `E2E Template ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
},
})
: null;
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: `${tokenNamePrefix}-${envelopeType.toLowerCase()}`,
expiresIn: null,
});
const embeddedToken = await resolveEmbeddingToken(
page,
token,
envelopeToEdit ? `envelopeId:${envelopeToEdit.id}` : undefined,
);
if (envelopeToEdit) {
const hash = createEmbeddedEnvelopeEditHash({
externalId,
features: features ?? DEFAULT_EMBEDDED_EDITOR_CONFIG,
css,
cssVars,
darkModeDisabled,
});
await page.goto(
`/embed/v2/authoring/envelope/edit/${envelopeToEdit.id}?token=${encodeURIComponent(embeddedToken)}#${hash}`,
);
} else {
const hash = createEmbeddedEnvelopeCreateHash({
envelopeType,
externalId,
folderId,
features,
css,
cssVars,
darkModeDisabled,
});
await page.goto(
`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(embeddedToken)}#${hash}`,
);
}
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
return {
root: page,
isEmbedded: true,
envelopeId: envelopeToEdit?.id,
envelopeType,
userId: user.id,
userEmail: user.email,
userName: user.name ?? '',
teamId: team.id,
};
};
export const getEnvelopeEditorSettingsTrigger = (root: Page) =>
root.locator('button[title="Settings"]');
export const getEnvelopeItemTitleInputs = (root: Page) =>
root.locator('[data-testid^="envelope-item-title-input-"]');
export const getEnvelopeItemDragHandles = (root: Page) =>
root.locator('[data-testid^="envelope-item-drag-handle-"]');
export const getEnvelopeItemRemoveButtons = (root: Page) =>
root.locator('[data-testid^="envelope-item-remove-button-"]');
export const getEnvelopeItemReplaceButtons = (root: Page) =>
root.locator('[data-testid^="envelope-item-replace-button-"]');
export const getEnvelopeItemDropzoneInput = (root: Page) =>
root.locator('[data-testid="envelope-item-dropzone"] input[type="file"]');
export const addEnvelopeItemPdf = async (root: Page, fileName = 'embedded-envelope-item.pdf') => {
await getEnvelopeItemDropzoneInput(root).setInputFiles({
name: fileName,
mimeType: 'application/pdf',
buffer: examplePdfBuffer,
});
};
export const getRecipientEmailInputs = (root: Page) =>
root.locator('[data-testid="signer-email-input"]');
export const getRecipientNameInputs = (root: Page) =>
root.locator('input[placeholder^="Recipient "]');
export const getRecipientRows = (root: Page) =>
root.locator('[data-testid="signer-email-input"]').locator('xpath=ancestor::fieldset[1]');
export const getRecipientRemoveButtons = (root: Page) =>
root.locator('[data-testid="remove-signer-button"]');
export const getSigningOrderInputs = (root: Page) =>
root.locator('[data-testid="signing-order-input"]');
export const clickEnvelopeEditorStep = async (
root: Page,
stepId: 'upload' | 'addFields' | 'preview',
) => {
await root.waitForTimeout(200);
await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click();
};
export const clickAddMyselfButton = async (root: Page) => {
await root.getByRole('button', { name: 'Add Myself' }).click();
};
export const clickAddSignerButton = async (root: Page) => {
await root.getByRole('button', { name: 'Add Signer' }).click();
};
export const setRecipientEmail = async (root: Page, index: number, email: string) => {
await getRecipientEmailInputs(root).nth(index).fill(email);
};
export const setRecipientName = async (root: Page, index: number, name: string) => {
await getRecipientNameInputs(root).nth(index).fill(name);
};
export const setRecipientRole = async (
root: Page,
index: number,
roleLabel:
| 'Needs to sign'
| 'Needs to approve'
| 'Needs to view'
| 'Receives copy'
| 'Can prepare',
) => {
const row = getRecipientRows(root).nth(index);
await row.locator('button[role="combobox"]').first().click();
await root.getByRole('option', { name: roleLabel }).click();
};
export const assertRecipientRole = async (
root: Page,
index: number,
roleLabel:
| 'Needs to sign'
| 'Needs to approve'
| 'Needs to view'
| 'Receives copy'
| 'Can prepare',
) => {
const row = getRecipientRows(root).nth(index);
const roleValueByLabel: Record<typeof roleLabel, string> = {
'Needs to sign': 'SIGNER',
'Needs to approve': 'APPROVER',
'Needs to view': 'VIEWER',
'Receives copy': 'CC',
'Can prepare': 'ASSISTANT',
};
await expect(row.locator('button[role="combobox"]').first()).toHaveAttribute(
'title',
roleValueByLabel[roleLabel],
);
};
export const toggleSigningOrder = async (root: Page, enabled: boolean) => {
const checkbox = root.locator('#signingOrder');
const currentState = await checkbox.getAttribute('aria-checked');
const isEnabled = currentState === 'true';
if (isEnabled !== enabled) {
await checkbox.click();
}
};
export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) => {
const checkbox = root.locator('#allowDictateNextSigner');
const currentState = await checkbox.getAttribute('aria-checked');
const isEnabled = currentState === 'true';
if (isEnabled !== enabled) {
await checkbox.click();
}
};
export const setSigningOrderValue = async (root: Page, index: number, value: number) => {
const input = getSigningOrderInputs(root).nth(index);
await input.fill(value.toString());
await input.blur();
};
export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {
if (!surface.isEmbedded) {
return;
}
const isUpdateFlow =
(await surface.root.getByRole('button', { name: 'Update Document' }).count()) > 0 ||
(await surface.root.getByRole('button', { name: 'Update Template' }).count()) > 0;
const actionButtonName = isUpdateFlow
? surface.envelopeType === 'DOCUMENT'
? 'Update Document'
: 'Update Template'
: surface.envelopeType === 'DOCUMENT'
? 'Create Document'
: 'Create Template';
await surface.root.getByRole('button', { name: actionButtonName }).click();
const completionHeading = isUpdateFlow
? surface.envelopeType === 'DOCUMENT'
? 'Document Updated'
: 'Template Updated'
: surface.envelopeType === 'DOCUMENT'
? 'Document Created'
: 'Template Created';
await expect(surface.root.getByRole('heading', { name: completionHeading })).toBeVisible();
};
const resolveEmbeddingToken = async (
page: Page,
inputToken: string,
scope?: string,
): Promise<string> => {
if (!inputToken.startsWith('api_')) {
return inputToken;
}
const response = await page
.context()
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2/embedding/create-presign-token`, {
headers: {
Authorization: `Bearer ${inputToken}`,
'Content-Type': 'application/json',
},
data: scope ? { scope } : {},
});
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to exchange API token (${response.status()}): ${text}`);
}
const data: unknown = await response.json();
if (typeof data !== 'object' || data === null || !('token' in data)) {
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
}
const token = data.token;
if (typeof token !== 'string' || token.length === 0) {
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
}
return token;
};
+22
View File
@@ -0,0 +1,22 @@
import type { Page } from '@playwright/test';
import type Konva from 'konva';
export const getKonvaElementCountForPage = async (
page: Page,
pageNumber: number,
elementSelector: string,
) => {
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
return await page.evaluate(
({ pageNumber, elementSelector }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
const pageOne = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`);
return pageOne?.find(elementSelector).length || 0;
},
{ pageNumber, elementSelector },
);
};
@@ -0,0 +1,326 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { TCachedLicense, TLicenseClaim } from '@documenso/lib/types/license';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const LICENSE_FILE_NAME = '.documenso-license.json';
const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json';
/**
* Get the path to the license file.
*
* The server reads from process.cwd() which is apps/remix when the dev server runs.
* Tests run from packages/app-tests, so we need to go up to the root then into apps/remix.
*/
const getLicenseFilePath = () => {
// From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json
return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME);
};
/**
* Get the path to the backup license file.
*/
const getBackupLicenseFilePath = () => {
return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME);
};
/**
* Backup the existing license file if it exists.
*/
const backupLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(licensePath);
await fs.rename(licensePath, backupPath);
} catch (e) {
// File doesn't exist, nothing to backup
console.log(e);
}
};
/**
* Restore the backup license file if it exists.
*/
const restoreLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(backupPath);
await fs.rename(backupPath, licensePath);
} catch (e) {
// Backup doesn't exist, nothing to restore
console.log(e);
}
};
/**
* Write a license file with the given data.
* Pass null to delete the license file.
*/
const writeLicenseFile = async (data: TCachedLicense | null) => {
const licensePath = getLicenseFilePath();
if (data === null) {
await fs.unlink(licensePath).catch(() => {
// File doesn't exist, ignore
});
} else {
await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8');
}
};
/**
* Create a mock license object with the given flags.
*/
const createMockLicenseWithFlags = (flags: TLicenseClaim): TCachedLicense => {
return {
lastChecked: new Date().toISOString(),
license: {
status: 'ACTIVE',
createdAt: new Date(),
name: 'Test License',
periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
cancelAtPeriodEnd: false,
licenseKey: 'test-license-key',
flags,
},
requestedLicenseKey: 'test-license-key',
derivedStatus: 'ACTIVE',
unauthorizedFlagUsage: false,
};
};
// Run tests serially to avoid race conditions with the license file
test.describe.configure({ mode: 'serial' });
// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE.
test.describe.skip('Enterprise Feature Restrictions', () => {
test.beforeAll(async () => {
// Backup any existing license file before running tests
await backupLicenseFile();
});
test.afterAll(async () => {
// Restore the backup license file after all tests complete
await restoreLicenseFile();
});
test.beforeEach(async () => {
// Clean up license file before each test to ensure clean state
await writeLicenseFile(null);
});
test.afterEach(async () => {
// Clean up license file after each test
await writeLicenseFile(null);
});
test('[ADMIN CLAIMS]: shows restricted features with asterisk when no license', async ({
page,
}) => {
// Ensure no license file exists
await writeLicenseFile(null);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Check that enterprise features have asterisks (are restricted)
// These are the enterprise features that should be marked with *
await expect(page.getByText(/Email domains\s¹/)).toBeVisible();
await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/21 CFR\s¹/)).toBeVisible();
await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible();
// Check that the alert is visible
await expect(
page.getByText('Your current license does not include these features.'),
).toBeVisible();
await expect(page.getByRole('link', { name: 'Learn more' })).toBeVisible();
// Check that enterprise feature checkboxes are disabled
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
await expect(emailDomainsCheckbox).toBeDisabled();
const cfr21Checkbox = page.locator('#flag-cfr21');
await expect(cfr21Checkbox).toBeDisabled();
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
await expect(authPortalCheckbox).toBeDisabled();
});
test('[ADMIN CLAIMS]: no restrictions when license has all enterprise features', async ({
page,
}) => {
// Create a license with ALL enterprise features enabled
await writeLicenseFile(
createMockLicenseWithFlags({
emailDomains: true,
embedAuthoring: true,
embedAuthoringWhiteLabel: true,
cfr21: true,
authenticationPortal: true,
billing: true,
}),
);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Check that enterprise features do NOT have asterisks
// They should show without the * since the license covers them
await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible();
await expect(page.getByText(/Embed authoring\s¹/)).not.toBeVisible();
await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible();
await expect(page.getByText(/Authentication portal\s¹/)).not.toBeVisible();
// The plain labels should be visible (without asterisks)
await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains');
await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR');
// The alert should NOT be visible
await expect(
page.getByText('Your current license does not include these features.'),
).not.toBeVisible();
// Check that enterprise feature checkboxes are enabled
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
await expect(emailDomainsCheckbox).toBeEnabled();
const cfr21Checkbox = page.locator('#flag-cfr21');
await expect(cfr21Checkbox).toBeEnabled();
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
await expect(authPortalCheckbox).toBeEnabled();
});
test('[ADMIN CLAIMS]: only unlicensed features show asterisk with partial license', async ({
page,
}) => {
// Create a license with SOME enterprise features (emailDomains and cfr21)
await writeLicenseFile(
createMockLicenseWithFlags({
emailDomains: true,
cfr21: true,
// embedAuthoring, embedAuthoringWhiteLabel, authenticationPortal are NOT included
}),
);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Features NOT in license should have asterisks
await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible();
// Features IN license should NOT have asterisks
await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible();
await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible();
// The plain labels for licensed features should be visible
await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains');
await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR');
// Alert should be visible since some features are restricted
await expect(
page.getByText('Your current license does not include these features.'),
).toBeVisible();
// Licensed features should be enabled
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
await expect(emailDomainsCheckbox).toBeEnabled();
const cfr21Checkbox = page.locator('#flag-cfr21');
await expect(cfr21Checkbox).toBeEnabled();
// Unlicensed features should be disabled
const embedAuthoringCheckbox = page.locator('#flag-embedAuthoring');
await expect(embedAuthoringCheckbox).toBeDisabled();
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
await expect(authPortalCheckbox).toBeDisabled();
});
test('[ADMIN CLAIMS]: non-enterprise features are always enabled', async ({ page }) => {
// Ensure no license file exists
await writeLicenseFile(null);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Non-enterprise features should NOT have asterisks
await expect(page.getByText(/Unlimited documents\s¹/)).not.toBeVisible();
await expect(page.getByText(/Branding\s¹/)).not.toBeVisible();
await expect(page.getByText(/Embed signing\s¹/)).not.toBeVisible();
// Non-enterprise features should always be enabled
const unlimitedDocsCheckbox = page.locator('#flag-unlimitedDocuments');
await expect(unlimitedDocsCheckbox).toBeEnabled();
const brandingCheckbox = page.locator('#flag-allowCustomBranding');
await expect(brandingCheckbox).toBeEnabled();
const embedSigningCheckbox = page.locator('#flag-embedSigning');
await expect(embedSigningCheckbox).toBeEnabled();
});
});
@@ -0,0 +1,392 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const LICENSE_FILE_NAME = '.documenso-license.json';
const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json';
/**
* Get the path to the license file.
*
* The server reads from process.cwd() which is apps/remix when the dev server runs.
* Tests run from packages/app-tests, so we need to go up to the root then into apps/remix.
*/
const getLicenseFilePath = () => {
// From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json
return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME);
};
/**
* Get the path to the backup license file.
*/
const getBackupLicenseFilePath = () => {
return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME);
};
/**
* Backup the existing license file if it exists.
*/
const backupLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(licensePath);
await fs.rename(licensePath, backupPath);
} catch (e) {
// File doesn't exist, nothing to backup
console.log(e);
}
};
/**
* Restore the backup license file if it exists.
*/
const restoreLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(backupPath);
await fs.rename(backupPath, licensePath);
} catch (e) {
// Backup doesn't exist, nothing to restore
console.log(e);
}
};
/**
* Write a license file with the given data.
* Pass null to delete the license file.
*/
const writeLicenseFile = async (data: TCachedLicense | null) => {
const licensePath = getLicenseFilePath();
if (data === null) {
await fs.unlink(licensePath).catch(() => {
// File doesn't exist, ignore
});
} else {
await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8');
}
};
/**
* Create a mock license object with the given status and unauthorized flag.
*/
const createMockLicense = (
status: 'ACTIVE' | 'EXPIRED' | 'PAST_DUE',
unauthorizedFlagUsage: boolean,
): TCachedLicense => {
return {
lastChecked: new Date().toISOString(),
license: {
status,
createdAt: new Date(),
name: 'Test License',
periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
cancelAtPeriodEnd: false,
licenseKey: 'test-license-key',
flags: {},
},
requestedLicenseKey: 'test-license-key',
derivedStatus: unauthorizedFlagUsage ? 'UNAUTHORIZED' : status,
unauthorizedFlagUsage,
};
};
/**
* Create a mock license object with no license data (only unauthorized flag).
*/
const createMockUnauthorizedWithoutLicense = (): TCachedLicense => {
return {
lastChecked: new Date().toISOString(),
license: null,
unauthorizedFlagUsage: true,
derivedStatus: 'UNAUTHORIZED',
};
};
// Run tests serially to avoid race conditions with the license file
test.describe.configure({ mode: 'serial' });
// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE.
test.describe.skip('License Status Banner', () => {
test.beforeAll(async () => {
// Backup any existing license file before running tests
await backupLicenseFile();
});
test.afterAll(async () => {
// Restore the backup license file after all tests complete
await restoreLicenseFile();
});
test.beforeEach(async () => {
// Clean up license file before each test to ensure clean state
await writeLicenseFile(null);
});
test.afterEach(async () => {
// Clean up license file after each test
await writeLicenseFile(null);
});
test('[ADMIN]: no banner when license file is missing', async ({ page }) => {
// Ensure no license file exists BEFORE any page loads
await writeLicenseFile(null);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should not be visible (no license file)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner messages should not be visible (no license file means no banner)
await expect(page.getByText('License payment overdue')).not.toBeVisible();
await expect(page.getByText('License expired')).not.toBeVisible();
await expect(page.getByText('Invalid License Type')).not.toBeVisible();
await expect(page.getByText('Missing License')).not.toBeVisible();
});
test('[ADMIN]: no banner when license is ACTIVE', async ({ page }) => {
// Create an ACTIVE license BEFORE any page loads
await writeLicenseFile(createMockLicense('ACTIVE', false));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should not be visible (license is ACTIVE)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner messages should not be visible (license is ACTIVE)
await expect(page.getByText('License payment overdue')).not.toBeVisible();
await expect(page.getByText('License expired')).not.toBeVisible();
await expect(page.getByText('Invalid License Type')).not.toBeVisible();
});
test('[ADMIN]: admin banner shows PAST_DUE warning', async ({ page }) => {
// Create a PAST_DUE license BEFORE any page loads
await writeLicenseFile(createMockLicense('PAST_DUE', false));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (only shows for EXPIRED + unauthorized)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show PAST_DUE message
await expect(page.getByText('License payment overdue')).toBeVisible();
await expect(
page.getByText('Please update your payment to avoid service disruptions.'),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test('[ADMIN]: admin banner shows EXPIRED error', async ({ page }) => {
// Create an EXPIRED license WITHOUT unauthorized usage BEFORE any page loads
await writeLicenseFile(createMockLicense('EXPIRED', false));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (requires BOTH expired AND unauthorized)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show EXPIRED message
await expect(page.getByText('License expired')).toBeVisible();
await expect(
page.getByText('Please renew your license to continue using enterprise features.'),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test.skip('[ADMIN]: global banner shows when EXPIRED with unauthorized usage', async ({
page,
}) => {
// Create an EXPIRED license WITH unauthorized usage BEFORE any page loads
await writeLicenseFile(createMockLicense('EXPIRED', true));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner SHOULD be visible (EXPIRED + unauthorized)
await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible();
// Admin banner should show UNAUTHORIZED message (takes precedence over EXPIRED)
await expect(page.getByText('Invalid License Type')).toBeVisible();
await expect(
page.getByText(
'Your Documenso instance is using features that are not part of your license.',
),
).toBeVisible();
});
test('[ADMIN]: admin banner shows UNAUTHORIZED when flags are misused with license', async ({
page,
}) => {
// Create an ACTIVE license but WITH unauthorized flag usage BEFORE any page loads
await writeLicenseFile(createMockLicense('ACTIVE', true));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (requires EXPIRED status)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show UNAUTHORIZED message
await expect(page.getByText('Invalid License Type')).toBeVisible();
await expect(
page.getByText(
'Your Documenso instance is using features that are not part of your license.',
),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test('[ADMIN]: admin banner shows Invalid License Type when unauthorized without license data', async ({
page,
}) => {
// Create a license file with unauthorized flag but no license data BEFORE any page loads
// Note: Even without license data, the banner shows "Invalid License Type" because the
// license file exists (just with license: null). The "Missing License" message would only
// show if the entire license prop was null, which doesn't happen with a valid file.
await writeLicenseFile(createMockUnauthorizedWithoutLicense());
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (no EXPIRED status, only unauthorized flag)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show Invalid License Type message (unauthorized flag is set)
await expect(page.getByText('Invalid License Type')).toBeVisible();
await expect(
page.getByText(
'Your Documenso instance is using features that are not part of your license.',
),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test.skip('[ADMIN]: global banner visible on non-admin pages when EXPIRED with unauthorized', async ({
page,
}) => {
// Create an EXPIRED license WITH unauthorized usage BEFORE any page loads
await writeLicenseFile(createMockLicense('EXPIRED', true));
const { user } = await seedUser();
// Navigate to documents page - license is read during page load
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
// Global banner SHOULD be visible on any authenticated page (EXPIRED + unauthorized)
await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible();
});
});
@@ -205,9 +205,13 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('textbox', { name: 'Reply to email' }).fill('organisation@documenso.com');
// Update email document settings by enabling/disabling some checkboxes
await page.getByRole('checkbox', { name: 'Send recipient signed email' }).uncheck();
await page.getByRole('checkbox', { name: 'Send document pending email' }).uncheck();
await page.getByRole('checkbox', { name: 'Send document deleted email' }).uncheck();
await page.getByRole('checkbox', { name: 'Email the owner when a recipient signs' }).uncheck();
await page
.getByRole('checkbox', { name: 'Email the signer if the document is still pending' })
.uncheck();
await page
.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' })
.uncheck();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
@@ -225,7 +229,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentPending: false, // unchecked
documentCompleted: true,
documentDeleted: false, // unchecked
ownerRecipientExpired: true,
ownerDocumentCompleted: true,
ownerDocumentCreated: true,
});
// Edit the team email settings
@@ -240,12 +246,12 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('option', { name: 'Override organisation settings' }).click();
// Update some email settings
await page.getByRole('checkbox', { name: 'Send recipient signing request email' }).uncheck();
await page.getByRole('checkbox', { name: 'Email recipients with a signing request' }).uncheck();
await page
.getByRole('checkbox', { name: 'Send document completed email', exact: true })
.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true })
.uncheck();
await page
.getByRole('checkbox', { name: 'Send document completed email to the owner' })
.getByRole('checkbox', { name: 'Email the owner when the document is completed' })
.uncheck();
await page.getByRole('button', { name: 'Update' }).first().click();
@@ -264,7 +270,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentPending: true,
documentCompleted: false,
documentDeleted: true,
ownerRecipientExpired: true,
ownerDocumentCompleted: false,
ownerDocumentCreated: true,
});
// Verify that a document can be created successfully with the team email settings
@@ -284,7 +292,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentPending: true,
documentCompleted: false,
documentDeleted: true,
ownerRecipientExpired: true,
ownerDocumentCompleted: false,
ownerDocumentCreated: true,
});
// Test inheritance by setting team back to inherit from organisation
@@ -309,7 +319,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentPending: false,
documentCompleted: true,
documentDeleted: false,
ownerRecipientExpired: true,
ownerDocumentCompleted: true,
ownerDocumentCreated: true,
});
// Verify that a document can be created successfully with the email settings
@@ -329,6 +341,8 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentPending: false,
documentCompleted: true,
documentDeleted: false,
ownerRecipientExpired: true,
ownerDocumentCompleted: true,
ownerDocumentCreated: true,
});
});
@@ -0,0 +1,425 @@
import { expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prefixedId } from '@documenso/lib/universal/id';
import {
mapSecondaryIdToDocumentId,
mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { prisma } from '@documenso/prisma';
import {
seedBlankDocument,
seedCompletedDocument,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedBlankTemplate, seedDirectTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
export const PDF_PAGE_SELECTOR = 'img[data-page-number]';
async function addSecondEnvelopeItem(envelopeId: string) {
const firstItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId },
orderBy: { order: 'asc' },
include: { documentData: true },
});
const newDocumentData = await prisma.documentData.create({
data: {
type: firstItem.documentData.type,
data: firstItem.documentData.data,
initialData: firstItem.documentData.initialData,
},
});
await prisma.envelopeItem.create({
data: {
id: prefixedId('envelope_item'),
title: `${firstItem.title} - Page 2`,
documentDataId: newDocumentData.id,
order: 2,
envelopeId,
},
});
}
test.describe('PDF Viewer Rendering', () => {
test.describe('Authenticated Pages', () => {
test('should render PDF on all authenticated pages (V1 and V2)', async ({ page }) => {
const { user, team } = await seedUser();
const documentV1 = await seedBlankDocument(user, team.id);
const documentV2 = await seedBlankDocument(user, team.id, { internalVersion: 2 });
await addSecondEnvelopeItem(documentV2.id);
const templateV1 = await seedBlankTemplate(user, team.id);
const templateV2 = await seedBlankTemplate(user, team.id, {
createTemplateOptions: { internalVersion: 2 },
});
await addSecondEnvelopeItem(templateV2.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${documentV1.id}`,
});
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/documents/${documentV2.id}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/templates/${templateV1.id}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/templates/${templateV2.id}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/documents/${documentV1.id}/edit`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/documents/${documentV2.id}/edit?step=addFields`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/templates/${templateV1.id}/edit`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/templates/${templateV2.id}/edit?step=addFields`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/documents/${documentV1.id}`);
await page.locator(PDF_PAGE_SELECTOR).first().waitFor({ state: 'visible', timeout: 30_000 });
});
});
test.describe('Recipient Signing', () => {
test('should render PDF on signing page (V1 and V2)', async ({ page }) => {
const { user, team } = await seedUser();
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['signer-v1@test.documenso.com'],
fields: [FieldType.SIGNATURE],
});
const { document: documentV2, recipients: recipientsV2 } =
await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['signer-v2@test.documenso.com'],
fields: [FieldType.SIGNATURE],
updateDocumentOptions: { internalVersion: 2 },
});
await addSecondEnvelopeItem(documentV2.id);
await page.goto(`/sign/${recipientsV1[0].token}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/sign/${recipientsV2[0].token}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
});
test.describe('Direct Template', () => {
test('should render PDF on direct template page (V1 and V2)', async ({ page }) => {
const { user, team } = await seedUser();
const templateV1 = await seedDirectTemplate({
title: 'PDF Viewer Test Template V1',
userId: user.id,
teamId: team.id,
});
const templateV2 = await seedDirectTemplate({
title: 'PDF Viewer Test Template V2',
userId: user.id,
teamId: team.id,
internalVersion: 2,
});
await addSecondEnvelopeItem(templateV2.id);
await page.goto(formatDirectTemplatePath(templateV1.directLink?.token || ''));
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(formatDirectTemplatePath(templateV2.directLink?.token || ''));
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
});
test.describe('Share Page', () => {
test('should render PDF on share page (V1 and V2)', async ({ page }) => {
const { user, team } = await seedUser();
const qrTokenV1 = prefixedId('qr');
const qrTokenV2 = prefixedId('qr');
const documentV1 = await seedCompletedDocument(
user,
team.id,
['share-v1@test.documenso.com'],
{
createDocumentOptions: { qrToken: qrTokenV1 },
},
);
const documentV2 = await seedCompletedDocument(
user,
team.id,
['share-v2@test.documenso.com'],
{
createDocumentOptions: { qrToken: qrTokenV2 },
internalVersion: 2,
},
);
await addSecondEnvelopeItem(documentV2.id);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrTokenV1}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrTokenV2}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should not return 500 for invalid QR share token', async ({ page }) => {
const response = await page.request.get('/share/qr_invalid_token_for_regression_check', {
maxRedirects: 0,
});
expect(response.status()).toBe(302);
expect(response.headers().location).toBe('/');
});
});
test.describe('Embed Pages', () => {
test('should render PDF on embed sign page (V1 and V2)', async ({ page }) => {
const { user, team } = await seedUser();
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['embed-signer-v1@test.documenso.com'],
fields: [FieldType.SIGNATURE],
});
const { document: documentV2, recipients: recipientsV2 } =
await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['embed-signer-v2@test.documenso.com'],
fields: [FieldType.SIGNATURE],
updateDocumentOptions: { internalVersion: 2 },
});
await addSecondEnvelopeItem(documentV2.id);
await page.goto(`/embed/sign/${recipientsV1[0].token}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/embed/sign/${recipientsV2[0].token}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
// Todo: Multisign does not support multiple envelope items.
// await page.getByRole('button', { name: /Page 2/ }).click();
// await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should render PDF on embed direct template page (V1 and V2)', async ({ page }) => {
const { user, team } = await seedUser();
const templateV1 = await seedDirectTemplate({
title: 'Embed Direct Template V1',
userId: user.id,
teamId: team.id,
});
const templateV2 = await seedDirectTemplate({
title: 'Embed Direct Template V2',
userId: user.id,
teamId: team.id,
internalVersion: 2,
});
await addSecondEnvelopeItem(templateV2.id);
await page.goto(`/embed/direct/${templateV1.directLink?.token || ''}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/embed/direct/${templateV2.directLink?.token || ''}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should render PDF on embed multisign page (V1 and V2)', async ({ page }) => {
const { user, team } = await seedUser();
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['multisign-v1@test.documenso.com'],
fields: [FieldType.SIGNATURE],
});
const { document: documentV2, recipients: recipientsV2 } =
await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['multisign-v2@test.documenso.com'],
fields: [FieldType.SIGNATURE],
updateDocumentOptions: { internalVersion: 2 },
});
await addSecondEnvelopeItem(documentV2.id);
await page.goto(`/embed/v1/multisign?token=${recipientsV1[0].token}`);
await expect(page.getByText('Sign Documents')).toBeVisible({ timeout: 15_000 });
// Todo: Multisign does not support multiple envelope items.
// await page.getByRole('button', { name: /View/i }).first().click();
// await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/embed/v1/multisign?token=${recipientsV2[0].token}`);
await expect(page.getByText('Sign Documents')).toBeVisible({ timeout: 15_000 });
await page.getByRole('button', { name: /View/i }).first().click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
// Todo: Multisign does not support multiple envelope items.
// await page.getByRole('button', { name: /Page 2/ }).click();
// await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should render PDF on embed authoring document create page', async ({ page }) => {
const { user, team } = await seedUser();
const { token: apiToken } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'pdf-viewer-test',
expiresIn: null,
});
const { token: presignToken } = await createEmbeddingPresignToken({
apiToken,
});
const embedParams = { darkModeDisabled: false, features: {} };
const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
await page.goto(
`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/create?token=${presignToken}#${hash}`,
);
await expect(page.getByText('Configure Document')).toBeVisible({ timeout: 15_000 });
const titleInput = page.getByLabel('Title');
await titleInput.click();
await titleInput.fill('PDF Viewer E2E Test');
const emailInput = page.getByPlaceholder('Email').first();
await emailInput.click();
await emailInput.fill('test-signer@documenso.com');
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page
.locator('input[type=file]')
.first()
.evaluate((el) => {
if (el instanceof HTMLInputElement) {
el.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should render PDF on embed document edit page', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
const documentId = mapSecondaryIdToDocumentId(document.secondaryId);
const { token: apiToken } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'pdf-viewer-doc-edit-test',
expiresIn: null,
});
const { token: presignToken } = await createEmbeddingPresignToken({
apiToken,
scope: `documentId:${documentId}`,
});
const embedParams = {
darkModeDisabled: false,
features: {},
onlyEditFields: true,
};
const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
await page.goto(
`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/edit/${documentId}?token=${presignToken}#${hash}`,
);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should render PDF on embed template edit page', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
const templateId = mapSecondaryIdToTemplateId(template.secondaryId);
const { token: apiToken } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'pdf-viewer-template-edit-test',
expiresIn: null,
});
const { token: presignToken } = await createEmbeddingPresignToken({
apiToken,
scope: `templateId:${templateId}`,
});
const embedParams = {
darkModeDisabled: false,
features: {},
onlyEditFields: true,
};
const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
await page.goto(
`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/template/edit/${templateId}?token=${presignToken}#${hash}`,
);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
});
});
@@ -1,4 +1,4 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import { expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
@@ -39,24 +39,30 @@ const TEST_FORM_VALUES = {
* Returns true if the PDF has form fields, false if they've been flattened.
*/
async function pdfHasFormFields(pdfBuffer: Uint8Array): Promise<boolean> {
const pdfDoc = await PDFDocument.load(pdfBuffer);
const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
const form = pdfDoc.getForm();
const fields = form.getFields();
const form = await pdfDoc.getForm();
return fields.length > 0;
if (!form) {
return false;
}
return form.fieldCount > 0;
}
/**
* Helper to get form field names from a PDF.
*/
async function getPdfFormFieldNames(pdfBuffer: Uint8Array): Promise<string[]> {
const pdfDoc = await PDFDocument.load(pdfBuffer);
const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
const form = pdfDoc.getForm();
const fields = form.getFields();
const form = await pdfDoc.getForm();
return fields.map((field) => field.getName());
if (!form) {
return [];
}
return form.getFieldNames();
}
/**
@@ -66,17 +72,21 @@ async function getPdfTextFieldValue(
pdfBuffer: Uint8Array,
fieldName: string,
): Promise<string | undefined> {
const pdfDoc = await PDFDocument.load(pdfBuffer);
const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
const form = pdfDoc.getForm();
const form = await pdfDoc.getForm();
try {
const textField = form.getTextField(fieldName);
return textField.getText() ?? '';
} catch {
if (!form) {
return undefined;
}
const textField = form.getTextField(fieldName);
if (!textField) {
return undefined;
}
return textField.getValue();
}
test.describe.configure({
@@ -42,19 +42,21 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: {
// Step 3: Add fields for each recipient instance
// Add signature field for first instance
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
// Switch to second instance and add their field
await page.getByRole('combobox').first().click();
await page.getByText('Second Instance').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Switch to different recipient and add their field
// Switch to different recipient and add their fields
await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 150 } });
// Save template
await page.getByRole('button', { name: 'Save Template' }).click();
@@ -207,17 +209,17 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
// Add fields for each recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.getByRole('combobox').first().click();
await page.getByText('Duplicate Recipient 2').first().click();
await page.getByRole('button', { name: 'Date' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 200 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200 } });
// Save template
await page.getByRole('button', { name: 'Save Template' }).click();
@@ -270,7 +272,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'First Instance' }).first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 300 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 300 } });
await page.waitForTimeout(2500);
@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
@@ -47,7 +48,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -55,7 +56,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -75,7 +76,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -110,7 +111,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -118,7 +119,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -138,7 +139,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -179,7 +180,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -187,7 +188,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -207,7 +208,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -250,7 +251,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -258,7 +259,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -136,7 +136,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(4).click();
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Type' }).click();
@@ -163,7 +163,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(5).click();
await page.getByRole('combobox').nth(6).click();
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
await triggerAutosave(page);
@@ -187,7 +187,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(6).click();
await page.getByRole('combobox').nth(7).click();
await page.getByRole('option', { name: 'Europe/London' }).click();
await triggerAutosave(page);
@@ -247,7 +247,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
const newExternalId = 'MULTI-TEST-123';
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await page.getByRole('combobox').nth(6).click();
await page.getByRole('combobox').nth(7).click();
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
await triggerAutosave(page);
@@ -0,0 +1,256 @@
import { expect, test } from '@playwright/test';
import { FolderType } from '@documenso/prisma/client';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
test.describe.configure({ mode: 'parallel' });
const seedBulkActionsTestRequirements = async () => {
const sender = await seedUser({ setTeamEmailAsOwner: true });
const [template1, template2, template3] = await Promise.all([
seedBlankTemplate(sender.user, sender.team.id, {
createTemplateOptions: { title: 'Bulk Test Template 1' },
}),
seedBlankTemplate(sender.user, sender.team.id, {
createTemplateOptions: { title: 'Bulk Test Template 2' },
}),
seedBlankTemplate(sender.user, sender.team.id, {
createTemplateOptions: { title: 'Bulk Test Template 3' },
}),
]);
const folder = await seedBlankFolder(sender.user, sender.team.id, {
createFolderOptions: {
name: 'Target Template Folder',
teamId: sender.team.id,
type: FolderType.TEMPLATE,
},
});
return {
sender,
templates: [template1, template2, template3],
folder,
};
};
test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
});
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
const { sender, templates } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await page.getByLabel('Clear selection').click();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
const { sender, folder } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await page.goto(`/t/${sender.team.url}/templates/f/${folder.id}`);
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Bulk Test Template 2' })).toBeVisible();
});
test('[BULK_ACTIONS]: can delete multiple templates', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Delete Templates')).toBeVisible();
await expect(page.getByText('You are about to delete 2 templates')).toBeVisible();
await expect(page.getByText('irreversible')).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Templates deleted');
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Bulk Test Template 2' })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Bulk Test Template 3' })).toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful move', async ({ page }) => {
const { sender, folder } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Templates deleted');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
const { sender, folder } = await seedBulkActionsTestRequirements();
const otherFolder = await seedBlankFolder(sender.user, sender.team.id, {
createFolderOptions: {
name: 'Other Template Folder',
teamId: sender.team.id,
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
await page.getByPlaceholder('Search folders...').fill('Target');
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
await expect(page.getByRole('button', { name: otherFolder.name })).not.toBeVisible();
await page.getByPlaceholder('Search folders...').fill('Other');
await expect(page.getByRole('button', { name: folder.name })).not.toBeVisible();
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
await page.getByPlaceholder('Search folders...').fill('NonExistent');
await expect(page.getByText('No folders found')).toBeVisible();
});
test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ page }) => {
const { sender, templates, folder } = await seedBulkActionsTestRequirements();
const { prisma } = await import('@documenso/prisma');
await prisma.envelope.updateMany({
where: { id: templates[0].id },
data: { folderId: folder.id },
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates/f/${folder.id}`,
});
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await page.goto(`/t/${sender.team.url}/templates`);
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
await page.goto(`/t/${sender.team.url}/templates/f/${folder.id}`);
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).not.toBeVisible();
});
@@ -117,7 +117,13 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByRole('button', { name: 'Duplicate' }).click();
await expect(page.getByText('Template duplicated').first()).toBeVisible();
await expect(page.getByText('Template Duplicated').first()).toBeVisible();
// The dialog should navigate to the new template's edit page.
await page.waitForURL(/\/templates\/.*\/edit/);
// Navigate back to the templates list and verify the count is now 2.
await page.goto(`/t/${team.url}/templates`);
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
});
@@ -0,0 +1,553 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { TemplateType } from '@prisma/client';
import { customAlphabet } from 'nanoid';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
const nanoid = customAlphabet('1234567890abcdef', 10);
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
/**
* Helper to set up the standard two-team-one-org scenario:
*
* - One organisation with two teams (teamA and teamB).
* - ownerA owns both the org and teamA.
* - memberB is only a member of teamB (no relation to teamA).
* - An ORGANISATION template is created on teamA.
*/
const seedOrgTemplateScenario = async () => {
const { user: ownerA, organisation, team: teamA } = await seedUser();
const teamBUrl = `team-b-${nanoid()}`;
await createTeam({
userId: ownerA.id,
teamName: `Team B ${teamBUrl}`,
teamUrl: teamBUrl,
organisationId: organisation.id,
inheritMembers: false,
});
const teamB = await prisma.team.findFirstOrThrow({
where: { url: teamBUrl },
});
// memberB is only added to teamB, not teamA.
const memberB = await seedTeamMember({
teamId: teamB.id,
role: 'MEMBER',
});
const orgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Org Template ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
},
});
return { ownerA, organisation, teamA, teamB, memberB, orgTemplate };
};
/**
* Helper to make tRPC queries via the authenticated page context.
*/
const trpcQuery = async (
page: Page,
procedure: string,
input: Record<string, unknown>,
teamId?: number,
) => {
const inputParam = encodeURIComponent(JSON.stringify({ json: input }));
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`;
const headers: Record<string, string> = {};
if (teamId) {
headers['x-team-id'] = teamId.toString();
}
const res = await page.context().request.get(url, { headers });
return { res, json: res.ok() ? await res.json() : null };
};
const trpcMutation = async (
page: Page,
procedure: string,
input: Record<string, unknown>,
teamId?: number,
) => {
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}`;
const headers: Record<string, string> = {
'content-type': 'application/json',
};
if (teamId) {
headers['x-team-id'] = teamId.toString();
}
const res = await page.context().request.post(url, {
data: JSON.stringify({ json: input }),
headers,
});
return { res, json: res.ok() ? await res.json() : null };
};
// ─── UI: Tab Visibility ──────────────────────────────────────────────────────
test.describe('Organisation Templates - UI Tabs', () => {
test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => {
const { ownerA, teamA } = await seedOrgTemplateScenario();
await apiSignin({
page,
email: ownerA.email,
redirectPath: `/t/${teamA.url}/templates`,
});
await expect(page.getByTestId('template-tab-team')).toBeVisible();
await expect(page.getByTestId('template-tab-organisation')).toBeVisible();
});
test('should not show tabs for personal organisations', async ({ page }) => {
const { user, team } = await seedUser({ isPersonalOrganisation: true });
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates`,
});
await expect(page.getByTestId('template-tab-team')).not.toBeVisible();
await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible();
});
});
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
test.describe('Organisation Templates - Listing', () => {
test('should list org templates from other teams under the Organisation tab', async ({
page,
}) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({
page,
email: memberB.email,
redirectPath: `/t/${teamB.url}/templates`,
});
// Team tab should show 0 (memberB has no templates on teamB).
await expect(page.getByTestId('template-tab-team')).toBeVisible();
// Switch to Organisation tab.
await page.getByTestId('template-tab-organisation').click();
// Should see the org template from teamA.
await expect(page.getByText(orgTemplate.title)).toBeVisible();
});
test('should not show private templates from other teams under Organisation tab', async ({
page,
}) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
// Create a private template on teamA — should NOT appear in org tab.
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Private Template ${nanoid()}`,
templateType: TemplateType.PRIVATE,
},
});
await apiSignin({
page,
email: memberB.email,
redirectPath: `/t/${teamB.url}/templates?view=organisation`,
});
await expect(page.getByText(privateTemplate.title)).not.toBeVisible();
});
});
// ─── UI: Organisation Template Detail Page ───────────────────────────────────
test.describe('Organisation Templates - Detail Page', () => {
test('should show org template detail but hide edit/delete actions', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({
page,
email: memberB.email,
redirectPath: `/t/${teamB.url}/templates?view=organisation`,
});
// Click into the org template.
await page.getByText(orgTemplate.title).click();
// Should see the template title.
await expect(page.getByRole('heading', { name: orgTemplate.title })).toBeVisible();
// Should see the Use button.
await expect(page.getByRole('button', { name: 'Use' })).toBeVisible();
// Should NOT see the Edit Template button.
await expect(page.getByRole('link', { name: 'Edit Template' })).not.toBeVisible();
});
});
// ─── API: findOrganisationTemplates ──────────────────────────────────────────
test.describe('Organisation Templates - findOrganisationTemplates API', () => {
test('should return org templates for a member of a sibling team', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({ page, email: memberB.email });
const { res, json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
expect(res.ok()).toBeTruthy();
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).toContain(orgTemplate.title);
});
test('should not return private templates from sibling teams', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Private No Show ${nanoid()}`,
templateType: TemplateType.PRIVATE,
},
});
await apiSignin({ page, email: memberB.email });
const { json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).not.toContain(privateTemplate.title);
});
test('should not return org templates to a user outside the organisation', async ({ page }) => {
const { orgTemplate } = await seedOrgTemplateScenario();
// Create a completely separate user with their own org.
const { user: outsider, team: outsiderTeam } = await seedUser();
await apiSignin({ page, email: outsider.email });
const { json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
outsiderTeam.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).not.toContain(orgTemplate.title);
});
test('should respect document visibility based on the viewer team role', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const everyoneTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Visibility Everyone ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
visibility: 'EVERYONE',
},
});
const adminOnlyTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Visibility Admin ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
visibility: 'ADMIN',
},
});
// memberB has role MEMBER on teamB — should only see EVERYONE visibility.
await apiSignin({ page, email: memberB.email });
const { json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).toContain(everyoneTemplate.title);
expect(titles).not.toContain(adminOnlyTemplate.title);
await apiSignout({ page });
// ownerA has role ADMIN on teamA — should see both.
await apiSignin({ page, email: ownerA.email });
const { json: adminJson } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamA.id,
);
const adminTitles = adminJson.result.data.json.data.map((t: { title: string }) => t.title);
expect(adminTitles).toContain(everyoneTemplate.title);
expect(adminTitles).toContain(adminOnlyTemplate.title);
});
});
// ─── API: getOrganisationTemplateById ────────────────────────────────────────
test.describe('Organisation Templates - getOrganisationTemplateById API', () => {
test('should allow a sibling team member to fetch an org template', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({ page, email: memberB.email });
const { res, json } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: orgTemplate.id },
teamB.id,
);
expect(res.ok()).toBeTruthy();
expect(json.result.data.json.title).toBe(orgTemplate.title);
});
test('should reject access from a user outside the organisation', async ({ page }) => {
const { orgTemplate } = await seedOrgTemplateScenario();
const { user: outsider, team: outsiderTeam } = await seedUser();
await apiSignin({ page, email: outsider.email });
const { res } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: orgTemplate.id },
outsiderTeam.id,
);
// Should fail — outsider is not in the same org.
expect(res.ok()).toBeFalsy();
});
test('should reject fetching a private template via the org endpoint', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Private ${nanoid()}`,
templateType: TemplateType.PRIVATE,
},
});
await apiSignin({ page, email: memberB.email });
const { res } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: privateTemplate.id },
teamB.id,
);
// Should fail — template is PRIVATE, not ORGANISATION.
expect(res.ok()).toBeFalsy();
});
});
// ─── API: createDocumentFromTemplate with org template ───────────────────────
test.describe('Organisation Templates - Use from different team', () => {
test('should allow creating a document from an org template owned by a sibling team', async ({
page,
}) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
// Add a recipient to the org template so we can use it.
await prisma.recipient.create({
data: {
email: 'recipient@test.documenso.com',
name: 'Recipient',
token: Math.random().toString().slice(2, 7),
envelopeId: orgTemplate.id,
},
});
const orgTemplateWithRecipients = await prisma.envelope.findFirstOrThrow({
where: { id: orgTemplate.id },
include: { recipients: true },
});
await apiSignin({ page, email: memberB.email });
const templateId = mapSecondaryIdToTemplateId(orgTemplateWithRecipients.secondaryId);
const { res } = await trpcMutation(
page,
'template.createDocumentFromTemplate',
{
templateId,
recipients: orgTemplateWithRecipients.recipients.map((r) => ({
id: r.id,
email: r.email,
name: r.name ?? '',
})),
},
teamB.id,
);
expect(res.ok()).toBeTruthy();
});
});
// ─── Adversarial: Cross-organisation access ──────────────────────────────────
test.describe('Organisation Templates - Adversarial', () => {
test('should not allow accessing org template from a different organisation via getEnvelopeById', async ({
page,
}) => {
const { orgTemplate } = await seedOrgTemplateScenario();
const { user: outsider, team: outsiderTeam } = await seedUser();
await apiSignin({ page, email: outsider.email });
// Try to fetch via the standard envelope.get endpoint.
const { res } = await trpcQuery(
page,
'envelope.get',
{ envelopeId: orgTemplate.id },
outsiderTeam.id,
);
expect(res.ok()).toBeFalsy();
});
test('should not allow a sibling team member to fetch a private template via org endpoint', async ({
page,
}) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Adversarial Private ${nanoid()}`,
templateType: TemplateType.PRIVATE,
},
});
await apiSignin({ page, email: memberB.email });
// Attempt 1: Try via org template endpoint.
const { res: orgRes } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: privateTemplate.id },
teamB.id,
);
expect(orgRes.ok()).toBeFalsy();
// Attempt 2: Try via standard envelope endpoint.
const { res: envelopeRes } = await trpcQuery(
page,
'envelope.get',
{ envelopeId: privateTemplate.id },
teamB.id,
);
expect(envelopeRes.ok()).toBeFalsy();
});
test('should not list org templates from a completely unrelated organisation', async ({
page,
}) => {
// Create scenario A.
const { orgTemplate: orgTemplateA } = await seedOrgTemplateScenario();
// Create scenario B (separate org, separate teams).
const { memberB: memberFromOrgB, teamB: teamFromOrgB } = await seedOrgTemplateScenario();
await apiSignin({ page, email: memberFromOrgB.email });
const { json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamFromOrgB.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).not.toContain(orgTemplateA.title);
});
test('should not allow unauthenticated access to org template endpoints', async ({ page }) => {
const { orgTemplate, teamB } = await seedOrgTemplateScenario();
// No apiSignin — unauthenticated.
const { res: findRes } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
expect(findRes.ok()).toBeFalsy();
expect(findRes.status()).toBe(401);
const { res: getRes } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: orgTemplate.id },
teamB.id,
);
expect(getRes.ok()).toBeFalsy();
expect(getRes.status()).toBe(401);
});
test('should not return org template data via findTemplates (team endpoint)', async ({
page,
}) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({ page, email: memberB.email });
// The standard findTemplates endpoint should NOT include org templates from other teams.
const { json } = await trpcQuery(
page,
'template.findTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).not.toContain(orgTemplate.title);
});
});
@@ -23,7 +23,7 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
await signSignaturePad(page);
await page.getByRole('button', { name: 'Complete', exact: true }).click();
await page.getByRole('button', { name: 'Create account', exact: true }).click();
await page.waitForURL('/unverified-account');
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { alphaid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -279,8 +280,8 @@ test('[WEBHOOKS]: cannot see unrelated webhooks', async ({ page }) => {
const user1Data = await seedUser();
const user2Data = await seedUser();
const webhookUrl1 = `https://example.com/webhook-team1-${Date.now()}`;
const webhookUrl2 = `https://example.com/webhook-team2-${Date.now()}`;
const webhookUrl1 = `https://example.com/webhook-team1-${alphaid(12)}`;
const webhookUrl2 = `https://example.com/webhook-team2-${alphaid(12)}`;
// Create webhooks for both teams with DOCUMENT_CREATED event
const webhook1 = await seedWebhook({
+5 -4
View File
@@ -5,9 +5,9 @@
"description": "",
"main": "index.js",
"scripts": {
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
"test:e2e": "NODE_OPTIONS=--experimental-require-module NODE_ENV=test NEXT_PRIVATE_LOGGER_FILE_PATH=./logs.json start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
"test:dev": "NODE_OPTIONS='--import tsx' playwright test",
"test-ui:dev": "NODE_OPTIONS='--import tsx' playwright test --ui",
"test:e2e": "NODE_OPTIONS='--import tsx' NODE_ENV=test NEXT_PRIVATE_LOGGER_FILE_PATH=./logs.json start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
},
"keywords": [],
"author": "",
@@ -18,10 +18,11 @@
"@playwright/test": "1.56.1",
"@types/node": "^20",
"@types/pngjs": "^6.0.5",
"tsx": "^4.20.6",
"pixelmatch": "^7.1.0",
"pngjs": "^7.0.0"
},
"dependencies": {
"start-server-and-test": "^2.1.3"
}
}
}
+12 -1
View File
@@ -83,10 +83,21 @@ export default defineConfig({
testMatch: /e2e\/api\/.*\.spec\.ts/,
workers: 10, // Limited by DB connections before it gets flakey.
},
// Run UI Tests
// License tests that share a single license file - must run serially
{
name: 'license',
testMatch: /e2e\/license\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1200 },
},
workers: 1, // Must run serially since they share a license file
},
// Run UI Tests (excluding license tests which have their own project)
{
name: 'ui',
testMatch: /e2e\/(?!api\/).*\.spec\.ts/,
testIgnore: /e2e\/license\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1200 },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 246 KiB

+1 -1
View File
@@ -17,7 +17,7 @@
"@oslojs/encoding": "^1.1.0",
"@simplewebauthn/server": "^13.2.2",
"arctic": "^3.7.0",
"hono": "4.11.4",
"hono": "^4.12.5",
"luxon": "^3.7.2",
"nanoid": "^5.1.6",
"ts-pattern": "^5.9.0",
@@ -17,6 +17,7 @@ export const AuthenticationErrorCode = {
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
SignupDisabled: 'SIGNUP_DISABLED',
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
// IncorrectIdentityProvider: 'INCORRECT_IDENTITY_PROVIDER',
// IncorrectPassword: 'INCORRECT_PASSWORD',
@@ -30,11 +30,17 @@ type HandleOAuthAuthorizeUrlOptions = {
prompt?: 'none' | 'login' | 'consent' | 'select_account';
};
const isOidcPrompt = (value: unknown): value is HandleOAuthAuthorizeUrlOptions['prompt'] => {
return value === 'none' || value === 'login' || value === 'consent' || value === 'select_account';
};
const oauthCookieMaxAge = 60 * 10; // 10 minutes.
export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOptions) => {
const { c, clientOptions, redirectPath } = options;
let prompt = options.prompt ?? 'login';
if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
@@ -63,12 +69,12 @@ export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOp
);
// Pass the prompt to the authorization endpoint.
if (process.env.NEXT_PRIVATE_OIDC_PROMPT !== '') {
const prompt = process.env.NEXT_PRIVATE_OIDC_PROMPT ?? 'login';
url.searchParams.append('prompt', prompt);
if (process.env.NEXT_PRIVATE_OIDC_PROMPT && isOidcPrompt(process.env.NEXT_PRIVATE_OIDC_PROMPT)) {
prompt = process.env.NEXT_PRIVATE_OIDC_PROMPT;
}
url.searchParams.set('prompt', prompt);
setCookie(c, `${clientOptions.id}_oauth_state`, state, {
...sessionCookieOptions,
sameSite: 'lax',
@@ -3,8 +3,13 @@ import { OAuth2Client, decodeIdToken } from 'arctic';
import type { Context } from 'hono';
import { deleteCookie } from 'hono/cookie';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
import { env } from '@documenso/lib/utils/env';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { prisma } from '@documenso/prisma';
@@ -26,6 +31,13 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken, redirectPath } =
await validateOauth({ c, clientOptions });
if (
email.toLowerCase() === legacyServiceAccountEmail() ||
email.toLowerCase() === deletedServiceAccountEmail()
) {
return c.text('FORBIDDEN', 403);
}
// Find the account if possible.
const existingAccount = await prisma.account.findFirst({
where: {
@@ -105,6 +117,24 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
return c.redirect(redirectPath, 302);
}
// Check if signups are disabled.
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
return c.redirect(errorUrl.toString(), 302);
}
// Check domain restriction for new SSO users.
if (!isEmailDomainAllowedForSignup(email)) {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
return c.redirect(errorUrl.toString(), 302);
}
// Handle new user.
const createdUser = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
+152 -7
View File
@@ -2,9 +2,11 @@ import { sValidator } from '@hono/standard-validator';
import { compare } from '@node-rs/bcrypt';
import { UserSecurityAuditLogType } from '@prisma/client';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { DateTime } from 'luxon';
import { z } from 'zod';
import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
@@ -14,10 +16,23 @@ import { isTwoFactorAuthenticationEnabled } from '@documenso/lib/server-only/2fa
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
import { verifyCaptchaToken } from '@documenso/lib/server-only/captcha/verify-captcha';
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import {
forgotPasswordRateLimit,
loginRateLimit,
resendVerifyEmailRateLimit,
resetPasswordRateLimit,
signupRateLimit,
verifyEmailRateLimit,
} from '@documenso/lib/server-only/rate-limit/rate-limits';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
import { getUserByResetToken } from '@documenso/lib/server-only/user/get-user-by-reset-token';
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { env } from '@documenso/lib/utils/env';
@@ -46,7 +61,20 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
.post('/authorize', sValidator('json', ZSignInSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email, password, totpCode, backupCode, csrfToken } = c.req.valid('json');
const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json');
const loginLimitResult = await loginRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const loginLimited = rateLimitResponse(c, loginLimitResult);
if (loginLimited) {
throw new HTTPException(429, {
res: loginLimited,
});
}
const csrfCookieToken = await getCsrfCookie(c);
@@ -57,6 +85,18 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
});
}
await verifyCaptchaToken({
token: captchaToken,
ipAddress: requestMetadata.ipAddress,
});
if (
email.toLowerCase() === legacyServiceAccountEmail() ||
email.toLowerCase() === deletedServiceAccountEmail()
) {
return c.text('FORBIDDEN', 403);
}
const user = await prisma.user.findFirst({
where: {
email: email.toLowerCase(),
@@ -89,7 +129,11 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
if (is2faEnabled) {
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
const isValid = await validateTwoFactorAuthentication({
backupCode,
totpCode,
user,
});
if (!isValid) {
await prisma.userSecurityAuditLog.create({
@@ -142,13 +186,38 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Signup endpoint.
*/
.post('/signup', sValidator('json', ZSignUpSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
throw new AppError('SIGNUP_DISABLED', {
message: 'Signups are disabled.',
throw new AppError(AuthenticationErrorCode.SignupDisabled, {
statusCode: 400,
});
}
const { name, email, password, signature } = c.req.valid('json');
const { name, email, password, signature, captchaToken } = c.req.valid('json');
const signupLimitResult = await signupRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
});
const signupLimited = rateLimitResponse(c, signupLimitResult);
if (signupLimited) {
throw new HTTPException(429, {
res: signupLimited,
});
}
await verifyCaptchaToken({
token: captchaToken,
ipAddress: requestMetadata.ipAddress,
});
if (!isEmailDomainAllowedForSignup(email)) {
throw new AppError(AuthenticationErrorCode.SignupDisabled, {
statusCode: 400,
});
}
const user = await createUser({ name, email, password, signature }).catch((err) => {
console.error(err);
@@ -209,7 +278,24 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Verify email endpoint.
*/
.post('/verify-email', sValidator('json', ZVerifyEmailSchema), async (c) => {
const { state, userId } = await verifyEmail({ token: c.req.valid('json').token });
const requestMetadata = c.get('requestMetadata');
const { token } = c.req.valid('json');
const verifyLimitResult = await verifyEmailRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
const verifyLimited = rateLimitResponse(c, verifyLimitResult);
if (verifyLimited) {
throw new HTTPException(429, {
res: verifyLimited,
});
}
const { state, userId } = await verifyEmail({ token });
// If email is verified, automatically authenticate user.
if (state === EMAIL_VERIFICATION_STATE.VERIFIED && userId !== null) {
@@ -224,8 +310,23 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Resend verification email endpoint.
*/
.post('/resend-verify-email', sValidator('json', ZResendVerifyEmailSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email } = c.req.valid('json');
const resendLimitResult = await resendVerifyEmailRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const resendLimited = rateLimitResponse(c, resendLimitResult);
if (resendLimited) {
throw new HTTPException(429, {
res: resendLimited,
});
}
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
@@ -239,8 +340,30 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Forgot password endpoint.
*/
.post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email } = c.req.valid('json');
const forgotLimitResult = await forgotPasswordRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const forgotLimited = rateLimitResponse(c, forgotLimitResult);
if (forgotLimited) {
throw new HTTPException(429, {
res: forgotLimited,
});
}
if (
email.toLowerCase() === legacyServiceAccountEmail() ||
email.toLowerCase() === deletedServiceAccountEmail()
) {
return c.text('FORBIDDEN', 403);
}
await forgotPassword({
email,
});
@@ -251,9 +374,31 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Reset password endpoint.
*/
.post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { token, password } = c.req.valid('json');
const requestMetadata = c.get('requestMetadata');
const resetLimitResult = await resetPasswordRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
const resetLimited = rateLimitResponse(c, resetLimitResult);
if (resetLimited) {
throw new HTTPException(429, {
res: resetLimited,
});
}
const user = await getUserByResetToken({ token });
if (
user.email.toLowerCase() === legacyServiceAccountEmail() ||
user.email.toLowerCase() === deletedServiceAccountEmail()
) {
return c.text('FORBIDDEN', 403);
}
const { userId } = await resetPassword({
token,
+24
View File
@@ -3,8 +3,13 @@ import { UserSecurityAuditLogType } from '@prisma/client';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { passkeyRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
import type { TAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { ZAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { getAuthenticatorOptions } from '@documenso/lib/utils/authenticator';
@@ -21,6 +26,18 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
.post('/authorize', sValidator('json', ZPasskeyAuthorizeSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const passkeyLimitResult = await passkeyRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
});
const passkeyLimited = rateLimitResponse(c, passkeyLimitResult);
if (passkeyLimited) {
throw new HTTPException(429, {
res: passkeyLimited,
});
}
const { csrfToken, credential } = c.req.valid('json');
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
@@ -74,6 +91,13 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
const user = passkey.user;
if (
user.email.toLowerCase() === legacyServiceAccountEmail() ||
user.email.toLowerCase() === deletedServiceAccountEmail()
) {
return c.text('FORBIDDEN', 403);
}
const { rpId, origin } = getAuthenticatorOptions();
const verification = await verifyAuthenticationResponse({
+8 -4
View File
@@ -1,16 +1,19 @@
import { z } from 'zod';
import { zEmail } from '@documenso/lib/utils/zod';
export const ZCurrentPasswordSchema = z
.string()
.min(6, { message: 'Must be at least 6 characters in length' })
.max(72);
export const ZSignInSchema = z.object({
email: z.string().email().min(1),
email: zEmail().min(1),
password: ZCurrentPasswordSchema,
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
csrfToken: z.string().trim(),
captchaToken: z.string().trim().optional(),
});
export type TSignInSchema = z.infer<typeof ZSignInSchema>;
@@ -34,15 +37,16 @@ export const ZPasswordSchema = z
export const ZSignUpSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
email: zEmail(),
password: ZPasswordSchema,
signature: z.string().nullish(),
captchaToken: z.string().trim().optional(),
});
export type TSignUpSchema = z.infer<typeof ZSignUpSchema>;
export const ZForgotPasswordSchema = z.object({
email: z.string().email().min(1),
email: zEmail().min(1),
});
export type TForgotPasswordSchema = z.infer<typeof ZForgotPasswordSchema>;
@@ -61,7 +65,7 @@ export const ZVerifyEmailSchema = z.object({
export type TVerifyEmailSchema = z.infer<typeof ZVerifyEmailSchema>;
export const ZResendVerifyEmailSchema = z.object({
email: z.string().email().min(1),
email: zEmail().min(1),
});
export type TResendVerifyEmailSchema = z.infer<typeof ZResendVerifyEmailSchema>;
+1
View File
@@ -2,6 +2,7 @@ This file lists all features currently licensed under the Documenso Enterprise E
Copyright (c) 2023 Documenso, Inc
- The Stripe Billing Module
- Organisation Authentication Portal
- Document Action Reauthentication (Passkeys and 2FA)
- 21 CFR
- Email domains
+2 -2
View File
@@ -13,7 +13,7 @@
"clean": "rimraf node_modules"
},
"dependencies": {
"@aws-sdk/client-sesv2": "^3.936.0",
"@aws-sdk/client-sesv2": "^3.998.0",
"@documenso/lib": "*",
"@documenso/prisma": "*",
"luxon": "^3.7.2",
@@ -21,4 +21,4 @@
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
}
}
}
@@ -111,40 +111,40 @@ export const createEmailDomain = async ({ domain, organisationId }: CreateEmailD
data: privateKeyFlattened,
});
const emailDomain = await prisma.$transaction(async (tx) => {
await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => {
if (err.name === 'AlreadyExistsException') {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Domain already exists in SES',
});
}
// Verify domain with SES outside a transaction to avoid holding a
// connection open during the external API call.
await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => {
if (err.name === 'AlreadyExistsException') {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Domain already exists in SES',
});
}
throw err;
});
throw err;
});
// Create email domain record.
return await tx.emailDomain.create({
data: {
id: generateDatabaseId('email_domain'),
domain,
status: EmailDomainStatus.PENDING,
organisationId,
selector: recordName,
publicKey: publicKeyFlattened,
privateKey: encryptedPrivateKey,
},
select: {
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
publicKey: true,
createdAt: true,
updatedAt: true,
emails: true,
},
});
const emailDomain = await prisma.emailDomain.create({
data: {
id: generateDatabaseId('email_domain'),
domain,
status: EmailDomainStatus.PENDING,
organisationId,
selector: recordName,
publicKey: publicKeyFlattened,
privateKey: encryptedPrivateKey,
},
select: {
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
publicKey: true,
createdAt: true,
updatedAt: true,
lastVerifiedAt: true,
emails: true,
},
});
return {
@@ -103,61 +103,59 @@ export const linkOrganisationAccount = async ({
return;
}
await prisma.$transaction(
async (tx) => {
// Link the user if not linked yet.
if (!userAlreadyLinked) {
await tx.account.create({
data: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: clientOptions.id,
providerAccountId: oauthConfig.providerAccountId,
access_token: oauthConfig.accessToken,
expires_at: oauthConfig.expiresAt,
token_type: 'Bearer',
id_token: oauthConfig.idToken,
userId: user.id,
},
});
// Log link event.
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
},
});
// If account already exists in an unverified state, remove the password to ensure
// they cannot sign in using that method since we cannot confirm the password
// was set by the user.
if (!user.emailVerified) {
await tx.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
password: null,
// Todo: (RR7) Will need to update the "password" account after the migration.
},
});
}
}
// Only add the user to the organisation if they are not already a member.
if (!organisationMember) {
await addUserToOrganisation({
// Link the user if not linked yet.
if (!userAlreadyLinked) {
await prisma.$transaction(async (tx) => {
await tx.account.create({
data: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: clientOptions.id,
providerAccountId: oauthConfig.providerAccountId,
access_token: oauthConfig.accessToken,
expires_at: oauthConfig.expiresAt,
token_type: 'Bearer',
id_token: oauthConfig.idToken,
userId: user.id,
organisationId: tokenMetadata.data.organisationId,
organisationGroups: organisation.groups,
organisationMemberRole:
organisation.organisationAuthenticationPortal.defaultOrganisationRole,
},
});
// Log link event.
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
},
});
// If account already exists in an unverified state, remove the password to ensure
// they cannot sign in using that method since we cannot confirm the password
// was set by the user.
if (!user.emailVerified) {
await tx.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
password: null,
// Todo: (RR7) Will need to update the "password" account after the migration.
},
});
}
},
{ timeout: 30_000 },
);
});
}
// Only add the user to the organisation if they are not already a member.
// Done outside the above transaction to avoid nested transactions and
// holding connections during the job trigger network I/O.
if (!organisationMember) {
await addUserToOrganisation({
userId: user.id,
organisationId: tokenMetadata.data.organisationId,
organisationGroups: organisation.groups,
organisationMemberRole: organisation.organisationAuthenticationPortal.defaultOrganisationRole,
});
}
};
@@ -0,0 +1,93 @@
import { DeleteEmailIdentityCommand } from '@aws-sdk/client-sesv2';
import { EmailDomainStatus } from '@prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { prisma } from '@documenso/prisma';
import { getSesClient, verifyDomainWithDKIM } from './create-email-domain';
type ReregisterEmailDomainOptions = {
emailDomainId: string;
};
/**
* Re-register an email domain in SES using the same DKIM key pair.
*
* This deletes the existing SES identity and recreates it with the same
* selector and private key, so the user does not need to update their DNS records.
*
* Permission is assumed to be checked in the caller.
*/
export const reregisterEmailDomain = async ({ emailDomainId }: ReregisterEmailDomainOptions) => {
const encryptionKey = DOCUMENSO_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
const emailDomain = await prisma.emailDomain.findUnique({
where: {
id: emailDomainId,
},
});
if (!emailDomain) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email domain not found',
});
}
const sesClient = getSesClient();
// Delete the existing SES identity, ignoring if it no longer exists.
await sesClient
.send(
new DeleteEmailIdentityCommand({
EmailIdentity: emailDomain.domain,
}),
)
.catch((err) => {
if (err.name === 'NotFoundException') {
return;
}
throw err;
});
// Decrypt the stored private key.
const decryptedPrivateKeyBytes = symmetricDecrypt({
key: encryptionKey,
data: emailDomain.privateKey,
});
const decryptedPrivateKey = new TextDecoder().decode(decryptedPrivateKeyBytes);
// The selector field in the DB is the full record name (e.g. "documenso-orgid._domainkey.example.com").
// We need to extract just the selector part (before "._domainkey.").
const selectorParts = emailDomain.selector.split('._domainkey.');
const selector = selectorParts[0];
if (!selector) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Could not extract selector from email domain record',
});
}
// Recreate the SES identity with the same DKIM key pair.
await verifyDomainWithDKIM(emailDomain.domain, selector, decryptedPrivateKey);
// Reset status to PENDING and update lastVerifiedAt.
const updatedEmailDomain = await prisma.emailDomain.update({
where: {
id: emailDomainId,
},
data: {
status: EmailDomainStatus.PENDING,
lastVerifiedAt: new Date(),
},
});
return updatedEmailDomain;
};
@@ -35,6 +35,7 @@ export const verifyEmailDomain = async (emailDomainId: string) => {
},
data: {
status: isVerified ? EmailDomainStatus.ACTIVE : EmailDomainStatus.PENDING,
lastVerifiedAt: new Date(),
},
});
@@ -22,6 +22,12 @@ export const useLimits = () => {
export type LimitsProviderProps = {
initialValue?: TLimitsResponseSchema;
/**
* Bypass limits for embed authoring. This is just client side bypass since
* all embeds should be paid plans.
*/
disableLimitsFetch?: boolean;
teamId: number;
children?: React.ReactNode;
};
@@ -32,12 +38,17 @@ export const LimitsProvider = ({
remaining: FREE_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
},
disableLimitsFetch,
teamId,
children,
}: LimitsProviderProps) => {
const [limits, setLimits] = useState(() => initialValue);
const refreshLimits = useCallback(async () => {
if (disableLimitsFetch) {
return;
}
const newLimits = await getLimits({ teamId });
setLimits((oldLimits) => {
@@ -54,6 +65,10 @@ export const LimitsProvider = ({
}, [refreshLimits]);
useEffect(() => {
if (disableLimitsFetch) {
return;
}
const onFocus = () => {
void refreshLimits();
};
+1 -1
View File
@@ -70,7 +70,7 @@ export const getServerLimits = async ({
}
// Early return for users with an expired subscription.
if (subscription && subscription.status !== SubscriptionStatus.ACTIVE) {
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
return {
quota: INACTIVE_PLAN_LIMITS,
remaining: INACTIVE_PLAN_LIMITS,
@@ -27,6 +27,7 @@ export const createCheckoutSession = async ({
],
success_url: `${returnUrl}?success=true`,
cancel_url: `${returnUrl}?canceled=true`,
billing_address_collection: 'required',
subscription_data: {
metadata: subscriptionMetadata,
},
@@ -1,19 +1,89 @@
import { SubscriptionStatus } from '@prisma/client';
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { extractStripeClaimId } from './on-subscription-updated';
export type OnSubscriptionDeletedOptions = {
subscription: Stripe.Subscription;
};
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
await prisma.subscription.update({
const existingSubscription = await prisma.subscription.findUnique({
where: {
planId: subscription.id,
},
include: {
organisation: {
include: {
organisationClaim: true,
},
},
},
});
// If the subscription doesn't exist, we don't need to do anything.
if (!existingSubscription) {
return;
}
const subscriptionClaimId = await extractClaimIdFromStripeSubscription(subscription);
// Individuals get their subscription deleted so they can return to the
// free plan.
if (subscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
await prisma.$transaction(async (tx) => {
await tx.subscription.delete({
where: {
id: existingSubscription.id,
},
});
await tx.organisationClaim.update({
where: {
id: existingSubscription.organisation.organisationClaim.id,
},
data: {
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
...createOrganisationClaimUpsertData(internalClaims[INTERNAL_CLAIM_ID.FREE]),
},
});
});
return;
}
// For all other cases, mark the subscription as inactive since
// they should still have a "Personal" account.
await prisma.subscription.update({
where: {
id: existingSubscription.id,
},
data: {
status: SubscriptionStatus.INACTIVE,
},
});
};
/**
* Extracts the claim ID from the Stripe subscription.
*
* Returns `null` if no claim ID found.
*/
const extractClaimIdFromStripeSubscription = async (subscription: Stripe.Subscription) => {
const deletedItem = subscription.items.data[0];
if (!deletedItem) {
return null;
}
try {
return await extractStripeClaimId(deletedItem.price);
} catch (error) {
console.error(error);
return null;
}
};

Some files were not shown because too many files have changed in this diff Show More