chore: merge main, resolve biome formatting conflicts

Merge origin/main into feat/bulk-download. Take PR's bulk action bar
design (escape-key dismiss, popover pill with count badge, optional
download button via onDownloadClick) and the documents page bulk
download state (envelopeMetaCache cached across pages, selected
envelopes mapped from cache for download).

Imports reordered into biome's @documenso → external → relative
groups; combined the @prisma/client import line to include
DocumentStatus as PrismaDocumentStatus alongside EnvelopeType,
FolderType, OrganisationType.
This commit is contained in:
ephraimduncan
2026-05-12 11:50:10 +00:00
1495 changed files with 22084 additions and 33478 deletions
+2 -3
View File
@@ -1,6 +1,3 @@
import { TsRestHttpError, fetchRequestHandler } from '@ts-rest/serverless/fetch';
import { Hono } from 'hono';
import { ApiContractV1 } from '@documenso/api/v1/contract';
import { ApiContractV1Implementation } from '@documenso/api/v1/implementation';
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
@@ -10,6 +7,8 @@ import { subscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/sub
import { unsubscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/unsubscribe';
// This is a bit nasty. Todo: Extract
import type { HonoEnv } from '@documenso/remix/server/router';
import { fetchRequestHandler, TsRestHttpError } from '@ts-rest/serverless/fetch';
import { Hono } from 'hono';
// This is bad, ts-router will be created on each request.
// But don't really have a choice here.
+1 -3
View File
@@ -5,8 +5,6 @@
"types": "./index.ts",
"license": "MIT",
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"clean": "rimraf node_modules"
},
"files": [
@@ -26,4 +24,4 @@
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
}
}
}
+1 -1
View File
@@ -3,6 +3,6 @@
"include": ["."],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"strict": true,
"strict": true
}
}
+1 -2
View File
@@ -1,9 +1,8 @@
import { initContract } from '@ts-rest/core';
import {
ZCreateTemplateV2RequestSchema,
ZCreateTemplateV2ResponseSchema,
} from '@documenso/trpc/server/template-router/schema';
import { initContract } from '@ts-rest/core';
import {
ZAuthorizationHeadersSchema,
+195 -223
View File
@@ -1,24 +1,20 @@
import { DocumentDataType, EnvelopeType, SigningStatus } from '@prisma/client';
import { tsr } from '@ts-rest/serverless/fetch';
import { match } from 'ts-pattern';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DocumentDataType, EnvelopeType, SigningStatus } from '@prisma/client';
import { tsr } from '@ts-rest/serverless/fetch';
import { match } from 'ts-pattern';
import '@documenso/lib/constants/time-zones';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { updateDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { updateDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import {
getEnvelopeById,
getEnvelopeWhereInput,
} from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getEnvelopeById, getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field';
import { updateEnvelopeFields } from '@documenso/lib/server-only/field/update-envelope-fields';
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
@@ -42,16 +38,10 @@ import {
} from '@documenso/lib/types/field-meta';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import {
getPresignGetUrl,
getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions';
import { getPresignGetUrl, getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import {
mapSecondaryIdToDocumentId,
mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope';
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { ApiContractV1 } from './contract';
@@ -527,9 +517,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
const timezone = meta?.timezone
? TIME_ZONES.find((tz) => tz === meta?.timezone)
: DEFAULT_DOCUMENT_TIME_ZONE;
const timezone = meta?.timezone ? TIME_ZONES.find((tz) => tz === meta?.timezone) : DEFAULT_DOCUMENT_TIME_ZONE;
const isTimeZoneValid = meta?.timezone ? TIME_ZONES.includes(String(timezone)) : true;
@@ -728,64 +716,176 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}
}),
createDocumentFromTemplate: authenticatedMiddleware(
async (args, user, team, { logger, metadata }) => {
const { body, params } = args;
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => {
const { body, params } = args;
logger.info({
input: {
templateId: params.templateId,
logger.info({
input: {
templateId: params.templateId,
},
});
const { remaining } = await getServerLimits({ userId: user.id, teamId: team.id });
if (remaining.documents <= 0) {
return {
status: 400,
body: {
message: 'You have reached the maximum number of documents allowed for this month',
},
});
};
}
const { remaining } = await getServerLimits({ userId: user.id, teamId: team.id });
const templateId = Number(params.templateId);
if (remaining.documents <= 0) {
return {
status: 400,
body: {
message: 'You have reached the maximum number of documents allowed for this month',
},
};
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
const template = await getEnvelopeById({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId: user.id,
teamId: team.id,
});
if (template.envelopeItems.length !== 1) {
throw new Error('API V1 does not support templates with multiple documents');
}
// V1 API request schema uses indices for recipients
// So we remap the recipients to attach the IDs
const mappedRecipients = body.recipients.map((recipient, index) => {
const existingRecipient = template.recipients.at(index);
if (!existingRecipient) {
throw new Error('Recipient not found.');
}
const templateId = Number(params.templateId);
return {
id: existingRecipient.id,
name: recipient.name,
email: recipient.email,
signingOrder: recipient.signingOrder,
role: recipient.role, // You probably shouldn't be able to change the role.
};
});
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
const createdEnvelope = await createDocumentFromTemplate({
id: {
type: 'templateId',
id: templateId,
},
externalId: body.externalId || null,
userId: user.id,
teamId: team.id,
recipients: mappedRecipients,
override: {
...body.meta,
title: body.title,
},
attachments: body.attachments,
formValues: body.formValues,
requestMetadata: metadata,
});
const template = await getEnvelopeById({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId: user.id,
teamId: team.id,
const envelopeItems = await prisma.envelopeItem.findMany({
where: {
envelopeId: createdEnvelope.id,
},
include: {
documentData: true,
},
});
const firstEnvelopeItemData = envelopeItems[0].documentData;
if (!firstEnvelopeItemData) {
throw new Error('Document data not found.');
}
if (body.formValues) {
const pdf = await getFileServerSide(firstEnvelopeItemData);
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(pdf),
formValues: body.formValues,
});
if (template.envelopeItems.length !== 1) {
throw new Error('API V1 does not support templates with multiple documents');
}
const newDocumentData = await putNormalizedPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
});
// V1 API request schema uses indices for recipients
// So we remap the recipients to attach the IDs
const mappedRecipients = body.recipients.map((recipient, index) => {
const existingRecipient = template.recipients.at(index);
await prisma.envelopeItem.update({
where: {
id: firstEnvelopeItemData.id,
},
data: {
title: body.title || fileName,
documentDataId: newDocumentData.id,
},
});
}
if (!existingRecipient) {
throw new Error('Recipient not found.');
}
if (body.authOptions || body.formValues) {
await prisma.envelope.update({
where: {
id: createdEnvelope.id,
},
data: {
formValues: body.formValues,
authOptions: body.authOptions,
},
});
}
return {
id: existingRecipient.id,
return {
status: 200,
body: {
documentId: mapSecondaryIdToDocumentId(createdEnvelope.secondaryId),
recipients: createdEnvelope.recipients.map((recipient) => ({
recipientId: recipient.id,
name: recipient.name,
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingOrder: recipient.signingOrder,
role: recipient.role, // You probably shouldn't be able to change the role.
};
});
const createdEnvelope = await createDocumentFromTemplate({
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
}),
generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => {
const { body, params } = args;
logger.info({
input: {
templateId: params.templateId,
},
});
const { remaining } = await getServerLimits({ userId: user.id, teamId: team.id });
if (remaining.documents <= 0) {
return {
status: 400,
body: {
message: 'You have reached the maximum number of documents allowed for this month',
},
};
}
const templateId = Number(params.templateId);
let envelope: Awaited<ReturnType<typeof createDocumentFromTemplate>> | null = null;
try {
envelope = await createDocumentFromTemplate({
id: {
type: 'templateId',
id: templateId,
@@ -793,165 +893,49 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
externalId: body.externalId || null,
userId: user.id,
teamId: team.id,
recipients: mappedRecipients,
recipients: body.recipients,
prefillFields: body.prefillFields,
folderId: body.folderId,
override: {
...body.meta,
title: body.title,
...body.meta,
},
attachments: body.attachments,
formValues: body.formValues,
requestMetadata: metadata,
});
} catch (err) {
return AppError.toRestAPIError(err);
}
const envelopeItems = await prisma.envelopeItem.findMany({
if (body.authOptions) {
await prisma.envelope.update({
where: {
envelopeId: createdEnvelope.id,
id: envelope.id,
},
include: {
documentData: true,
data: {
authOptions: body.authOptions,
},
});
}
const firstEnvelopeItemData = envelopeItems[0].documentData;
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (!firstEnvelopeItemData) {
throw new Error('Document data not found.');
}
if (body.formValues) {
const pdf = await getFileServerSide(firstEnvelopeItemData);
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(pdf),
formValues: body.formValues,
});
const newDocumentData = await putNormalizedPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
});
await prisma.envelopeItem.update({
where: {
id: firstEnvelopeItemData.id,
},
data: {
title: body.title || fileName,
documentDataId: newDocumentData.id,
},
});
}
if (body.authOptions || body.formValues) {
await prisma.envelope.update({
where: {
id: createdEnvelope.id,
},
data: {
formValues: body.formValues,
authOptions: body.authOptions,
},
});
}
return {
status: 200,
body: {
documentId: mapSecondaryIdToDocumentId(createdEnvelope.secondaryId),
recipients: createdEnvelope.recipients.map((recipient) => ({
recipientId: recipient.id,
name: recipient.name,
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingOrder: recipient.signingOrder,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
},
),
generateDocumentFromTemplate: authenticatedMiddleware(
async (args, user, team, { logger, metadata }) => {
const { body, params } = args;
logger.info({
input: {
templateId: params.templateId,
},
});
const { remaining } = await getServerLimits({ userId: user.id, teamId: team.id });
if (remaining.documents <= 0) {
return {
status: 400,
body: {
message: 'You have reached the maximum number of documents allowed for this month',
},
};
}
const templateId = Number(params.templateId);
let envelope: Awaited<ReturnType<typeof createDocumentFromTemplate>> | null = null;
try {
envelope = await createDocumentFromTemplate({
id: {
type: 'templateId',
id: templateId,
},
externalId: body.externalId || null,
userId: user.id,
teamId: team.id,
recipients: body.recipients,
prefillFields: body.prefillFields,
folderId: body.folderId,
override: {
title: body.title,
...body.meta,
},
formValues: body.formValues,
requestMetadata: metadata,
});
} catch (err) {
return AppError.toRestAPIError(err);
}
if (body.authOptions) {
await prisma.envelope.update({
where: {
id: envelope.id,
},
data: {
authOptions: body.authOptions,
},
});
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
return {
status: 200,
body: {
documentId: legacyDocumentId,
recipients: envelope.recipients.map((recipient) => ({
recipientId: recipient.id,
name: recipient.name,
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingOrder: recipient.signingOrder,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
},
),
return {
status: 200,
body: {
documentId: legacyDocumentId,
recipients: envelope.recipients.map((recipient) => ({
recipientId: recipient.id,
name: recipient.name,
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingOrder: recipient.signingOrder,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
}),
sendDocument: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => {
const { id: documentId } = args.params;
@@ -1374,16 +1358,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
const createdFields = await prisma.$transaction(async (tx) => {
return Promise.all(
fields.map(async (fieldData) => {
const {
recipientId,
type,
pageNumber,
pageWidth,
pageHeight,
pageX,
pageY,
fieldMeta,
} = fieldData;
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta } = fieldData;
if (pageNumber <= 0) {
throw new Error('Invalid page number');
@@ -1404,9 +1379,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
throw new Error('Recipient has already signed the document');
}
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(
type,
);
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(type);
if (advancedField && !fieldMeta) {
throw new Error(
@@ -1510,8 +1483,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
updateField: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => {
const { id: documentId, fieldId } = args.params;
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta } =
args.body;
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta } = args.body;
logger.info({
input: {
+3 -4
View File
@@ -1,7 +1,3 @@
import type { Team, User } from '@prisma/client';
import type { TsRestRequest } from '@ts-rest/serverless';
import type { Logger } from 'pino';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import type { BaseApiLog, RootApiLog } from '@documenso/lib/types/api-logs';
@@ -9,6 +5,9 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid } from '@documenso/lib/universal/id';
import { logger } from '@documenso/lib/utils/logger';
import type { Team, User } from '@prisma/client';
import type { TsRestRequest } from '@ts-rest/serverless';
import type { Logger } from 'pino';
type B = {
// appRoute: any;
+1 -2
View File
@@ -1,6 +1,5 @@
import { generateOpenApi } from '@ts-rest/open-api';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { generateOpenApi } from '@ts-rest/open-api';
import { ApiContractV1 } from './contract';
+21 -40
View File
@@ -1,17 +1,4 @@
import { extendZodWithOpenApi } from '@anatine/zod-openapi';
import {
DocumentDataType,
DocumentDistributionMethod,
DocumentSigningOrder,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
} from '@prisma/client';
import { TemplateType } from '@prisma/client';
import { z } from 'zod';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
@@ -25,6 +12,18 @@ import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-emai
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { zEmail } from '@documenso/lib/utils/zod';
import {
DocumentDataType,
DocumentDistributionMethod,
DocumentSigningOrder,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
TemplateType,
} from '@prisma/client';
import { z } from 'zod';
extendZodWithOpenApi(z);
@@ -36,10 +35,7 @@ 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(),
folderId: z.string().describe('Filter documents by folder ID. When omitted, returns root documents.').optional(),
});
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
@@ -84,9 +80,7 @@ export const ZSuccessfulGetDocumentResponseSchema = ZSuccessfulDocumentResponseS
),
});
export type TSuccessfulGetDocumentResponseSchema = z.infer<
typeof ZSuccessfulGetDocumentResponseSchema
>;
export type TSuccessfulGetDocumentResponseSchema = z.infer<typeof ZSuccessfulGetDocumentResponseSchema>;
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
@@ -109,9 +103,7 @@ export const ZResendDocumentForSigningMutationSchema = z.object({
recipients: z.array(z.number()),
});
export type TResendDocumentForSigningMutationSchema = z.infer<
typeof ZResendDocumentForSigningMutationSchema
>;
export type TResendDocumentForSigningMutationSchema = z.infer<typeof ZResendDocumentForSigningMutationSchema>;
export const ZSuccessfulResendDocumentResponseSchema = z.object({
message: z.string(),
@@ -161,16 +153,14 @@ export const ZCreateDocumentMutationSchema = z.object({
subject: z.string(),
message: z.string(),
timezone: z.string().default(DEFAULT_DOCUMENT_TIME_ZONE).openapi({
description:
'The timezone of the date. Must be one of the options listed in the list below.',
description: 'The timezone of the date. Must be one of the options listed in the list below.',
enum: TIME_ZONES,
}),
dateFormat: z
.string()
.default(DEFAULT_DOCUMENT_DATE_FORMAT)
.openapi({
description:
'The format of the date. Must be one of the options listed in the list below.',
description: 'The format of the date. Must be one of the options listed in the list below.',
enum: DATE_FORMATS.map((format) => format.value),
}),
redirectUrl: z.string(),
@@ -235,9 +225,7 @@ export const ZCreateDocumentMutationResponseSchema = z.object({
),
});
export type TCreateDocumentMutationResponseSchema = z.infer<
typeof ZCreateDocumentMutationResponseSchema
>;
export type TCreateDocumentMutationResponseSchema = z.infer<typeof ZCreateDocumentMutationResponseSchema>;
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
title: z.string().min(1),
@@ -289,9 +277,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
.optional(),
});
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
typeof ZCreateDocumentFromTemplateMutationSchema
>;
export type TCreateDocumentFromTemplateMutationSchema = z.infer<typeof ZCreateDocumentFromTemplateMutationSchema>;
export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
documentId: z.number(),
@@ -376,9 +362,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(),
});
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
typeof ZGenerateDocumentFromTemplateMutationSchema
>;
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<typeof ZGenerateDocumentFromTemplateMutationSchema>;
export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
documentId: z.number(),
@@ -469,10 +453,7 @@ const ZCreateFieldSchema = z.object({
fieldMeta: ZFieldMetaSchema.openapi({}),
});
export const ZCreateFieldMutationSchema = z.union([
ZCreateFieldSchema,
z.array(ZCreateFieldSchema).min(1),
]);
export const ZCreateFieldMutationSchema = z.union([ZCreateFieldSchema, z.array(ZCreateFieldSchema).min(1)]);
export type TCreateFieldMutationSchema = z.infer<typeof ZCreateFieldMutationSchema>;
@@ -1,9 +1,8 @@
import { FieldType } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
import { FieldType } from '@prisma/client';
export type FieldTestData = TFieldAndMeta & {
page: number;
@@ -25,11 +24,7 @@ const fullColumnWidth = 57.37499999999998;
const rowHeight = 6.7;
const rowPadding = 0;
const calculatePositionPageOne = (
row: number,
column: number,
width: 'full' | 'column' = 'column',
) => {
const calculatePositionPageOne = (row: number, column: number, width: 'full' | 'column' = 'column') => {
const alignmentGridStartX = 31;
const alignmentGridStartY = 19;
@@ -41,11 +36,7 @@ const calculatePositionPageOne = (
};
};
const calculatePositionPageTwo = (
row: number,
column: number,
width: 'full' | 'column' = 'column',
) => {
const calculatePositionPageTwo = (row: number, column: number, width: 'full' | 'column' = 'column') => {
const alignmentGridStartX = 31;
const alignmentGridStartY = 16.35;
@@ -86,6 +77,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fontSize: 10,
textAlign: 'left',
type: 'email',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(0, 0),
@@ -96,6 +88,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fieldMeta: {
textAlign: 'center',
type: 'email',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(0, 1),
@@ -107,6 +100,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fontSize: 20,
textAlign: 'right',
type: 'email',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(0, 2),
@@ -156,6 +150,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fontSize: 10,
textAlign: 'left',
type: 'date',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(2, 0),
@@ -166,6 +161,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fieldMeta: {
textAlign: 'center',
type: 'date',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(2, 1),
@@ -177,6 +173,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fontSize: 20,
textAlign: 'right',
type: 'date',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(2, 2),
@@ -424,6 +421,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fieldMeta: {
fontSize: 10,
type: 'signature',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(9, 0),
@@ -434,6 +432,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(9, 1),
@@ -445,6 +444,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fieldMeta: {
fontSize: 20,
type: 'signature',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(9, 2),
@@ -1,10 +1,9 @@
import { FieldType } from '@prisma/client';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
import {
CheckboxValidationRules,
numberFormatValues,
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { FieldType } from '@prisma/client';
import type { FieldTestData } from './field-alignment-pdf';
import { signatureBase64Demo } from './field-alignment-pdf';
@@ -0,0 +1,789 @@
import { FieldType } from '@prisma/client';
import type { FieldTestData } from './field-alignment-pdf';
/**
* Overflow test data extends FieldTestData with a `seedFieldMeta` property.
*
* - `fieldMeta`: Minimal field meta sent via the API. Omit properties that the API
* auto-applies via ZEnvelopeFieldAndMetaSchema defaults (e.g. `overflow: 'auto'` for
* date/email/signature fields). This tests that the API correctly sets defaults.
*
* - `seedFieldMeta`: Full field meta written directly to the DB by the seed function.
* Must include ALL properties explicitly since the seed bypasses API validation/defaults.
* Used by `seedOverflowTestDocument` in initial-seed.ts.
*/
export type OverflowFieldTestData = Omit<FieldTestData, 'fieldMeta'> & {
fieldMeta?: FieldTestData['fieldMeta'];
seedFieldMeta: FieldTestData['fieldMeta'];
};
const SINGLE_LINE_HEIGHT = 1.75;
const MULTI_LINE_HEIGHT = 12;
const DEFAULT_BOX_WIDTH = 25;
const SINGLE_TYPE_BOX_WIDTH = 35;
const DEFAULT_START_X = 10;
/**
* Pages 1-3: Date, Email, Signature
* Single-line section (rows 0-2): single column, full width
* Pages 1-2 multi-line: 3×3 grid (rows = TA_LEFT/CENTER/RIGHT, columns = short/medium/long text)
* Page 3 multi-line: stacked single column (signature has no text align control)
*/
const SINGLE_TYPE_ML_COLUMN_X = [2.5, 35, 67.5];
const SINGLE_TYPE_ML_BOX_WIDTH = 30;
const SINGLE_TYPE_ML_ROW_Y = [45, 63, 83];
const calculateSingleLinePosition = (row: number) => {
const singleLineYPositions = [15, 23, 31];
return {
positionX: DEFAULT_START_X,
positionY: singleLineYPositions[row],
width: SINGLE_TYPE_BOX_WIDTH,
height: SINGLE_LINE_HEIGHT,
};
};
/** Pages 1-2: multi-line 3×3 grid */
const calculateMultiLinePosition = (row: number, column: number) => {
return {
positionX: SINGLE_TYPE_ML_COLUMN_X[column],
positionY: SINGLE_TYPE_ML_ROW_Y[row],
width: SINGLE_TYPE_ML_BOX_WIDTH,
height: MULTI_LINE_HEIGHT,
};
};
/** Page 3: multi-line stacked single column */
const calculateStackedMultiLinePosition = (row: number) => {
const yPositions = [45, 63, 81];
return {
positionX: DEFAULT_START_X,
positionY: yPositions[row],
width: SINGLE_TYPE_BOX_WIDTH,
height: MULTI_LINE_HEIGHT,
};
};
/**
* Pages 4-5: Text Auto Mode (3x3 grid)
*/
const TEXT_AUTO_COLUMN_X = [5, 35.5, 66];
const TEXT_AUTO_BOX_WIDTH = 28;
const calculateTextAutoPosition = (row: number, column: number, isSingleLine: boolean) => {
if (isSingleLine) {
// Single-line: all 9 items evenly spaced down the page.
// Order: row0-col0, row0-col1, row0-col2, row1-col0, ...
const startY = 10;
const endY = 92;
const spacing = (endY - startY) / 8; // 9 items, 8 gaps = 10.25%
const itemIndex = row * 3 + column;
return {
positionX: TEXT_AUTO_COLUMN_X[column],
positionY: startY + itemIndex * spacing,
width: TEXT_AUTO_BOX_WIDTH,
height: SINGLE_LINE_HEIGHT,
};
}
// Multi-line: 3 rows evenly spaced, bottom row near page bottom.
// Box is 12% tall. Top of last box at 80% so bottom edge is at 92%.
const multiLineYPositions = [10, 45, 80];
return {
positionX: TEXT_AUTO_COLUMN_X[column],
positionY: multiLineYPositions[row],
width: TEXT_AUTO_BOX_WIDTH,
height: MULTI_LINE_HEIGHT,
};
};
/**
* Page 6: Explicit Modes
*/
const HORIZONTAL_CENTERED_X = (100 - DEFAULT_BOX_WIDTH) / 2; // 37.5%
const calculateExplicitHorizontalPosition = (row: number) => {
const yPositions = [15, 21, 27];
return {
positionX: HORIZONTAL_CENTERED_X,
positionY: yPositions[row],
width: DEFAULT_BOX_WIDTH,
height: SINGLE_LINE_HEIGHT,
};
};
const calculateExplicitVerticalPosition = (column: number) => {
const xPositions = [5, 37.5, 70];
return {
positionX: xPositions[column],
positionY: 43,
width: DEFAULT_BOX_WIDTH,
height: MULTI_LINE_HEIGHT,
};
};
export const OVERFLOW_TEST_FIELDS: OverflowFieldTestData[] = [
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 1: DATE OVERFLOW
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Single-line: Row 0-2 (default date meta — API auto-adds overflow: 'auto')
{
type: FieldType.DATE,
fieldMeta: undefined,
seedFieldMeta: { type: 'date', overflow: 'auto' },
page: 1,
...calculateSingleLinePosition(0),
customText: 'Apr 16 2026',
},
{
type: FieldType.DATE,
fieldMeta: undefined,
seedFieldMeta: { type: 'date', overflow: 'auto' },
page: 1,
...calculateSingleLinePosition(1),
customText: 'Wednesday, April 16, 2026 at 14:30:45 UTC',
},
{
type: FieldType.DATE,
fieldMeta: undefined,
seedFieldMeta: { type: 'date', overflow: 'auto' },
page: 1,
...calculateSingleLinePosition(2),
customText: 'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia',
},
// Multi-line 3×3: Row 0 = TA_LEFT (short / medium / long)
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'left' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'left' },
page: 1,
...calculateMultiLinePosition(0, 0),
customText: 'Apr 16 2026',
},
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'left' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'left' },
page: 1,
...calculateMultiLinePosition(0, 1),
customText: 'Wednesday, April 16, 2026 at 14:30:45 Coordinated Universal Time',
},
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'left' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'left' },
page: 1,
...calculateMultiLinePosition(0, 2),
customText:
'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia. Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
// Multi-line 3×3: Row 1 = TA_CENTER (short / medium / long)
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'center' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'center' },
page: 1,
...calculateMultiLinePosition(1, 0),
customText: 'Apr 16 2026',
},
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'center' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'center' },
page: 1,
...calculateMultiLinePosition(1, 1),
customText: 'Wednesday, April 16, 2026 at 14:30:45 Coordinated Universal Time',
},
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'center' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'center' },
page: 1,
...calculateMultiLinePosition(1, 2),
customText:
'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia. Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
// Multi-line 3×3: Row 2 = TA_RIGHT (short / medium / long)
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'right' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'right' },
page: 1,
...calculateMultiLinePosition(2, 0),
customText: 'Apr 16 2026',
},
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'right' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'right' },
page: 1,
...calculateMultiLinePosition(2, 1),
customText: 'Wednesday, April 16, 2026 at 14:30:45 Coordinated Universal Time',
},
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'right' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'right' },
page: 1,
...calculateMultiLinePosition(2, 2),
customText:
'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia. Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 2: EMAIL OVERFLOW
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Single-line: Row 0-2 (default email meta — API auto-adds overflow: 'auto')
{
type: FieldType.EMAIL,
fieldMeta: undefined,
seedFieldMeta: { type: 'email', overflow: 'auto' },
page: 2,
...calculateSingleLinePosition(0),
customText: 'example@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: undefined,
seedFieldMeta: { type: 'email', overflow: 'auto' },
page: 2,
...calculateSingleLinePosition(1),
customText: 'example+medium-overflow-test@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: undefined,
seedFieldMeta: { type: 'email', overflow: 'auto' },
page: 2,
...calculateSingleLinePosition(2),
customText:
'example+maximum-overflow-testing-across-the-page-width-to-verify-text-extends-beyond-field@documenso.com',
},
// Multi-line 3×3: Row 0 = TA_LEFT (short / medium / long)
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'left' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'left' },
page: 2,
...calculateMultiLinePosition(0, 0),
customText: 'example@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'left' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'left' },
page: 2,
...calculateMultiLinePosition(0, 1),
customText: 'example+medium-wrapped-text@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'left' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'left' },
page: 2,
...calculateMultiLinePosition(0, 2),
customText:
'example+this-is-an-extremely-long-email-address-that-is-designed-to-overflow-vertically-out-of-the-field-box-and-extend-well-beyond-the-bottom-of-the-page-to-verify-that-the-vertical-overflow-logic-correctly-handles-text-that-wraps@documenso.com',
},
// Multi-line 3×3: Row 1 = TA_CENTER (short / medium / long)
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'center' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'center' },
page: 2,
...calculateMultiLinePosition(1, 0),
customText: 'example@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'center' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'center' },
page: 2,
...calculateMultiLinePosition(1, 1),
customText: 'example+medium-wrapped-text@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'center' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'center' },
page: 2,
...calculateMultiLinePosition(1, 2),
customText:
'example+this-is-an-extremely-long-email-address-that-is-designed-to-overflow-vertically-out-of-the-field-box-and-extend-well-beyond-the-bottom-of-the-page-to-verify-that-the-vertical-overflow-logic-correctly-handles-text-that-wraps@documenso.com',
},
// Multi-line 3×3: Row 2 = TA_RIGHT (short / medium / long)
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'right' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'right' },
page: 2,
...calculateMultiLinePosition(2, 0),
customText: 'example@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'right' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'right' },
page: 2,
...calculateMultiLinePosition(2, 1),
customText: 'example+medium-wrapped-text@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'right' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'right' },
page: 2,
...calculateMultiLinePosition(2, 2),
customText:
'example+this-is-an-extremely-long-email-address-that-is-designed-to-overflow-vertically-out-of-the-field-box-and-extend-well-beyond-the-bottom-of-the-page-to-verify-that-the-vertical-overflow-logic-correctly-handles-text-that-wraps@documenso.com',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 3: SIGNATURE OVERFLOW
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Single-line: Row 0-2 (default signature meta — API auto-adds overflow: 'auto')
{
type: FieldType.SIGNATURE,
fieldMeta: undefined,
seedFieldMeta: { type: 'signature', overflow: 'auto' },
page: 3,
...calculateSingleLinePosition(0),
customText: '',
signature: 'John Doe',
},
{
type: FieldType.SIGNATURE,
fieldMeta: undefined,
seedFieldMeta: { type: 'signature', overflow: 'auto' },
page: 3,
...calculateSingleLinePosition(1),
customText: '',
signature: 'My Signature should overflow the field width',
},
{
type: FieldType.SIGNATURE,
fieldMeta: undefined,
seedFieldMeta: { type: 'signature', overflow: 'auto' },
page: 3,
...calculateSingleLinePosition(2),
customText: '',
signature:
'My Signature should overflow the full signature field width and continue across the page to verify text is no longer clipped by the box boundary',
},
// Multi-line stacked: short / medium / long
{
type: FieldType.SIGNATURE,
fieldMeta: undefined,
seedFieldMeta: { type: 'signature', overflow: 'auto' },
page: 3,
...calculateStackedMultiLinePosition(0),
customText: '',
signature: 'John Doe',
},
{
type: FieldType.SIGNATURE,
fieldMeta: undefined,
seedFieldMeta: { type: 'signature', overflow: 'auto' },
page: 3,
...calculateStackedMultiLinePosition(1),
customText: '',
signature: 'My Signature wraps within the tall field',
},
{
type: FieldType.SIGNATURE,
fieldMeta: undefined,
seedFieldMeta: { type: 'signature', overflow: 'auto' },
page: 3,
...calculateStackedMultiLinePosition(2),
customText: '',
signature:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 4: TEXT AUTO - SINGLE-LINE (3x3 grid)
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Row 0 (top)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
page: 4,
...calculateTextAutoPosition(0, 0, true),
customText: 'This text should overflow horizontally',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
page: 4,
...calculateTextAutoPosition(0, 1, true),
customText: 'This text should overflow horizontally',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
page: 4,
...calculateTextAutoPosition(0, 2, true),
customText: 'This text should overflow horizontally',
},
// Row 1 (middle)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
page: 4,
...calculateTextAutoPosition(1, 0, true),
customText: 'This text should overflow horizontally',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
page: 4,
...calculateTextAutoPosition(1, 1, true),
customText: 'This text should overflow horizontally',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
page: 4,
...calculateTextAutoPosition(1, 2, true),
customText: 'This text should overflow horizontally',
},
// Row 2 (bottom)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
page: 4,
...calculateTextAutoPosition(2, 0, true),
customText: 'This text should overflow horizontally',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
page: 4,
...calculateTextAutoPosition(2, 1, true),
customText: 'This text should overflow horizontally',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
page: 4,
...calculateTextAutoPosition(2, 2, true),
customText: 'This text should overflow horizontally',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 5: TEXT AUTO - MULTI-LINE (3x3 grid)
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Row 0 (top)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
page: 5,
...calculateTextAutoPosition(0, 0, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
page: 5,
...calculateTextAutoPosition(0, 1, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
page: 5,
...calculateTextAutoPosition(0, 2, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
// Row 1 (middle)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
page: 5,
...calculateTextAutoPosition(1, 0, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
page: 5,
...calculateTextAutoPosition(1, 1, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
page: 5,
...calculateTextAutoPosition(1, 2, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
// Row 2 (bottom)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
page: 5,
...calculateTextAutoPosition(2, 0, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
page: 5,
...calculateTextAutoPosition(2, 1, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
page: 5,
...calculateTextAutoPosition(2, 2, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 6: TEXT AUTO - MULTI-LINE HEIGHT OVERFLOW
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Same 3×3 grid as page 5 but with longer text that overflows vertically.
// left / top
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
page: 6,
...calculateTextAutoPosition(0, 0, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// center / top
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
page: 6,
...calculateTextAutoPosition(0, 1, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// right / top
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
page: 6,
...calculateTextAutoPosition(0, 2, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// left / middle
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
page: 6,
...calculateTextAutoPosition(1, 0, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// center / middle
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
page: 6,
...calculateTextAutoPosition(1, 1, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// right / middle
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
page: 6,
...calculateTextAutoPosition(1, 2, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// left / bottom
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
page: 6,
...calculateTextAutoPosition(2, 0, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// center / bottom
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
page: 6,
...calculateTextAutoPosition(2, 1, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// right / bottom
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
page: 6,
...calculateTextAutoPosition(2, 2, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 7: EXPLICIT MODES
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Section A: Horizontal mode (3 boxes in a row)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'left' },
seedFieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'left' },
page: 7,
...calculateExplicitHorizontalPosition(0),
customText: 'Explicit horizontal overflow text that should extend beyond the field',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'center' },
seedFieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'center' },
page: 7,
...calculateExplicitHorizontalPosition(1),
customText: 'Explicit horizontal overflow text that should extend beyond the field',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'right' },
seedFieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'right' },
page: 7,
...calculateExplicitHorizontalPosition(2),
customText: 'Explicit horizontal overflow text that should extend beyond the field',
},
// Section B: Vertical mode (3 boxes in a column)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'top' },
page: 7,
...calculateExplicitVerticalPosition(0),
customText:
'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'middle' },
page: 7,
...calculateExplicitVerticalPosition(1),
customText:
'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'bottom' },
page: 7,
...calculateExplicitVerticalPosition(2),
customText:
'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 8: CROP MODE
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Box 1: Single-line crop
{
type: FieldType.TEXT,
fieldMeta: undefined,
seedFieldMeta: { type: 'text' },
page: 8,
positionX: 10,
positionY: 15,
width: 25,
height: SINGLE_LINE_HEIGHT,
customText: 'This text should be cropped and not overflow',
},
// Box 2: Multi-line crop
{
type: FieldType.TEXT,
fieldMeta: undefined,
seedFieldMeta: { type: 'text' },
page: 8,
positionX: 10,
positionY: 30,
width: 25,
height: MULTI_LINE_HEIGHT,
customText:
'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty',
},
] as const;
@@ -1,8 +1,7 @@
import { expect, test } from '@playwright/test';
import { nanoid } from '@documenso/lib/universal/id';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
@@ -1,19 +1,14 @@
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 { 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 type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../../fixtures/authentication';
@@ -1,7 +1,3 @@
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';
@@ -9,6 +5,9 @@ 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 type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../../fixtures/authentication';
@@ -171,9 +170,7 @@ test.describe('Template Search - Cross-Team Isolation', () => {
// ─── Recipient Email Search ──────────────────────────────────────────────────
test.describe('Template Search - Recipient Email', () => {
test('should find templates by recipient email within team but not cross-team', async ({
page,
}) => {
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();
@@ -1,15 +1,11 @@
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 { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import {
seedBlankDocument,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
test.describe('Document API', () => {
test('sendDocument: should respect sendCompletionEmails setting', async ({ request }) => {
@@ -193,9 +189,7 @@ test.describe('Document API', () => {
expect(response.status()).toBe(400);
});
test('sendDocument: should fail when signer has only non-signature fields', async ({
request,
}) => {
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
@@ -1,5 +1,3 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
@@ -12,16 +10,14 @@ import { prisma } from '@documenso/prisma';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe('Template Field Prefill API v1', () => {
test('should create a document from template with prefilled fields', async ({
page,
request,
}) => {
test('should create a document from template with prefilled fields', async ({ page, request }) => {
// 1. Create a user
const { user, team } = await seedUser();
@@ -196,9 +192,7 @@ test.describe('Template Field Prefill API v1', () => {
});
// 7. Navigate to the template
await page.goto(
`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
);
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`);
// 8. Create a document from the template with prefilled fields
const response = await request.post(
@@ -381,10 +375,7 @@ test.describe('Template Field Prefill API v1', () => {
await expect(page.getByText('Select B')).toBeVisible();
});
test('should create a document from template without prefilled fields', async ({
page,
request,
}) => {
test('should create a document from template without prefilled fields', async ({ page, request }) => {
// 1. Create a user
const { user, team } = await seedUser();
@@ -487,9 +478,7 @@ test.describe('Template Field Prefill API v1', () => {
});
// 7. Navigate to the template
await page.goto(
`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
);
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`);
// 8. Create a document from the template without prefilled fields
const response = await request.post(
@@ -1,11 +1,6 @@
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 {
mapSecondaryIdToDocumentId,
mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope';
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { DocumentDataType, FieldType } from '@documenso/prisma/client';
import {
@@ -16,6 +11,7 @@ import {
} from '@documenso/prisma/seed/documents';
import { seedBlankTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
@@ -25,9 +21,7 @@ test.describe.configure({
test.describe('Document Access API V1', () => {
test.describe('Document GET endpoint', () => {
test('should block unauthorized access to documents not owned by the user', async ({
request,
}) => {
test('should block unauthorized access to documents not owned by the user', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
@@ -319,9 +313,7 @@ test.describe('Document Access API V1', () => {
});
test.describe('Document recipients POST endpoint', () => {
test('should block unauthorized access to document recipients endpoint', async ({
request,
}) => {
test('should block unauthorized access to document recipients endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
@@ -371,9 +363,7 @@ test.describe('Document Access API V1', () => {
});
test.describe('Document recipients PATCH endpoint', () => {
test('should block unauthorized access to PATCH on recipients endpoint', async ({
request,
}) => {
test('should block unauthorized access to PATCH on recipients endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
@@ -459,9 +449,7 @@ test.describe('Document Access API V1', () => {
});
test.describe('Document recipients DELETE endpoint', () => {
test('should block unauthorized access to DELETE on recipients endpoint', async ({
request,
}) => {
test('should block unauthorized access to DELETE on recipients endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
@@ -972,9 +960,7 @@ test.describe('Document Access API V1', () => {
expect(resB.ok()).toBeTruthy();
expect(resB.status()).toBe(200);
expect(reqData.documents.every((doc: { userId: number }) => doc.userId !== userA.id)).toBe(
true,
);
expect(reqData.documents.every((doc: { userId: number }) => doc.userId !== userA.id)).toBe(true);
expect(reqData.documents.length).toBe(0);
expect(reqData.totalPages).toBe(0);
});
@@ -999,9 +985,7 @@ test.describe('Document Access API V1', () => {
expect(resA.ok()).toBeTruthy();
expect(resA.status()).toBe(200);
expect(reqData.documents.length).toBeGreaterThan(0);
expect(reqData.documents.every((doc: { userId: number }) => doc.userId === userA.id)).toBe(
true,
);
expect(reqData.documents.every((doc: { userId: number }) => doc.userId === userA.id)).toBe(true);
});
});
@@ -1027,9 +1011,7 @@ test.describe('Document Access API V1', () => {
expect(resB.ok()).toBeTruthy();
expect(resB.status()).toBe(200);
expect(reqData.templates.every((tpl: { userId: number }) => tpl.userId !== userA.id)).toBe(
true,
);
expect(reqData.templates.every((tpl: { userId: number }) => tpl.userId !== userA.id)).toBe(true);
expect(reqData.templates.length).toBe(0);
expect(reqData.totalPages).toBe(0);
});
@@ -1054,16 +1036,12 @@ test.describe('Document Access API V1', () => {
expect(resA.ok()).toBeTruthy();
expect(resA.status()).toBe(200);
expect(reqData.templates.length).toBeGreaterThan(0);
expect(reqData.templates.every((tpl: { userId: number }) => tpl.userId === userA.id)).toBe(
true,
);
expect(reqData.templates.every((tpl: { userId: number }) => tpl.userId === userA.id)).toBe(true);
});
});
test.describe('Create document from template endpoint', () => {
test('should block unauthorized access to create-document-from-template endpoint', async ({
request,
}) => {
test('should block unauthorized access to create-document-from-template endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
@@ -1104,9 +1082,7 @@ test.describe('Document Access API V1', () => {
expect(resB.status()).toBe(401);
});
test('should allow authorized access to create-document-from-template endpoint', async ({
request,
}) => {
test('should allow authorized access to create-document-from-template endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { token: tokenA } = await createApiToken({
userId: userA.id,
@@ -1,8 +1,5 @@
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
@@ -13,6 +10,8 @@ import type {
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
@@ -138,9 +137,7 @@ test.describe('Envelope distribute validation', () => {
expect(errorResponse.message).toContain('Signers must have at least one signature field');
});
test('should fail to distribute when signer has non-signature fields only', async ({
request,
}) => {
test('should fail to distribute when signer has non-signature fields only', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
@@ -321,9 +318,7 @@ test.describe('Envelope distribute validation', () => {
expect(distributeRes.status()).toBe(200);
});
test('should fail when one of multiple signers is missing signature field', async ({
request,
}) => {
test('should fail when one of multiple signers is missing signature field', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
@@ -1,16 +1,13 @@
import { expect, test } from '@playwright/test';
import type { APIRequestContext } from 'playwright-core';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { CreateEmbeddingPresignTokenOptions } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
import type { VerifyEmbeddingPresignTokenOptions } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import type { APIRequestContext } from 'playwright-core';
test.describe('Embedding Presign API', () => {
test('createEmbeddingPresignToken: should create a token with default expiration', async ({
request,
}) => {
test('createEmbeddingPresignToken: should create a token with default expiration', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
@@ -34,9 +31,7 @@ test.describe('Embedding Presign API', () => {
expect(responseData.expiresIn).toBe(3600); // Default 1 hour in seconds
});
test('createEmbeddingPresignToken: should create a token with custom expiration', async ({
request,
}) => {
test('createEmbeddingPresignToken: should create a token with custom expiration', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
@@ -187,9 +182,7 @@ test.describe('Embedding Presign API', () => {
expect(verifyResponseData.success).toBe(true);
});
test('verifyEmbeddingPresignToken: should reject a scope mismatched token', async ({
request,
}) => {
test('verifyEmbeddingPresignToken: should reject a scope mismatched token', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
@@ -233,19 +226,16 @@ const createPresignToken = async (
apiToken: string,
data?: Partial<CreateEmbeddingPresignTokenOptions>,
) => {
return await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
data: {
apiToken,
...data,
},
return await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`, {
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
);
data: {
apiToken,
...data,
},
});
};
const verifyPresignToken = async (
@@ -253,14 +243,11 @@ const verifyPresignToken = async (
apiToken: string,
data: VerifyEmbeddingPresignTokenOptions,
) => {
return await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
data,
return await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`, {
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
);
data,
});
};
@@ -1,9 +1,5 @@
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { pick } from 'remeda';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
@@ -19,17 +15,20 @@ import {
RecipientRole,
} from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TUpdateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types';
import type { TFindEnvelopesResponse } from '@documenso/trpc/server/envelope-router/find-envelopes.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import { pick } from 'remeda';
import { ALIGNMENT_TEST_FIELDS } from '../../../constants/field-alignment-pdf';
import { FIELD_META_TEST_FIELDS } from '../../../constants/field-meta-pdf';
@@ -94,9 +93,7 @@ test.describe('API V2 Envelopes', () => {
const files = [
{
name: 'field-font-alignment.pdf',
data: fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
),
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf')),
},
];
@@ -232,9 +229,7 @@ test.describe('API V2 Envelopes', () => {
},
{
name: 'field-font-alignment.pdf',
data: fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
),
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf')),
},
];
@@ -294,15 +289,11 @@ test.describe('API V2 Envelopes', () => {
expect(envelope.documentMeta.dateFormat).toBe(payload.meta.dateFormat);
expect(envelope.documentMeta.distributionMethod).toBe(payload.meta.distributionMethod);
expect(envelope.documentMeta.signingOrder).toBe(payload.meta.signingOrder);
expect(envelope.documentMeta.allowDictateNextSigner).toBe(
payload.meta.allowDictateNextSigner,
);
expect(envelope.documentMeta.allowDictateNextSigner).toBe(payload.meta.allowDictateNextSigner);
expect(envelope.documentMeta.redirectUrl).toBe(payload.meta.redirectUrl);
expect(envelope.documentMeta.language).toBe(payload.meta.language);
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(
payload.meta.uploadSignatureEnabled,
);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(payload.meta.uploadSignatureEnabled);
expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo);
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
@@ -324,9 +315,7 @@ test.describe('API V2 Envelopes', () => {
role: recipient.role,
signingOrder: recipient.signingOrder,
accessAuth: recipient.authOptions?.accessAuth,
}).toEqual(
pick(payload.recipients[0], ['email', 'name', 'role', 'signingOrder', 'accessAuth']),
);
}).toEqual(pick(payload.recipients[0], ['email', 'name', 'role', 'signingOrder', 'accessAuth']));
expect({
type: field.type,
@@ -335,16 +324,7 @@ test.describe('API V2 Envelopes', () => {
positionY: field.positionY.toNumber(),
width: field.width.toNumber(),
height: field.height.toNumber(),
}).toEqual(
pick(payload.recipients[0].fields[0], [
'type',
'page',
'positionX',
'positionY',
'width',
'height',
]),
);
}).toEqual(pick(payload.recipients[0].fields[0], ['type', 'page', 'positionX', 'positionY', 'width', 'height']));
// Expect string based ID to work.
expect(field.envelopeItemId).toBe(
@@ -363,13 +343,9 @@ test.describe('API V2 Envelopes', () => {
*/
test('Envelope full test', async ({ request }) => {
// Step 1: Create initial envelope with Prisma (with first envelope item)
const alignmentPdf = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
);
const alignmentPdf = fs.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'));
const fieldMetaPdf = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-meta.pdf'),
);
const fieldMetaPdf = fs.readFileSync(path.join(__dirname, '../../../../../assets/field-meta.pdf'));
const formData = new FormData();
@@ -382,10 +358,7 @@ test.describe('API V2 Envelopes', () => {
);
// Only add one file for now.
formData.append(
'files',
new File([alignmentPdf], 'field-font-alignment.pdf', { type: 'application/pdf' }),
);
formData.append('files', new File([alignmentPdf], 'field-font-alignment.pdf', { type: 'application/pdf' }));
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
@@ -418,10 +391,7 @@ test.describe('API V2 Envelopes', () => {
const createEnvelopeItemFormData = new FormData();
createEnvelopeItemFormData.append('payload', JSON.stringify(createEnvelopeItemsPayload));
createEnvelopeItemFormData.append(
'files',
new File([fieldMetaPdf], 'field-meta.pdf', { type: 'application/pdf' }),
);
createEnvelopeItemFormData.append('files', new File([fieldMetaPdf], 'field-meta.pdf', { type: 'application/pdf' }));
const createItemsRes = await request.post(`${baseUrl}/envelope/item/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
@@ -481,12 +451,8 @@ test.describe('API V2 Envelopes', () => {
const envelopeResponse = (await getRes.json()) as TGetEnvelopeResponse;
const recipientId = envelopeResponse.recipients[0].id;
const alignmentItem = envelopeResponse.envelopeItems.find(
(item: { order: number }) => item.order === 1,
);
const fieldMetaItem = envelopeResponse.envelopeItems.find(
(item: { order: number }) => item.order === 2,
);
const alignmentItem = envelopeResponse.envelopeItems.find((item: { order: number }) => item.order === 1);
const fieldMetaItem = envelopeResponse.envelopeItems.find((item: { order: number }) => item.order === 2);
expect(recipientId).toBeDefined();
expect(alignmentItem).toBeDefined();
@@ -555,9 +521,7 @@ test.describe('API V2 Envelopes', () => {
// Verify structure
expect(finalEnvelope.envelopeItems.length).toBe(2);
expect(finalEnvelope.recipients.length).toBe(1);
expect(finalEnvelope.fields.length).toBe(
ALIGNMENT_TEST_FIELDS.length + FIELD_META_TEST_FIELDS.length,
);
expect(finalEnvelope.fields.length).toBe(ALIGNMENT_TEST_FIELDS.length + FIELD_META_TEST_FIELDS.length);
expect(finalEnvelope.title).toBe('Envelope Full Field Test');
expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT);
@@ -568,17 +532,11 @@ test.describe('API V2 Envelopes', () => {
});
test.describe('Envelope find endpoint', () => {
const createEnvelope = async (
request: APIRequestContext,
token: string,
payload: TCreateEnvelopePayload,
) => {
const createEnvelope = async (request: APIRequestContext, token: string, payload: TCreateEnvelopePayload) => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const pdfData = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
);
const pdfData = fs.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'));
formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
@@ -1199,9 +1157,7 @@ test.describe('API V2 Envelopes', () => {
expect(distributeResponse.recipients[1].signingUrl).toBeTruthy();
});
test('Distribute envelope with empty email recipient and auth requirements fails', async ({
request,
}) => {
test('Distribute envelope with empty email recipient and auth requirements fails', async ({ request }) => {
const payload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Auth Requirements',
@@ -1,6 +1,3 @@
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';
@@ -15,6 +12,8 @@ import {
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import { apiSignin } from '../../fixtures/authentication';
@@ -78,9 +77,7 @@ test.describe('Find Documents API - Personal Context', () => {
expect(json!.totalPages).toBe(0);
});
test('should return only documents owned by the user and not the other user', async ({
request,
}) => {
test('should return only documents owned by the user and not the other user', async ({ request }) => {
// The v2 API token scopes to a team. A personal team token only returns
// docs belonging to that team — cross-team received docs are NOT included.
await seedDraftDocument(userA, teamA.id, [], {
@@ -287,9 +284,7 @@ test.describe('Find Documents API - Personal Context', () => {
expect(json!.data[0].title).toBe('Quarterly Report 2024');
});
test('should search by recipient email and not return docs with different recipients', async ({
request,
}) => {
test('should search by recipient email and not return docs with different recipients', async ({ request }) => {
const { user: userC } = await seedUser();
await seedPendingDocument(userA, teamA.id, [userB], {
@@ -404,9 +399,7 @@ test.describe('Find Documents API - Personal Context', () => {
}
});
test('should only show root-level documents when no folderId is provided', async ({
request,
}) => {
test('should only show root-level documents when no folderId is provided', async ({ request }) => {
const folder = await prisma.folder.create({
data: {
name: 'Test Folder',
@@ -499,9 +492,7 @@ test.describe('Find Documents API - Personal Context', () => {
expect(doc.recipients[0].email).toBe(userB.email);
});
test('should not return deleted documents but should return non-deleted ones', async ({
request,
}) => {
test('should not return deleted documents but should return non-deleted ones', async ({ request }) => {
const deletedDoc = await seedDraftDocument(userA, teamA.id, [], {
createDocumentOptions: { title: 'Deleted Document' },
});
@@ -562,9 +553,7 @@ test.describe('Find Documents API - Personal Context', () => {
});
test.describe('Find Documents API - Team Context', () => {
test('should return team documents for team members and exclude non-team docs', async ({
request,
}) => {
test('should return team documents for team members and exclude non-team docs', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
@@ -723,9 +712,7 @@ test.describe('Find Documents API - Team Context', () => {
expect(json!.count).toBe(2);
});
test('should enforce visibility across admin and manager levels with adequate data', async ({
request,
}) => {
test('should enforce visibility across admin and manager levels with adequate data', async ({ request }) => {
// Note: MEMBER role cannot create API tokens (requires MANAGE_TEAM permission).
// MEMBER visibility is tested in the UI test file instead.
const { team, owner } = await seedTeam();
@@ -978,9 +965,7 @@ test.describe('Find Documents API - Team with Team Email', () => {
expect(titles).not.toContain('External Noise Doc');
});
test('team email documents should respect visibility rules with adequate controls', async ({
request,
}) => {
test('team email documents should respect visibility rules with adequate controls', async ({ request }) => {
const { team } = await seedTeam();
const teamEmail = `team-vis-email-${team.id}@test.documenso.com`;
@@ -1033,9 +1018,7 @@ test.describe('Find Documents API - Team with Team Email', () => {
});
test.describe('Find Documents API - Deleted Document Handling', () => {
test('should not show soft-deleted documents for owner but show non-deleted ones', async ({
request,
}) => {
test('should not show soft-deleted documents for owner but show non-deleted ones', async ({ request }) => {
const { user, team } = await seedUser();
const deletedDoc = await seedPendingDocument(user, team.id, [], {
@@ -1068,9 +1051,7 @@ test.describe('Find Documents API - Deleted Document Handling', () => {
expect(titles).not.toContain('Soft Deleted by Owner');
});
test('should not show documents where owner soft-deleted their copy in personal context', async ({
request,
}) => {
test('should not show documents where owner soft-deleted their copy in personal context', async ({ request }) => {
// In personal context, documentDeletedAt on recipient hides the doc for that user.
// Note: the v2 API scopes to a team, so we test this by having the owner
// soft-delete a doc from their own personal team.
@@ -1102,9 +1083,7 @@ test.describe('Find Documents API - Deleted Document Handling', () => {
expect(titles).not.toContain('Owner Soft Deleted');
});
test('should not show deleted team documents for any team member but show non-deleted ones', async ({
request,
}) => {
test('should not show deleted team documents for any team member but show non-deleted ones', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
@@ -1198,9 +1177,7 @@ test.describe('Find Documents API - Edge Cases', () => {
expect(res.ok()).toBeFalsy();
});
test('personal documents should not appear in team context even with adequate team data', async ({
request,
}) => {
test('personal documents should not appear in team context even with adequate team data', async ({ request }) => {
const { team, owner } = await seedTeam();
const teamMember = await seedTeamMember({
@@ -1276,9 +1253,7 @@ const trpcQuery = async (
};
test.describe('Find Documents API - Adversarial: x-team-id Header Spoofing', () => {
test('should reject request when user spoofs x-team-id to a team they do not belong to', async ({
page,
}) => {
test('should reject request when user spoofs x-team-id to a team they do not belong to', async ({ page }) => {
// Setup: two separate teams with documents
const { team: teamA, owner: ownerA } = await seedTeam();
const { team: teamB, owner: ownerB } = await seedTeam();
@@ -1365,9 +1340,7 @@ test.describe('Find Documents API - Adversarial: x-team-id Header Spoofing', ()
});
test.describe('Find Documents API - Adversarial: Cross-Team folderId', () => {
test('should NOT return documents from another team when folderId belongs to that team', async ({
request,
}) => {
test('should NOT return documents from another team when folderId belongs to that team', async ({ request }) => {
// Setup: two teams each with a folder and documents
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
@@ -1452,9 +1425,7 @@ test.describe('Find Documents API - Adversarial: Cross-Team folderId', () => {
});
test.describe('Find Documents API - Adversarial: Cross-Team senderIds', () => {
test('should NOT return documents when senderIds contains users from another team', async ({
page,
}) => {
test('should NOT return documents when senderIds contains users from another team', async ({ page }) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const { team: teamB, owner: ownerB } = await seedTeam();
@@ -1495,9 +1466,7 @@ test.describe('Find Documents API - Adversarial: Cross-Team senderIds', () => {
expect(docs2[0].title).toBe('TeamA Doc by OwnerA');
});
test('senderIds with mix of valid and cross-team userIds should only return matching team docs', async ({
page,
}) => {
test('senderIds with mix of valid and cross-team userIds should only return matching team docs', async ({ page }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const { user: outsider } = await seedUser();
@@ -1542,9 +1511,7 @@ test.describe('Find Documents API - Adversarial: Cross-Team senderIds', () => {
});
test.describe('Find Documents API - Adversarial: Cross-Team templateId', () => {
test('should NOT return documents from another team when filtering by their templateId', async ({
request,
}) => {
test('should NOT return documents from another team when filtering by their templateId', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
@@ -1,15 +1,7 @@
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 {
DocumentStatus,
DocumentVisibility,
EnvelopeType,
TeamMemberRole,
} from '@documenso/prisma/client';
import { DocumentStatus, DocumentVisibility, EnvelopeType, TeamMemberRole } from '@documenso/prisma/client';
import {
seedBlankDocument,
seedCompletedDocument,
@@ -19,6 +11,8 @@ import {
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TFindEnvelopesResponse } from '@documenso/trpc/server/envelope-router/find-envelopes.types';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import { apiSignin } from '../../fixtures/authentication';
@@ -81,9 +75,7 @@ test.describe('Find Envelopes API - Basic', () => {
expect(json!.totalPages).toBe(0);
});
test('should return only envelopes owned by the user and not the other user', async ({
request,
}) => {
test('should return only envelopes owned by the user and not the other user', async ({ request }) => {
await seedDraftDocument(userA, teamA.id, [], {
createDocumentOptions: { title: 'UserA Doc' },
});
@@ -445,9 +437,7 @@ test.describe('Find Envelopes API - Token Masking', () => {
test.describe('Find Envelopes API - Team Context', () => {
// Regression test: findEnvelopes previously had `{ userId }` as a top-level OR branch with no
// teamId constraint, so personal docs leaked into team context. Fixed in the Kysely refactor.
test('should return team envelopes for team members and exclude personal docs', async ({
request,
}) => {
test('should return team envelopes for team members and exclude personal docs', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
@@ -672,9 +662,7 @@ test.describe('Find Envelopes API - Team Context', () => {
// ─── Team with Team Email ────────────────────────────────────────────────────
test.describe('Find Envelopes API - Team Email', () => {
test('should include envelopes received by team email from external senders', async ({
request,
}) => {
test('should include envelopes received by team email from external senders', async ({ request }) => {
const { team, owner } = await seedTeam();
const teamEmailAddr = `team-find-env-${team.id}@test.documenso.com`;
await seedTeamEmail({ email: teamEmailAddr, teamId: team.id });
@@ -711,9 +699,7 @@ test.describe('Find Envelopes API - Team Email', () => {
expect(titles).not.toContain('External Noise Doc');
});
test('should NOT include external noise from other teams when team has team email', async ({
request,
}) => {
test('should NOT include external noise from other teams when team has team email', async ({ request }) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const teamEmailAddr = `team-noise-${teamA.id}@test.documenso.com`;
await seedTeamEmail({ email: teamEmailAddr, teamId: teamA.id });
@@ -809,9 +795,7 @@ const trpcQuery = async (
};
test.describe('Find Envelopes API - Adversarial: x-team-id Header Spoofing', () => {
test('should reject request when user spoofs x-team-id to a team they do not belong to', async ({
page,
}) => {
test('should reject request when user spoofs x-team-id to a team they do not belong to', async ({ page }) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const { team: teamB, owner: ownerB } = await seedTeam();
@@ -883,9 +867,7 @@ test.describe('Find Envelopes API - Adversarial: x-team-id Header Spoofing', ()
});
test.describe('Find Envelopes API - Adversarial: Cross-Team folderId', () => {
test('should NOT return envelopes from another team when folderId belongs to that team', async ({
request,
}) => {
test('should NOT return envelopes from another team when folderId belongs to that team', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
@@ -965,9 +947,7 @@ test.describe('Find Envelopes API - Adversarial: Cross-Team folderId', () => {
});
test.describe('Find Envelopes API - Adversarial: Cross-Team templateId', () => {
test('should NOT return envelopes from another team when filtering by their templateId', async ({
request,
}) => {
test('should NOT return envelopes from another team when filtering by their templateId', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
@@ -1007,9 +987,7 @@ test.describe('Find Envelopes API - Adversarial: Cross-Team templateId', () => {
// ─── Personal vs Team Isolation ──────────────────────────────────────────────
test.describe('Find Envelopes API - Cross-User Isolation', () => {
test('other users personal envelopes should never appear regardless of context', async ({
request,
}) => {
test('other users personal envelopes should never appear regardless of context', async ({ request }) => {
const { team } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const { user: outsider, team: outsiderTeam } = await seedUser();
@@ -0,0 +1,244 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { PDF } from '@libpdf/core';
import type { APIRequestContext } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
import { apiSeedDraftDocument, apiSeedPendingDocument } from '../../fixtures/api-seeds';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
const trpcMutation = async (request: APIRequestContext, procedure: string, input: Record<string, unknown>) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
headers: {
'content-type': 'application/json',
},
data: JSON.stringify({ json: input }),
});
expect(res.ok(), `${procedure} failed: ${await res.text()}`).toBeTruthy();
};
const getPdfBytes = async (response: Awaited<ReturnType<APIRequestContext['get']>>) => {
const body = await response.body();
expect(body.subarray(0, 5).toString()).toBe('%PDF-');
return new Uint8Array(body);
};
const signAndCompleteRecipient = async ({
request,
token,
documentId,
fieldId,
}: {
request: APIRequestContext;
token: string;
documentId: number;
fieldId: number;
}) => {
await trpcMutation(request, 'envelope.field.sign', {
token,
fieldId,
fieldValue: {
type: FieldType.SIGNATURE,
value: 'Signature',
},
});
await trpcMutation(request, 'recipient.completeDocumentWithToken', {
token,
documentId,
});
};
test.describe('API V2 partial signed PDF downloads', () => {
test('returns a PDF with inserted fields, supports ETag, and rejects after completion', async ({ request }) => {
const { envelope, token, distributeResult } = await apiSeedPendingDocument(request, {
recipients: [
{ email: 'partial-signer-1@test.documenso.com', name: 'Partial Signer 1' },
{ email: 'partial-signer-2@test.documenso.com', name: 'Partial Signer 2' },
],
fieldsPerRecipient: [
[{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 15, height: 5 }],
[
{
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: 15,
width: 15,
height: 5,
},
],
],
});
const [recipientOne, recipientTwo] = distributeResult.recipients;
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
const envelopeItem = envelope.envelopeItems[0];
const recipientOneField = envelope.fields.find(
(field) => field.recipientId === recipientOne.id && field.type === FieldType.SIGNATURE,
);
const recipientTwoField = envelope.fields.find(
(field) => field.recipientId === recipientTwo.id && field.type === FieldType.SIGNATURE,
);
if (!recipientOneField || !recipientTwoField) {
throw new Error('Expected signature fields not found');
}
await signAndCompleteRecipient({
request,
token: recipientOne.token,
documentId,
fieldId: recipientOneField.id,
});
await expect(async () => {
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: envelope.id,
},
include: {
recipients: true,
},
});
expect(dbEnvelope.status).toBe(DocumentStatus.PENDING);
expect(dbEnvelope.recipients.find((recipient) => recipient.id === recipientOne.id)?.signingStatus).toBe(
SigningStatus.SIGNED,
);
}).toPass();
const downloadUrl = `${API_BASE_URL}/envelope/item/${envelopeItem.id}/download?version=pending`;
const pendingResponse = await request.get(downloadUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(pendingResponse.status()).toBe(200);
expect(pendingResponse.headers()['content-type']).toContain('application/pdf');
expect(pendingResponse.headers()['cache-control']).toBe('no-store, private');
expect(pendingResponse.headers()['content-disposition']).toContain('_pending.pdf');
const etag = pendingResponse.headers().etag;
expect(etag).toBeTruthy();
const pendingPdfBytes = await getPdfBytes(pendingResponse);
const pendingPdf = await PDF.load(pendingPdfBytes);
const originalEnvelopeItem = await prisma.envelopeItem.findUniqueOrThrow({
where: {
id: envelopeItem.id,
},
include: {
documentData: true,
},
});
const originalPdfBytes = await getFileServerSide({
type: originalEnvelopeItem.documentData.type,
data: originalEnvelopeItem.documentData.initialData,
});
const originalPdf = await PDF.load(new Uint8Array(originalPdfBytes));
// Pending PDF should have the same page count as the original (no cert/audit pages).
expect(pendingPdf.getPageCount()).toBe(originalPdf.getPageCount());
const cachedResponse = await request.get(downloadUrl, {
headers: {
Authorization: `Bearer ${token}`,
'If-None-Match': etag,
},
});
expect(cachedResponse.status()).toBe(304);
await signAndCompleteRecipient({
request,
token: recipientTwo.token,
documentId,
fieldId: recipientTwoField.id,
});
await expect(async () => {
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: envelope.id,
},
});
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
}).toPass({ timeout: 15_000 });
const completedResponse = await request.get(downloadUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const completedError = await completedResponse.json();
expect(completedResponse.status()).toBe(400);
expect(completedError.code).toBe('ENVELOPE_COMPLETED');
const signedResponse = await request.get(
`${API_BASE_URL}/envelope/item/${envelopeItem.id}/download?version=signed`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
expect(signedResponse.status()).toBe(200);
await getPdfBytes(signedResponse);
});
test('rejects draft and legacy pending envelopes', async ({ request }) => {
const draft = await apiSeedDraftDocument(request, {
recipients: [{ email: 'partial-draft@test.documenso.com', name: 'Draft Signer' }],
});
const draftResponse = await request.get(
`${API_BASE_URL}/envelope/item/${draft.envelope.envelopeItems[0].id}/download?version=pending`,
{
headers: {
Authorization: `Bearer ${draft.token}`,
},
},
);
const draftError = await draftResponse.json();
expect(draftResponse.status()).toBe(400);
expect(draftError.code).toBe('ENVELOPE_DRAFT');
const legacy = await apiSeedPendingDocument(request);
await prisma.envelope.update({
where: {
id: legacy.envelope.id,
},
data: {
internalVersion: 1,
},
});
const legacyResponse = await request.get(
`${API_BASE_URL}/envelope/item/${legacy.envelope.envelopeItems[0].id}/download?version=pending`,
{
headers: {
Authorization: `Bearer ${legacy.token}`,
},
},
);
const legacyError = await legacyResponse.json();
expect(legacyResponse.status()).toBe(400);
expect(legacyError.code).toBe('ENVELOPE_LEGACY');
});
});
@@ -1,10 +1,5 @@
import { PDF, StandardFonts } from '@libpdf/core';
import type { APIRequestContext } from '@playwright/test';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
@@ -16,6 +11,10 @@ import type {
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import { PDF, StandardFonts } from '@libpdf/core';
import type { APIRequestContext } from '@playwright/test';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
@@ -66,11 +65,7 @@ test.describe('Placeholder-based field creation', () => {
return res.json();
};
const createEnvelopeItemsWithPdf = async (
request: APIRequestContext,
envelopeId: string,
pdfFilename: string,
) => {
const createEnvelopeItemsWithPdf = async (request: APIRequestContext, envelopeId: string, pdfFilename: string) => {
const pdfPath = path.join(FIXTURES_DIR, pdfFilename);
const pdfData = fs.readFileSync(pdfPath);
@@ -129,10 +124,7 @@ test.describe('Placeholder-based field creation', () => {
expect(res.ok()).toBeTruthy();
};
const getEnvelope = async (
request: APIRequestContext,
envelopeId: string,
): Promise<TGetEnvelopeResponse> => {
const getEnvelope = async (request: APIRequestContext, envelopeId: string): Promise<TGetEnvelopeResponse> => {
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
headers: { Authorization: `Bearer ${token}` },
});
@@ -281,9 +273,7 @@ test.describe('Placeholder-based field creation', () => {
expect(createFieldsRes.ok()).toBeFalsy();
});
test('should create fields using a mix of coordinate and placeholder positioning', async ({
request,
}) => {
test('should create fields using a mix of coordinate and placeholder positioning', async ({ request }) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipient(request, envelope.id);
@@ -409,9 +399,7 @@ test.describe('Placeholder-based field creation', () => {
expect(uniqueYPositions.size).toBe(3);
});
test('should map placeholder recipients by signing order when adding items', async ({
request,
}) => {
test('should map placeholder recipients by signing order when adding items', async ({ request }) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipients(request, envelope.id, [
@@ -1,5 +1,3 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
@@ -12,16 +10,14 @@ import { prisma } from '@documenso/prisma';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe('Template Field Prefill API v2', () => {
test('should create a document from template with prefilled fields', async ({
page,
request,
}) => {
test('should create a document from template with prefilled fields', async ({ page, request }) => {
// 1. Create a user
const { user, team } = await seedUser();
@@ -196,9 +192,7 @@ test.describe('Template Field Prefill API v2', () => {
});
// 7. Navigate to the template
await page.goto(
`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
);
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`);
// 8. Create a document from the template with prefilled fields using v2 API
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
@@ -378,10 +372,7 @@ test.describe('Template Field Prefill API v2', () => {
await expect(page.getByText('Select B')).toBeVisible();
});
test('should create a document from template without prefilled fields', async ({
page,
request,
}) => {
test('should create a document from template without prefilled fields', async ({ page, request }) => {
// 1. Create a user
const { user, team } = await seedUser();
@@ -484,9 +475,7 @@ test.describe('Template Field Prefill API v2', () => {
});
// 7. Navigate to the template
await page.goto(
`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
);
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`);
// 8. Create a document from the template without prefilled fields using v2 API
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,10 @@
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
@@ -35,33 +34,23 @@ test.describe('Envelope Attachments API V2', () => {
});
test.describe('Envelope attachment find endpoint', () => {
test('should block unauthorized access to envelope attachment find endpoint', async ({
request,
}) => {
test('should block unauthorized access to envelope attachment find endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.get(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
{
headers: { Authorization: `Bearer ${tokenB}` },
},
);
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment find endpoint', async ({
request,
}) => {
test('should allow authorized access to envelope attachment find endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.get(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
{
headers: { Authorization: `Bearer ${tokenA}` },
},
);
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
@@ -69,9 +58,7 @@ test.describe('Envelope Attachments API V2', () => {
});
test.describe('Envelope attachment create endpoint', () => {
test('should block unauthorized access to envelope attachment create endpoint', async ({
request,
}) => {
test('should block unauthorized access to envelope attachment create endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, {
@@ -89,9 +76,7 @@ test.describe('Envelope Attachments API V2', () => {
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment create endpoint', async ({
request,
}) => {
test('should allow authorized access to envelope attachment create endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, {
@@ -111,9 +96,7 @@ test.describe('Envelope Attachments API V2', () => {
});
test.describe('Envelope attachment update endpoint', () => {
test('should block unauthorized access to envelope attachment update endpoint', async ({
request,
}) => {
test('should block unauthorized access to envelope attachment update endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
@@ -140,9 +123,7 @@ test.describe('Envelope Attachments API V2', () => {
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment update endpoint', async ({
request,
}) => {
test('should allow authorized access to envelope attachment update endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
@@ -171,9 +152,7 @@ test.describe('Envelope Attachments API V2', () => {
});
test.describe('Envelope attachment delete endpoint', () => {
test('should block unauthorized access to envelope attachment delete endpoint', async ({
request,
}) => {
test('should block unauthorized access to envelope attachment delete endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
@@ -194,9 +173,7 @@ test.describe('Envelope Attachments API V2', () => {
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment delete endpoint', async ({
request,
}) => {
test('should allow authorized access to envelope attachment delete endpoint', async ({ request }) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
@@ -1,13 +1,12 @@
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import { EnvelopeType, FolderType } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import { EnvelopeType, FolderType } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
@@ -16,375 +15,366 @@ test.describe.configure({
});
// Todo: Remove skip once the API endpoints are released.
test.describe.skip('Envelope Bulk API V2', () => {
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
test.describe
.skip('Envelope Bulk API V2', () => {
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
test.beforeEach(async () => {
({ user: userA, team: teamA } = await seedUser());
({ token: tokenA } = await createApiToken({
userId: userA.id,
teamId: teamA.id,
tokenName: 'userA',
expiresIn: null,
}));
test.beforeEach(async () => {
({ user: userA, team: teamA } = await seedUser());
({ token: tokenA } = await createApiToken({
userId: userA.id,
teamId: teamA.id,
tokenName: 'userA',
expiresIn: null,
}));
({ user: userB, team: teamB } = await seedUser());
({ token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
}));
});
test.describe('Envelope bulk move endpoint', () => {
test('should block unauthorized access to envelope bulk move endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// UserB tries to move userA's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: null,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.movedCount).toBe(0);
// Verify in database that the document was not modified
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBeNull();
({ user: userB, team: teamB } = await seedUser());
({ token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
}));
});
test('should block moving envelopes to unauthorized folder', async ({ request }) => {
// Create a document owned by userB
const doc = await seedBlankDocument(userB, teamB.id);
test.describe('Envelope bulk move endpoint', () => {
test('should block unauthorized access to envelope bulk move endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
// UserB tries to move userA's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: null,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.movedCount).toBe(0);
// Verify in database that the document was not modified
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBeNull();
});
// UserB tries to move their document to userA's folder
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
test('should block moving envelopes to unauthorized folder', async ({ request }) => {
// Create a document owned by userB
const doc = await seedBlankDocument(userB, teamB.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
});
// UserB tries to move their document to userA's folder
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// Verify in database that the document was not modified
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBeNull();
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
test('should allow authorized access to envelope bulk move endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// Verify in database that the document was not modified
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
});
// UserA moves their own document to their own folder
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.movedCount).toBe(1);
// Verify in database that the document was moved to the folder
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBe(folderA.id);
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBeNull();
test('should only move authorized envelopes when given mixed array of envelope IDs', async ({ request }) => {
// Create documents owned by userA
const docA1 = await seedBlankDocument(userA, teamA.id);
const docA2 = await seedBlankDocument(userA, teamA.id);
// Create a document owned by userB
const docB = await seedBlankDocument(userB, teamB.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
});
// UserA tries to move a mix of their own documents and userB's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docA1.id, docB.id, docA2.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
// Only userA's documents should be moved
expect(body.movedCount).toBe(2);
// Verify userA's documents were moved
const docA1InDb = await prisma.envelope.findFirst({
where: { id: docA1.id },
});
expect(docA1InDb).not.toBeNull();
expect(docA1InDb?.folderId).toBe(folderA.id);
const docA2InDb = await prisma.envelope.findFirst({
where: { id: docA2.id },
});
expect(docA2InDb).not.toBeNull();
expect(docA2InDb?.folderId).toBe(folderA.id);
// Verify userB's document was NOT moved
const docBInDb = await prisma.envelope.findFirst({
where: { id: docB.id },
});
expect(docBInDb).not.toBeNull();
expect(docBInDb?.folderId).toBeNull();
});
test('should move zero envelopes when all envelope IDs in array are unauthorized', async ({ request }) => {
// Create documents owned by userB
const docB1 = await seedBlankDocument(userB, teamB.id);
const docB2 = await seedBlankDocument(userB, teamB.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
});
// UserA tries to move userB's documents
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docB1.id, docB2.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.movedCount).toBe(0);
// Verify userB's documents were NOT moved
const docB1InDb = await prisma.envelope.findFirst({
where: { id: docB1.id },
});
expect(docB1InDb).not.toBeNull();
expect(docB1InDb?.folderId).toBeNull();
const docB2InDb = await prisma.envelope.findFirst({
where: { id: docB2.id },
});
expect(docB2InDb).not.toBeNull();
expect(docB2InDb?.folderId).toBeNull();
});
});
test('should allow authorized access to envelope bulk move endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
test.describe('Envelope bulk delete endpoint', () => {
test('should block unauthorized access to envelope bulk delete endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
// UserB tries to delete userA's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.deletedCount).toBe(0);
// Unauthorized envelope ID should be in failedIds
expect(body.failedIds).toEqual([doc.id]);
// Verify in database that the document still exists
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.id).toBe(doc.id);
expect(docInDb?.deletedAt).toBeNull();
});
// UserA moves their own document to their own folder
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
test('should allow authorized access to envelope bulk delete endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// UserA deletes their own document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [doc.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.deletedCount).toBe(1);
expect(body.failedIds).toEqual([]);
// Verify in database that the document no longer exists
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).toBeNull();
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
test('should only delete authorized envelopes when given mixed array of envelope IDs', async ({ request }) => {
// Create documents owned by userA
const docA1 = await seedBlankDocument(userA, teamA.id);
const docA2 = await seedBlankDocument(userA, teamA.id);
const body = await res.json();
expect(body.movedCount).toBe(1);
// Create a document owned by userB
const docB = await seedBlankDocument(userB, teamB.id);
// Verify in database that the document was moved to the folder
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
// UserA tries to delete a mix of their own documents and userB's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docA1.id, docB.id, docA2.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
// Only userA's documents should be deleted
expect(body.deletedCount).toBe(2);
// Unauthorized envelope ID (docB) should be in failedIds
expect(body.failedIds).toEqual([docB.id]);
// Verify userA's documents were deleted
const docA1InDb = await prisma.envelope.findFirst({
where: { id: docA1.id },
});
expect(docA1InDb).toBeNull();
const docA2InDb = await prisma.envelope.findFirst({
where: { id: docA2.id },
});
expect(docA2InDb).toBeNull();
// Verify userB's document was NOT deleted
const docBInDb = await prisma.envelope.findFirst({
where: { id: docB.id },
});
expect(docBInDb).not.toBeNull();
expect(docBInDb?.id).toBe(docB.id);
expect(docBInDb?.deletedAt).toBeNull();
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBe(folderA.id);
});
test('should delete zero envelopes when all envelope IDs in array are unauthorized', async ({ request }) => {
// Create documents owned by userB
const docB1 = await seedBlankDocument(userB, teamB.id);
const docB2 = await seedBlankDocument(userB, teamB.id);
test('should only move authorized envelopes when given mixed array of envelope IDs', async ({
request,
}) => {
// Create documents owned by userA
const docA1 = await seedBlankDocument(userA, teamA.id);
const docA2 = await seedBlankDocument(userA, teamA.id);
// UserA tries to delete userB's documents
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docB1.id, docB2.id],
},
});
// Create a document owned by userB
const docB = await seedBlankDocument(userB, teamB.id);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
const body = await res.json();
expect(body.deletedCount).toBe(0);
// All unauthorized envelope IDs should be in failedIds
expect(body.failedIds).toEqual(expect.arrayContaining([docB1.id, docB2.id]));
expect(body.failedIds).toHaveLength(2);
// Verify userB's documents were NOT deleted
const docB1InDb = await prisma.envelope.findFirst({
where: { id: docB1.id },
});
expect(docB1InDb).not.toBeNull();
expect(docB1InDb?.id).toBe(docB1.id);
expect(docB1InDb?.deletedAt).toBeNull();
const docB2InDb = await prisma.envelope.findFirst({
where: { id: docB2.id },
});
expect(docB2InDb).not.toBeNull();
expect(docB2InDb?.id).toBe(docB2.id);
expect(docB2InDb?.deletedAt).toBeNull();
});
// UserA tries to move a mix of their own documents and userB's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docA1.id, docB.id, docA2.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
// Only userA's documents should be moved
expect(body.movedCount).toBe(2);
// Verify userA's documents were moved
const docA1InDb = await prisma.envelope.findFirst({
where: { id: docA1.id },
});
expect(docA1InDb).not.toBeNull();
expect(docA1InDb?.folderId).toBe(folderA.id);
const docA2InDb = await prisma.envelope.findFirst({
where: { id: docA2.id },
});
expect(docA2InDb).not.toBeNull();
expect(docA2InDb?.folderId).toBe(folderA.id);
// Verify userB's document was NOT moved
const docBInDb = await prisma.envelope.findFirst({
where: { id: docB.id },
});
expect(docBInDb).not.toBeNull();
expect(docBInDb?.folderId).toBeNull();
});
test('should move zero envelopes when all envelope IDs in array are unauthorized', async ({
request,
}) => {
// Create documents owned by userB
const docB1 = await seedBlankDocument(userB, teamB.id);
const docB2 = await seedBlankDocument(userB, teamB.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
});
// UserA tries to move userB's documents
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docB1.id, docB2.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.movedCount).toBe(0);
// Verify userB's documents were NOT moved
const docB1InDb = await prisma.envelope.findFirst({
where: { id: docB1.id },
});
expect(docB1InDb).not.toBeNull();
expect(docB1InDb?.folderId).toBeNull();
const docB2InDb = await prisma.envelope.findFirst({
where: { id: docB2.id },
});
expect(docB2InDb).not.toBeNull();
expect(docB2InDb?.folderId).toBeNull();
});
});
test.describe('Envelope bulk delete endpoint', () => {
test('should block unauthorized access to envelope bulk delete endpoint', async ({
request,
}) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// UserB tries to delete userA's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.deletedCount).toBe(0);
// Unauthorized envelope ID should be in failedIds
expect(body.failedIds).toEqual([doc.id]);
// Verify in database that the document still exists
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.id).toBe(doc.id);
expect(docInDb?.deletedAt).toBeNull();
});
test('should allow authorized access to envelope bulk delete endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// UserA deletes their own document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [doc.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.deletedCount).toBe(1);
expect(body.failedIds).toEqual([]);
// Verify in database that the document no longer exists
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).toBeNull();
});
test('should only delete authorized envelopes when given mixed array of envelope IDs', async ({
request,
}) => {
// Create documents owned by userA
const docA1 = await seedBlankDocument(userA, teamA.id);
const docA2 = await seedBlankDocument(userA, teamA.id);
// Create a document owned by userB
const docB = await seedBlankDocument(userB, teamB.id);
// UserA tries to delete a mix of their own documents and userB's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docA1.id, docB.id, docA2.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
// Only userA's documents should be deleted
expect(body.deletedCount).toBe(2);
// Unauthorized envelope ID (docB) should be in failedIds
expect(body.failedIds).toEqual([docB.id]);
// Verify userA's documents were deleted
const docA1InDb = await prisma.envelope.findFirst({
where: { id: docA1.id },
});
expect(docA1InDb).toBeNull();
const docA2InDb = await prisma.envelope.findFirst({
where: { id: docA2.id },
});
expect(docA2InDb).toBeNull();
// Verify userB's document was NOT deleted
const docBInDb = await prisma.envelope.findFirst({
where: { id: docB.id },
});
expect(docBInDb).not.toBeNull();
expect(docBInDb?.id).toBe(docB.id);
expect(docBInDb?.deletedAt).toBeNull();
});
test('should delete zero envelopes when all envelope IDs in array are unauthorized', async ({
request,
}) => {
// Create documents owned by userB
const docB1 = await seedBlankDocument(userB, teamB.id);
const docB2 = await seedBlankDocument(userB, teamB.id);
// UserA tries to delete userB's documents
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docB1.id, docB2.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.deletedCount).toBe(0);
// All unauthorized envelope IDs should be in failedIds
expect(body.failedIds).toEqual(expect.arrayContaining([docB1.id, docB2.id]));
expect(body.failedIds).toHaveLength(2);
// Verify userB's documents were NOT deleted
const docB1InDb = await prisma.envelope.findFirst({
where: { id: docB1.id },
});
expect(docB1InDb).not.toBeNull();
expect(docB1InDb?.id).toBe(docB1.id);
expect(docB1InDb?.deletedAt).toBeNull();
const docB2InDb = await prisma.envelope.findFirst({
where: { id: docB2.id },
});
expect(docB2InDb).not.toBeNull();
expect(docB2InDb?.id).toBe(docB2.id);
expect(docB2InDb?.deletedAt).toBeNull();
});
});
});
@@ -1,8 +1,5 @@
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
@@ -16,6 +13,8 @@ import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import type { TUpdateEnvelopeItemsRequest } from '@documenso/trpc/server/envelope-router/update-envelope-items.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
@@ -64,11 +63,7 @@ const getEnvelope = async (request: APIRequestContext, authToken: string, envelo
* 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 distributeEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => {
const recipientEmail = `signer-${Date.now()}@test.documenso.com`;
// Create a SIGNER recipient.
@@ -228,9 +223,7 @@ test.describe('Update envelope items', () => {
expect(dbItem.title).toBe('Updated Pending Title');
});
test('should allow title-only update when order matches existing on a PENDING envelope', async ({
request,
}) => {
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);
@@ -318,9 +311,7 @@ test.describe('Update envelope items', () => {
expect(res.status()).toBe(400);
});
test('should reject combined title and order change on a PENDING envelope', async ({
request,
}) => {
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);
@@ -499,9 +490,7 @@ test.describe('Update envelope items', () => {
expect(res.ok()).toBeFalsy();
});
test('should reject update for an envelope item that does not belong to the envelope', async ({
request,
}) => {
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);
@@ -1,23 +1,16 @@
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 { expect, type Page, test } from '@playwright/test';
import path from 'path';
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 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 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');
@@ -89,9 +82,7 @@ const uploadPdf = async (page: Page, team: { url: string }, pdfPath: string) =>
};
test.describe('PDF Placeholders with single recipient', () => {
test('[AUTO_PLACING_FIELDS]: should create placeholder recipients even with default recipients', async ({
page,
}) => {
test('[AUTO_PLACING_FIELDS]: should create placeholder recipients even with default recipients', async ({ page }) => {
const { user, team } = await seedUser();
await setTeamDefaultRecipients(team.id, [
@@ -115,9 +106,7 @@ test.describe('PDF Placeholders with single recipient', () => {
where: { envelopeId },
});
const placeholderRecipient = recipients.find(
(recipient) => recipient.email === 'recipient.1@documenso.com',
);
const placeholderRecipient = recipients.find((recipient) => recipient.email === 'recipient.1@documenso.com');
const defaultRecipient = recipients.find((recipient) => recipient.email === user.email);
@@ -133,23 +122,17 @@ test.describe('PDF Placeholders with single recipient', () => {
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
page,
}) => {
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.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,
}) => {
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);
@@ -164,9 +147,7 @@ test.describe('PDF Placeholders with single recipient', () => {
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
page,
}) => {
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);
@@ -187,26 +168,18 @@ test.describe('PDF Placeholders with single recipient', () => {
});
test.describe('PDF Placeholders with multiple recipients', () => {
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
page,
}) => {
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').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(1)).toHaveValue('recipient.2@documenso.com');
await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue(
'recipient.3@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.
@@ -223,9 +196,7 @@ test.describe('PDF Placeholders with multiple recipients', () => {
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({
page,
}) => {
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);
@@ -244,9 +215,7 @@ test.describe('PDF Placeholders with multiple recipients', () => {
});
test.describe('PDF Placeholders without recipient identifier', () => {
test('[AUTO_PLACING_FIELDS]: should skip placeholders without a recipient identifier', async ({
page,
}) => {
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);
@@ -277,9 +246,7 @@ test.describe('PDF Placeholders without recipient identifier', () => {
});
test.describe('PDF Placeholders with invalid field types', () => {
test('[AUTO_PLACING_FIELDS]: should skip invalid field types and process valid ones', async ({
page,
}) => {
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);
@@ -1,7 +1,6 @@
import { expect, test } from '@playwright/test';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -1,9 +1,8 @@
import { expect, test } from '@playwright/test';
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -1,17 +1,10 @@
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { seedPendingDocumentNoFields, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import {
createDocumentAuthOptions,
createRecipientAuthOptions,
} from '@documenso/lib/utils/document-auth';
import {
seedPendingDocumentNoFields,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
@@ -106,9 +99,7 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
});
// Currently document auth for signing/approving/viewing is not required.
test.skip('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({
page,
}) => {
test.skip('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({ page }) => {
const { user, team } = await seedUser();
const { user: recipientWithAccount } = await seedUser();
@@ -133,14 +124,10 @@ test.skip('[DOCUMENT_AUTH]: should deny signing document when required for globa
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign the document',
);
await expect(page.getByRole('paragraph')).toContainText('Reauthentication is required to sign the document');
});
test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth', async ({
page,
}) => {
test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth', async ({ page }) => {
const { user, team } = await seedUser();
const { user: recipientWithAccount } = await seedUser();
@@ -170,17 +157,13 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth'
}
await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign this field',
);
await expect(page.getByRole('paragraph')).toContainText('Reauthentication is required to sign this field');
await page.getByRole('button', { name: 'Cancel' }).click();
}
}
});
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient auth', async ({
page,
}) => {
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient auth', async ({ page }) => {
const { user, team } = await seedUser();
const { user: recipientWithInheritAuth } = await seedUser();
@@ -190,11 +173,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [
recipientWithInheritAuth,
recipientWithExplicitNoneAuth,
recipientWithExplicitAccountAuth,
],
recipients: [recipientWithInheritAuth, recipientWithExplicitNoneAuth, recipientWithExplicitAccountAuth],
recipientsCreateOptions: [
{
authOptions: createRecipientAuthOptions({
@@ -237,9 +216,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
}
await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign this field',
);
await expect(page.getByRole('paragraph')).toContainText('Reauthentication is required to sign this field');
await page.getByRole('button', { name: 'Cancel' }).click();
}
@@ -278,9 +255,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
}
});
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient and global auth', async ({
page,
}) => {
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient and global auth', async ({ page }) => {
const { user, team } = await seedUser();
const { user: recipientWithInheritAuth } = await seedUser();
@@ -290,11 +265,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [
recipientWithInheritAuth,
recipientWithExplicitNoneAuth,
recipientWithExplicitAccountAuth,
],
recipients: [recipientWithInheritAuth, recipientWithExplicitNoneAuth, recipientWithExplicitAccountAuth],
recipientsCreateOptions: [
{
authOptions: createRecipientAuthOptions({
@@ -343,9 +314,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
}
await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign this field',
);
await expect(page.getByRole('paragraph')).toContainText('Reauthentication is required to sign this field');
await page.getByRole('button', { name: 'Cancel' }).click();
}
@@ -0,0 +1,163 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { FieldType, SigningStatus } from '@prisma/client';
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
type SeededEnvelopes = {
assistantToken: string;
otherEnvelopeFieldId: number;
};
/**
* Seeds two unrelated pending envelopes:
* - Envelope A has an ASSISTANT (with a token) plus a SIGNER.
* - Envelope B is owned by a different user and has a SIGNER with a TEXT field.
*
* Returns the assistant's token from envelope A and the TEXT field id from
* envelope B so callers can exercise signing routes across envelopes.
*/
const seedTwoPendingEnvelopes = async (request: APIRequestContext): Promise<SeededEnvelopes> => {
const envelopeA = await apiSeedPendingDocument(request, {
title: '[TEST] Envelope A',
recipients: [
{
email: `assistant-${Date.now()}@documenso.com`,
name: 'Assistant',
role: 'ASSISTANT',
signingOrder: 1,
},
{
email: `signer-a-${Date.now()}@documenso.com`,
name: 'Signer A',
role: 'SIGNER',
signingOrder: 2,
},
],
fieldsPerRecipient: [
[],
// SIGNER needs a SIGNATURE field so distribution succeeds.
[{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 }],
],
});
const assistant = envelopeA.distributeResult.recipients.find((r) => r.role === 'ASSISTANT');
if (!assistant) {
throw new Error('Assistant recipient not found in envelope A');
}
const envelopeB = await apiSeedPendingDocument(request, {
title: '[TEST] Envelope B',
recipients: [
{
email: `signer-b-${Date.now()}@documenso.com`,
name: 'Signer B',
role: 'SIGNER',
signingOrder: 1,
},
],
// A TEXT field is used as the cross-envelope target. The V2 route has a
// separate guard that blocks assistants from signing SIGNATURE fields,
// which would mask whether the recipient lookup itself was scoped.
fieldsPerRecipient: [
[
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 },
{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
],
],
});
const otherEnvelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelopeB.envelope.id },
include: { fields: true },
});
const textField = otherEnvelope.fields.find((f) => f.type === FieldType.TEXT);
if (!textField) {
throw new Error('TEXT field not found in envelope B');
}
return {
assistantToken: assistant.token,
otherEnvelopeFieldId: textField.id,
};
};
const trpcMutation = async (request: APIRequestContext, procedure: string, input: Record<string, unknown>) => {
return await request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
headers: { 'content-type': 'application/json' },
data: JSON.stringify({ json: input }),
});
};
test.describe('[ASSISTANT_SIGNING_AUTH]: cross-envelope field access', () => {
test('envelope.field.sign (V2) rejects fieldId from another envelope', async ({ request }) => {
const { assistantToken, otherEnvelopeFieldId } = await seedTwoPendingEnvelopes(request);
const res = await trpcMutation(request, 'envelope.field.sign', {
token: assistantToken,
fieldId: otherEnvelopeFieldId,
fieldValue: { type: FieldType.TEXT, value: 'TEXT' },
});
expect(res.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: otherEnvelopeFieldId },
});
expect(fieldAfter.inserted).toBe(false);
expect(fieldAfter.customText).toBe('');
});
test('field.signFieldWithToken (V1) rejects fieldId from another envelope', async ({ request }) => {
const { assistantToken, otherEnvelopeFieldId } = await seedTwoPendingEnvelopes(request);
const res = await trpcMutation(request, 'field.signFieldWithToken', {
token: assistantToken,
fieldId: otherEnvelopeFieldId,
value: 'TEXT',
isBase64: false,
});
expect(res.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: otherEnvelopeFieldId },
});
expect(fieldAfter.inserted).toBe(false);
expect(fieldAfter.customText).toBe('');
});
test('field.removeSignedFieldWithToken (V1) rejects fieldId from another envelope', async ({ request }) => {
const { assistantToken, otherEnvelopeFieldId } = await seedTwoPendingEnvelopes(request);
// Pre-insert the field so a successful (incorrect) uninsert is detectable.
await prisma.field.update({
where: { id: otherEnvelopeFieldId },
data: { inserted: true, customText: 'pre-existing-value' },
});
const res = await trpcMutation(request, 'field.removeSignedFieldWithToken', {
token: assistantToken,
fieldId: otherEnvelopeFieldId,
});
expect(res.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: otherEnvelopeFieldId },
include: { recipient: true },
});
expect(fieldAfter.inserted).toBe(true);
expect(fieldAfter.customText).toBe('pre-existing-value');
expect(fieldAfter.recipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
});
});
@@ -1,21 +1,12 @@
import { expect, test } from '@playwright/test';
import {
DocumentSigningOrder,
DocumentStatus,
FieldType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { signDirectSignaturePad, signSignaturePad } from '../fixtures/signature';
test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dictation is enabled', async ({
page,
}) => {
test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dictation is enabled', async ({ page }) => {
const { user, team } = await seedUser();
const { user: firstSigner } = await seedUser();
const { user: secondSigner } = await seedUser();
@@ -157,9 +148,7 @@ test('[NEXT_RECIPIENT_DICTATION]: should not show dictation UI when disabled', a
await page.getByRole('button', { name: 'Complete' }).click();
// Verify next recipient UI is not shown
await expect(
page.getByText('The next recipient to sign this document will be'),
).not.toBeVisible();
await expect(page.getByText('The next recipient to sign this document will be')).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
// Submit and verify completion
@@ -243,9 +232,7 @@ test('[NEXT_RECIPIENT_DICTATION]: should work with parallel signing flow', async
await page.getByRole('button', { name: 'Complete' }).click();
// Verify next recipient UI is not shown in parallel flow
await expect(
page.getByText('The next recipient to sign this document will be'),
).not.toBeVisible();
await expect(page.getByText('The next recipient to sign this document will be')).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
// Submit and verify completion
@@ -274,9 +261,7 @@ test('[NEXT_RECIPIENT_DICTATION]: should work with parallel signing flow', async
}).toPass();
});
test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer', async ({
page,
}) => {
test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer', async ({ page }) => {
const { user, team } = await seedUser();
const { user: assistant } = await seedUser();
const { user: signer } = await seedUser();
@@ -1,10 +1,9 @@
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';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -64,10 +63,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Cancel' }).click();
await triggerAutosave(page);
@@ -118,10 +114,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Cancel' }).click();
await triggerAutosave(page);
@@ -180,10 +173,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Cancel' }).click();
await triggerAutosave(page);
@@ -244,10 +234,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder');
await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text');
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Save' })
.click();
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Save' }).click();
await triggerAutosave(page);
@@ -269,11 +256,7 @@ test.describe('AutoSave Fields Step', () => {
expect(textField.fieldMeta).toBeDefined();
if (
textField.fieldMeta &&
typeof textField.fieldMeta === 'object' &&
'type' in textField.fieldMeta
) {
if (textField.fieldMeta && typeof textField.fieldMeta === 'object' && 'type' in textField.fieldMeta) {
expect(textField.fieldMeta.type).toBe('text');
expect(textField.fieldMeta.label).toBe('Test Field');
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
@@ -1,10 +1,9 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
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';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
@@ -1,12 +1,11 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
@@ -167,15 +166,9 @@ test.describe('AutoSave Signers Step', () => {
expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true);
expect(retrievedRecipients.length).toBe(3);
const firstRecipient = retrievedRecipients.find(
(r) => r.email === 'recipient1@documenso.com',
);
const secondRecipient = retrievedRecipients.find(
(r) => r.email === 'recipient2@documenso.com',
);
const thirdRecipient = retrievedRecipients.find(
(r) => r.email === 'recipient3@documenso.com',
);
const firstRecipient = retrievedRecipients.find((r) => r.email === 'recipient1@documenso.com');
const secondRecipient = retrievedRecipients.find((r) => r.email === 'recipient2@documenso.com');
const thirdRecipient = retrievedRecipients.find((r) => r.email === 'recipient3@documenso.com');
expect(firstRecipient?.signingOrder).toBe(2);
expect(secondRecipient?.signingOrder).toBe(3);
@@ -1,11 +1,10 @@
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';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
@@ -109,9 +108,7 @@ test.describe('AutoSave Subject Step', () => {
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
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 the document is completed', { exact: true }).click();
await page.getByText('Email recipients when a pending document is deleted').click();
await triggerAutosave(page);
@@ -132,30 +129,22 @@ test.describe('AutoSave Subject Step', () => {
await expect(page.getByText('Email the owner when a recipient signs')).toBeChecked({
checked: emailSettings?.recipientSigned,
});
await expect(
page.getByText("Email recipients when they're removed from a pending document"),
).toBeChecked({
await expect(page.getByText("Email recipients when they're removed from a pending document")).toBeChecked({
checked: emailSettings?.recipientRemoved,
});
await expect(
page.getByText('Email recipients when the document is completed', { exact: true }),
).toBeChecked({
await expect(page.getByText('Email recipients when the document is completed', { exact: true })).toBeChecked({
checked: emailSettings?.documentCompleted,
});
await expect(
page.getByText('Email recipients when a pending document is deleted'),
).toBeChecked({
await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({
checked: emailSettings?.documentDeleted,
});
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
checked: emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
{
checked: emailSettings?.documentPending,
},
);
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,
});
@@ -174,9 +163,7 @@ test.describe('AutoSave Subject Step', () => {
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 the document is completed', { exact: true }).click();
await page.getByText('Email recipients when a pending document is deleted').click();
await triggerAutosave(page);
@@ -206,30 +193,22 @@ test.describe('AutoSave Subject Step', () => {
await expect(page.getByText('Email the owner when a recipient signs')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigned,
});
await expect(
page.getByText("Email recipients when they're removed from a pending document"),
).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('Email recipients when the document is completed', { 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('Email recipients when a pending document is deleted'),
).toBeChecked({
await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
});
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
});
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 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,
});
@@ -1,8 +1,7 @@
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';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -1,10 +1,9 @@
import { type Page, expect, test } from '@playwright/test';
import type { Document, Team } from '@prisma/client';
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';
import { expect, type Page, test } from '@playwright/test';
import type { Document, Team } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
@@ -12,11 +11,7 @@ import { signSignaturePad } from '../fixtures/signature';
/**
* Test helper to complete the document creation flow with duplicate recipients
*/
const completeDocumentFlowWithDuplicateRecipients = async (options: {
page: Page;
team: Team;
document: Document;
}) => {
const completeDocumentFlowWithDuplicateRecipients = async (options: { page: Page; team: Team; document: Document }) => {
const { page, team, document } = options;
// Step 1: Settings - Continue with defaults
@@ -97,9 +92,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await expect(page).toHaveURL(new RegExp(`/t/${team.url}/documents`));
});
test('should allow adding duplicate recipient after saving document initially', async ({
page,
}) => {
test('should allow adding duplicate recipient after saving document initially', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
@@ -162,10 +155,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
test('should isolate fields per recipient token even with duplicate emails', async ({
page,
context,
}) => {
test('should isolate fields per recipient token even with duplicate emails', async ({ page, context }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
@@ -208,9 +198,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
// Verify only one signature field is visible for this recipient
expect(
await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(),
).toHaveLength(1);
expect(await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all()).toHaveLength(1);
// Verify recipient name is correct
await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name);
@@ -218,10 +206,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Sign the document
await signSignaturePad(page);
await page
.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])')
.first()
.click();
await page.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])').first().click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
@@ -232,9 +217,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
}
});
test('should handle duplicate recipient workflow with different field types', async ({
page,
}) => {
test('should handle duplicate recipient workflow with different field types', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
@@ -300,9 +283,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
test('should preserve field assignments when editing document with duplicates', async ({
page,
}) => {
test('should preserve field assignments when editing document with duplicates', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
@@ -333,18 +314,14 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
// Verify their field is visible and can be selected
const firstRecipientFields = await page
.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`)
.all();
const firstRecipientFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all();
expect(firstRecipientFields.length).toBeGreaterThan(0);
// Switch to second duplicate recipient
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
// Verify they have their own field
const secondRecipientFields = await page
.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`)
.all();
const secondRecipientFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all();
expect(secondRecipientFields.length).toBeGreaterThan(0);
// Add another field to the second duplicate
@@ -1,11 +1,6 @@
import { expect, test } from '@playwright/test';
import {
seedBlankDocument,
seedDraftDocument,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedBlankDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -1,7 +1,6 @@
import { expect, test } from '@playwright/test';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -1,21 +1,11 @@
import { expect, test } from '@playwright/test';
import {
DocumentSigningOrder,
DocumentStatus,
FieldType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
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,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
@@ -121,9 +111,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
});
test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients', async ({
page,
}) => {
test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
@@ -304,9 +292,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await expect(page.getByRole('link', { name: 'Test Title' })).toBeVisible();
});
test('[DOCUMENT_FLOW]: should not be able to create a document without signatures', async ({
page,
}) => {
test('[DOCUMENT_FLOW]: should not be able to create a document without signatures', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
@@ -337,9 +323,7 @@ test('[DOCUMENT_FLOW]: should not be able to create a document without signature
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(
page.getByRole('dialog').getByText('No signature field found').first(),
).toBeVisible();
await expect(page.getByRole('dialog').getByText('No signature field found').first()).toBeVisible();
});
test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) => {
@@ -382,12 +366,8 @@ test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) =
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
await page
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Complete' : 'Approve' })
.click();
await page
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
.click();
await page.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Complete' : 'Approve' }).click();
await page.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' }).click();
await page.waitForURL(`${signUrl}/complete`);
}
});
@@ -460,10 +440,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
await page.getByRole('button', { name: 'Approve' }).click();
await expect(
page
.getByRole('dialog')
.getByText('You are about to complete approving the following document')
.first(),
page.getByRole('dialog').getByText('You are about to complete approving the following document').first(),
).toBeVisible();
await page.getByRole('button', { name: 'Approve' }).click();
@@ -606,9 +583,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
expect(createdDocument?.recipients.length).toBe(3);
for (let i = 0; i < 3; i++) {
const recipient = createdDocument?.recipients.find(
(r) => r.email === `user${i + 1}@example.com`,
);
const recipient = createdDocument?.recipients.find((r) => r.email === `user${i + 1}@example.com`);
expect(recipient).not.toBeNull();
const fields = await prisma.field.findMany({
@@ -653,9 +628,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
expect(finalDocument?.status).toBe(DocumentStatus.COMPLETED);
});
test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode', async ({
page,
}) => {
test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode', async ({ page }) => {
const { user, team } = await seedUser();
const { document, recipients } = await seedPendingDocumentWithFullFields({
@@ -1,9 +1,7 @@
import type { Download } from '@playwright/test';
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 { type Download, expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
@@ -1,11 +1,6 @@
import { expect, test } from '@playwright/test';
import {
seedCompletedDocument,
seedDraftDocument,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
@@ -72,9 +67,7 @@ test('[DOCUMENTS]: seeded documents should be visible', async ({ page }) => {
}
});
test('[DOCUMENTS]: deleting a completed document should not remove it from recipients', async ({
page,
}) => {
test('[DOCUMENTS]: deleting a completed document should not remove it from recipients', async ({ page }) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
@@ -116,9 +109,7 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
}
});
test('[DOCUMENTS]: deleting a pending document should remove it from recipients', async ({
page,
}) => {
test('[DOCUMENTS]: deleting a pending document should remove it from recipients', async ({ page }) => {
const { sender, pendingDocument } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
@@ -223,9 +214,7 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({
page,
}) => {
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({ page }) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
@@ -273,9 +262,7 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({
page,
}) => {
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({ page }) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
const recipientA = recipients[0];
const recipientB = recipients[1];
@@ -1,11 +1,3 @@
import { expect, test } from '@playwright/test';
import {
DocumentStatus,
DocumentVisibility,
OrganisationMemberRole,
TeamMemberRole,
} from '@prisma/client';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
@@ -17,6 +9,8 @@ import {
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, DocumentVisibility, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
@@ -66,9 +60,7 @@ test.describe('Find Documents UI - Personal Context', () => {
await checkDocumentTabCount(page, 'Completed', 1);
});
test('received documents from other teams should NOT appear in personal context', async ({
page,
}) => {
test('received documents from other teams should NOT appear in personal context', async ({ page }) => {
// The UI always uses the team code path (findTeamDocumentsFilter) which filters by teamId.
// Documents sent TO a user by another user's team live on the sender's teamId,
// so they do NOT appear in the recipient's personal team context.
@@ -97,12 +89,8 @@ test.describe('Find Documents UI - Personal Context', () => {
// Only the owner's own doc should appear (received docs are on sender's team)
await checkDocumentTabCount(page, 'All', 1);
await expect(page.getByRole('link', { name: 'Owner Own Draft' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'Received Pending Doc', exact: true }),
).not.toBeVisible();
await expect(
page.getByRole('link', { name: 'Received Completed Doc', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Received Pending Doc', exact: true })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Received Completed Doc', exact: true })).not.toBeVisible();
});
test('should NOT show documents from other users', async ({ page }) => {
@@ -120,9 +108,7 @@ test.describe('Find Documents UI - Personal Context', () => {
});
await checkDocumentTabCount(page, 'All', 0);
await expect(
page.getByRole('link', { name: 'UserB Secret Document', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'UserB Secret Document', exact: true })).not.toBeVisible();
});
test('personal context without team email should show 0 inbox', async ({ page }) => {
@@ -175,9 +161,7 @@ test.describe('Find Documents UI - Personal Context', () => {
await checkDocumentTabCount(page, 'All', 1);
await expect(page.getByRole('link', { name: 'Quarterly Report 2024' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'Annual Budget Plan', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Annual Budget Plan', exact: true })).not.toBeVisible();
});
test('should not show deleted documents', async ({ page }) => {
@@ -203,9 +187,7 @@ test.describe('Find Documents UI - Personal Context', () => {
await checkDocumentTabCount(page, 'All', 1);
await expect(page.getByRole('link', { name: 'Active Personal Doc' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'Deleted Personal Doc', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Deleted Personal Doc', exact: true })).not.toBeVisible();
});
test('should only show root-level documents when not in a folder', async ({ page }) => {
@@ -235,9 +217,7 @@ test.describe('Find Documents UI - Personal Context', () => {
await checkDocumentTabCount(page, 'All', 1);
await expect(page.getByRole('link', { name: 'Root Level Doc' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'Folder Level Doc', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Folder Level Doc', exact: true })).not.toBeVisible();
});
});
@@ -290,9 +270,7 @@ test.describe('Find Documents UI - Team Context', () => {
await expect(page.getByRole('link', { name: 'Team Pending Doc' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Team Draft Doc' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Team Completed Doc' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'Outside Noise Doc', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Outside Noise Doc', exact: true })).not.toBeVisible();
await apiSignout({ page });
}
@@ -346,9 +324,7 @@ test.describe('Find Documents UI - Team Context', () => {
await expect(page.getByRole('link', { name: 'Team A Draft' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Team A Completed' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Team B Draft', exact: true })).not.toBeVisible();
await expect(
page.getByRole('link', { name: 'Team B Completed', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Team B Completed', exact: true })).not.toBeVisible();
});
test('should NOT show personal documents in team context', async ({ page }) => {
@@ -383,9 +359,7 @@ test.describe('Find Documents UI - Team Context', () => {
});
await expect(page.getByRole('link', { name: 'Team Doc by Member' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'Personal Doc not in Team', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Personal Doc not in Team', exact: true })).not.toBeVisible();
});
test('should enforce ADMIN visibility correctly across roles', async ({ page }) => {
@@ -478,16 +452,12 @@ test.describe('Find Documents UI - Team Context', () => {
});
await checkDocumentTabCount(page, 'Completed', 1);
await expect(page.getByRole('link', { name: 'Everyone Doc' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'Manager Plus Doc', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Manager Plus Doc', exact: true })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Admin Only Doc', exact: true })).not.toBeVisible();
await apiSignout({ page });
});
test('document owner sees their document regardless of visibility restriction', async ({
page,
}) => {
test('document owner sees their document regardless of visibility restriction', async ({ page }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
@@ -538,9 +508,7 @@ test.describe('Find Documents UI - Team Context', () => {
await checkDocumentTabCount(page, 'Completed', 2);
await expect(page.getByRole('link', { name: 'Member Owned Admin Doc' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Everyone Doc Control' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'Owner Admin Doc Hidden', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Owner Admin Doc Hidden', exact: true })).not.toBeVisible();
await apiSignout({ page });
});
@@ -596,9 +564,7 @@ test.describe('Find Documents UI - Team Context', () => {
await checkDocumentTabCount(page, 'Completed', 2);
await expect(page.getByRole('link', { name: 'Admin Doc Member Recipient' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Everyone Doc Baseline' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'Admin Doc No Member', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Admin Doc No Member', exact: true })).not.toBeVisible();
await apiSignout({ page });
});
@@ -785,9 +751,7 @@ test.describe('Find Documents UI - Team with Team Email', () => {
await checkDocumentTabCount(page, 'All', 2);
await expect(page.getByRole('link', { name: 'Sent by Holder Pending' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Sent by Holder Completed' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'External Own Draft', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'External Own Draft', exact: true })).not.toBeVisible();
});
});
@@ -846,15 +810,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
// Verify no B docs leaked
await page.getByRole('tab', { name: 'All' }).click();
await expect(page.getByRole('link', { name: 'A Own Draft' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'B Draft Private', exact: true }),
).not.toBeVisible();
await expect(
page.getByRole('link', { name: 'B Pending Private', exact: true }),
).not.toBeVisible();
await expect(
page.getByRole('link', { name: 'B Completed Private', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'B Draft Private', exact: true })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'B Pending Private', exact: true })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'B Completed Private', exact: true })).not.toBeVisible();
});
test('team member cannot see documents from another team via search', async ({ page }) => {
@@ -903,9 +861,7 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
// Should find the TeamA doc but NOT the TeamB doc
await checkDocumentTabCount(page, 'All', 1);
await expect(page.getByRole('link', { name: 'Super Secret TeamA Document' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'Super Secret TeamB Document', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Super Secret TeamB Document', exact: true })).not.toBeVisible();
});
test('search by recipient name should respect team visibility', async ({ page }) => {
@@ -957,9 +913,7 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
await apiSignout({ page });
});
test('outside user does NOT see cross-team received docs in their personal context', async ({
page,
}) => {
test('outside user does NOT see cross-team received docs in their personal context', async ({ page }) => {
// The UI always uses the team code path (findTeamDocumentsFilter) which filters by teamId.
// Documents from team.id will NOT appear in outsideTeam's context.
const { team, owner } = await seedTeam();
@@ -1009,12 +963,8 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
await checkDocumentTabCount(page, 'Inbox', 0); // No team email → 0
await checkDocumentTabCount(page, 'All', 1); // Check All tab last so we can verify visible links
await expect(page.getByRole('link', { name: 'Outside Own Draft' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'Team Doc For Outside User', exact: true }),
).not.toBeVisible();
await expect(
page.getByRole('link', { name: 'Team Doc For Other User Only', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Team Doc For Outside User', exact: true })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Team Doc For Other User Only', exact: true })).not.toBeVisible();
});
});
@@ -1,12 +1,7 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
seedBlankDocument,
seedCompletedDocument,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedBlankDocument, seedCompletedDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -1,8 +1,5 @@
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';
@@ -15,6 +12,8 @@ import type {
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 { expect, type Page, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import {
@@ -34,11 +33,7 @@ const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../asset
/**
* Place a field on the PDF canvas in the envelope editor.
*/
const placeFieldOnPdf = async (
root: Page,
fieldName: 'Signature' | 'Text',
position: { x: number; y: number },
) => {
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();
@@ -72,10 +67,7 @@ const createPendingEnvelopeViaApi = async () => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }),
);
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
const createRes = await fetch(`${V2_API_BASE_URL}/envelope/create`, {
method: 'POST',
@@ -224,9 +216,7 @@ test.describe('document editor', () => {
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();
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();
@@ -235,9 +225,7 @@ test.describe('document editor', () => {
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();
await expect(page.getByText('The following signers are missing signature fields')).not.toBeVisible();
});
test('resend document sends reminder', async ({ page }) => {
@@ -1,16 +1,15 @@
import { type Page, expect, test } from '@playwright/test';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { expect, type Page, test } from '@playwright/test';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
@@ -67,10 +66,7 @@ const getAttachmentItems = (root: Page) => root.locator('.rounded-md.border.bord
const getAttachmentDeleteButtons = (root: Page) => getAttachmentItems(root).locator('button');
const assertAttachmentVisibleInPopover = async (
root: Page,
{ label, url }: { label: string; url: string },
) => {
const assertAttachmentVisibleInPopover = async (root: Page, { label, url }: { label: string; url: string }) => {
const items = getAttachmentItems(root);
const matchingItem = items.filter({ hasText: label });
@@ -137,9 +133,7 @@ const assertAttachmentsInDatabase = async ({
}
};
const runAttachmentFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<AttachmentFlowResult> => {
const runAttachmentFlow = async (surface: TEnvelopeEditorSurface): Promise<AttachmentFlowResult> => {
const externalId = `e2e-attachments-${nanoid()}`;
await updateExternalId(surface, externalId);
@@ -205,9 +199,7 @@ const runAttachmentFlow = async (
};
};
const runEmbeddedAttachmentFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<AttachmentFlowResult> => {
const runEmbeddedAttachmentFlow = async (surface: TEnvelopeEditorSurface): Promise<AttachmentFlowResult> => {
const externalId = `e2e-attachments-${nanoid()}`;
await updateExternalId(surface, externalId);
@@ -1,20 +1,19 @@
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 { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
createEmbeddedEnvelopeCreateHash,
getEnvelopeEditorSettingsTrigger,
openEmbeddedEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
@@ -33,10 +32,14 @@ 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 }
*
* The `%` on saturation and lightness is required: theme.css consumes these
* via `hsl(var(--token))`, and CSS Color 4 space-separated `hsl()` rejects
* bare numbers there. See `apps/remix/app/utils/css-vars.ts`.
*/
const EXPECTED_CSS_VARS = {
'--background': '0 100 50',
'--primary': '120 100 50',
'--background': '0 100% 50%',
'--primary': '120 100% 50%',
'--radius': '1rem',
};
@@ -65,7 +68,7 @@ const enableEmbedAuthoringWhiteLabel = async (userId: number) => {
const DEFAULT_BODY_BG_COLOR = 'rgb(255, 255, 255)';
/**
* When `--background` is set to `0 100 50` (hsl(0, 100%, 50%)) the body background
* 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)';
@@ -185,9 +188,7 @@ const openEmbeddedCreateWithUser = async (
folderId: options.folderId,
});
await page.goto(
`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(presignToken)}#${hash}`,
);
await page.goto(`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(presignToken)}#${hash}`);
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
@@ -228,8 +229,7 @@ const setupMinimalEnvelope = async (surface: TEnvelopeEditorSurface, 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';
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');
@@ -1,11 +1,9 @@
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 { expect, type Page, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
clickAddMyselfButton,
clickAddSignerButton,
@@ -19,6 +17,7 @@ import {
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { getKonvaElementCountForPage } from '../fixtures/konva';
@@ -78,11 +77,7 @@ type FieldButtonName =
| 'Checkbox'
| 'Dropdown';
const placeFieldOnPdf = async (
root: Page,
fieldName: FieldButtonName,
position: { x: number; y: number },
) => {
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();
@@ -103,9 +98,7 @@ const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number
await canvas.click({ position, force: true });
};
const runAddAndPersistSignatureTextFields = async (
surface: TEnvelopeEditorSurface,
): Promise<TFieldFlowResult> => {
const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
const externalId = `e2e-fields-${nanoid()}`;
if (surface.isEmbedded && !surface.envelopeId) {
@@ -150,8 +143,7 @@ const getFieldMetaType = (fieldMeta: unknown) => {
return typeof fieldMeta.type === 'string' ? fieldMeta.type : null;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
const assertFieldsPersistedInDatabase = async ({
surface,
@@ -178,9 +170,7 @@ const assertFieldsPersistedInDatabase = async ({
},
});
const recipient = envelope.recipients.find(
(currentRecipient) => currentRecipient.email === recipientEmail,
);
const recipient = envelope.recipients.find((currentRecipient) => currentRecipient.email === recipientEmail);
expect(recipient).toBeDefined();
@@ -214,9 +204,7 @@ const MULTI_RECIPIENT_VALUES = {
},
};
const runMultiRecipientFieldFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<TMultiRecipientFlowResult> => {
const runMultiRecipientFieldFlow = async (surface: TEnvelopeEditorSurface): Promise<TMultiRecipientFlowResult> => {
const externalId = `e2e-multi-recip-${nanoid()}`;
const root = surface.root;
@@ -322,9 +310,7 @@ type TAllFieldTypesFlowResult = {
externalId: string;
};
const runAllFieldTypesFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<TAllFieldTypesFlowResult> => {
const runAllFieldTypesFlow = async (surface: TEnvelopeEditorSurface): Promise<TAllFieldTypesFlowResult> => {
const externalId = `e2e-all-fields-${nanoid()}`;
const root = surface.root;
@@ -562,9 +548,7 @@ type TDuplicateDeleteFlowResult = {
externalId: string;
};
const runDuplicateDeleteFieldFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<TDuplicateDeleteFlowResult> => {
const runDuplicateDeleteFieldFlow = async (surface: TEnvelopeEditorSurface): Promise<TDuplicateDeleteFlowResult> => {
const externalId = `e2e-dup-del-${nanoid()}`;
const root = surface.root;
@@ -1,12 +1,10 @@
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 { expect, type Page, test } from '@playwright/test';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
clickAddMyselfButton,
clickEnvelopeEditorStep,
@@ -17,6 +15,7 @@ import {
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
@@ -29,9 +28,7 @@ test.use({
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const multiPagePdfBuffer = fs.readFileSync(
path.join(__dirname, '../../../../assets/field-font-alignment.pdf'),
);
const multiPagePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/field-font-alignment.pdf'));
// --- Shared helpers ---
@@ -55,27 +52,20 @@ const getEditButton = (root: Page, index: number) =>
const getEditDialog = (root: Page) => root.getByRole('dialog');
const getEditDialogTitleInput = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-title-input"]');
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 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 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 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 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 },
);
await expect(root.locator('[data-pdf-content]').first()).toHaveAttribute('data-page-count', String(expectedCount), {
timeout: 15000,
});
};
const navigateToFieldsPage = async (surface: TEnvelopeEditorSurface) => {
@@ -1,15 +1,13 @@
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 { expect, type Page, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import {
type TEnvelopeEditorSurface,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getEnvelopeItemDragHandles,
@@ -20,6 +18,7 @@ import {
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
@@ -76,10 +75,7 @@ const navigateToAddFieldsAndBack = async (root: Page) => {
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
};
const getEnvelopeItemsFromDatabase = async (
surface: TEnvelopeEditorSurface,
externalId: string,
) => {
const getEnvelopeItemsFromDatabase = async (surface: TEnvelopeEditorSurface, externalId: string) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
@@ -199,9 +195,7 @@ const runEnvelopeItemCrudFlow = async ({
targetIndex: 1,
});
await expect
.poll(async () => await getCurrentTitles(root))
.toEqual(['Envelope Item B', 'Envelope Item A']);
await expect.poll(async () => await getCurrentTitles(root)).toEqual(['Envelope Item B', 'Envelope Item A']);
if (!isEmbedded) {
await navigateToAddFieldsAndBack(root);
@@ -366,14 +360,10 @@ test.describe('pending envelope title editing', () => {
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',
);
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',
);
const itemAuditLog = updatedEnvelope.auditLogs.find((log) => log.type === 'ENVELOPE_ITEM_UPDATED');
expect(itemAuditLog).toBeDefined();
// Verify the upload dropzone is still disabled for pending envelopes.
@@ -1,11 +1,9 @@
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 { expect, type Page, test } from '@playwright/test';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
assertRecipientRole,
clickAddMyselfButton,
@@ -24,6 +22,7 @@ import {
setRecipientName,
setRecipientRole,
setSigningOrderValue,
type TEnvelopeEditorSurface,
toggleAllowDictateSigners,
toggleSigningOrder,
} from '../fixtures/envelope-editor';
@@ -125,24 +124,17 @@ const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise<Recipi
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(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(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(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');
@@ -210,9 +202,7 @@ const assertRecipientsPersistedInDatabase = async ({
expect(recipient.signingOrder).toBe(expectedRecipient.signingOrder);
});
expect(envelope.recipients.some((recipient) => recipient.email === removedRecipientEmail)).toBe(
false,
);
expect(envelope.recipients.some((recipient) => recipient.email === removedRecipientEmail)).toBe(false);
};
test.describe('document editor', () => {
@@ -1,13 +1,11 @@
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 { expect, type Page, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
clickAddMyselfButton,
clickEnvelopeEditorStep,
@@ -20,6 +18,7 @@ import {
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { getKonvaElementCountForPage } from '../fixtures/konva';
@@ -39,9 +38,7 @@ type TestFilePayload = {
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const multiPagePdfBuffer = fs.readFileSync(
path.join(__dirname, '../../../../assets/field-font-alignment.pdf'),
);
const multiPagePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/field-font-alignment.pdf'));
const createPdfPayload = (name: string, buffer: Buffer = examplePdfBuffer): TestFilePayload => ({
name,
@@ -77,10 +74,7 @@ const replaceEnvelopeItemPdf = async (
// 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(),
]);
const [fileChooser] = await Promise.all([root.waitForEvent('filechooser'), replaceButton.click()]);
await fileChooser.setFiles(file);
@@ -99,18 +93,12 @@ const replaceEnvelopeItemPdf = async (
};
const assertPdfPageCount = async (root: Page, expectedCount: number) => {
await expect(root.locator('[data-pdf-content]').first()).toHaveAttribute(
'data-page-count',
String(expectedCount),
{ timeout: 15000 },
);
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 },
) => {
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();
@@ -172,9 +160,7 @@ type BasicReplaceFlowResult = {
originalDocumentDataId: string | null;
};
const runBasicReplaceFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<BasicReplaceFlowResult> => {
const runBasicReplaceFlow = async (surface: TEnvelopeEditorSurface): Promise<BasicReplaceFlowResult> => {
const { root, isEmbedded } = surface;
const externalId = `e2e-replace-${nanoid()}`;
@@ -313,9 +299,7 @@ const uploadMultiPagePdf = async (root: Page) => {
});
};
const runFieldCleanupReplaceFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<FieldCleanupFlowResult> => {
const runFieldCleanupReplaceFlow = async (surface: TEnvelopeEditorSurface): Promise<FieldCleanupFlowResult> => {
const { root, isEmbedded } = surface;
const externalId = `e2e-replace-fields-${nanoid()}`;
@@ -1,8 +1,5 @@
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';
@@ -13,6 +10,8 @@ import type {
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import { expect, type Page, test } from '@playwright/test';
import { EnvelopeType, FieldType, RecipientRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import {
@@ -31,11 +30,7 @@ const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../asset
/**
* Place a field on the PDF canvas in the envelope editor.
*/
const placeFieldOnPdf = async (
root: Page,
fieldName: 'Signature' | 'Text',
position: { x: number; y: number },
) => {
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();
@@ -69,10 +64,7 @@ const createDocumentWithRecipientAndField = async () => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }),
);
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
const createRes = await fetch(`${V2_API_BASE_URL}/envelope/create`, {
method: 'POST',
@@ -1,16 +1,15 @@
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 { expect, type Page, test } from '@playwright/test';
import { DocumentDistributionMethod, DocumentVisibility } from '@prisma/client';
import {
type TEnvelopeEditorSurface,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
@@ -78,11 +77,7 @@ const clickSettingsDialogHeader = async (root: Page) => {
};
const getComboboxByLabel = (root: Page, label: string) =>
root
.locator(`label:has-text("${label}")`)
.locator('xpath=..')
.locator('[role="combobox"]')
.first();
root.locator(`label:has-text("${label}")`).locator('xpath=..').locator('[role="combobox"]').first();
const selectMultiSelectOption = async (
root: Page,
@@ -96,10 +91,7 @@ const selectMultiSelectOption = async (
await clickSettingsDialogHeader(root);
};
const runSettingsFlow = async (
{ root }: TEnvelopeEditorSurface,
{ externalId, isEmbedded }: SettingsFlowData,
) => {
const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, isEmbedded }: SettingsFlowData) => {
await openSettingsDialog(root);
await getComboboxByLabel(root, 'Language').click();
@@ -161,9 +153,7 @@ const runSettingsFlow = async (
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-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();
@@ -221,11 +211,7 @@ const runSettingsFlow = async (
const hasActionAuthSelect = (await actionAuthSelect.count()) > 0;
if (hasActionAuthSelect) {
await selectMultiSelectOption(
root,
'documentActionSelectValue',
TEST_SETTINGS_VALUES.actionAuth,
);
await selectMultiSelectOption(root, 'documentActionSelectValue', TEST_SETTINGS_VALUES.actionAuth);
}
if (isEmbedded) {
@@ -245,36 +231,23 @@ const runSettingsFlow = async (
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(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, '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(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(),
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-mode-select"]')).toContainText(TEST_SETTINGS_VALUES.reminderMode);
await expect(root.locator('[data-testid="reminder-send-after-amount"]')).toHaveValue(
String(TEST_SETTINGS_VALUES.reminderSendAfterAmount),
);
@@ -301,15 +274,9 @@ const runSettingsFlow = async (
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 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(
@@ -373,9 +340,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
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.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);
@@ -395,9 +360,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
}
};
const parseAuthOptions = (
authOptions: unknown,
): { globalAccessAuth: string[]; globalActionAuth: string[] } => {
const parseAuthOptions = (authOptions: unknown): { globalAccessAuth: string[]; globalActionAuth: string[] } => {
if (!isRecord(authOptions)) {
return {
globalAccessAuth: [],
@@ -415,8 +378,7 @@ const parseAuthOptions = (
};
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
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 }) => {
@@ -1,12 +1,11 @@
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 { type APIRequestContext, expect, type Page, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
import { createApiToken } from '../../../lib/server-only/public-api/create-api-token';
@@ -202,10 +201,7 @@ test('cert and audit log pages match A4 page dimensions', async ({ page, request
});
});
test('cert and audit log pages match tabloid landscape page dimensions', async ({
page,
request,
}) => {
test('cert and audit log pages match tabloid landscape page dimensions', async ({ page, request }) => {
await signAndVerifyPageDimensions({
page,
request,
@@ -1,17 +1,16 @@
import { createCanvas } from '@napi-rs/canvas';
import type { TestInfo } from '@playwright/test';
import { 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 pixelMatch from 'pixelmatch';
import { PNG } from 'pngjs';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma';
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
import { seedUser } from '@documenso/prisma/seed/users';
import { createCanvas } from '@napi-rs/canvas';
import type { TestInfo } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import pixelMatch from 'pixelmatch';
import { PNG } from 'pngjs';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
import { isBase64Image } from '../../../lib/constants/signatures';
@@ -31,6 +30,9 @@ const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
test.describe.configure({ mode: 'parallel', timeout: 60000 });
/**
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
*/
test.skip('seed alignment test document', async ({ page }) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@@ -69,9 +71,7 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
});
// Step 1: Create initial envelope with Prisma (with first envelope item)
const alignmentPdf = fs.readFileSync(
path.join(__dirname, '../../../../assets/field-font-alignment.pdf'),
);
const alignmentPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/field-font-alignment.pdf'));
const fieldMetaPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/field-meta.pdf'));
@@ -220,9 +220,7 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
? {
create: {
recipientId: envelope.recipients[0].id,
signatureImageAsBase64: isBase64Image(foundField.signature)
? foundField.signature
: null,
signatureImageAsBase64: isBase64Image(foundField.signature) ? foundField.signature : null,
typedSignature: isBase64Image(foundField.signature) ? null : foundField.signature,
},
}
@@ -232,6 +230,44 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
}),
);
// Override email fields with test values after distribution.
// Email fields are auto-inserted with the signer's email during distribution,
// so we override customText directly to test with specific values.
const emailFields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
type: FieldType.EMAIL,
},
include: {
envelopeItem: {
select: {
title: true,
},
},
},
});
await Promise.all(
emailFields.map(async (field) => {
const testFields = field.envelopeItem.title === 'alignment-pdf' ? ALIGNMENT_TEST_FIELDS : FIELD_META_TEST_FIELDS;
const foundField = testFields.find(
(f) =>
f.type === FieldType.EMAIL &&
field.page === f.page &&
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
);
if (foundField) {
await prisma.field.update({
where: { id: field.id },
data: { customText: foundField.customText },
});
}
}),
);
const recipientToken = envelope.recipients[0].token;
const signUrl = `/sign/${recipientToken}`;
@@ -335,6 +371,42 @@ test.skip('download envelope images', async ({ page, request }) => {
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
// Override email fields with test values after distribution.
const emailFields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
type: FieldType.EMAIL,
},
include: {
envelopeItem: {
select: {
title: true,
},
},
},
});
await Promise.all(
emailFields.map(async (field) => {
const testFields = field.envelopeItem.title === 'alignment-pdf' ? ALIGNMENT_TEST_FIELDS : FIELD_META_TEST_FIELDS;
const foundField = testFields.find(
(f) =>
f.type === FieldType.EMAIL &&
field.page === f.page &&
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
);
if (foundField) {
await prisma.field.update({
where: { id: field.id },
data: { customText: foundField.customText },
});
}
}),
);
const token = envelope.recipients[0].token;
const signUrl = `/sign/${token}`;
@@ -445,17 +517,10 @@ type CompareSignedPdfWithImagesOptions = {
testInfo: TestInfo;
};
const compareSignedPdfWithImages = async ({
id,
pdfData,
images,
testInfo,
}: CompareSignedPdfWithImagesOptions) => {
const compareSignedPdfWithImages = async ({ id, pdfData, images, testInfo }: CompareSignedPdfWithImagesOptions) => {
const renderedImages = await renderPdfToImage(pdfData);
const blankCertificateFile = fs.readFileSync(
path.join(__dirname, '../../visual-regression/blank-certificate.png'),
);
const blankCertificateFile = fs.readFileSync(path.join(__dirname, '../../visual-regression/blank-certificate.png'));
const blankCertificateImage = PNG.sync.read(blankCertificateFile).data;
for (const [index, { image, width, height }] of renderedImages.entries()) {
@@ -1,7 +1,5 @@
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';
@@ -13,6 +11,7 @@ import type {
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
@@ -24,9 +23,7 @@ const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/exam
test.describe.configure({ mode: 'parallel' });
test('[ENVELOPE_EXPIRATION]: sending document sets expiresAt on recipients', async ({
request,
}) => {
test('[ENVELOPE_EXPIRATION]: sending document sets expiresAt on recipients', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
@@ -98,9 +95,7 @@ test('[ENVELOPE_EXPIRATION]: sending document sets expiresAt on recipients', asy
expect(diffDays).toBeLessThan(100);
});
test('[ENVELOPE_EXPIRATION]: sending document with custom org expiration period', async ({
request,
}) => {
test('[ENVELOPE_EXPIRATION]: sending document with custom org expiration period', async ({ request }) => {
const { user, organisation, team } = await seedUser();
// Set org expiration to 7 days.
@@ -1,16 +1,13 @@
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 { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level', async ({
page,
}) => {
test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level', async ({ page }) => {
const { user, organisation } = await seedUser({
isPersonalOrganisation: false,
});
@@ -1,9 +1,8 @@
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 { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
@@ -0,0 +1,531 @@
import fs from 'node:fs';
import path from 'node:path';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma';
import { seedOverflowTestDocument } from '@documenso/prisma/seed/initial-seed';
import { seedUser } from '@documenso/prisma/seed/users';
import { createCanvas } from '@napi-rs/canvas';
import type { TestInfo } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import pixelMatch from 'pixelmatch';
import { PNG } from 'pngjs';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
import { isBase64Image } from '../../../lib/constants/signatures';
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 { OVERFLOW_TEST_FIELDS } from '../../constants/field-overflow-pdf';
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 });
/**
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
*/
test.skip('seed overflow test document', async ({ page }) => {
const user = await prisma.user.findFirstOrThrow({
where: {
email: 'example@documenso.com',
},
include: {
ownedOrganisations: {
include: {
teams: true,
},
},
},
});
const userId = user.id;
const teamId = user.ownedOrganisations[0].teams[0].id;
await seedOverflowTestDocument({
userId,
teamId,
recipientName: user.name || '',
recipientEmail: user.email,
insertFields: false,
status: DocumentStatus.DRAFT,
});
});
test('overflow visual regression', async ({ page, request }, testInfo) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Step 1: Create initial envelope with overflow PDF
const overflowPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/field-overflow.pdf'));
const formData = new FormData();
const overflowFields = OVERFLOW_TEST_FIELDS.map((field) => ({
identifier: 'field-overflow',
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
fieldMeta: field.fieldMeta,
}));
const createEnvelopePayload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Overflow Test',
recipients: [
{
email: user.email,
name: user.name || '',
role: RecipientRole.SIGNER,
fields: overflowFields,
},
],
};
formData.append('payload', JSON.stringify(createEnvelopePayload));
formData.append('files', new File([overflowPdf], 'field-overflow', { type: 'application/pdf' }));
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(createEnvelopeRequest.ok()).toBeTruthy();
expect(createEnvelopeRequest.status()).toBe(200);
const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json();
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: createdEnvelopeId,
},
include: {
recipients: true,
envelopeItems: true,
},
});
const recipientId = envelope.recipients[0].id;
const overflowItem = envelope.envelopeItems.find((item: { order: number }) => item.order === 1);
expect(recipientId).toBeDefined();
expect(overflowItem).toBeDefined();
if (!overflowItem) {
throw new Error('Envelope item not found');
}
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
} satisfies TDistributeEnvelopeRequest,
});
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
const uninsertedFields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
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: {
select: {
title: true,
},
},
},
});
await Promise.all(
uninsertedFields.map(async (field) => {
const foundField = OVERFLOW_TEST_FIELDS.find(
(f) =>
field.page === f.page &&
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) &&
Number(field.width).toFixed(2) === f.width.toFixed(2) &&
Number(field.height).toFixed(2) === f.height.toFixed(2),
);
if (!foundField) {
throw new Error('Field not found');
}
await prisma.field.update({
where: {
id: field.id,
},
data: {
inserted: true,
customText: foundField.customText,
signature: foundField.signature
? {
create: {
recipientId: envelope.recipients[0].id,
signatureImageAsBase64: isBase64Image(foundField.signature) ? foundField.signature : null,
typedSignature: isBase64Image(foundField.signature) ? null : foundField.signature,
},
}
: undefined,
},
});
}),
);
// Override email fields with test values after distribution.
// Email fields are auto-inserted with the signer's email during distribution,
// so we override customText directly to test overflow with specific text lengths.
const emailFields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
type: FieldType.EMAIL,
},
});
await Promise.all(
emailFields.map(async (field) => {
const foundField = OVERFLOW_TEST_FIELDS.find(
(f) =>
f.type === FieldType.EMAIL &&
field.page === f.page &&
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
);
if (foundField) {
await prisma.field.update({
where: { id: field.id },
data: { customText: foundField.customText },
});
}
}),
);
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 completedDocument = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
},
include: {
envelopeItems: {
orderBy: {
order: 'asc',
},
include: {
documentData: true,
},
},
},
});
const storedImages = fs.readdirSync(path.join(__dirname, '../../visual-regression'));
await Promise.all(
completedDocument.envelopeItems.map(async (item) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: item,
token: recipientToken,
version: 'signed',
});
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
const loadedImages = storedImages
.filter((image) => image.startsWith(`field-overflow-`))
.sort((leftImage, rightImage) => {
return getVisualRegressionImageIndex(leftImage) - getVisualRegressionImageIndex(rightImage);
})
.map((image) => fs.readFileSync(path.join(__dirname, '../../visual-regression', image)));
await compareSignedPdfWithImages({
id: 'field-overflow',
pdfData: new Uint8Array(pdfData),
images: loadedImages,
testInfo,
});
}),
);
});
/**
* Used to download the envelope images when updating the visual regression test.
*
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
*/
test.skip('download overflow 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 seedOverflowTestDocument({
userId: user.id,
teamId: team.id,
recipientName: user.name || '',
recipientEmail: user.email,
insertFields: true,
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();
// Override email fields with test values after distribution.
const emailFields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
type: FieldType.EMAIL,
},
});
await Promise.all(
emailFields.map(async (field) => {
const foundField = OVERFLOW_TEST_FIELDS.find(
(f) =>
f.type === FieldType.EMAIL &&
field.page === f.page &&
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
);
if (foundField) {
await prisma.field.update({
where: { id: field.id },
data: { customText: foundField.customText },
});
}
}),
);
const token = envelope.recipients[0].token;
const signUrl = `/sign/${token}`;
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 completedDocument = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
},
include: {
envelopeItems: {
orderBy: {
order: 'asc',
},
include: {
documentData: true,
},
},
},
});
await Promise.all(
completedDocument.envelopeItems.map(async (item) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: item,
token,
version: 'signed',
});
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
const pdfImages = await renderPdfToImage(new Uint8Array(pdfData));
for (const [index, { image }] of pdfImages.entries()) {
fs.writeFileSync(
path.join(__dirname, '../../visual-regression', `field-overflow-${index}.png`),
new Uint8Array(image),
);
}
}),
);
});
// ============================================================================
// Helper functions
// ============================================================================
async function renderPdfToImage(pdfBytes: Uint8Array) {
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
const pdf = await loadingTask.promise;
// Increase for higher resolution
const scale = 4;
return await Promise.all(
Array.from({ length: pdf.numPages }, async (_, index) => {
const page = await pdf.getPage(index + 1);
const viewport = page.getViewport({ scale });
const canvas = createCanvas(viewport.width, viewport.height);
const canvasContext = canvas.getContext('2d');
canvasContext.imageSmoothingEnabled = false;
await page.render({
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
canvas,
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
canvasContext,
viewport,
}).promise;
return {
image: await canvas.encode('png'),
// Rounded down because the certificate page somehow gives dimensions with decimals
width: Math.floor(viewport.width),
height: Math.floor(viewport.height),
};
}),
);
}
type CompareSignedPdfWithImagesOptions = {
id: string;
pdfData: Uint8Array;
images: Buffer[];
testInfo: TestInfo;
};
const compareSignedPdfWithImages = async ({ id, pdfData, images, testInfo }: CompareSignedPdfWithImagesOptions) => {
const renderedImages = await renderPdfToImage(pdfData);
expect(images).toHaveLength(renderedImages.length);
for (const [index, { image, width, height }] of renderedImages.entries()) {
const isCertificate = index === renderedImages.length - 1;
// Skip certificate page comparison.
if (isCertificate) {
continue;
}
const diff = new PNG({ width, height });
const storedImage = PNG.sync.read(images[index]).data;
const newImage = PNG.sync.read(image).data;
const comparison = pixelMatch(
new Uint8Array(storedImage),
new Uint8Array(newImage),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
diff.data as unknown as Uint8Array,
width,
height,
{
threshold: 0.25,
// includeAA: true, // This allows stricter testing.
},
);
console.log(`${id}-${index}: ${comparison}`);
const diffFilePath = path.join(testInfo.outputPath(), `${id}-${index}-diff.png`);
const oldFilePath = path.join(testInfo.outputPath(), `${id}-${index}-old.png`);
const newFilePath = path.join(testInfo.outputPath(), `${id}-${index}-new.png`);
fs.writeFileSync(diffFilePath, new Uint8Array(PNG.sync.write(diff)));
fs.writeFileSync(oldFilePath, new Uint8Array(images[index]));
fs.writeFileSync(newFilePath, new Uint8Array(image));
expect.soft(comparison).toBeLessThan(2);
}
};
const getVisualRegressionImageIndex = (image: string) => {
const match = image.match(/-(\d+)\.png$/);
if (!match) {
throw new Error(`Unexpected visual regression image name: ${image}`);
}
return Number(match[1]);
};
@@ -1,10 +1,9 @@
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 { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { DateTime } from 'luxon';
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
@@ -58,11 +57,9 @@ test.describe('V2 envelope field insertion during signing', () => {
}
const x =
(Number(sigField.positionX) / 100) * canvasBox.width +
((Number(sigField.width) / 100) * canvasBox.width) / 2;
(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;
(Number(sigField.positionY) / 100) * canvasBox.height + ((Number(sigField.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
await page.waitForTimeout(500);
@@ -165,17 +162,12 @@ test.describe('V2 envelope field insertion during signing', () => {
}
// 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,
);
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 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;
(Number(field.positionY) / 100) * canvasBox.height + ((Number(field.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
@@ -1,13 +1,12 @@
import { PDF } from '@libpdf/core';
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { PDF } from '@libpdf/core';
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
@@ -106,9 +105,7 @@ test.describe('Signing Certificate Tests', () => {
expect(pdfDoc.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
});
test('team document with signing certificate enabled should include certificate', async ({
page,
}) => {
test('team document with signing certificate enabled should include certificate', async ({ page }) => {
const { owner, team } = await seedTeam();
const { document, recipients } = await seedPendingDocumentWithFullFields({
@@ -211,9 +208,7 @@ test.describe('Signing Certificate Tests', () => {
expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
});
test('team document with signing certificate disabled should not include certificate', async ({
page,
}) => {
test('team document with signing certificate disabled should not include certificate', async ({ page }) => {
const { owner, team } = await seedTeam();
const { document, recipients } = await seedPendingDocumentWithFullFields({
@@ -304,9 +299,7 @@ test.describe('Signing Certificate Tests', () => {
version: 'signed',
});
const completedDocumentData = await fetch(documentUrl).then(
async (res) => await res.arrayBuffer(),
);
const completedDocumentData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
// Load the PDF and check number of pages
const completedPdf = await PDF.load(new Uint8Array(completedDocumentData));
+7 -26
View File
@@ -16,10 +16,9 @@
* });
* });
*/
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';
@@ -34,6 +33,7 @@ import type {
} 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';
import { type APIRequestContext, expect } from '@playwright/test';
// ---------------------------------------------------------------------------
// Constants
@@ -543,12 +543,7 @@ export const apiSeedDraftDocument = async (
// 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,
);
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);
@@ -694,12 +689,7 @@ export const apiSeedPendingDocument = async (
}
// Distribute
const distributeResult = await apiDistributeEnvelope(
request,
ctx.token,
envelopeId,
options.distributeMeta,
);
const distributeResult = await apiDistributeEnvelope(request, ctx.token, envelopeId, options.distributeMeta);
const envelope = await apiGetEnvelope(request, ctx.token, envelopeId);
@@ -758,12 +748,7 @@ export const apiSeedTemplate = async (
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 recipientsRes = await apiCreateRecipients(request, ctx.token, envelopeId, options.recipients);
const envelopeData = await apiGetEnvelope(request, ctx.token, envelopeId);
const firstItemId = envelopeData.envelopeItems[0]?.id;
@@ -833,9 +818,7 @@ export const apiSeedDirectTemplate = async (
// 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,
);
const directRecipient = templateResult.envelope.recipients.find((r) => r.email === directRecipientEmail);
if (!directRecipient) {
throw new Error(`Direct template recipient not found: ${directRecipientEmail}`);
@@ -887,9 +870,7 @@ export const apiSeedMultipleDraftDocuments = async (
const ctx = context ?? (await apiCreateTestContext('e2e-multi-doc'));
const results = await Promise.all(
documents.map(async (docOptions) =>
apiSeedDraftDocument(request, { ...docOptions, context: ctx }),
),
documents.map(async (docOptions) => apiSeedDraftDocument(request, { ...docOptions, context: ctx })),
);
return {
@@ -1,6 +1,5 @@
import type { Page } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { Page } from '@playwright/test';
type LoginOptions = {
page: Page;
@@ -1,14 +1,13 @@
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 type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { apiSignin } from './authentication';
@@ -216,9 +215,7 @@ export const openEmbeddedEnvelopeEditor = async (
darkModeDisabled,
});
await page.goto(
`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(embeddedToken)}#${hash}`,
);
await page.goto(`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(embeddedToken)}#${hash}`);
}
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
@@ -235,14 +232,11 @@ export const openEmbeddedEnvelopeEditor = async (
};
};
export const getEnvelopeEditorSettingsTrigger = (root: Page) =>
root.locator('button[title="Settings"]');
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 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 getEnvelopeItemDragHandles = (root: Page) => root.locator('[data-testid^="envelope-item-drag-handle-"]');
export const getEnvelopeItemRemoveButtons = (root: Page) =>
root.locator('[data-testid^="envelope-item-remove-button-"]');
@@ -261,25 +255,18 @@ export const addEnvelopeItemPdf = async (root: Page, fileName = 'embedded-envelo
});
};
export const getRecipientEmailInputs = (root: Page) =>
root.locator('[data-testid="signer-email-input"]');
export const getRecipientEmailInputs = (root: Page) => root.locator('[data-testid="signer-email-input"]');
export const getRecipientNameInputs = (root: Page) =>
root.locator('input[placeholder^="Recipient "]');
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 getRecipientRemoveButtons = (root: Page) => root.locator('[data-testid="remove-signer-button"]');
export const getSigningOrderInputs = (root: Page) =>
root.locator('[data-testid="signing-order-input"]');
export const getSigningOrderInputs = (root: Page) => root.locator('[data-testid="signing-order-input"]');
export const clickEnvelopeEditorStep = async (
root: Page,
stepId: 'upload' | 'addFields' | 'preview',
) => {
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();
};
@@ -303,12 +290,7 @@ export const setRecipientName = async (root: Page, index: number, name: string)
export const setRecipientRole = async (
root: Page,
index: number,
roleLabel:
| 'Needs to sign'
| 'Needs to approve'
| 'Needs to view'
| 'Receives copy'
| 'Can prepare',
roleLabel: 'Needs to sign' | 'Needs to approve' | 'Needs to view' | 'Receives copy' | 'Can prepare',
) => {
const row = getRecipientRows(root).nth(index);
@@ -319,12 +301,7 @@ export const setRecipientRole = async (
export const assertRecipientRole = async (
root: Page,
index: number,
roleLabel:
| 'Needs to sign'
| 'Needs to approve'
| 'Needs to view'
| 'Receives copy'
| 'Can prepare',
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> = {
@@ -335,10 +312,7 @@ export const assertRecipientRole = async (
'Can prepare': 'ASSISTANT',
};
await expect(row.locator('button[role="combobox"]').first()).toHaveAttribute(
'title',
roleValueByLabel[roleLabel],
);
await expect(row.locator('button[role="combobox"]').first()).toHaveAttribute('title', roleValueByLabel[roleLabel]);
};
export const toggleSigningOrder = async (root: Page, enabled: boolean) => {
@@ -397,11 +371,7 @@ export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) =
await expect(surface.root.getByRole('heading', { name: completionHeading })).toBeVisible();
};
const resolveEmbeddingToken = async (
page: Page,
inputToken: string,
scope?: string,
): Promise<string> => {
const resolveEmbeddingToken = async (page: Page, inputToken: string, scope?: string): Promise<string> => {
if (!inputToken.startsWith('api_')) {
return inputToken;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Locator } from '@playwright/test';
import { type Page, expect } from '@playwright/test';
import { expect, type Page } from '@playwright/test';
export const expectTextToBeVisible = async (page: Page, text: string) => {
await expect(page.getByText(text).first()).toBeVisible();
+1 -5
View File
@@ -1,11 +1,7 @@
import type { Page } from '@playwright/test';
import type Konva from 'konva';
export const getKonvaElementCountForPage = async (
page: Page,
pageNumber: number,
elementSelector: string,
) => {
export const getKonvaElementCountForPage = async (page: Page, pageNumber: number, elementSelector: string) => {
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
return await page.evaluate(
@@ -1,12 +1,11 @@
import { expect, test } from '@playwright/test';
import path from 'node:path';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility, FolderType, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { expectTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
@@ -88,9 +87,7 @@ test('[TEAMS]: can create a document inside a document folder', async ({ page })
page.getByRole('button', { name: 'Document (Legacy)' }).click(),
]);
await fileChooser.setFiles(
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
);
await fileChooser.setFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
await page.waitForTimeout(3000);
@@ -405,9 +402,7 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
page.getByRole('button', { name: 'Template (Legacy)' }).click(),
]);
await fileChooser.setFiles(
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
);
await fileChooser.setFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
await page.waitForTimeout(3000);
@@ -677,9 +672,7 @@ test('[TEAMS]: can navigate between template folders', async ({ page }) => {
await expect(page.getByText('Team Contract Template 1')).toBeVisible();
});
test('[TEAMS]: folder visibility is properly applied based on team member roles', async ({
page,
}) => {
test('[TEAMS]: folder visibility is properly applied based on team member roles', async ({ page }) => {
const { team, teamOwner } = await seedTeamDocuments();
const teamMember1 = await seedTeamMember({
@@ -893,9 +886,7 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
page.getByRole('button', { name: 'Document (Legacy)' }).click(),
]);
await fileChooser.setFiles(
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
);
await fileChooser.setFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
await page.waitForTimeout(3000);
@@ -2119,9 +2110,7 @@ test('[TEAMS]: team member cannot see admin folder in folder list', async ({ pag
await expect(page.getByText('Admin Only Folder')).not.toBeVisible();
});
test('[TEAMS]: team member can access admin folder via URL and see everyone documents', async ({
page,
}) => {
test('[TEAMS]: team member can access admin folder via URL and see everyone documents', async ({ page }) => {
const { team, teamOwner } = await seedTeamDocuments();
const teamMember = await seedTeamMember({
@@ -2203,9 +2192,7 @@ test('[TEAMS]: team member cannot see manager folder in folder list', async ({ p
await expect(page.getByText('Manager Folder')).not.toBeVisible();
});
test('[TEAMS]: team member can access manager folder via URL and see everyone documents', async ({
page,
}) => {
test('[TEAMS]: team member can access manager folder via URL and see everyone documents', async ({ page }) => {
const { team, teamOwner } = await seedTeamDocuments();
const teamMember = await seedTeamMember({
@@ -2305,9 +2292,7 @@ test('[TEAMS]: team member can see everyone folders', async ({ page }) => {
await expect(page.getByText('Everyone Folder')).toBeVisible();
});
test('[TEAMS]: team member can only see everyone documents in everyone folder', async ({
page,
}) => {
test('[TEAMS]: team member can only see everyone documents in everyone folder', async ({ page }) => {
const { team, teamOwner } = await seedTeamDocuments();
const teamMember = await seedTeamMember({
@@ -2362,9 +2347,7 @@ test('[TEAMS]: team member can only see everyone documents in everyone folder',
await expect(page.getByText('Admin Document')).not.toBeVisible();
});
test('[TEAMS]: team manager can see manager and everyone folders in folder list', async ({
page,
}) => {
test('[TEAMS]: team manager can see manager and everyone folders in folder list', async ({ page }) => {
const { team, teamOwner } = await seedTeamDocuments();
const teamManager = await seedTeamMember({
@@ -2408,9 +2391,7 @@ test('[TEAMS]: team manager can see manager and everyone folders in folder list'
await expect(page.getByText('Everyone Folder')).toBeVisible();
});
test('[TEAMS]: team manager can see manager and everyone documents in manager folder', async ({
page,
}) => {
test('[TEAMS]: team manager can see manager and everyone documents in manager folder', async ({ page }) => {
const { team, teamOwner } = await seedTeamDocuments();
const teamManager = await seedTeamMember({
@@ -2460,18 +2441,14 @@ test('[TEAMS]: team manager can see manager and everyone documents in manager fo
redirectPath: `/t/${team.url}/documents/f/${managerFolder.id}`,
});
await expect(
page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' }),
).toBeVisible();
await expect(page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' })).toBeVisible();
await expect(page.getByText('Manager Folder - Everyone Document')).toBeVisible();
await expect(page.getByText('Manager Folder - Manager Document')).toBeVisible();
await expect(page.getByText('Manager Folder - Admin Document')).not.toBeVisible();
});
test('[TEAMS]: team manager can see manager and everyone documents in everyone folder', async ({
page,
}) => {
test('[TEAMS]: team manager can see manager and everyone documents in everyone folder', async ({ page }) => {
const { team, teamOwner } = await seedTeamDocuments();
const teamManager = await seedTeamMember({
@@ -2723,9 +2700,7 @@ test('[TEAMS]: team owner can see all documents in manager folder', async ({ pag
redirectPath: `/t/${team.url}/documents/f/${managerFolder.id}`,
});
await expect(
page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' }),
).toBeVisible();
await expect(page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' })).toBeVisible();
await expect(page.getByText('Manager Folder - Everyone Document')).toBeVisible();
await expect(page.getByText('Manager Folder - Manager Document')).toBeVisible();
await expect(page.getByText('Manager Folder - Admin Document')).toBeVisible();
@@ -2936,9 +2911,7 @@ test('[TEAMS]: team admin can see all documents in manager folder', async ({ pag
redirectPath: `/t/${team.url}/documents/f/${managerFolder.id}`,
});
await expect(
page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' }),
).toBeVisible();
await expect(page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' })).toBeVisible();
await expect(page.getByText('Manager Folder - Everyone Document')).toBeVisible();
await expect(page.getByText('Manager Folder - Manager Document')).toBeVisible();
await expect(page.getByText('Manager Folder - Admin Document')).toBeVisible();
@@ -1,9 +1,8 @@
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 { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -101,226 +100,215 @@ const createMockLicenseWithFlags = (flags: TLicenseClaim): TCachedLicense => {
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,
test.describe
.skip('Enterprise Feature Restrictions', () => {
test.beforeAll(async () => {
// Backup any existing license file before running tests
await backupLicenseFile();
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
test.afterAll(async () => {
// Restore the backup license file after all tests complete
await restoreLicenseFile();
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
test.beforeEach(async () => {
// Clean up license file before each test to ensure clean state
await writeLicenseFile(null);
});
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
test.afterEach(async () => {
// Clean up license file after each test
await writeLicenseFile(null);
});
// 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();
test('[ADMIN CLAIMS]: shows restricted features with asterisk when no license', async ({ page }) => {
// Ensure no license file exists
await writeLicenseFile(null);
// 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();
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Check that enterprise feature checkboxes are disabled
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
await expect(emailDomainsCheckbox).toBeDisabled();
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
const cfr21Checkbox = page.locator('#flag-cfr21');
await expect(cfr21Checkbox).toBeDisabled();
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
await expect(authPortalCheckbox).toBeDisabled();
// 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();
});
});
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();
});
});
@@ -1,9 +1,8 @@
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 { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -116,277 +115,250 @@ const createMockUnauthorizedWithoutLicense = (): TCachedLicense => {
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,
test.describe
.skip('License Status Banner', () => {
test.beforeAll(async () => {
// Backup any existing license file before running tests
await backupLicenseFile();
});
// Navigate to admin page - license is read during page load
await apiSignin({
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,
email: adminUser.email,
redirectPath: '/admin',
}) => {
// 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();
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).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));
// Global banner should not be visible (no license file)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
const { user } = await seedUser();
// 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();
// 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();
});
});
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();
});
});
@@ -1,16 +1,11 @@
import { expect, test } from '@playwright/test';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { nanoid } from '@documenso/lib/universal/id';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import {
expectTextToBeVisible,
expectTextToNotBeVisible,
openDropdownMenu,
} from '../fixtures/generic';
import { expectTextToBeVisible, expectTextToNotBeVisible, openDropdownMenu } from '../fixtures/generic';
test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
const { user, organisation } = await seedUser({
@@ -29,9 +24,7 @@ test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
await page.waitForURL(`/o/${organisation.url}/settings/general`);
await page.getByRole('button', { name: 'Delete' }).click();
await page
.getByLabel(`Confirm by typing delete ${organisation.name}`)
.fill(`delete ${organisation.name}`);
await page.getByLabel(`Confirm by typing delete ${organisation.name}`).fill(`delete ${organisation.name}`);
await page.getByRole('button', { name: 'Delete' }).click();
await page.waitForURL(`/settings/organisations`);
@@ -138,15 +131,9 @@ test('[ORGANISATIONS]: inherit members', async ({ page }) => {
// Check from admin POV that member counts are correct
// You should only see the manager/admins from the organisation in this table.
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail),
).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: memberEmail })).not.toBeVisible();
await expect(page.getByRole('row').filter({ hasText: memberEmail2 })).not.toBeVisible();
await expect(page.getByRole('row').filter({ hasText: memberEmail3 })).not.toBeVisible();
@@ -157,31 +144,17 @@ test('[ORGANISATIONS]: inherit members', async ({ page }) => {
await page.getByRole('option', { name: 'Member 1' }).first().click();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('button', { name: 'Add Members' }).click();
await expect(
page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail),
).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail)).toBeVisible();
await page.goto(`/t/${teamWithInheritMembers.url}/settings/members`);
// Check from member POV that member counts are correct for inherit members team.
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail2),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail3),
).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail3)).toBeVisible();
// Disable inherit mode.
await page.goto(`/t/${teamWithInheritMembers.url}/settings/groups`);
@@ -191,15 +164,9 @@ test('[ORGANISATIONS]: inherit members', async ({ page }) => {
// Expect the inherited members to disappear
await page.goto(`/t/${teamWithInheritMembers.url}/settings/members`);
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail),
).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: memberEmail })).not.toBeVisible();
await expect(page.getByRole('row').filter({ hasText: memberEmail2 })).not.toBeVisible();
await expect(page.getByRole('row').filter({ hasText: memberEmail3 })).not.toBeVisible();
@@ -312,25 +279,21 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP');
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Member1' }).click();
await page.getByRole('option', { name: 'Member2' }).click();
await page.getByRole('option', { name: 'Member3' }).click();
// Close the multiselect dropdown so it doesn't overlap the submit button.
await page.getByRole('heading', { name: 'Create group' }).click();
await page.getByTestId('dialog-create-organisation-button').click();
await expect(page.getByText('Group has been created.').first()).toBeVisible();
await page.goto(`/o/${organisation.url}/settings/members`);
// Confirm org roles have been applied to these members.
await expect(
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail1),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail2),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail3),
).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail3)).toBeVisible();
// Test updating the group.
await page.goto(`/o/${organisation.url}/settings/groups`);
@@ -338,8 +301,12 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_A');
await page.getByRole('combobox').filter({ hasText: 'Organisation Admin' }).click();
await page.getByRole('option', { name: 'Organisation Member' }).click();
await page.getByRole('combobox').filter({ hasText: 'Member1, Member2, Member3' }).click();
await page.getByRole('option', { name: 'Member3' }).click();
// Remove Member3 by clicking the X on its chip in the multiselect.
await page
.getByTestId('group-members-picker')
.locator('div', { hasText: /^Member3/ })
.getByRole('button', { name: 'Remove' })
.click();
await page.getByRole('button', { name: 'Update' }).click();
await expect(page.getByText('Group has been updated successfully').first()).toBeVisible();
@@ -348,25 +315,21 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
// Create a custom member group with the 3 admins to check that they still get the ADMIN roles.
await page.getByRole('button', { name: 'Create group' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_ADMINS');
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Admin1' }).click();
await page.getByRole('option', { name: 'Admin2' }).click();
await page.getByRole('option', { name: 'Admin3' }).click();
// Close the multiselect dropdown so it doesn't overlap the submit button.
await page.getByRole('heading', { name: 'Create group' }).click();
await page.getByTestId('dialog-create-organisation-button').click();
await expect(page.getByText('Group has been created.').first()).toBeVisible();
await page.goto(`/o/${organisation.url}/settings/members`);
// Confirm admins still get admin roles.
await expect(
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail1),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail2),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail3),
).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail3)).toBeVisible();
// Create another custom group with 3 members with "ORGANISATION MEMBER" role.
await page.goto(`/o/${organisation.url}/settings/groups`);
@@ -374,17 +337,21 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_B');
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Member4' }).click();
await page.getByRole('option', { name: 'Member5' }).click();
// Close the multiselect dropdown so it doesn't overlap the submit button.
await page.getByRole('heading', { name: 'Create group' }).click();
await page.getByTestId('dialog-create-organisation-button').click();
await expect(page.getByText('Group has been created.').first()).toBeVisible();
// Assign CUSTOM_GROUP_A to TeamA
await page.goto(`/t/${teamA}/settings/groups`);
await page.getByRole('button', { name: 'Add groups' }).click();
await page.getByRole('combobox').click();
await page.getByTestId('team-groups-picker').click();
await page.getByRole('option', { name: 'CUSTOM_GROUP_A', exact: true }).click();
// Close the multiselect dropdown so it doesn't overlap the Next button.
await page.getByRole('heading', { name: 'Add groups' }).click();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Manager' }).click();
@@ -394,8 +361,10 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
// Assign CUSTOM_GROUP_B to TeamA
await page.goto(`/t/${teamA}/settings/groups`);
await page.getByRole('button', { name: 'Add groups' }).click();
await page.getByRole('combobox').click();
await page.getByTestId('team-groups-picker').click();
await page.getByRole('option', { name: 'CUSTOM_GROUP_B', exact: true }).click();
// Close the multiselect dropdown so it doesn't overlap the Next button.
await page.getByRole('heading', { name: 'Add groups' }).click();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Manager' }).click();
@@ -426,12 +395,8 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
// Navigate to team members and validate members are there.
await page.goto(`/t/${teamA}/settings/members`);
await expect(
page.getByRole('row').filter({ hasText: 'Team Manager' }).getByText(memberEmail1),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Manager' }).getByText(memberEmail2),
).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Manager' }).getByText(memberEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Team Manager' }).getByText(memberEmail2)).toBeVisible();
// Member 1 should see inherit team and teamA
await apiSignout({ page });
@@ -572,8 +537,6 @@ test('[ORGANISATIONS]: leave organisation', async ({ page }) => {
await page.getByRole('button', { name: 'Leave' }).click();
await page.getByRole('button', { name: 'Leave' }).click();
await expect(
page.getByText('You have successfully left this organisation').first(),
).toBeVisible();
await expect(page.getByText('You have successfully left this organisation').first()).toBeVisible();
await expect(page.getByText('No results found').first()).toBeVisible();
});
@@ -1,10 +1,9 @@
import { expect, test } from '@playwright/test';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '@documenso/prisma/client';
import { seedTeamDocumentWithMeta } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -206,12 +205,8 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
// Update email document settings by enabling/disabling some checkboxes
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('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();
@@ -247,12 +242,8 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
// Update some email settings
await page.getByRole('checkbox', { name: 'Email recipients with a signing request' }).uncheck();
await page
.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true })
.uncheck();
await page
.getByRole('checkbox', { name: 'Email the owner when the document is completed' })
.uncheck();
await page.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true }).uncheck();
await page.getByRole('checkbox', { name: 'Email the owner when the document is completed' }).uncheck();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
@@ -1,15 +1,9 @@
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 { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { prisma } from '@documenso/prisma';
import {
@@ -19,6 +13,8 @@ import {
} from '@documenso/prisma/seed/documents';
import { seedBlankTemplate, seedDirectTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
@@ -118,14 +114,13 @@ test.describe('PDF Viewer Rendering', () => {
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 },
});
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}`);
@@ -173,24 +168,14 @@ test.describe('PDF Viewer Rendering', () => {
const qrTokenV1 = prefixedId('qr');
const qrTokenV2 = prefixedId('qr');
const documentV1 = await seedCompletedDocument(
user,
team.id,
['share-v1@test.documenso.com'],
{
createDocumentOptions: { qrToken: qrTokenV1 },
},
);
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,
},
);
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}`);
@@ -224,14 +209,13 @@ test.describe('PDF Viewer Rendering', () => {
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 },
});
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}`);
@@ -281,14 +265,13 @@ test.describe('PDF Viewer Rendering', () => {
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 },
});
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}`);
@@ -325,9 +308,7 @@ test.describe('PDF Viewer Rendering', () => {
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 page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/create?token=${presignToken}#${hash}`);
await expect(page.getByText('Configure Document')).toBeVisible({ timeout: 15_000 });
@@ -1,9 +1,8 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
@@ -31,9 +30,7 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
await page.getByRole('button', { name: 'Update' }).click();
await expect(page.getByRole('status').first()).toContainText(
'Your public profile has been updated.',
);
await expect(page.getByRole('status').first()).toContainText('Your public profile has been updated.');
// Link direct template to public profile.
await page.getByRole('button', { name: 'Link template' }).click();
@@ -41,9 +38,7 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('textbox', { name: 'Title *' }).fill('public-direct-template-title');
await page
.getByRole('textbox', { name: 'Description *' })
.fill('public-direct-template-description');
await page.getByRole('textbox', { name: 'Description *' }).fill('public-direct-template-description');
await page.getByRole('button', { name: 'Update' }).click();
// Wait for toast
@@ -1,8 +1,5 @@
import { PDF } from '@libpdf/core';
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 { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
@@ -14,6 +11,8 @@ import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { PDF } from '@libpdf/core';
import { expect, test } from '@playwright/test';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
@@ -68,10 +67,7 @@ async function getPdfFormFieldNames(pdfBuffer: Uint8Array): Promise<string[]> {
/**
* Helper to get the value of a text field in a PDF.
*/
async function getPdfTextFieldValue(
pdfBuffer: Uint8Array,
fieldName: string,
): Promise<string | undefined> {
async function getPdfTextFieldValue(pdfBuffer: Uint8Array, fieldName: string): Promise<string | undefined> {
const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
const form = await pdfDoc.getForm();
@@ -94,14 +90,10 @@ test.describe.configure({
});
test.describe('Form Flattening', () => {
const formFieldsPdf = fs.readFileSync(
path.join(__dirname, '../../../../assets/form-fields-test.pdf'),
);
const formFieldsPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/form-fields-test.pdf'));
test.describe('Envelope Creation (DOCUMENT type)', () => {
test('should flatten form fields when creating a DOCUMENT envelope with formValues', async ({
request,
}) => {
test('should flatten form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -119,10 +111,7 @@ test.describe('Form Flattening', () => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
formData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
@@ -156,9 +145,7 @@ test.describe('Form Flattening', () => {
expect(hasFormFields).toBe(false);
});
test('should flatten form fields when creating a DOCUMENT envelope without formValues', async ({
request,
}) => {
test('should flatten form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -176,10 +163,7 @@ test.describe('Form Flattening', () => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
formData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
@@ -211,9 +195,7 @@ test.describe('Form Flattening', () => {
});
test.describe('Template Creation (TEMPLATE type)', () => {
test('should NOT flatten form fields when creating a TEMPLATE envelope', async ({
request,
}) => {
test('should NOT flatten form fields when creating a TEMPLATE envelope', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -231,10 +213,7 @@ test.describe('Form Flattening', () => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
formData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
@@ -274,9 +253,7 @@ test.describe('Form Flattening', () => {
expect(fieldNames).toContain(FORM_FIELDS.DROPDOWN);
});
test('should preserve form fields in template even when formValues are provided', async ({
request,
}) => {
test('should preserve form fields in template even when formValues are provided', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -294,10 +271,7 @@ test.describe('Form Flattening', () => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
formData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
@@ -336,9 +310,7 @@ test.describe('Form Flattening', () => {
});
test.describe('Document from Template', () => {
test('should flatten form fields when creating document from template with formValues', async ({
request,
}) => {
test('should flatten form fields when creating document from template with formValues', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -362,10 +334,7 @@ test.describe('Form Flattening', () => {
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
templateFormData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
@@ -431,9 +400,7 @@ test.describe('Form Flattening', () => {
expect(hasFormFields).toBe(false);
});
test('should flatten form fields when creating document from template without formValues', async ({
request,
}) => {
test('should flatten form fields when creating document from template without formValues', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -457,10 +424,7 @@ test.describe('Form Flattening', () => {
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
templateFormData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
@@ -516,9 +480,7 @@ test.describe('Form Flattening', () => {
expect(hasFormFields).toBe(false);
});
test('should use template formValues when creating document without override', async ({
request,
}) => {
test('should use template formValues when creating document without override', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -546,10 +508,7 @@ test.describe('Form Flattening', () => {
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
templateFormData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
@@ -636,10 +595,7 @@ test.describe('Form Flattening', () => {
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
templateFormData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
@@ -773,10 +729,7 @@ test.describe('Form Flattening', () => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
formData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
@@ -822,10 +775,7 @@ test.describe('Form Flattening', () => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
formData.append('files', new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
@@ -1,7 +1,5 @@
import { expect, test } from '@playwright/test';
import * as fs from 'node:fs';
import * as 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';
@@ -12,6 +10,7 @@ import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -46,9 +45,7 @@ const setTeamDefaultRecipients = async (
};
test.describe('Default Recipients', () => {
test('[DEFAULT_RECIPIENTS]: default recipients are added to documents created via UI', async ({
page,
}) => {
test('[DEFAULT_RECIPIENTS]: default recipients are added to documents created via UI', async ({ page }) => {
const { team, owner } = await seedTeam({
createTeamMembers: 2,
});
@@ -142,17 +139,13 @@ test.describe('Default Recipients', () => {
expect(defaultRecipient).toBeDefined();
expect(defaultRecipient?.role).toBe(RecipientRole.CC);
const regularSigner = envelope.recipients.find(
(r) => r.email === 'regular-signer@documenso.com',
);
const regularSigner = envelope.recipients.find((r) => r.email === 'regular-signer@documenso.com');
expect(regularSigner).toBeDefined();
}).toPass();
});
// TODO: Are we intending to allow default recipients to be removed from a document?
test.skip('[DEFAULT_RECIPIENTS]: default recipients cannot be removed from a document', async ({
page,
}) => {
test.skip('[DEFAULT_RECIPIENTS]: default recipients cannot be removed from a document', async ({ page }) => {
const { team, owner } = await seedTeam({
createTeamMembers: 2,
});
@@ -245,9 +238,7 @@ test.describe('Default Recipients', () => {
await expect(removeButton).toBeDisabled();
});
test('[DEFAULT_RECIPIENTS]: documents created via API have default recipients', async ({
request,
}) => {
test('[DEFAULT_RECIPIENTS]: documents created via API have default recipients', async ({ request }) => {
const { team, owner } = await seedTeam({
createTeamMembers: 2,
});
@@ -336,9 +327,7 @@ test.describe('Default Recipients', () => {
expect(defaultRecipient?.role).toBe(RecipientRole.CC);
});
test('[DEFAULT_RECIPIENTS]: documents created from template have default recipients', async ({
page,
}) => {
test('[DEFAULT_RECIPIENTS]: documents created from template have default recipients', async ({ page }) => {
const { team, owner } = await seedTeam({
createTeamMembers: 2,
});
@@ -413,9 +402,7 @@ test.describe('Default Recipients', () => {
expect(document.recipients.length).toBe(2);
const templateRecipient = document.recipients.find(
(r) => r.email === 'template-recipient@documenso.com',
);
const templateRecipient = document.recipients.find((r) => r.email === 'template-recipient@documenso.com');
expect(templateRecipient).toBeDefined();
const defaultRecipient = document.recipients.find(
@@ -1,7 +1,6 @@
import { test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedUser } from '@documenso/prisma/seed/users';
import { test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { expectTextToBeVisible } from '../fixtures/generic';
@@ -9,10 +8,7 @@ import { expectTextToBeVisible } from '../fixtures/generic';
test('[TEAMS]: create team', async ({ page }) => {
const { user, organisation } = await seedUser();
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
'Test skipped because billing is enabled.',
);
test.skip(process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true', 'Test skipped because billing is enabled.');
await apiSignin({
page,
@@ -1,12 +1,11 @@
import { expect, test } from '@playwright/test';
import { DocumentStatus, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedDocuments, seedTeamDocuments } 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 { expect, test } from '@playwright/test';
import { DocumentStatus, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
@@ -110,11 +109,7 @@ test('[TEAMS]: search respects team document visibility', async ({ page }) => {
});
test('[TEAMS]: search does not reveal documents from other teams', async ({ page }) => {
const {
team: teamA,
teamOwner: teamAOwner,
teamMember2: teamAMember,
} = await seedTeamDocuments();
const { team: teamA, teamOwner: teamAOwner, teamMember2: teamAMember } = await seedTeamDocuments();
const { team: teamB, teamOwner: teamBOwner } = await seedTeamDocuments();
await seedDocuments([
@@ -157,9 +152,7 @@ test('[TEAMS]: search does not reveal documents from other teams', async ({ page
await apiSignout({ page });
});
test('[TEAMS]: search respects recipient visibility regardless of team visibility', async ({
page,
}) => {
test('[TEAMS]: search respects recipient visibility regardless of team visibility', async ({ page }) => {
const { team, owner } = await seedTeam();
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
@@ -186,9 +179,7 @@ test('[TEAMS]: search respects recipient visibility regardless of team visibilit
await page.waitForURL(/query=Admin(%20|\+|\s)Document/);
await checkDocumentTabCount(page, 'All', 1);
await expect(
page.getByRole('link', { name: 'Admin Document with Member Recipient' }),
).toBeVisible();
await expect(page.getByRole('link', { name: 'Admin Document with Member Recipient' })).toBeVisible();
await apiSignout({ page });
});
@@ -228,9 +219,7 @@ test('[TEAMS]: search by recipient name respects visibility', async ({ page }) =
await page.waitForURL(/query=Unique(%20|\+|\s)Recipient/);
await checkDocumentTabCount(page, 'All', 1);
await expect(
page.getByRole('link', { name: 'Admin Document for Unique Recipient' }),
).toBeVisible();
await expect(page.getByRole('link', { name: 'Admin Document for Unique Recipient' })).toBeVisible();
await apiSignout({ page });
@@ -245,9 +234,7 @@ test('[TEAMS]: search by recipient name respects visibility', async ({ page }) =
await page.waitForURL(/query=Unique(%20|\+|\s)Recipient/);
await checkDocumentTabCount(page, 'All', 0);
await expect(
page.getByRole('link', { name: 'Admin Document for Unique Recipient' }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Admin Document for Unique Recipient' })).not.toBeVisible();
await apiSignout({ page });
});
@@ -1,21 +1,12 @@
import { seedBlankDocument, seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
import {
seedBlankDocument,
seedDocuments,
seedTeamDocuments,
} from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
import {
expectTextToBeVisible,
expectToastTextToBeVisible,
openDropdownMenu,
} from '../fixtures/generic';
import { expectTextToBeVisible, expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
test('[TEAMS]: check team documents count', async ({ page }) => {
const { team, teamOwner, teamMember2 } = await seedTeamDocuments();
@@ -53,11 +44,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
test('[TEAMS]: check team documents count with internal team email', async ({ page }) => {
const { team, teamOwner, teamMember2, teamMember4 } = await seedTeamDocuments();
const {
team: team2,
teamOwner: team2Owner,
teamMember2: team2Member2,
} = await seedTeamDocuments();
const { team: team2, teamOwner: team2Owner, teamMember2: team2Member2 } = await seedTeamDocuments();
const teamEmailMember = teamMember4;
@@ -499,9 +486,7 @@ test('[TEAMS]: check document visibility based on team member role', async ({ pa
await expectTextToBeVisible(page, 'Document Visible to Admin with Recipient');
});
test('[TEAMS]: ensure document owner can see document regardless of visibility', async ({
page,
}) => {
test('[TEAMS]: ensure document owner can see document regardless of visibility', async ({ page }) => {
const { team, owner } = await seedTeam();
// Seed a member user
@@ -568,9 +553,7 @@ test('[TEAMS]: ensure recipient can see document regardless of visibility', asyn
});
// Check that the member user can see the document
await expect(
page.getByRole('link', { name: 'Admin Document with Member Recipient', exact: true }),
).toBeVisible();
await expect(page.getByRole('link', { name: 'Admin Document with Member Recipient', exact: true })).toBeVisible();
await apiSignout({ page });
});
@@ -605,16 +588,12 @@ test('[TEAMS]: check that MEMBER role cannot see ADMIN-only documents', async ({
});
// Check that the member user cannot see the ADMIN-only document
await expect(
page.getByRole('link', { name: 'Admin Only Document', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Admin Only Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
});
test('[TEAMS]: check that MEMBER role cannot see MANAGER_AND_ABOVE-only documents', async ({
page,
}) => {
test('[TEAMS]: check that MEMBER role cannot see MANAGER_AND_ABOVE-only documents', async ({ page }) => {
const { team, owner } = await seedTeam();
// Seed a member user
@@ -644,9 +623,7 @@ test('[TEAMS]: check that MEMBER role cannot see MANAGER_AND_ABOVE-only document
});
// Check that the member user cannot see the ADMIN-only document
await expect(
page.getByRole('link', { name: 'Admin Only Document', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Admin Only Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
});
@@ -681,9 +658,7 @@ test('[TEAMS]: check that MANAGER role cannot see ADMIN-only documents', async (
});
// Check that the manager user cannot see the ADMIN-only document
await expect(
page.getByRole('link', { name: 'Admin Only Document', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Admin Only Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
});
@@ -718,9 +693,7 @@ test('[TEAMS]: check that ADMIN role can see MANAGER_AND_ABOVE documents', async
});
// Check that the admin user can see the MANAGER_AND_ABOVE document
await expect(
page.getByRole('link', { name: 'Manager and Above Document', exact: true }),
).toBeVisible();
await expect(page.getByRole('link', { name: 'Manager and Above Document', exact: true })).toBeVisible();
await apiSignout({ page });
});
@@ -757,9 +730,7 @@ test('[TEAMS]: check that ADMIN role can change document visibility', async ({ p
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Admins only');
});
test('[TEAMS]: check that MEMBER role cannot change visibility of EVERYONE documents', async ({
page,
}) => {
test('[TEAMS]: check that MEMBER role cannot change visibility of EVERYONE documents', async ({ page }) => {
const { team, owner } = await seedTeam();
const teamMember = await seedTeamMember({
@@ -783,9 +754,7 @@ test('[TEAMS]: check that MEMBER role cannot change visibility of EVERYONE docum
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
});
test('[TEAMS]: check that MEMBER role cannot change visibility of MANAGER_AND_ABOVE documents', async ({
page,
}) => {
test('[TEAMS]: check that MEMBER role cannot change visibility of MANAGER_AND_ABOVE documents', async ({ page }) => {
const { team, owner } = await seedTeam();
const teamMember = await seedTeamMember({
@@ -809,9 +778,7 @@ test('[TEAMS]: check that MEMBER role cannot change visibility of MANAGER_AND_AB
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
});
test('[TEAMS]: check that MEMBER role cannot change visibility of ADMIN documents', async ({
page,
}) => {
test('[TEAMS]: check that MEMBER role cannot change visibility of ADMIN documents', async ({ page }) => {
const { team, owner } = await seedTeam();
const teamMember = await seedTeamMember({
@@ -835,9 +802,7 @@ test('[TEAMS]: check that MEMBER role cannot change visibility of ADMIN document
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
});
test('[TEAMS]: check that MANAGER role cannot change visibility of ADMIN documents', async ({
page,
}) => {
test('[TEAMS]: check that MANAGER role cannot change visibility of ADMIN documents', async ({ page }) => {
const { team, owner } = await seedTeam();
const teamManager = await seedTeamMember({
@@ -863,16 +828,8 @@ test('[TEAMS]: check that MANAGER role cannot change visibility of ADMIN documen
test('[TEAMS]: users cannot see documents from other teams', async ({ page }) => {
// Seed two teams with documents
const {
team: teamA,
teamOwner: teamAOwner,
teamMember2: teamAMember,
} = await seedTeamDocuments();
const {
team: teamB,
teamOwner: teamBOwner,
teamMember2: teamBMember,
} = await seedTeamDocuments();
const { team: teamA, teamOwner: teamAOwner, teamMember2: teamAMember } = await seedTeamDocuments();
const { team: teamB, teamOwner: teamBOwner, teamMember2: teamBMember } = await seedTeamDocuments();
// Seed a document in team B
await seedDocuments([
@@ -928,9 +885,7 @@ test('[TEAMS]: personal documents are not visible in team context', async ({ pag
});
// Verify that the personal document is not visible in the team context
await expect(
page.getByRole('link', { name: 'Personal Document', exact: true }),
).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Personal Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
});
@@ -1,8 +1,7 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeamEmailVerification } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
@@ -25,10 +24,7 @@ test('[TEAMS]: send team email request', async ({ page }) => {
await page.getByRole('button', { name: 'Add' }).click();
await expect(
page
.getByRole('status')
.filter({ hasText: 'We have sent a confirmation email for verification.' })
.first(),
page.getByRole('status').filter({ hasText: 'We have sent a confirmation email for verification.' }).first(),
).toBeVisible();
});
@@ -55,10 +51,7 @@ test('[TEAMS]: delete team email', async ({ page }) => {
redirectPath: `/t/${team.url}/settings`,
});
const settingsBtn = page
.locator('section div')
.filter({ hasText: 'Team email' })
.getByRole('button');
const settingsBtn = page.locator('section div').filter({ hasText: 'Team email' }).getByRole('button');
await openDropdownMenu(page, settingsBtn);
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
@@ -1,15 +1,8 @@
import { expect, test } from '@playwright/test';
import {
mapSecondaryIdToDocumentId,
mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope';
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import {
seedTeamDocumentWithMeta,
seedTeamTemplateWithMeta,
} from '@documenso/prisma/seed/documents';
import { seedTeamDocumentWithMeta, seedTeamTemplateWithMeta } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -25,9 +18,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
const document = await seedTeamDocumentWithMeta(team);
// Create a document and check the settings
await page.goto(
`/t/${team.url}/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/edit`,
);
await page.goto(`/t/${team.url}/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/edit`);
// Verify that the settings match
await page.getByRole('button', { name: 'Advanced Options' }).click();
@@ -1,21 +1,16 @@
import { type Page, expect, test } from '@playwright/test';
import type { Envelope, Team } from '@prisma/client';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import type { Envelope, Team } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
/**
* Test helper to complete template creation with duplicate recipients
*/
const completeTemplateFlowWithDuplicateRecipients = async (options: {
page: Page;
team: Team;
template: Envelope;
}) => {
const completeTemplateFlowWithDuplicateRecipients = async (options: { page: Page; team: Team; template: Envelope }) => {
const { page, team, template } = options;
// Step 1: Settings - Continue with defaults
await page.getByRole('button', { name: 'Continue' }).click();
@@ -85,10 +80,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
await expect(page).toHaveURL(`/t/${team.url}/templates`);
});
test('should create document from template with duplicate recipients using same email', async ({
page,
context,
}) => {
test('should create document from template with duplicate recipients using same email', async ({ page, context }) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
@@ -105,10 +97,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
// Navigate to template and create document
await page.goto(`/t/${team.url}/templates`);
await page
.getByRole('row', { name: template.title })
.getByRole('button', { name: 'Use Template' })
.click();
await page.getByRole('row', { name: template.title }).getByRole('button', { name: 'Use Template' }).click();
// Fill recipient information with same email for both instances
await expect(page.getByRole('heading', { name: 'Create document' })).toBeVisible();
@@ -172,9 +161,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name);
// Verify only one signature field is visible for this recipient
expect(
await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(),
).toHaveLength(1);
expect(await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all()).toHaveLength(1);
}
});
@@ -1,11 +1,10 @@
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';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -65,10 +64,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Cancel' }).click();
await triggerAutosave(page);
@@ -99,9 +95,7 @@ test.describe('AutoSave Fields Step', () => {
expect(fields.length).toBe(3);
expect(fields.map((field) => field.type).toSorted()).toEqual(
['SIGNATURE', 'TEXT', 'SIGNATURE'].toSorted(),
);
expect(fields.map((field) => field.type).toSorted()).toEqual(['SIGNATURE', 'TEXT', 'SIGNATURE'].toSorted());
}).toPass();
});
@@ -128,10 +122,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Cancel' }).click();
await triggerAutosave(page);
@@ -197,10 +188,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Cancel' }).click();
await triggerAutosave(page);
@@ -272,10 +260,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Save' })
.click();
await page.getByTestId('field-advanced-settings-footer').getByRole('button', { name: 'Save' }).click();
await page.waitForTimeout(2500);
await triggerAutosave(page);
@@ -293,9 +278,7 @@ test.describe('AutoSave Fields Step', () => {
const fields = retrievedTemplate.fields;
expect(fields.length).toBe(2);
expect(fields.map((field) => field.type).toSorted()).toEqual(
['SIGNATURE', 'TEXT'].toSorted(),
);
expect(fields.map((field) => field.type).toSorted()).toEqual(['SIGNATURE', 'TEXT'].toSorted());
const textField = fields.find((field) => field.type === 'TEXT');
expect(textField).toBeDefined();
@@ -306,11 +289,7 @@ test.describe('AutoSave Fields Step', () => {
expect(textField.fieldMeta).toBeDefined();
if (
textField.fieldMeta &&
typeof textField.fieldMeta === 'object' &&
'type' in textField.fieldMeta
) {
if (textField.fieldMeta && typeof textField.fieldMeta === 'object' && 'type' in textField.fieldMeta) {
expect(textField.fieldMeta.type).toBe('text');
expect(textField.fieldMeta.label).toBe('Test Field');
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
@@ -1,10 +1,9 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
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';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -50,9 +49,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(
retrievedTemplate.title,
);
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(retrievedTemplate.title);
}).toPass();
});
@@ -1,13 +1,12 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
@@ -121,18 +120,12 @@ test.describe('AutoSave Signers Step - Templates', () => {
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
await page
.getByTestId('placeholder-recipient-email-input')
.nth(1)
.fill('recipient2@documenso.com');
await page.getByTestId('placeholder-recipient-email-input').nth(1).fill('recipient2@documenso.com');
await page.getByTestId('placeholder-recipient-name-input').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
await page
.getByTestId('placeholder-recipient-email-input')
.nth(2)
.fill('recipient3@documenso.com');
await page.getByTestId('placeholder-recipient-email-input').nth(2).fill('recipient3@documenso.com');
await page.getByTestId('placeholder-recipient-name-input').nth(2).fill('Recipient 3');
await triggerAutosave(page);
@@ -185,11 +178,7 @@ export interface GetRecipientsForTemplateOptions {
teamId: number;
}
const getRecipientsForTemplate = async ({
templateId,
userId,
teamId,
}: GetRecipientsForTemplateOptions) => {
const getRecipientsForTemplate = async ({ templateId, userId, teamId }: GetRecipientsForTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
@@ -1,10 +1,9 @@
import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
@@ -55,9 +54,7 @@ test('[TEMPLATE_FLOW] add document visibility settings', async ({ page }) => {
// Set document visibility.
await page.getByTestId('documentVisibilitySelectValue').click();
await page.getByLabel('Managers and above').click();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Managers and above');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
@@ -67,9 +64,7 @@ test('[TEMPLATE_FLOW] add document visibility settings', async ({ page }) => {
await page.goto(`/t/${team.url}/templates/${template.id}/edit`);
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Managers and above');
});
test('[TEMPLATE_FLOW] team member visibility permissions', async ({ page }) => {
@@ -103,9 +98,7 @@ test('[TEMPLATE_FLOW] team member visibility permissions', async ({ page }) => {
// Manager should be able to set visibility to managers and above
await page.getByTestId('documentVisibilitySelectValue').click();
await page.getByLabel('Managers and above').click();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Managers and above');
await expect(page.getByText('Admins only')).toBeDisabled();
// Save and verify
@@ -133,9 +126,7 @@ test('[TEMPLATE_FLOW] team member visibility permissions', async ({ page }) => {
});
// Navigate to the new template
await page.goto(
`/t/${team.url}/templates/${mapSecondaryIdToTemplateId(everyoneTemplate.secondaryId)}/edit`,
);
await page.goto(`/t/${team.url}/templates/${mapSecondaryIdToTemplateId(everyoneTemplate.secondaryId)}/edit`);
// Regular member should be able to see but not modify visibility
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
@@ -1,7 +1,6 @@
import { expect, test } from '@playwright/test';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -1,9 +1,8 @@
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 { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
@@ -1,20 +1,16 @@
import { expect, test } from '@playwright/test';
import { DocumentDataType, TeamMemberRole } from '@prisma/client';
import path from 'path';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentDataType, TeamMemberRole } from '@prisma/client';
import path from 'path';
import { apiSignin } from '../fixtures/authentication';
const EXAMPLE_PDF_PATH = path.join(__dirname, '../../../../assets/example.pdf');
const FIELD_ALIGNMENT_TEST_PDF_PATH = path.join(
__dirname,
'../../../../assets/field-font-alignment.pdf',
);
const FIELD_ALIGNMENT_TEST_PDF_PATH = path.join(__dirname, '../../../../assets/field-font-alignment.pdf');
/**
* 1. Create a template with all settings filled out
@@ -230,9 +226,7 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
* This test verifies that we can create a document from a template using a custom document
* instead of the template's default document.
*/
test('[TEMPLATE]: should create a document from a template with custom document', async ({
page,
}) => {
test('[TEMPLATE]: should create a document from a template with custom document', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
@@ -267,13 +261,11 @@ test('[TEMPLATE]: should create a document from a template with custom document'
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page
.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`)
.evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
page.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`).evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(FIELD_ALIGNMENT_TEST_PDF_PATH);
@@ -305,9 +297,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
const firstDocumentData = document.envelopeItems[0].documentData;
const expectedDocumentDataType =
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3'
? DocumentDataType.S3_PATH
: DocumentDataType.BYTES_64;
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3' ? DocumentDataType.S3_PATH : DocumentDataType.BYTES_64;
expect(document.title).toEqual('TEMPLATE_WITH_CUSTOM_DOC');
expect(firstDocumentData.type).toEqual(expectedDocumentDataType);
@@ -316,9 +306,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
// Todo: Doesn't really work due to normalization of the PDF which won't let us directly compare the data.
// Probably need to do a pixel match
expect(firstDocumentData.data).not.toEqual(template.envelopeItems[0].documentData.data);
expect(firstDocumentData.initialData).not.toEqual(
template.envelopeItems[0].documentData.initialData,
);
expect(firstDocumentData.initialData).not.toEqual(template.envelopeItems[0].documentData.initialData);
} else {
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
expect(firstDocumentData.data).toBeTruthy();
@@ -330,9 +318,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
* This test verifies that we can create a team document from a template using a custom document
* instead of the template's default document.
*/
test('[TEMPLATE]: should create a team document from a template with custom document', async ({
page,
}) => {
test('[TEMPLATE]: should create a team document from a template with custom document', async ({ page }) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 2,
});
@@ -370,13 +356,11 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page
.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`)
.evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
page.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`).evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(FIELD_ALIGNMENT_TEST_PDF_PATH);
@@ -406,9 +390,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
});
const expectedDocumentDataType =
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3'
? DocumentDataType.S3_PATH
: DocumentDataType.BYTES_64;
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3' ? DocumentDataType.S3_PATH : DocumentDataType.BYTES_64;
const firstDocumentData = document.envelopeItems[0].documentData;
@@ -420,9 +402,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
// Todo: Doesn't really work due to normalization of the PDF which won't let us directly compare the data.
// Probably need to do a pixel match
expect(firstDocumentData.data).not.toEqual(template.envelopeItems[0].documentData.data);
expect(firstDocumentData.initialData).not.toEqual(
template.envelopeItems[0].documentData.initialData,
);
expect(firstDocumentData.initialData).not.toEqual(template.envelopeItems[0].documentData.initialData);
} else {
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
expect(firstDocumentData.data).toBeTruthy();
@@ -505,18 +485,12 @@ test('[TEMPLATE]: should create a document from a template using template docume
});
expect(document.title).toEqual('TEMPLATE_WITH_ORIGINAL_DOC');
expect(firstDocumentData.initialData).toEqual(
templateWithData.envelopeItems[0].documentData.data,
);
expect(firstDocumentData.initialData).toEqual(
templateWithData.envelopeItems[0].documentData.initialData,
);
expect(firstDocumentData.initialData).toEqual(templateWithData.envelopeItems[0].documentData.data);
expect(firstDocumentData.initialData).toEqual(templateWithData.envelopeItems[0].documentData.initialData);
expect(firstDocumentData.type).toEqual(templateWithData.envelopeItems[0].documentData.type);
});
test('[TEMPLATE]: should persist document visibility when creating from template', async ({
page,
}) => {
test('[TEMPLATE]: should persist document visibility when creating from template', async ({ page }) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 2,
});
@@ -533,9 +507,7 @@ test('[TEMPLATE]: should persist document visibility when creating from template
await page.getByLabel('Title').fill('TEMPLATE_WITH_VISIBILITY');
await page.getByTestId('documentVisibilitySelectValue').click();
await page.getByLabel('Managers and above').click();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Managers and above');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
@@ -1,7 +1,3 @@
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { customAlphabet } from 'nanoid';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
@@ -10,6 +6,9 @@ import { prisma } from '@documenso/prisma';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { customAlphabet } from 'nanoid';
import { apiSignin } from '../fixtures/authentication';
@@ -139,9 +138,7 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
},
});
const directTemplatePath = formatDirectTemplatePath(
directTemplateWithAuth.directLink?.token || '',
);
const directTemplatePath = formatDirectTemplatePath(directTemplateWithAuth.directLink?.token || '');
await page.goto(directTemplatePath);
@@ -181,9 +178,7 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page })
},
});
const directTemplatePath = formatDirectTemplatePath(
directTemplateWithAuth.directLink?.token || '',
);
const directTemplatePath = formatDirectTemplatePath(directTemplateWithAuth.directLink?.token || '');
await page.goto(directTemplatePath);
@@ -312,9 +307,7 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
},
});
const updatedSecondRecipient = createdEnvelopeRecipients.find(
(recipient) => recipient.signingOrder === 2,
);
const updatedSecondRecipient = createdEnvelopeRecipients.find((recipient) => recipient.signingOrder === 2);
expect(updatedSecondRecipient?.name).toBe(newName);
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
@@ -399,9 +392,7 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
},
});
const updatedSecondRecipient = createdEnvelopeRecipients.find(
(recipient) => recipient.signingOrder === 2,
);
const updatedSecondRecipient = createdEnvelopeRecipients.find((recipient) => recipient.signingOrder === 2);
expect(updatedSecondRecipient?.name).toBe(newName);
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
@@ -1,8 +1,7 @@
import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
@@ -1,8 +1,3 @@
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';
@@ -10,6 +5,10 @@ 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 type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { TemplateType } from '@prisma/client';
import { customAlphabet } from 'nanoid';
import { apiSignin, apiSignout } from '../fixtures/authentication';
@@ -65,12 +64,7 @@ const seedOrgTemplateScenario = async () => {
/**
* Helper to make tRPC queries via the authenticated page context.
*/
const trpcQuery = async (
page: Page,
procedure: string,
input: Record<string, unknown>,
teamId?: number,
) => {
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}`;
@@ -85,12 +79,7 @@ const trpcQuery = async (
return { res, json: res.ok() ? await res.json() : null };
};
const trpcMutation = async (
page: Page,
procedure: string,
input: Record<string, unknown>,
teamId?: number,
) => {
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> = {
@@ -142,9 +131,7 @@ test.describe('Organisation Templates - UI Tabs', () => {
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
test.describe('Organisation Templates - Listing', () => {
test('should list org templates from other teams under the Organisation tab', async ({
page,
}) => {
test('should list org templates from other teams under the Organisation tab', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({
@@ -163,9 +150,7 @@ test.describe('Organisation Templates - Listing', () => {
await expect(page.getByText(orgTemplate.title)).toBeVisible();
});
test('should not show private templates from other teams under Organisation tab', async ({
page,
}) => {
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.
@@ -245,12 +230,7 @@ test.describe('Organisation Templates - findOrganisationTemplates API', () => {
await apiSignin({ page, email: memberB.email });
const { json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
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);
@@ -297,12 +277,7 @@ test.describe('Organisation Templates - findOrganisationTemplates API', () => {
// 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 { 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);
@@ -389,9 +364,7 @@ test.describe('Organisation Templates - getOrganisationTemplateById API', () =>
// ─── 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,
}) => {
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.
@@ -443,19 +416,12 @@ test.describe('Organisation Templates - Adversarial', () => {
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,
);
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,
}) => {
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, {
@@ -477,18 +443,11 @@ test.describe('Organisation Templates - Adversarial', () => {
expect(orgRes.ok()).toBeFalsy();
// Attempt 2: Try via standard envelope endpoint.
const { res: envelopeRes } = await trpcQuery(
page,
'envelope.get',
{ envelopeId: privateTemplate.id },
teamB.id,
);
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,
}) => {
test('should not list org templates from a completely unrelated organisation', async ({ page }) => {
// Create scenario A.
const { orgTemplate: orgTemplateA } = await seedOrgTemplateScenario();
@@ -532,22 +491,150 @@ test.describe('Organisation Templates - Adversarial', () => {
expect(getRes.status()).toBe(401);
});
test('should not return org template data via findTemplates (team endpoint)', async ({
page,
}) => {
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 { 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);
});
});
// ─── API: envelope.item.getManyByToken (org template fallback) ───────────────
test.describe('Organisation Templates - envelope.item.getManyByToken API', () => {
test('should allow a sibling team member to fetch envelope items for an org template', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({ page, email: memberB.email });
const { res, json } = await trpcQuery(
page,
'envelope.item.getManyByToken',
{ envelopeId: orgTemplate.id, access: { type: 'user' } },
teamB.id,
);
expect(res.ok()).toBeTruthy();
const items = json.result.data.json.data;
expect(Array.isArray(items)).toBe(true);
expect(items.length).toBeGreaterThan(0);
expect(items[0].envelopeId).toBe(orgTemplate.id);
});
test('should allow the owning team member to fetch envelope items (own-team path)', async ({ page }) => {
const { ownerA, teamA, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({ page, email: ownerA.email });
const { res, json } = await trpcQuery(
page,
'envelope.item.getManyByToken',
{ envelopeId: orgTemplate.id, access: { type: 'user' } },
teamA.id,
);
expect(res.ok()).toBeTruthy();
const items = json.result.data.json.data;
expect(items.length).toBeGreaterThan(0);
expect(items[0].envelopeId).toBe(orgTemplate.id);
});
test('should reject 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,
'envelope.item.getManyByToken',
{ envelopeId: orgTemplate.id, access: { type: 'user' } },
outsiderTeam.id,
);
expect(res.ok()).toBeFalsy();
});
test('should reject fetching items for a PRIVATE template from a sibling team', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Private Items ${nanoid()}`,
templateType: TemplateType.PRIVATE,
},
});
await apiSignin({ page, email: memberB.email });
const { res } = await trpcQuery(
page,
'envelope.item.getManyByToken',
{ envelopeId: privateTemplate.id, access: { type: 'user' } },
teamB.id,
);
expect(res.ok()).toBeFalsy();
});
test('should respect document visibility for the viewer team role', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const adminOnlyTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Items Admin Only ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
visibility: 'ADMIN',
},
});
// memberB is a MEMBER on teamB — must not be able to read items for an ADMIN-only template.
await apiSignin({ page, email: memberB.email });
const { res: memberRes } = await trpcQuery(
page,
'envelope.item.getManyByToken',
{ envelopeId: adminOnlyTemplate.id, access: { type: 'user' } },
teamB.id,
);
expect(memberRes.ok()).toBeFalsy();
await apiSignout({ page });
// ownerA is ADMIN on teamA — should succeed via the own-team path.
await apiSignin({ page, email: ownerA.email });
const { res: adminRes, json: adminJson } = await trpcQuery(
page,
'envelope.item.getManyByToken',
{ envelopeId: adminOnlyTemplate.id, access: { type: 'user' } },
teamA.id,
);
expect(adminRes.ok()).toBeTruthy();
expect(adminJson.result.data.json.data.length).toBeGreaterThan(0);
});
test('should reject unauthenticated callers using the user access type', async ({ page }) => {
const { orgTemplate, teamB } = await seedOrgTemplateScenario();
// No apiSignin — unauthenticated.
const { res } = await trpcQuery(
page,
'envelope.item.getManyByToken',
{ envelopeId: orgTemplate.id, access: { type: 'user' } },
teamB.id,
);
expect(res.ok()).toBeFalsy();
});
});
@@ -1,8 +1,7 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
@@ -1,11 +1,6 @@
import { type Page, expect, test } from '@playwright/test';
import { prisma } from '@documenso/prisma';
import {
extractUserVerificationToken,
seedTestEmail,
seedUser,
} from '@documenso/prisma/seed/users';
import { extractUserVerificationToken, seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { signSignaturePad } from '../fixtures/signature';
@@ -1,6 +1,5 @@
import { type Page, expect, test } from '@playwright/test';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { expectTextToBeVisible } from '../fixtures/generic';
@@ -59,24 +58,14 @@ test('[USER] revoke sessions', async ({ page }: { page: Page }) => {
});
// Find table row which does not have text 'Current' and click the button called Revoke within the row.
await page
.getByRole('row')
.filter({ hasNotText: 'Current' })
.nth(1)
.getByRole('button', { name: 'Revoke' })
.click();
await page.getByRole('row').filter({ hasNotText: 'Current' }).nth(1).getByRole('button', { name: 'Revoke' }).click();
await expectTextToBeVisible(page, 'Session revoked');
// Expect (1 sessions + 1 header) rows length
await expect(page.getByRole('row')).toHaveCount(2);
// Revoke own session.
await page
.getByRole('row')
.filter({ hasText: 'Current' })
.first()
.getByRole('button', { name: 'Revoke' })
.click();
await page.getByRole('row').filter({ hasText: 'Current' }).first().getByRole('button', { name: 'Revoke' }).click();
await expect(page).toHaveURL('/signin');
});
@@ -1,8 +1,7 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';

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