diff --git a/packages/app-tests/e2e/document-auth/access-auth.spec.ts b/packages/app-tests/e2e/document-auth/access-auth.spec.ts new file mode 100644 index 000000000..0306689ce --- /dev/null +++ b/packages/app-tests/e2e/document-auth/access-auth.spec.ts @@ -0,0 +1,97 @@ +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, unseedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page }) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const document = await seedPendingDocument(user, [ + recipientWithAccount, + 'recipientwithoutaccount@documenso.com', + ]); + + const recipients = await prisma.recipient.findMany({ + where: { + documentId: document.id, + }, + }); + + const tokens = recipients.map((recipient) => recipient.token); + + for (const token of tokens) { + await page.goto(`/sign/${token}`); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + } + + await unseedUser(user.id); +}); + +test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page }) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const document = await seedPendingDocument( + user, + [recipientWithAccount, 'recipientwithoutaccount@documenso.com'], + { + createDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: 'ACCOUNT', + globalActionAuth: null, + }), + }, + }, + ); + + const recipients = await prisma.recipient.findMany({ + where: { + documentId: document.id, + }, + }); + + // Check that both are denied access. + for (const recipient of recipients) { + const { email, token } = recipient; + + await page.goto(`/sign/${token}`); + await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible(); + await expect(page.getByRole('paragraph')).toContainText(email); + } + + await apiSignin({ + page, + email: recipientWithAccount.email, + redirectPath: '/', + }); + + // Check that the one logged in is granted access. + for (const recipient of recipients) { + const { email, token } = recipient; + + await page.goto(`/sign/${token}`); + + // Recipient should be granted access. + if (recipient.email === recipientWithAccount.email) { + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + } + + // Recipient should still be denied. + if (recipient.email !== recipientWithAccount.email) { + await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible(); + await expect(page.getByRole('paragraph')).toContainText(email); + } + } + + await unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); diff --git a/packages/app-tests/e2e/document-auth/action-auth.spec.ts b/packages/app-tests/e2e/document-auth/action-auth.spec.ts new file mode 100644 index 000000000..f0d3cee95 --- /dev/null +++ b/packages/app-tests/e2e/document-auth/action-auth.spec.ts @@ -0,0 +1,405 @@ +import { expect, test } from '@playwright/test'; + +import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; +import { + createDocumentAuthOptions, + createRecipientAuthOptions, +} from '@documenso/lib/utils/document-auth'; +import { FieldType } from '@documenso/prisma/client'; +import { + seedPendingDocumentNoFields, + seedPendingDocumentWithFullFields, +} from '@documenso/prisma/seed/documents'; +import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin, apiSignout } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [recipientWithAccount, seedTestEmail()], + }); + + // Check that both are granted access. + for (const recipient of recipients) { + const { token, Field } = recipient; + + const signUrl = `/sign/${token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + // Add signature. + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4); + await page.mouse.up(); + } + + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.getByLabel('Custom Text').fill('TEXT'); + await page.getByRole('button', { name: 'Save Text' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true'); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + } + + await unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); + +test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ page }) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [recipientWithAccount], + updateDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: null, + globalActionAuth: 'ACCOUNT', + }), + }, + }); + + const recipient = recipients[0]; + + const { token, Field } = recipient; + + const signUrl = `/sign/${token}`; + + await apiSignin({ + page, + email: recipientWithAccount.email, + redirectPath: signUrl, + }); + + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + // Add signature. + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4); + await page.mouse.up(); + } + + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.getByLabel('Custom Text').fill('TEXT'); + await page.getByRole('button', { name: 'Save Text' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true'); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + + await unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); + +test('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({ + page, +}) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const { recipients } = await seedPendingDocumentNoFields({ + owner: user, + recipients: [recipientWithAccount], + updateDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: null, + globalActionAuth: 'ACCOUNT', + }), + }, + }); + + const recipient = recipients[0]; + + const { token } = recipient; + + await page.goto(`/sign/${token}`); + 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 unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); + +test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth', async ({ + page, +}) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [recipientWithAccount, seedTestEmail()], + updateDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: null, + globalActionAuth: 'ACCOUNT', + }), + }, + }); + + // Check that both are denied access. + for (const recipient of recipients) { + const { token, Field } = recipient; + + await page.goto(`/sign/${token}`); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + await expect(page.getByRole('paragraph')).toContainText( + 'Reauthentication is required to sign the field', + ); + await page.getByRole('button', { name: 'Cancel' }).click(); + } + } + + await unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); + +test('[DOCUMENT_AUTH]: should allow field signing when required for recipient auth', async ({ + page, +}) => { + const user = await seedUser(); + + const recipientWithInheritAuth = await seedUser(); + const recipientWithExplicitNoneAuth = await seedUser(); + const recipientWithExplicitAccountAuth = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [ + recipientWithInheritAuth, + recipientWithExplicitNoneAuth, + recipientWithExplicitAccountAuth, + ], + recipientsCreateOptions: [ + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: null, + }), + }, + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: 'EXPLICIT_NONE', + }), + }, + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: 'ACCOUNT', + }), + }, + ], + fields: [FieldType.DATE], + }); + + for (const recipient of recipients) { + const { token, Field } = recipient; + const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); + + // This document has no global action auth, so only account should require auth. + const isAuthRequired = actionAuth === 'ACCOUNT'; + + const signUrl = `/sign/${token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + if (isAuthRequired) { + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + await expect(page.getByRole('paragraph')).toContainText( + 'Reauthentication is required to sign the field', + ); + await page.getByRole('button', { name: 'Cancel' }).click(); + } + + // Sign in and it should work. + await apiSignin({ + page, + email: recipient.email, + redirectPath: signUrl, + }); + } + + // Add signature. + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4); + await page.mouse.up(); + } + + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.getByLabel('Custom Text').fill('TEXT'); + await page.getByRole('button', { name: 'Save Text' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', { + timeout: 5000, + }); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + + if (isAuthRequired) { + await apiSignout({ page }); + } + } +}); + +test('[DOCUMENT_AUTH]: should allow field signing when required for recipient and global auth', async ({ + page, +}) => { + const user = await seedUser(); + + const recipientWithInheritAuth = await seedUser(); + const recipientWithExplicitNoneAuth = await seedUser(); + const recipientWithExplicitAccountAuth = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [ + recipientWithInheritAuth, + recipientWithExplicitNoneAuth, + recipientWithExplicitAccountAuth, + ], + recipientsCreateOptions: [ + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: null, + }), + }, + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: 'EXPLICIT_NONE', + }), + }, + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: 'ACCOUNT', + }), + }, + ], + fields: [FieldType.DATE], + updateDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: null, + globalActionAuth: 'ACCOUNT', + }), + }, + }); + + for (const recipient of recipients) { + const { token, Field } = recipient; + const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); + + // This document HAS global action auth, so account and inherit should require auth. + const isAuthRequired = actionAuth === 'ACCOUNT' || actionAuth === null; + + const signUrl = `/sign/${token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + if (isAuthRequired) { + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + await expect(page.getByRole('paragraph')).toContainText( + 'Reauthentication is required to sign the field', + ); + await page.getByRole('button', { name: 'Cancel' }).click(); + } + + // Sign in and it should work. + await apiSignin({ + page, + email: recipient.email, + redirectPath: signUrl, + }); + } + + // Add signature. + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4); + await page.mouse.up(); + } + + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.getByLabel('Custom Text').fill('TEXT'); + await page.getByRole('button', { name: 'Save Text' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', { + timeout: 5000, + }); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + + if (isAuthRequired) { + await apiSignout({ page }); + } + } +}); diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts index 2ae5145aa..1fceca900 100644 --- a/packages/prisma/seed/documents.ts +++ b/packages/prisma/seed/documents.ts @@ -1,4 +1,4 @@ -import type { User } from '@prisma/client'; +import type { Document, User } from '@prisma/client'; import { nanoid } from 'nanoid'; import fs from 'node:fs'; import path from 'node:path'; @@ -216,6 +216,140 @@ export const seedPendingDocument = async ( return document; }; +export const seedPendingDocumentNoFields = async ({ + owner, + recipients, + updateDocumentOptions, +}: { + owner: User; + recipients: (User | string)[]; + updateDocumentOptions?: Partial; +}) => { + const document: Document = await seedBlankDocument(owner); + + for (const recipient of recipients) { + const email = typeof recipient === 'string' ? recipient : recipient.email; + const name = typeof recipient === 'string' ? recipient : recipient.name ?? ''; + + await prisma.recipient.create({ + data: { + email, + name, + token: nanoid(), + readStatus: ReadStatus.OPENED, + sendStatus: SendStatus.SENT, + signingStatus: SigningStatus.NOT_SIGNED, + signedAt: new Date(), + Document: { + connect: { + id: document.id, + }, + }, + }, + }); + } + + const createdRecipients = await prisma.recipient.findMany({ + where: { + documentId: document.id, + }, + include: { + Field: true, + }, + }); + + const latestDocument = updateDocumentOptions + ? await prisma.document.update({ + where: { + id: document.id, + }, + data: updateDocumentOptions, + }) + : document; + + return { + document: latestDocument, + recipients: createdRecipients, + }; +}; + +export const seedPendingDocumentWithFullFields = async ({ + owner, + recipients, + recipientsCreateOptions, + updateDocumentOptions, + fields = [FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.SIGNATURE, FieldType.TEXT], +}: { + owner: User; + recipients: (User | string)[]; + recipientsCreateOptions?: Partial[]; + updateDocumentOptions?: Partial; + fields?: FieldType[]; +}) => { + const document: Document = await seedBlankDocument(owner); + + for (const [recipientIndex, recipient] of recipients.entries()) { + const email = typeof recipient === 'string' ? recipient : recipient.email; + const name = typeof recipient === 'string' ? recipient : recipient.name ?? ''; + + await prisma.recipient.create({ + data: { + email, + name, + token: nanoid(), + readStatus: ReadStatus.OPENED, + sendStatus: SendStatus.SENT, + signingStatus: SigningStatus.NOT_SIGNED, + signedAt: new Date(), + Document: { + connect: { + id: document.id, + }, + }, + Field: { + createMany: { + data: fields.map((fieldType, fieldIndex) => ({ + page: 1, + type: fieldType, + inserted: false, + customText: name, + positionX: new Prisma.Decimal((recipientIndex + 1) * 5), + positionY: new Prisma.Decimal((fieldIndex + 1) * 5), + width: new Prisma.Decimal(5), + height: new Prisma.Decimal(5), + documentId: document.id, + })), + }, + }, + ...(recipientsCreateOptions?.[recipientIndex] ?? {}), + }, + }); + } + + const createdRecipients = await prisma.recipient.findMany({ + where: { + documentId: document.id, + }, + include: { + Field: true, + }, + }); + + const latestDocument = updateDocumentOptions + ? await prisma.document.update({ + where: { + id: document.id, + }, + data: updateDocumentOptions, + }) + : document; + + return { + document: latestDocument, + recipients: createdRecipients, + }; +}; + export const seedCompletedDocument = async ( sender: User, recipients: (User | string)[], diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts index 353683a1d..fd8706fea 100644 --- a/packages/prisma/seed/users.ts +++ b/packages/prisma/seed/users.ts @@ -2,6 +2,8 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash'; import { prisma } from '..'; +export const seedTestEmail = () => `user-${Date.now()}@test.documenso.com`; + type SeedUserOptions = { name?: string; email?: string;