Merge branch 'main' into feat/add-pdf-image-renderer

This commit is contained in:
David Nguyen
2026-02-04 12:50:40 +11:00
committed by GitHub
125 changed files with 7880 additions and 938 deletions
@@ -0,0 +1,453 @@
import { PDF, StandardFonts } from '@libpdf/core';
import type { APIRequestContext } from '@playwright/test';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
const FIXTURES_DIR = path.join(__dirname, '../../../../assets/fixtures/auto-placement');
test.describe.configure({ mode: 'parallel' });
test.describe('Placeholder-based field creation', () => {
let user: User, team: Team, token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
}));
});
const createEnvelopeWithPdf = async (
request: APIRequestContext,
pdfFilename: string,
): Promise<TCreateEnvelopeResponse> => {
const pdfPath = path.join(FIXTURES_DIR, pdfFilename);
const pdfData = fs.readFileSync(pdfPath);
const formData = new FormData();
formData.append(
'payload',
JSON.stringify({
type: EnvelopeType.DOCUMENT,
title: 'Placeholder Fields Test',
} satisfies TCreateEnvelopePayload),
);
formData.append('files', new File([pdfData], pdfFilename, { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return res.json();
};
const createEnvelopeItemsWithPdf = async (
request: APIRequestContext,
envelopeId: string,
pdfFilename: string,
) => {
const pdfPath = path.join(FIXTURES_DIR, pdfFilename);
const pdfData = fs.readFileSync(pdfPath);
const formData = new FormData();
formData.append('payload', JSON.stringify({ envelopeId }));
formData.append('files', new File([pdfData], pdfFilename, { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/item/create-many`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return res.json();
};
const addRecipient = async (request: APIRequestContext, envelopeId: string) => {
const payload: TCreateEnvelopeRecipientsRequest = {
envelopeId,
data: [
{
email: user.email,
name: user.name || '',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: payload,
});
expect(res.ok()).toBeTruthy();
};
const addRecipients = async (
request: APIRequestContext,
envelopeId: string,
recipients: TCreateEnvelopeRecipientsRequest['data'],
) => {
const payload: TCreateEnvelopeRecipientsRequest = {
envelopeId,
data: recipients,
};
const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: payload,
});
expect(res.ok()).toBeTruthy();
};
const getEnvelope = async (
request: APIRequestContext,
envelopeId: string,
): Promise<TGetEnvelopeResponse> => {
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.ok()).toBeTruthy();
return res.json();
};
/**
* Create a PDF with the same placeholder appearing multiple times at different locations.
*/
const createPdfWithDuplicatePlaceholders = async (): Promise<Buffer> => {
const pdf = PDF.create();
const page = pdf.addPage({ size: 'letter' });
// Draw the same placeholder text at three different Y positions.
page.drawText('{{initials}}', { x: 50, y: 700, font: StandardFonts.Helvetica, size: 12 });
page.drawText('{{initials}}', { x: 50, y: 500, font: StandardFonts.Helvetica, size: 12 });
page.drawText('{{initials}}', { x: 50, y: 300, font: StandardFonts.Helvetica, size: 12 });
const bytes = await pdf.save();
return Buffer.from(bytes);
};
const createEnvelopeWithPdfBuffer = async (
request: APIRequestContext,
pdfBuffer: Buffer,
filename: string,
): Promise<TCreateEnvelopeResponse> => {
const formData = new FormData();
formData.append(
'payload',
JSON.stringify({
type: EnvelopeType.DOCUMENT,
title: 'Placeholder Fields Test',
} satisfies TCreateEnvelopePayload),
);
formData.append('files', new File([pdfBuffer], filename, { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return res.json();
};
test('should create a field at a placeholder location', async ({ request }) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.SIGNATURE,
placeholder: '{{signature}}',
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
expect(fields).toHaveLength(1);
expect(fields[0].type).toBe(FieldType.SIGNATURE);
// Verify the field has non-zero position/dimensions resolved from the placeholder.
expect(fields[0].positionX.toNumber()).toBeGreaterThan(0);
expect(fields[0].positionY.toNumber()).toBeGreaterThan(0);
expect(fields[0].width.toNumber()).toBeGreaterThan(0);
expect(fields[0].height.toNumber()).toBeGreaterThan(0);
});
test('should override width and height when provided', async ({ request }) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.NAME,
placeholder: '{{name}}',
width: 30,
height: 5,
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
expect(fields).toHaveLength(1);
expect(fields[0].width.toNumber()).toBeCloseTo(30, 1);
expect(fields[0].height.toNumber()).toBeCloseTo(5, 1);
});
test('should fail when placeholder text is not found in the PDF', async ({ request }) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.TEXT,
placeholder: '{{nonexistent}}',
},
],
},
});
expect(createFieldsRes.ok()).toBeFalsy();
});
test('should create fields using a mix of coordinate and placeholder positioning', async ({
request,
}) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.SIGNATURE,
placeholder: '{{signature}}',
},
{
recipientId,
type: FieldType.DATE,
page: 1,
positionX: 10,
positionY: 20,
width: 15,
height: 3,
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
orderBy: { type: 'asc' },
});
expect(fields).toHaveLength(2);
const dateField = fields.find((f) => f.type === FieldType.DATE);
const signatureField = fields.find((f) => f.type === FieldType.SIGNATURE);
expect(dateField).toBeDefined();
expect(dateField!.positionX.toNumber()).toBeCloseTo(10, 1);
expect(dateField!.positionY.toNumber()).toBeCloseTo(20, 1);
expect(signatureField).toBeDefined();
expect(signatureField!.positionX.toNumber()).toBeGreaterThan(0);
});
test('should create a field only at first occurrence by default', async ({ request }) => {
const pdfBuffer = await createPdfWithDuplicatePlaceholders();
const envelope = await createEnvelopeWithPdfBuffer(request, pdfBuffer, 'duplicates.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.INITIALS,
placeholder: '{{initials}}',
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
// Should only create one field (first occurrence).
expect(fields).toHaveLength(1);
expect(fields[0].type).toBe(FieldType.INITIALS);
});
test('should create fields at all occurrences when matchAll is true', async ({ request }) => {
const pdfBuffer = await createPdfWithDuplicatePlaceholders();
const envelope = await createEnvelopeWithPdfBuffer(request, pdfBuffer, 'duplicates.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.INITIALS,
placeholder: '{{initials}}',
matchAll: true,
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
orderBy: { positionY: 'asc' },
});
// Should create three fields (one for each occurrence).
expect(fields).toHaveLength(3);
// All should be INITIALS type.
expect(fields.every((f) => f.type === FieldType.INITIALS)).toBe(true);
// Verify they're at different Y positions.
const yPositions = fields.map((f) => f.positionY.toNumber());
const uniqueYPositions = new Set(yPositions);
expect(uniqueYPositions.size).toBe(3);
});
test('should map placeholder recipients by signing order when adding items', async ({
request,
}) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipients(request, envelope.id, [
{
email: 'second.recipient@documenso.com',
name: 'Second Recipient',
role: RecipientRole.SIGNER,
signingOrder: 2,
accessAuth: [],
actionAuth: [],
},
{
email: 'first.recipient@documenso.com',
name: 'First Recipient',
role: RecipientRole.SIGNER,
signingOrder: 1,
accessAuth: [],
actionAuth: [],
},
]);
await createEnvelopeItemsWithPdf(request, envelope.id, 'project-proposal-single-recipient.pdf');
const recipients = await prisma.recipient.findMany({
where: { envelopeId: envelope.id },
});
const firstRecipient = recipients.find((recipient) => recipient.signingOrder === 1);
expect(firstRecipient).toBeDefined();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
expect(fields.length).toBeGreaterThan(0);
expect(fields.every((field) => field.recipientId === firstRecipient!.id)).toBe(true);
});
});
@@ -4730,252 +4730,6 @@ test.describe('Document API V2', () => {
});
});
test.describe('Envelope item delete endpoint', () => {
test('should block unauthorized access to envelope item delete endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId: doc.id },
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/delete`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeId: doc.id,
envelopeItemId: envelopeItem.id,
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope item delete endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId: doc.id },
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeId: doc.id,
envelopeItemId: envelopeItem.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope attachment find endpoint', () => {
test('should block unauthorized access to envelope attachment find endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.get(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
{
headers: { Authorization: `Bearer ${tokenB}` },
},
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment find endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.get(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
{
headers: { Authorization: `Bearer ${tokenA}` },
},
);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope attachment create endpoint', () => {
test('should block unauthorized access to envelope attachment create endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.post(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`,
{
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeId: doc.id,
data: {
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
},
},
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment create endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.post(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`,
{
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeId: doc.id,
data: {
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
},
},
);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope attachment update endpoint', () => {
test('should block unauthorized access to envelope attachment update endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Original Label',
data: 'https://example.com/original.pdf',
},
});
const res = await request.post(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`,
{
headers: { Authorization: `Bearer ${tokenB}` },
data: {
id: attachment.id,
data: {
label: 'Updated Label',
data: 'https://example.com/updated.pdf',
},
},
},
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment update endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Original Label',
data: 'https://example.com/original.pdf',
},
});
const res = await request.post(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`,
{
headers: { Authorization: `Bearer ${tokenA}` },
data: {
id: attachment.id,
data: {
label: 'Updated Label',
data: 'https://example.com/updated.pdf',
},
},
},
);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope attachment delete endpoint', () => {
test('should block unauthorized access to envelope attachment delete endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
});
const res = await request.post(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`,
{
headers: { Authorization: `Bearer ${tokenB}` },
data: { id: attachment.id },
},
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment delete endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
});
const res = await request.post(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`,
{
headers: { Authorization: `Bearer ${tokenA}` },
data: { id: attachment.id },
},
);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope audit logs endpoint', () => {
test('should block unauthorized access to envelope audit logs endpoint', async ({
request,
@@ -0,0 +1,220 @@
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
test.describe('Envelope Attachments API V2', () => {
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
test.beforeEach(async () => {
({ user: userA, team: teamA } = await seedUser());
({ token: tokenA } = await createApiToken({
userId: userA.id,
teamId: teamA.id,
tokenName: 'userA',
expiresIn: null,
}));
({ user: userB, team: teamB } = await seedUser());
({ token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
}));
});
test.describe('Envelope attachment find endpoint', () => {
test('should block unauthorized access to envelope attachment find endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.get(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
{
headers: { Authorization: `Bearer ${tokenB}` },
},
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment find endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.get(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
{
headers: { Authorization: `Bearer ${tokenA}` },
},
);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope attachment create endpoint', () => {
test('should block unauthorized access to envelope attachment create endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeId: doc.id,
data: {
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment create endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeId: doc.id,
data: {
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope attachment update endpoint', () => {
test('should block unauthorized access to envelope attachment update endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Original Label',
data: 'https://example.com/original.pdf',
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
id: attachment.id,
data: {
label: 'Updated Label',
data: 'https://example.com/updated.pdf',
},
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment update endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Original Label',
data: 'https://example.com/original.pdf',
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
id: attachment.id,
data: {
label: 'Updated Label',
data: 'https://example.com/updated.pdf',
},
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Envelope attachment delete endpoint', () => {
test('should block unauthorized access to envelope attachment delete endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: { id: attachment.id },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope attachment delete endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const attachment = await prisma.envelopeAttachment.create({
data: {
envelopeId: doc.id,
type: 'link',
label: 'Test Attachment',
data: 'https://example.com/file.pdf',
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: { id: attachment.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
});
@@ -0,0 +1,390 @@
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import { EnvelopeType, FolderType } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedUser } from '@documenso/prisma/seed/users';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
// Todo: Remove skip once the API endpoints are released.
test.describe.skip('Envelope Bulk API V2', () => {
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
test.beforeEach(async () => {
({ user: userA, team: teamA } = await seedUser());
({ token: tokenA } = await createApiToken({
userId: userA.id,
teamId: teamA.id,
tokenName: 'userA',
expiresIn: null,
}));
({ user: userB, team: teamB } = await seedUser());
({ token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
}));
});
test.describe('Envelope bulk move endpoint', () => {
test('should block unauthorized access to envelope bulk move endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// UserB tries to move userA's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: null,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.movedCount).toBe(0);
// Verify in database that the document was not modified
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBeNull();
});
test('should block moving envelopes to unauthorized folder', async ({ request }) => {
// Create a document owned by userB
const doc = await seedBlankDocument(userB, teamB.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
});
// UserB tries to move their document to userA's folder
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// Verify in database that the document was not modified
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBeNull();
});
test('should allow authorized access to envelope bulk move endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
});
// UserA moves their own document to their own folder
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [doc.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.movedCount).toBe(1);
// Verify in database that the document was moved to the folder
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.folderId).toBe(folderA.id);
});
test('should only move authorized envelopes when given mixed array of envelope IDs', async ({
request,
}) => {
// Create documents owned by userA
const docA1 = await seedBlankDocument(userA, teamA.id);
const docA2 = await seedBlankDocument(userA, teamA.id);
// Create a document owned by userB
const docB = await seedBlankDocument(userB, teamB.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
});
// UserA tries to move a mix of their own documents and userB's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docA1.id, docB.id, docA2.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
// Only userA's documents should be moved
expect(body.movedCount).toBe(2);
// Verify userA's documents were moved
const docA1InDb = await prisma.envelope.findFirst({
where: { id: docA1.id },
});
expect(docA1InDb).not.toBeNull();
expect(docA1InDb?.folderId).toBe(folderA.id);
const docA2InDb = await prisma.envelope.findFirst({
where: { id: docA2.id },
});
expect(docA2InDb).not.toBeNull();
expect(docA2InDb?.folderId).toBe(folderA.id);
// Verify userB's document was NOT moved
const docBInDb = await prisma.envelope.findFirst({
where: { id: docB.id },
});
expect(docBInDb).not.toBeNull();
expect(docBInDb?.folderId).toBeNull();
});
test('should move zero envelopes when all envelope IDs in array are unauthorized', async ({
request,
}) => {
// Create documents owned by userB
const docB1 = await seedBlankDocument(userB, teamB.id);
const docB2 = await seedBlankDocument(userB, teamB.id);
// Create a folder owned by userA
const folderA = await seedBlankFolder(userA, teamA.id, {
createFolderOptions: {
name: 'UserA Folder',
type: FolderType.DOCUMENT,
},
});
// UserA tries to move userB's documents
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docB1.id, docB2.id],
envelopeType: EnvelopeType.DOCUMENT,
folderId: folderA.id,
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.movedCount).toBe(0);
// Verify userB's documents were NOT moved
const docB1InDb = await prisma.envelope.findFirst({
where: { id: docB1.id },
});
expect(docB1InDb).not.toBeNull();
expect(docB1InDb?.folderId).toBeNull();
const docB2InDb = await prisma.envelope.findFirst({
where: { id: docB2.id },
});
expect(docB2InDb).not.toBeNull();
expect(docB2InDb?.folderId).toBeNull();
});
});
test.describe('Envelope bulk delete endpoint', () => {
test('should block unauthorized access to envelope bulk delete endpoint', async ({
request,
}) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// UserB tries to delete userA's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
envelopeIds: [doc.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.deletedCount).toBe(0);
// Unauthorized envelope ID should be in failedIds
expect(body.failedIds).toEqual([doc.id]);
// Verify in database that the document still exists
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).not.toBeNull();
expect(docInDb?.id).toBe(doc.id);
expect(docInDb?.deletedAt).toBeNull();
});
test('should allow authorized access to envelope bulk delete endpoint', async ({ request }) => {
// Create a document owned by userA
const doc = await seedBlankDocument(userA, teamA.id);
// UserA deletes their own document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [doc.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.deletedCount).toBe(1);
expect(body.failedIds).toEqual([]);
// Verify in database that the document no longer exists
const docInDb = await prisma.envelope.findFirst({
where: { id: doc.id },
});
expect(docInDb).toBeNull();
});
test('should only delete authorized envelopes when given mixed array of envelope IDs', async ({
request,
}) => {
// Create documents owned by userA
const docA1 = await seedBlankDocument(userA, teamA.id);
const docA2 = await seedBlankDocument(userA, teamA.id);
// Create a document owned by userB
const docB = await seedBlankDocument(userB, teamB.id);
// UserA tries to delete a mix of their own documents and userB's document
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docA1.id, docB.id, docA2.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
// Only userA's documents should be deleted
expect(body.deletedCount).toBe(2);
// Unauthorized envelope ID (docB) should be in failedIds
expect(body.failedIds).toEqual([docB.id]);
// Verify userA's documents were deleted
const docA1InDb = await prisma.envelope.findFirst({
where: { id: docA1.id },
});
expect(docA1InDb).toBeNull();
const docA2InDb = await prisma.envelope.findFirst({
where: { id: docA2.id },
});
expect(docA2InDb).toBeNull();
// Verify userB's document was NOT deleted
const docBInDb = await prisma.envelope.findFirst({
where: { id: docB.id },
});
expect(docBInDb).not.toBeNull();
expect(docBInDb?.id).toBe(docB.id);
expect(docBInDb?.deletedAt).toBeNull();
});
test('should delete zero envelopes when all envelope IDs in array are unauthorized', async ({
request,
}) => {
// Create documents owned by userB
const docB1 = await seedBlankDocument(userB, teamB.id);
const docB2 = await seedBlankDocument(userB, teamB.id);
// UserA tries to delete userB's documents
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeIds: [docB1.id, docB2.id],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.deletedCount).toBe(0);
// All unauthorized envelope IDs should be in failedIds
expect(body.failedIds).toEqual(expect.arrayContaining([docB1.id, docB2.id]));
expect(body.failedIds).toHaveLength(2);
// Verify userB's documents were NOT deleted
const docB1InDb = await prisma.envelope.findFirst({
where: { id: docB1.id },
});
expect(docB1InDb).not.toBeNull();
expect(docB1InDb?.id).toBe(docB1.id);
expect(docB1InDb?.deletedAt).toBeNull();
const docB2InDb = await prisma.envelope.findFirst({
where: { id: docB2.id },
});
expect(docB2InDb).not.toBeNull();
expect(docB2InDb?.id).toBe(docB2.id);
expect(docB2InDb?.deletedAt).toBeNull();
});
});
});
@@ -0,0 +1,307 @@
import { type Page, expect, test } from '@playwright/test';
import path from 'path';
import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const FIXTURES_DIR = path.join(__dirname, '../../../assets/fixtures/auto-placement');
const SINGLE_PLACEHOLDER_PDF_PATH = path.join(
FIXTURES_DIR,
'project-proposal-single-recipient.pdf',
);
const MULTIPLE_PLACEHOLDER_PDF_PATH = path.join(
FIXTURES_DIR,
'project-proposal-multiple-fields-and-recipients.pdf',
);
const NO_RECIPIENT_PDF_PATH = path.join(FIXTURES_DIR, 'no-recipient-placeholders.pdf');
const INVALID_FIELD_TYPE_PDF_PATH = path.join(FIXTURES_DIR, 'invalid-field-type.pdf');
const FIELD_TYPE_ONLY_PDF_PATH = path.join(FIXTURES_DIR, 'field-type-only.pdf');
const setTeamDefaultRecipients = async (
teamId: number,
defaultRecipients: Array<{ email: string; name: string; role: RecipientRole }>,
) => {
const teamSettings = await prisma.teamGlobalSettings.findFirstOrThrow({
where: {
team: {
id: teamId,
},
},
});
await prisma.teamGlobalSettings.update({
where: {
id: teamSettings.id,
},
data: {
defaultRecipients,
},
});
};
const setupUserAndSignIn = async (page: Page) => {
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
return { user, team };
};
const uploadPdf = async (page: Page, team: { url: string }, pdfPath: string) => {
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page
.locator('input[type=file]')
.nth(1)
.evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(pdfPath);
// Wait for redirect to v2 envelope editor.
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
// Extract envelope ID from URL.
const urlParts = page.url().split('/');
const envelopeId = urlParts.find((part) => part.startsWith('envelope_'));
if (!envelopeId) {
throw new Error('Could not extract envelope ID from URL');
}
return envelopeId;
};
test.describe('PDF Placeholders with single recipient', () => {
test('[AUTO_PLACING_FIELDS]: should create placeholder recipients even with default recipients', async ({
page,
}) => {
const { user, team } = await seedUser();
await setTeamDefaultRecipients(team.id, [
{
email: user.email,
name: user.name || user.email,
role: RecipientRole.CC,
},
]);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
await expect(async () => {
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
});
const placeholderRecipient = recipients.find(
(recipient) => recipient.email === 'recipient.1@documenso.com',
);
const defaultRecipient = recipients.find((recipient) => recipient.email === user.email);
expect(placeholderRecipient).toBeDefined();
expect(defaultRecipient).toBeDefined();
const fields = await prisma.field.findMany({
where: { envelopeId },
});
expect(fields.length).toBeGreaterThan(0);
expect(fields.every((field) => field.recipientId === placeholderRecipient!.id)).toBe(true);
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
// V2 editor shows recipients on the upload page under "Recipients" heading.
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
'recipient.1@documenso.com',
);
await expect(page.getByLabel('Name').first()).toHaveValue('Recipient 1');
});
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
// V2 editor renders fields on a Konva canvas, so we verify via the database.
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
const fieldTypes = fields.map((f) => f.type).sort();
expect(fieldTypes).toEqual(['EMAIL', 'NAME', 'SIGNATURE', 'TEXT'].sort());
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
// Verify field metadata was correctly parsed from the placeholder.
await expect(async () => {
const textField = await prisma.field.findFirst({
where: { envelopeId, type: 'TEXT' },
});
expect(textField).toBeDefined();
expect(textField!.fieldMeta).toBeDefined();
const meta = textField!.fieldMeta as Record<string, unknown>;
expect(meta.required).toBe(true);
expect(meta.textAlign).toBe('right');
}).toPass();
});
});
test.describe('PDF Placeholders with multiple recipients', () => {
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, MULTIPLE_PLACEHOLDER_PDF_PATH);
// V2 editor shows recipients on the upload page.
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
'recipient.1@documenso.com',
);
await expect(page.getByTestId('signer-email-input').nth(1)).toHaveValue(
'recipient.2@documenso.com',
);
await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue(
'recipient.3@documenso.com',
);
// Verify recipients via the database for name validation since the v2 editor
// only shows the "Name" label on the first recipient row.
await expect(async () => {
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
orderBy: { signingOrder: 'asc' },
});
expect(recipients).toHaveLength(3);
expect(recipients[0].name).toBe('Recipient 1');
expect(recipients[1].name).toBe('Recipient 2');
expect(recipients[2].name).toBe('Recipient 3');
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, MULTIPLE_PLACEHOLDER_PDF_PATH);
// V2 editor renders fields on a Konva canvas, so we verify via the database.
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
const fieldTypes = fields.map((f) => f.type).sort();
expect(fieldTypes).toEqual(
['SIGNATURE', 'SIGNATURE', 'SIGNATURE', 'EMAIL', 'EMAIL', 'NAME', 'TEXT', 'NUMBER'].sort(),
);
}).toPass();
});
});
test.describe('PDF Placeholders without recipient identifier', () => {
test('[AUTO_PLACING_FIELDS]: should skip placeholders without a recipient identifier', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, NO_RECIPIENT_PDF_PATH);
// Placeholders like {{signature}}, {{name}}, {{email}} have no recipient
// identifier and should be skipped entirely. No fields or auto-created
// recipients should exist.
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
expect(fields).toHaveLength(0);
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should skip a bare field type placeholder', async ({ page }) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, FIELD_TYPE_ONLY_PDF_PATH);
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
expect(fields).toHaveLength(0);
}).toPass();
});
});
test.describe('PDF Placeholders with invalid field types', () => {
test('[AUTO_PLACING_FIELDS]: should skip invalid field types and process valid ones', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, INVALID_FIELD_TYPE_PDF_PATH);
// Only the valid placeholders (signature,r1 and email,r2) should create fields.
// The invalid ones (bogus,r1 and foobar,r2) should be skipped.
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
const fieldTypes = fields.map((f) => f.type).sort();
expect(fieldTypes).toEqual(['EMAIL', 'SIGNATURE'].sort());
}).toPass();
// Both valid recipients should still be created.
await expect(async () => {
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
orderBy: { signingOrder: 'asc' },
});
expect(recipients).toHaveLength(2);
}).toPass();
});
});
@@ -0,0 +1,253 @@
import { expect, test } from '@playwright/test';
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
test.describe.configure({ mode: 'parallel' });
const seedBulkActionsTestRequirements = async () => {
const sender = await seedUser({ setTeamEmailAsOwner: true });
const [doc1, doc2, doc3] = await Promise.all([
seedDraftDocument(sender.user, sender.team.id, [], {
createDocumentOptions: { title: 'Bulk Test Doc 1' },
}),
seedDraftDocument(sender.user, sender.team.id, [], {
createDocumentOptions: { title: 'Bulk Test Doc 2' },
}),
seedDraftDocument(sender.user, sender.team.id, [], {
createDocumentOptions: { title: 'Bulk Test Doc 3' },
}),
]);
const folder = await seedBlankFolder(sender.user, sender.team.id, {
createFolderOptions: {
name: 'Target Folder',
teamId: sender.team.id,
},
});
return {
sender,
documents: [doc1, doc2, doc3],
folder,
};
};
test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
});
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
const { sender, documents } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await page.getByLabel('Clear selection').click();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
const { sender, folder } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
});
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Delete Documents')).toBeVisible();
await expect(page.getByText('You are about to delete 2 documents')).toBeVisible();
await expect(page.getByText('irreversible')).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Documents deleted');
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Bulk Test Doc 3' })).toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful move', async ({ page }) => {
const { sender, folder } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Documents deleted');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
const { sender, folder } = await seedBulkActionsTestRequirements();
const otherFolder = await seedBlankFolder(sender.user, sender.team.id, {
createFolderOptions: {
name: 'Other Folder',
teamId: sender.team.id,
},
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
await page.getByPlaceholder('Search folders...').fill('Target');
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
await expect(page.getByRole('button', { name: otherFolder.name })).not.toBeVisible();
await page.getByPlaceholder('Search folders...').fill('Other');
await expect(page.getByRole('button', { name: folder.name })).not.toBeVisible();
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
await page.getByPlaceholder('Search folders...').fill('NonExistent');
await expect(page.getByText('No folders found')).toBeVisible();
});
test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ page }) => {
const { sender, documents, folder } = await seedBulkActionsTestRequirements();
const { prisma } = await import('@documenso/prisma');
await prisma.envelope.updateMany({
where: { id: documents[0].id },
data: { folderId: folder.id },
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents/f/${folder.id}`,
});
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await page.goto(`/t/${sender.team.url}/documents`);
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
});
@@ -0,0 +1,326 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { TCachedLicense, TLicenseClaim } from '@documenso/lib/types/license';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const LICENSE_FILE_NAME = '.documenso-license.json';
const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json';
/**
* Get the path to the license file.
*
* The server reads from process.cwd() which is apps/remix when the dev server runs.
* Tests run from packages/app-tests, so we need to go up to the root then into apps/remix.
*/
const getLicenseFilePath = () => {
// From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json
return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME);
};
/**
* Get the path to the backup license file.
*/
const getBackupLicenseFilePath = () => {
return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME);
};
/**
* Backup the existing license file if it exists.
*/
const backupLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(licensePath);
await fs.rename(licensePath, backupPath);
} catch (e) {
// File doesn't exist, nothing to backup
console.log(e);
}
};
/**
* Restore the backup license file if it exists.
*/
const restoreLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(backupPath);
await fs.rename(backupPath, licensePath);
} catch (e) {
// Backup doesn't exist, nothing to restore
console.log(e);
}
};
/**
* Write a license file with the given data.
* Pass null to delete the license file.
*/
const writeLicenseFile = async (data: TCachedLicense | null) => {
const licensePath = getLicenseFilePath();
if (data === null) {
await fs.unlink(licensePath).catch(() => {
// File doesn't exist, ignore
});
} else {
await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8');
}
};
/**
* Create a mock license object with the given flags.
*/
const createMockLicenseWithFlags = (flags: TLicenseClaim): TCachedLicense => {
return {
lastChecked: new Date().toISOString(),
license: {
status: 'ACTIVE',
createdAt: new Date(),
name: 'Test License',
periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
cancelAtPeriodEnd: false,
licenseKey: 'test-license-key',
flags,
},
requestedLicenseKey: 'test-license-key',
derivedStatus: 'ACTIVE',
unauthorizedFlagUsage: false,
};
};
// Run tests serially to avoid race conditions with the license file
test.describe.configure({ mode: 'serial' });
// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE.
test.describe.skip('Enterprise Feature Restrictions', () => {
test.beforeAll(async () => {
// Backup any existing license file before running tests
await backupLicenseFile();
});
test.afterAll(async () => {
// Restore the backup license file after all tests complete
await restoreLicenseFile();
});
test.beforeEach(async () => {
// Clean up license file before each test to ensure clean state
await writeLicenseFile(null);
});
test.afterEach(async () => {
// Clean up license file after each test
await writeLicenseFile(null);
});
test('[ADMIN CLAIMS]: shows restricted features with asterisk when no license', async ({
page,
}) => {
// Ensure no license file exists
await writeLicenseFile(null);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Check that enterprise features have asterisks (are restricted)
// These are the enterprise features that should be marked with *
await expect(page.getByText(/Email domains\s¹/)).toBeVisible();
await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/21 CFR\s¹/)).toBeVisible();
await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible();
// Check that the alert is visible
await expect(
page.getByText('Your current license does not include these features.'),
).toBeVisible();
await expect(page.getByRole('link', { name: 'Learn more' })).toBeVisible();
// Check that enterprise feature checkboxes are disabled
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
await expect(emailDomainsCheckbox).toBeDisabled();
const cfr21Checkbox = page.locator('#flag-cfr21');
await expect(cfr21Checkbox).toBeDisabled();
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
await expect(authPortalCheckbox).toBeDisabled();
});
test('[ADMIN CLAIMS]: no restrictions when license has all enterprise features', async ({
page,
}) => {
// Create a license with ALL enterprise features enabled
await writeLicenseFile(
createMockLicenseWithFlags({
emailDomains: true,
embedAuthoring: true,
embedAuthoringWhiteLabel: true,
cfr21: true,
authenticationPortal: true,
billing: true,
}),
);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Check that enterprise features do NOT have asterisks
// They should show without the * since the license covers them
await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible();
await expect(page.getByText(/Embed authoring\s¹/)).not.toBeVisible();
await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible();
await expect(page.getByText(/Authentication portal\s¹/)).not.toBeVisible();
// The plain labels should be visible (without asterisks)
await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains');
await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR');
// The alert should NOT be visible
await expect(
page.getByText('Your current license does not include these features.'),
).not.toBeVisible();
// Check that enterprise feature checkboxes are enabled
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
await expect(emailDomainsCheckbox).toBeEnabled();
const cfr21Checkbox = page.locator('#flag-cfr21');
await expect(cfr21Checkbox).toBeEnabled();
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
await expect(authPortalCheckbox).toBeEnabled();
});
test('[ADMIN CLAIMS]: only unlicensed features show asterisk with partial license', async ({
page,
}) => {
// Create a license with SOME enterprise features (emailDomains and cfr21)
await writeLicenseFile(
createMockLicenseWithFlags({
emailDomains: true,
cfr21: true,
// embedAuthoring, embedAuthoringWhiteLabel, authenticationPortal are NOT included
}),
);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Features NOT in license should have asterisks
await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible();
// Features IN license should NOT have asterisks
await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible();
await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible();
// The plain labels for licensed features should be visible
await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains');
await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR');
// Alert should be visible since some features are restricted
await expect(
page.getByText('Your current license does not include these features.'),
).toBeVisible();
// Licensed features should be enabled
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
await expect(emailDomainsCheckbox).toBeEnabled();
const cfr21Checkbox = page.locator('#flag-cfr21');
await expect(cfr21Checkbox).toBeEnabled();
// Unlicensed features should be disabled
const embedAuthoringCheckbox = page.locator('#flag-embedAuthoring');
await expect(embedAuthoringCheckbox).toBeDisabled();
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
await expect(authPortalCheckbox).toBeDisabled();
});
test('[ADMIN CLAIMS]: non-enterprise features are always enabled', async ({ page }) => {
// Ensure no license file exists
await writeLicenseFile(null);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Non-enterprise features should NOT have asterisks
await expect(page.getByText(/Unlimited documents\s¹/)).not.toBeVisible();
await expect(page.getByText(/Branding\s¹/)).not.toBeVisible();
await expect(page.getByText(/Embed signing\s¹/)).not.toBeVisible();
// Non-enterprise features should always be enabled
const unlimitedDocsCheckbox = page.locator('#flag-unlimitedDocuments');
await expect(unlimitedDocsCheckbox).toBeEnabled();
const brandingCheckbox = page.locator('#flag-allowCustomBranding');
await expect(brandingCheckbox).toBeEnabled();
const embedSigningCheckbox = page.locator('#flag-embedSigning');
await expect(embedSigningCheckbox).toBeEnabled();
});
});
@@ -0,0 +1,392 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const LICENSE_FILE_NAME = '.documenso-license.json';
const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json';
/**
* Get the path to the license file.
*
* The server reads from process.cwd() which is apps/remix when the dev server runs.
* Tests run from packages/app-tests, so we need to go up to the root then into apps/remix.
*/
const getLicenseFilePath = () => {
// From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json
return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME);
};
/**
* Get the path to the backup license file.
*/
const getBackupLicenseFilePath = () => {
return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME);
};
/**
* Backup the existing license file if it exists.
*/
const backupLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(licensePath);
await fs.rename(licensePath, backupPath);
} catch (e) {
// File doesn't exist, nothing to backup
console.log(e);
}
};
/**
* Restore the backup license file if it exists.
*/
const restoreLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(backupPath);
await fs.rename(backupPath, licensePath);
} catch (e) {
// Backup doesn't exist, nothing to restore
console.log(e);
}
};
/**
* Write a license file with the given data.
* Pass null to delete the license file.
*/
const writeLicenseFile = async (data: TCachedLicense | null) => {
const licensePath = getLicenseFilePath();
if (data === null) {
await fs.unlink(licensePath).catch(() => {
// File doesn't exist, ignore
});
} else {
await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8');
}
};
/**
* Create a mock license object with the given status and unauthorized flag.
*/
const createMockLicense = (
status: 'ACTIVE' | 'EXPIRED' | 'PAST_DUE',
unauthorizedFlagUsage: boolean,
): TCachedLicense => {
return {
lastChecked: new Date().toISOString(),
license: {
status,
createdAt: new Date(),
name: 'Test License',
periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
cancelAtPeriodEnd: false,
licenseKey: 'test-license-key',
flags: {},
},
requestedLicenseKey: 'test-license-key',
derivedStatus: unauthorizedFlagUsage ? 'UNAUTHORIZED' : status,
unauthorizedFlagUsage,
};
};
/**
* Create a mock license object with no license data (only unauthorized flag).
*/
const createMockUnauthorizedWithoutLicense = (): TCachedLicense => {
return {
lastChecked: new Date().toISOString(),
license: null,
unauthorizedFlagUsage: true,
derivedStatus: 'UNAUTHORIZED',
};
};
// Run tests serially to avoid race conditions with the license file
test.describe.configure({ mode: 'serial' });
// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE.
test.describe.skip('License Status Banner', () => {
test.beforeAll(async () => {
// Backup any existing license file before running tests
await backupLicenseFile();
});
test.afterAll(async () => {
// Restore the backup license file after all tests complete
await restoreLicenseFile();
});
test.beforeEach(async () => {
// Clean up license file before each test to ensure clean state
await writeLicenseFile(null);
});
test.afterEach(async () => {
// Clean up license file after each test
await writeLicenseFile(null);
});
test('[ADMIN]: no banner when license file is missing', async ({ page }) => {
// Ensure no license file exists BEFORE any page loads
await writeLicenseFile(null);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should not be visible (no license file)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner messages should not be visible (no license file means no banner)
await expect(page.getByText('License payment overdue')).not.toBeVisible();
await expect(page.getByText('License expired')).not.toBeVisible();
await expect(page.getByText('Invalid License Type')).not.toBeVisible();
await expect(page.getByText('Missing License')).not.toBeVisible();
});
test('[ADMIN]: no banner when license is ACTIVE', async ({ page }) => {
// Create an ACTIVE license BEFORE any page loads
await writeLicenseFile(createMockLicense('ACTIVE', false));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should not be visible (license is ACTIVE)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner messages should not be visible (license is ACTIVE)
await expect(page.getByText('License payment overdue')).not.toBeVisible();
await expect(page.getByText('License expired')).not.toBeVisible();
await expect(page.getByText('Invalid License Type')).not.toBeVisible();
});
test('[ADMIN]: admin banner shows PAST_DUE warning', async ({ page }) => {
// Create a PAST_DUE license BEFORE any page loads
await writeLicenseFile(createMockLicense('PAST_DUE', false));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (only shows for EXPIRED + unauthorized)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show PAST_DUE message
await expect(page.getByText('License payment overdue')).toBeVisible();
await expect(
page.getByText('Please update your payment to avoid service disruptions.'),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test('[ADMIN]: admin banner shows EXPIRED error', async ({ page }) => {
// Create an EXPIRED license WITHOUT unauthorized usage BEFORE any page loads
await writeLicenseFile(createMockLicense('EXPIRED', false));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (requires BOTH expired AND unauthorized)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show EXPIRED message
await expect(page.getByText('License expired')).toBeVisible();
await expect(
page.getByText('Please renew your license to continue using enterprise features.'),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test.skip('[ADMIN]: global banner shows when EXPIRED with unauthorized usage', async ({
page,
}) => {
// Create an EXPIRED license WITH unauthorized usage BEFORE any page loads
await writeLicenseFile(createMockLicense('EXPIRED', true));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner SHOULD be visible (EXPIRED + unauthorized)
await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible();
// Admin banner should show UNAUTHORIZED message (takes precedence over EXPIRED)
await expect(page.getByText('Invalid License Type')).toBeVisible();
await expect(
page.getByText(
'Your Documenso instance is using features that are not part of your license.',
),
).toBeVisible();
});
test('[ADMIN]: admin banner shows UNAUTHORIZED when flags are misused with license', async ({
page,
}) => {
// Create an ACTIVE license but WITH unauthorized flag usage BEFORE any page loads
await writeLicenseFile(createMockLicense('ACTIVE', true));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (requires EXPIRED status)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show UNAUTHORIZED message
await expect(page.getByText('Invalid License Type')).toBeVisible();
await expect(
page.getByText(
'Your Documenso instance is using features that are not part of your license.',
),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test('[ADMIN]: admin banner shows Invalid License Type when unauthorized without license data', async ({
page,
}) => {
// Create a license file with unauthorized flag but no license data BEFORE any page loads
// Note: Even without license data, the banner shows "Invalid License Type" because the
// license file exists (just with license: null). The "Missing License" message would only
// show if the entire license prop was null, which doesn't happen with a valid file.
await writeLicenseFile(createMockUnauthorizedWithoutLicense());
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (no EXPIRED status, only unauthorized flag)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show Invalid License Type message (unauthorized flag is set)
await expect(page.getByText('Invalid License Type')).toBeVisible();
await expect(
page.getByText(
'Your Documenso instance is using features that are not part of your license.',
),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test.skip('[ADMIN]: global banner visible on non-admin pages when EXPIRED with unauthorized', async ({
page,
}) => {
// Create an EXPIRED license WITH unauthorized usage BEFORE any page loads
await writeLicenseFile(createMockLicense('EXPIRED', true));
const { user } = await seedUser();
// Navigate to documents page - license is read during page load
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
// Global banner SHOULD be visible on any authenticated page (EXPIRED + unauthorized)
await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible();
});
});
@@ -0,0 +1,256 @@
import { expect, test } from '@playwright/test';
import { FolderType } from '@documenso/prisma/client';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
test.describe.configure({ mode: 'parallel' });
const seedBulkActionsTestRequirements = async () => {
const sender = await seedUser({ setTeamEmailAsOwner: true });
const [template1, template2, template3] = await Promise.all([
seedBlankTemplate(sender.user, sender.team.id, {
createTemplateOptions: { title: 'Bulk Test Template 1' },
}),
seedBlankTemplate(sender.user, sender.team.id, {
createTemplateOptions: { title: 'Bulk Test Template 2' },
}),
seedBlankTemplate(sender.user, sender.team.id, {
createTemplateOptions: { title: 'Bulk Test Template 3' },
}),
]);
const folder = await seedBlankFolder(sender.user, sender.team.id, {
createFolderOptions: {
name: 'Target Template Folder',
teamId: sender.team.id,
type: FolderType.TEMPLATE,
},
});
return {
sender,
templates: [template1, template2, template3],
folder,
};
};
test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
});
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
const { sender, templates } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await page.getByLabel('Clear selection').click();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
const { sender, folder } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await page.goto(`/t/${sender.team.url}/templates/f/${folder.id}`);
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Bulk Test Template 2' })).toBeVisible();
});
test('[BULK_ACTIONS]: can delete multiple templates', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Delete Templates')).toBeVisible();
await expect(page.getByText('You are about to delete 2 templates')).toBeVisible();
await expect(page.getByText('irreversible')).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Templates deleted');
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Bulk Test Template 2' })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Bulk Test Template 3' })).toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful move', async ({ page }) => {
const { sender, folder } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Templates deleted');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
const { sender, folder } = await seedBulkActionsTestRequirements();
const otherFolder = await seedBlankFolder(sender.user, sender.team.id, {
createFolderOptions: {
name: 'Other Template Folder',
teamId: sender.team.id,
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates`,
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
await page.getByPlaceholder('Search folders...').fill('Target');
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
await expect(page.getByRole('button', { name: otherFolder.name })).not.toBeVisible();
await page.getByPlaceholder('Search folders...').fill('Other');
await expect(page.getByRole('button', { name: folder.name })).not.toBeVisible();
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
await page.getByPlaceholder('Search folders...').fill('NonExistent');
await expect(page.getByText('No folders found')).toBeVisible();
});
test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ page }) => {
const { sender, templates, folder } = await seedBulkActionsTestRequirements();
const { prisma } = await import('@documenso/prisma');
await prisma.envelope.updateMany({
where: { id: templates[0].id },
data: { folderId: folder.id },
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/templates/f/${folder.id}`,
});
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await page.goto(`/t/${sender.team.url}/templates`);
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
await page.goto(`/t/${sender.team.url}/templates/f/${folder.id}`);
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).not.toBeVisible();
});
@@ -23,7 +23,7 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
await signSignaturePad(page);
await page.getByRole('button', { name: 'Complete', exact: true }).click();
await page.getByRole('button', { name: 'Create account', exact: true }).click();
await page.waitForURL('/unverified-account');
+12 -1
View File
@@ -83,10 +83,21 @@ export default defineConfig({
testMatch: /e2e\/api\/.*\.spec\.ts/,
workers: 10, // Limited by DB connections before it gets flakey.
},
// Run UI Tests
// License tests that share a single license file - must run serially
{
name: 'license',
testMatch: /e2e\/license\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1200 },
},
workers: 1, // Must run serially since they share a license file
},
// Run UI Tests (excluding license tests which have their own project)
{
name: 'ui',
testMatch: /e2e\/(?!api\/).*\.spec\.ts/,
testIgnore: /e2e\/license\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1200 },