mirror of
https://github.com/documenso/documenso.git
synced 2026-08-27 00:32:29 +10:00
fix: wip
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { signDirectSignaturePad } from '../fixtures/signature';
|
||||
|
||||
/**
|
||||
* An assistant sharing a signing step with an unsigned peer cannot dictate
|
||||
* the next signer: the flow does not advance until the whole step completes,
|
||||
* so the server ignores any dictated identity. The signing page must
|
||||
* therefore not OFFER dictation in that state — historically it did, because
|
||||
* the assistant's recipient list excludes their own group peers, and the
|
||||
* client derived dictation eligibility from that truncated list while the
|
||||
* server decided from the full one.
|
||||
*/
|
||||
test('[NEXT_RECIPIENT_DICTATION]: assistant with an unsigned group peer is not offered dictation', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: assistant } = await seedUser();
|
||||
const { user: peerSigner } = await seedUser();
|
||||
const { user: lastSigner } = await seedUser();
|
||||
|
||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [assistant, peerSigner, lastSigner],
|
||||
recipientsCreateOptions: [
|
||||
// The assistant shares step 1 with an unsigned peer; step 2 holds a
|
||||
// single recipient — the exact shape where dictation looks available
|
||||
// from the assistant's truncated recipient list.
|
||||
{ signingOrder: 1, role: RecipientRole.ASSISTANT },
|
||||
{ signingOrder: 1, role: RecipientRole.SIGNER },
|
||||
{ signingOrder: 2, role: RecipientRole.SIGNER },
|
||||
],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
allowDictateNextSigner: true,
|
||||
},
|
||||
update: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
allowDictateNextSigner: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const assistantRecipient = recipients[0];
|
||||
const lastRecipient = recipients[2];
|
||||
|
||||
const signUrl = `/sign/${assistantRecipient.token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Assist Document' })).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.getByRole('radio', { name: assistantRecipient.name }).click();
|
||||
|
||||
// Fill in the assistant's own fields.
|
||||
for (const field of assistantRecipient.fields) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
if (field.type === FieldType.SIGNATURE) {
|
||||
await signDirectSignaturePad(page);
|
||||
await page.getByRole('button', { name: 'Sign', exact: true }).click();
|
||||
}
|
||||
|
||||
if (field.type === FieldType.TEXT) {
|
||||
await page.locator('#custom-text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// The unsigned peer blocks advancement, so dictation must not be offered.
|
||||
await expect(dialog.getByText('The next recipient to sign this document will be')).not.toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
|
||||
|
||||
// Later recipients' fields are still uninserted, so the confirm button
|
||||
// reads "Proceed" rather than "Continue".
|
||||
await dialog.getByRole('button', { name: /Continue|Proceed/ }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
|
||||
// The assistant completed; nobody was renamed and the flow did not advance
|
||||
// past the unsigned peer.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const assistantAfter = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: assistantRecipient.id },
|
||||
});
|
||||
|
||||
return assistantAfter.signingStatus;
|
||||
})
|
||||
.toBe(SigningStatus.SIGNED);
|
||||
|
||||
const lastAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: lastRecipient.id } });
|
||||
|
||||
expect(lastAfter.name).toBe(lastRecipient.name);
|
||||
expect(lastAfter.email).toBe(lastRecipient.email);
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({ where: { id: document.id } });
|
||||
|
||||
expect(envelope.status).toBe('PENDING');
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
|
||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
/**
|
||||
* A recipient without a signing order sits in the LAST step — the convention
|
||||
* `effectiveOrder` encodes and the server sorts by (NULLS LAST). The
|
||||
* assistant scoping filters must agree with it:
|
||||
*
|
||||
* - an ordered assistant may assist a null-order recipient (they are in the
|
||||
* strictly later tail step), and
|
||||
* - a null-order assistant may assist NOBODY (nobody comes after the last
|
||||
* step) — historically `signingOrder ?? 0` treated them as FIRST, letting
|
||||
* their token prefill every ordered recipient's fields.
|
||||
*
|
||||
* Null orders are only produced via the API, which is why no editor-driven
|
||||
* test covers this.
|
||||
*/
|
||||
|
||||
const seedAssistantDocument = async (options: {
|
||||
assistantOrder: number | null;
|
||||
signerOrder: number | null;
|
||||
internalVersion?: number;
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { user: assistantUser } = await seedUser();
|
||||
const { user: signerUser } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: [assistantUser, signerUser],
|
||||
recipientsCreateOptions: [
|
||||
{ signingOrder: options.assistantOrder, role: RecipientRole.ASSISTANT },
|
||||
{ signingOrder: options.signerOrder, role: RecipientRole.SIGNER },
|
||||
],
|
||||
fields: [FieldType.TEXT],
|
||||
updateDocumentOptions: {
|
||||
internalVersion: options.internalVersion ?? 1,
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The seed returns recipients ordered by signingOrder (nulls last), so
|
||||
// positional destructuring would swap roles — select by role instead.
|
||||
const assistant = recipients.find((recipient) => recipient.role === RecipientRole.ASSISTANT);
|
||||
const signer = recipients.find((recipient) => recipient.role === RecipientRole.SIGNER);
|
||||
|
||||
if (!assistant || !signer) {
|
||||
throw new Error('Seeded recipients not found');
|
||||
}
|
||||
|
||||
const signerTextField = signer.fields.find((field) => field.type === FieldType.TEXT);
|
||||
|
||||
if (!signerTextField) {
|
||||
throw new Error('Seeded text field not found');
|
||||
}
|
||||
|
||||
return { assistant, signer, signerTextField };
|
||||
};
|
||||
|
||||
const callSignEnvelopeField = async (page: Page, input: { token: string; fieldId: number }) => {
|
||||
return await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.sign`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: JSON.stringify({
|
||||
json: {
|
||||
token: input.token,
|
||||
fieldId: input.fieldId,
|
||||
fieldValue: {
|
||||
type: FieldType.TEXT,
|
||||
value: 'TEXT',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
test('[ASSISTANT_NULL_ORDER]: an ordered assistant can assist a null-order (tail-step) recipient', async () => {
|
||||
const { assistant, signer, signerTextField } = await seedAssistantDocument({
|
||||
assistantOrder: 1,
|
||||
signerOrder: null,
|
||||
});
|
||||
|
||||
// The tail-step recipient is strictly later, so they must be assistable.
|
||||
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
|
||||
|
||||
expect(assistableRecipients.map((recipient) => recipient.id)).toContain(signer.id);
|
||||
|
||||
// Their non-signature fields must be visible to the assistant.
|
||||
const fields = await getFieldsForToken({ token: assistant.token });
|
||||
|
||||
expect(fields.map((field) => field.id)).toContain(signerTextField.id);
|
||||
|
||||
// And prefillable.
|
||||
await signFieldWithToken({
|
||||
token: assistant.token,
|
||||
fieldId: signerTextField.id,
|
||||
value: 'TEXT',
|
||||
});
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
|
||||
|
||||
expect(fieldAfter.inserted).toBe(true);
|
||||
});
|
||||
|
||||
test('[ASSISTANT_NULL_ORDER]: a null-order (tail-step) assistant cannot assist anyone', async () => {
|
||||
const { assistant, signer, signerTextField } = await seedAssistantDocument({
|
||||
assistantOrder: null,
|
||||
signerOrder: 1,
|
||||
});
|
||||
|
||||
// The null-order assistant sits in the last step: nobody comes after them.
|
||||
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
|
||||
|
||||
expect(assistableRecipients.map((recipient) => recipient.id)).toEqual([assistant.id]);
|
||||
|
||||
// Every ordered recipient is in an EARLIER step — prefilling must fail.
|
||||
await expect(
|
||||
signFieldWithToken({
|
||||
token: assistant.token,
|
||||
fieldId: signerTextField.id,
|
||||
value: 'TEXT',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
expect(fieldAfter.id).not.toBe(signer.id); // sanity: distinct entities
|
||||
});
|
||||
|
||||
test('[ASSISTANT_NULL_ORDER]: V2 route allows an ordered assistant to prefill a null-order recipient', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { assistant, signerTextField } = await seedAssistantDocument({
|
||||
assistantOrder: 1,
|
||||
signerOrder: null,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
const response = await callSignEnvelopeField(page, {
|
||||
token: assistant.token,
|
||||
fieldId: signerTextField.id,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
|
||||
|
||||
expect(fieldAfter.inserted).toBe(true);
|
||||
});
|
||||
|
||||
test('[ASSISTANT_NULL_ORDER]: V2 route rejects a null-order assistant prefilling an ordered recipient', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { assistant, signerTextField } = await seedAssistantDocument({
|
||||
assistantOrder: null,
|
||||
signerOrder: 1,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
const response = await callSignEnvelopeField(page, {
|
||||
token: assistant.token,
|
||||
fieldId: signerTextField.id,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } });
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
type SeededGroupEnvelope = {
|
||||
assistantToken: string;
|
||||
assistantOwnTextFieldId: number;
|
||||
peerTextFieldId: number;
|
||||
peerSignatureFieldId: number;
|
||||
laterTextFieldId: number;
|
||||
laterSignatureFieldId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Seeds a pending SEQUENTIAL envelope where the ASSISTANT shares a signing
|
||||
* step (duplicate signingOrder) with a SIGNER:
|
||||
*
|
||||
* - Step 1: ASSISTANT (own TEXT field) + "Peer Signer" (SIGNATURE + TEXT).
|
||||
* - Step 2: "Later Signer" (SIGNATURE + TEXT).
|
||||
*
|
||||
* Product rule under signing groups: assistants only assist STRICTLY LATER
|
||||
* steps — never their own group peers — and never insert SIGNATURE fields
|
||||
* belonging to anyone else.
|
||||
*/
|
||||
const seedGroupedAssistantEnvelope = async (request: APIRequestContext): Promise<SeededGroupEnvelope> => {
|
||||
const timestamp = Date.now();
|
||||
|
||||
const peerEmail = `peer-signer-${timestamp}@documenso.com`;
|
||||
const laterEmail = `later-signer-${timestamp}@documenso.com`;
|
||||
|
||||
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
|
||||
title: '[TEST] Grouped assistant envelope',
|
||||
meta: {
|
||||
signingOrder: 'SEQUENTIAL',
|
||||
},
|
||||
recipients: [
|
||||
{
|
||||
email: `assistant-${timestamp}@documenso.com`,
|
||||
name: 'Assistant',
|
||||
role: 'ASSISTANT',
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
email: peerEmail,
|
||||
name: 'Peer Signer',
|
||||
role: 'SIGNER',
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
email: laterEmail,
|
||||
name: 'Later Signer',
|
||||
role: 'SIGNER',
|
||||
signingOrder: 2,
|
||||
},
|
||||
],
|
||||
fieldsPerRecipient: [
|
||||
[{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 }],
|
||||
[
|
||||
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
|
||||
{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 25, width: 5, height: 5 },
|
||||
],
|
||||
[
|
||||
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 35, width: 5, height: 5 },
|
||||
{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 45, width: 5, height: 5 },
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const assistant = distributeResult.recipients.find((r) => r.role === 'ASSISTANT');
|
||||
const peer = distributeResult.recipients.find((r) => r.email === peerEmail);
|
||||
const later = distributeResult.recipients.find((r) => r.email === laterEmail);
|
||||
|
||||
if (!assistant || !peer || !later) {
|
||||
throw new Error('Seeded recipients not found');
|
||||
}
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
});
|
||||
|
||||
const findField = (recipientId: number, type: FieldType) => {
|
||||
const field = fields.find((f) => f.recipientId === recipientId && f.type === type);
|
||||
|
||||
if (!field) {
|
||||
throw new Error(`Field ${type} not found for recipient ${recipientId}`);
|
||||
}
|
||||
|
||||
return field;
|
||||
};
|
||||
|
||||
return {
|
||||
assistantToken: assistant.token,
|
||||
assistantOwnTextFieldId: findField(assistant.id, FieldType.TEXT).id,
|
||||
peerTextFieldId: findField(peer.id, FieldType.TEXT).id,
|
||||
peerSignatureFieldId: findField(peer.id, FieldType.SIGNATURE).id,
|
||||
laterTextFieldId: findField(later.id, FieldType.TEXT).id,
|
||||
laterSignatureFieldId: findField(later.id, FieldType.SIGNATURE).id,
|
||||
};
|
||||
};
|
||||
|
||||
const trpcMutation = async (request: APIRequestContext, procedure: string, input: Record<string, unknown>) => {
|
||||
return await request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: JSON.stringify({ json: input }),
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('[ASSISTANT_SIGNING_GROUPS]: same-step (group peer) field access', () => {
|
||||
test('field.signFieldWithToken (V1) rejects a group peer field', async ({ request }) => {
|
||||
const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request);
|
||||
|
||||
const res = await trpcMutation(request, 'field.signFieldWithToken', {
|
||||
token: assistantToken,
|
||||
fieldId: peerTextFieldId,
|
||||
value: 'TEXT',
|
||||
isBase64: false,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: peerTextFieldId },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
expect(fieldAfter.customText).toBe('');
|
||||
});
|
||||
|
||||
test('field.removeSignedFieldWithToken (V1) rejects a group peer field', async ({ request }) => {
|
||||
const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request);
|
||||
|
||||
// Pre-insert the peer's field so a successful (incorrect) uninsert is detectable.
|
||||
await prisma.field.update({
|
||||
where: { id: peerTextFieldId },
|
||||
data: { inserted: true, customText: 'pre-existing-value' },
|
||||
});
|
||||
|
||||
const res = await trpcMutation(request, 'field.removeSignedFieldWithToken', {
|
||||
token: assistantToken,
|
||||
fieldId: peerTextFieldId,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: peerTextFieldId },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(true);
|
||||
expect(fieldAfter.customText).toBe('pre-existing-value');
|
||||
});
|
||||
|
||||
test('envelope.field.sign (V2) rejects a group peer field', async ({ request }) => {
|
||||
const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request);
|
||||
|
||||
const res = await trpcMutation(request, 'envelope.field.sign', {
|
||||
token: assistantToken,
|
||||
fieldId: peerTextFieldId,
|
||||
fieldValue: { type: FieldType.TEXT, value: 'TEXT' },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: peerTextFieldId },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
});
|
||||
|
||||
test('getFieldsForToken excludes group peer fields but keeps own and later-step fields', async ({ request }) => {
|
||||
const {
|
||||
assistantToken,
|
||||
assistantOwnTextFieldId,
|
||||
peerTextFieldId,
|
||||
peerSignatureFieldId,
|
||||
laterTextFieldId,
|
||||
laterSignatureFieldId,
|
||||
} = await seedGroupedAssistantEnvelope(request);
|
||||
|
||||
const fields = await getFieldsForToken({ token: assistantToken });
|
||||
const fieldIds = fields.map((field) => field.id);
|
||||
|
||||
// Own fields and strictly-later non-signature fields remain visible.
|
||||
expect(fieldIds).toContain(assistantOwnTextFieldId);
|
||||
expect(fieldIds).toContain(laterTextFieldId);
|
||||
|
||||
// Group peer fields are never visible to the assistant.
|
||||
expect(fieldIds).not.toContain(peerTextFieldId);
|
||||
expect(fieldIds).not.toContain(peerSignatureFieldId);
|
||||
|
||||
// Signature fields of other recipients are never visible to the assistant.
|
||||
expect(fieldIds).not.toContain(laterSignatureFieldId);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ASSISTANT_SIGNING_GROUPS]: signature fields of other recipients', () => {
|
||||
test('field.signFieldWithToken (V1) rejects inserting a later recipient signature field', async ({ request }) => {
|
||||
const { assistantToken, laterSignatureFieldId } = await seedGroupedAssistantEnvelope(request);
|
||||
|
||||
const res = await trpcMutation(request, 'field.signFieldWithToken', {
|
||||
token: assistantToken,
|
||||
fieldId: laterSignatureFieldId,
|
||||
value: 'John Doe',
|
||||
isBase64: false,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: laterSignatureFieldId },
|
||||
include: { signature: true },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(false);
|
||||
expect(fieldAfter.signature).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ASSISTANT_SIGNING_GROUPS]: preserved assistant abilities', () => {
|
||||
test('field.signFieldWithToken (V1) still allows filling the assistant own field', async ({ request }) => {
|
||||
const { assistantToken, assistantOwnTextFieldId } = await seedGroupedAssistantEnvelope(request);
|
||||
|
||||
const res = await trpcMutation(request, 'field.signFieldWithToken', {
|
||||
token: assistantToken,
|
||||
fieldId: assistantOwnTextFieldId,
|
||||
value: 'MY OWN TEXT',
|
||||
isBase64: false,
|
||||
});
|
||||
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: assistantOwnTextFieldId },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(true);
|
||||
expect(fieldAfter.customText).toBe('MY OWN TEXT');
|
||||
});
|
||||
|
||||
test('field.signFieldWithToken (V1) still allows prefilling a later recipient text field', async ({ request }) => {
|
||||
const { assistantToken, laterTextFieldId } = await seedGroupedAssistantEnvelope(request);
|
||||
|
||||
const res = await trpcMutation(request, 'field.signFieldWithToken', {
|
||||
token: assistantToken,
|
||||
fieldId: laterTextFieldId,
|
||||
value: 'PREFILLED FOR LATER SIGNER',
|
||||
isBase64: false,
|
||||
});
|
||||
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
|
||||
const fieldAfter = await prisma.field.findUniqueOrThrow({
|
||||
where: { id: laterTextFieldId },
|
||||
});
|
||||
|
||||
expect(fieldAfter.inserted).toBe(true);
|
||||
expect(fieldAfter.customText).toBe('PREFILLED FOR LATER SIGNER');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, SendStatus } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Dictation lets a signer rewrite who signs next. It cannot be allowed to
|
||||
* operate on a signing group, for two reasons the server enforces separately:
|
||||
*
|
||||
* 1. The next step must hold exactly one recipient (`nextGroup.length === 1`),
|
||||
* otherwise there is no single "next signer" to rewrite.
|
||||
* 2. A signer whose own step is still pending (a peer has not signed) does not
|
||||
* advance the flow at all, so they never reach the dictation branch.
|
||||
*
|
||||
* Both are silent — passing `nextSigner` into a state that disallows dictation
|
||||
* is ignored rather than rejected — which is exactly why they need asserting.
|
||||
* The existing dictation specs all drive the UI and none use a grouped step.
|
||||
*/
|
||||
|
||||
const DICTATED_SIGNER = {
|
||||
name: 'Dictated Signer',
|
||||
email: 'dictated-signer@example.com',
|
||||
};
|
||||
|
||||
const expectRecipientUpdatedAuditLogCount = async (envelopeId: string, expected: number) => {
|
||||
const auditLogs = await prisma.documentAuditLog.findMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
},
|
||||
});
|
||||
|
||||
expect(auditLogs.length).toBe(expected);
|
||||
};
|
||||
|
||||
const seedDictationDocument = async (signingOrders: number[]) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const signers = await Promise.all(signingOrders.map(async () => (await seedUser()).user));
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: signers,
|
||||
recipientsCreateOptions: signingOrders.map((signingOrder) => ({
|
||||
signingOrder,
|
||||
// The seed defaults every recipient to SENT; later steps of a real
|
||||
// SEQUENTIAL document are NOT_SENT until their step unlocks.
|
||||
sendStatus: signingOrder === 1 ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||
})),
|
||||
// No fields, so completion is not blocked by unsigned required fields.
|
||||
fields: [],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
allowDictateNextSigner: true,
|
||||
},
|
||||
update: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
allowDictateNextSigner: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return signers.map((signer) => {
|
||||
const recipient = recipients.find((item) => item.email === signer.email);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error(`Seeded recipient ${signer.email} not found`);
|
||||
}
|
||||
|
||||
return recipient;
|
||||
});
|
||||
};
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: dictation is ignored when the next step is a group', async () => {
|
||||
// Steps: 1 = first, 2 = two grouped recipients.
|
||||
const [first, groupA, groupB] = await seedDictationDocument([1, 2, 2]);
|
||||
|
||||
await completeDocumentWithToken({
|
||||
token: first.token,
|
||||
id: { type: 'envelopeId', id: first.envelopeId },
|
||||
nextSigner: DICTATED_SIGNER,
|
||||
});
|
||||
|
||||
const groupAAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupA.id } });
|
||||
const groupBAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupB.id } });
|
||||
|
||||
// Neither member of the group may be rewritten.
|
||||
expect(groupAAfter.email).toBe(groupA.email);
|
||||
expect(groupAAfter.name).toBe(groupA.name);
|
||||
expect(groupBAfter.email).toBe(groupB.email);
|
||||
expect(groupBAfter.name).toBe(groupB.name);
|
||||
|
||||
// The dictated identity must not have leaked onto anyone.
|
||||
const dictated = await prisma.recipient.findFirst({
|
||||
where: { envelopeId: first.envelopeId, email: DICTATED_SIGNER.email },
|
||||
});
|
||||
|
||||
expect(dictated).toBeNull();
|
||||
|
||||
// A rewrite that did not happen must not be recorded as having happened.
|
||||
await expectRecipientUpdatedAuditLogCount(first.envelopeId, 0);
|
||||
|
||||
// The group is still activated as normal — only the rewrite is suppressed.
|
||||
expect(groupAAfter.sendStatus).toBe(SendStatus.SENT);
|
||||
expect(groupBAfter.sendStatus).toBe(SendStatus.SENT);
|
||||
});
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: a group member cannot dictate while a peer is still unsigned', async () => {
|
||||
// Steps: 1 = two grouped recipients, 2 = last.
|
||||
const [groupA, groupB, last] = await seedDictationDocument([1, 1, 2]);
|
||||
|
||||
// The first member of the group signs while their peer is still outstanding.
|
||||
await completeDocumentWithToken({
|
||||
token: groupA.token,
|
||||
id: { type: 'envelopeId', id: groupA.envelopeId },
|
||||
nextSigner: DICTATED_SIGNER,
|
||||
});
|
||||
|
||||
const lastWhilePeerPending = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: last.id },
|
||||
});
|
||||
|
||||
// The step never unlocked, so there was nothing to dictate.
|
||||
expect(lastWhilePeerPending.email).toBe(last.email);
|
||||
expect(lastWhilePeerPending.name).toBe(last.name);
|
||||
expect(lastWhilePeerPending.sendStatus).toBe(SendStatus.NOT_SENT);
|
||||
await expectRecipientUpdatedAuditLogCount(groupA.envelopeId, 0);
|
||||
|
||||
// The peer completing the group *does* advance to a single-recipient step,
|
||||
// so dictation applies — the positive control for the assertions above.
|
||||
await completeDocumentWithToken({
|
||||
token: groupB.token,
|
||||
id: { type: 'envelopeId', id: groupB.envelopeId },
|
||||
nextSigner: DICTATED_SIGNER,
|
||||
});
|
||||
|
||||
const lastAfterGroupComplete = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: last.id },
|
||||
});
|
||||
|
||||
expect(lastAfterGroupComplete.email).toBe(DICTATED_SIGNER.email);
|
||||
expect(lastAfterGroupComplete.name).toBe(DICTATED_SIGNER.name);
|
||||
expect(lastAfterGroupComplete.sendStatus).toBe(SendStatus.SENT);
|
||||
await expectRecipientUpdatedAuditLogCount(groupA.envelopeId, 1);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* A viewer's completion dialog activates the next-signer validator from
|
||||
* `allowDictateNextSigner` alone, while the name/email inputs only render
|
||||
* when a dictatable next recipient exists. When dictation is enabled but the
|
||||
* next step is not dictatable (a group of two or more, or the viewer is
|
||||
* last), submission must still work — historically it failed Zod validation
|
||||
* on the hidden inputs and "Mark as Viewed" silently did nothing.
|
||||
*/
|
||||
|
||||
const seedViewerDictationDocument = async (
|
||||
recipientsCreateOptions: {
|
||||
signingOrder: number;
|
||||
role?: RecipientRole;
|
||||
sendStatus?: SendStatus;
|
||||
}[],
|
||||
) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const signers = await Promise.all(recipientsCreateOptions.map(async () => (await seedUser()).user));
|
||||
|
||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: signers,
|
||||
recipientsCreateOptions,
|
||||
// No fields, so completion is not blocked by unsigned required fields.
|
||||
fields: [],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
allowDictateNextSigner: true,
|
||||
},
|
||||
update: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
allowDictateNextSigner: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { document, recipients };
|
||||
};
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: viewer can mark as viewed when the next step is a group', async ({ page }) => {
|
||||
const { document, recipients } = await seedViewerDictationDocument([
|
||||
{ signingOrder: 1, role: RecipientRole.VIEWER },
|
||||
// The next step is a group of two, so there is no single dictatable next
|
||||
// recipient — the dialog must not demand one.
|
||||
{ signingOrder: 2, sendStatus: SendStatus.NOT_SENT },
|
||||
{ signingOrder: 2, sendStatus: SendStatus.NOT_SENT },
|
||||
]);
|
||||
|
||||
const [viewer, groupA, groupB] = recipients;
|
||||
|
||||
const signUrl = `/sign/${viewer.token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'View Document' })).toBeVisible();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
// Retry the click: it can land before hydration attaches the handler.
|
||||
await expect(async () => {
|
||||
await page.getByRole('button', { name: 'Mark as viewed', exact: true }).click();
|
||||
await expect(dialog).toBeVisible({ timeout: 2_000 });
|
||||
}).toPass();
|
||||
|
||||
// No dictation inputs: a group cannot be dictated over.
|
||||
await expect(dialog.getByText('Next Recipient Name')).not.toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Mark as Viewed', exact: true }).click();
|
||||
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
|
||||
// The viewer completed and the group's step unlocked.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const updatedRecipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId: document.id },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
|
||||
return updatedRecipients.map((recipient) => [recipient.signingStatus, recipient.sendStatus]);
|
||||
})
|
||||
.toEqual([
|
||||
[SigningStatus.SIGNED, SendStatus.SENT],
|
||||
[SigningStatus.NOT_SIGNED, SendStatus.SENT],
|
||||
[SigningStatus.NOT_SIGNED, SendStatus.SENT],
|
||||
]);
|
||||
|
||||
// Nobody was renamed: no next-signer values existed to apply.
|
||||
const groupAAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupA.id } });
|
||||
const groupBAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupB.id } });
|
||||
|
||||
expect(groupAAfter.email).toBe(groupA.email);
|
||||
expect(groupBAfter.email).toBe(groupB.email);
|
||||
});
|
||||
|
||||
test('[NEXT_RECIPIENT_DICTATION]: viewer can mark as viewed when they are the last recipient', async ({ page }) => {
|
||||
const { recipients } = await seedViewerDictationDocument([
|
||||
{ signingOrder: 1 },
|
||||
{ signingOrder: 2, role: RecipientRole.VIEWER, sendStatus: SendStatus.NOT_SENT },
|
||||
]);
|
||||
|
||||
const [signer, viewer] = recipients;
|
||||
|
||||
// Advance the flow to the viewer's turn.
|
||||
await completeDocumentWithToken({
|
||||
token: signer.token,
|
||||
id: { type: 'envelopeId', id: signer.envelopeId },
|
||||
});
|
||||
|
||||
const signUrl = `/sign/${viewer.token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'View Document' })).toBeVisible();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
// Retry the click: it can land before hydration attaches the handler.
|
||||
await expect(async () => {
|
||||
await page.getByRole('button', { name: 'Mark as viewed', exact: true }).click();
|
||||
await expect(dialog).toBeVisible({ timeout: 2_000 });
|
||||
}).toPass();
|
||||
|
||||
// No dictation inputs: there is nobody after the viewer.
|
||||
await expect(dialog.getByText('Next Recipient Name')).not.toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Mark as Viewed', exact: true }).click();
|
||||
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const viewerAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: viewer.id } });
|
||||
|
||||
return viewerAfter.signingStatus;
|
||||
})
|
||||
.toBe(SigningStatus.SIGNED);
|
||||
});
|
||||
Reference in New Issue
Block a user