This commit is contained in:
David Nguyen
2026-08-26 12:10:29 +10:00
parent 9dc83bdb06
commit 6db71b13d4
70 changed files with 5964 additions and 892 deletions
@@ -0,0 +1,110 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
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 { FieldType } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
/**
* Field insertion must respect the recipient's signing window: an expired
* recipient can no longer act on the envelope at all. The V1 endpoints assert
* this; the V2 `envelope.field.sign` route historically did not.
*/
const callSignEnvelopeField = async (page: Page, input: { token: string; fieldId: number; value: string }) => {
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: input.value,
},
},
}),
});
};
const seedV2PendingDocumentWithTextField = async () => {
const { user, team } = await seedUser();
const { user: signer } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [signer],
fields: [FieldType.TEXT],
updateDocumentOptions: {
internalVersion: 2,
},
});
const recipient = recipients[0];
const textField = recipient.fields.find((field) => field.type === FieldType.TEXT);
if (!textField) {
throw new Error('Seeded text field not found');
}
return { recipient, textField };
};
test('[ENVELOPE_FIELD_SIGN]: rejects field insertion for an expired recipient', async ({ page }) => {
const { recipient, textField } = await seedV2PendingDocumentWithTextField();
await prisma.recipient.update({
where: { id: recipient.id },
data: {
// Expired one hour ago.
expiresAt: new Date(Date.now() - 60 * 60 * 1000),
},
});
// The seed pre-populates customText with a placeholder value.
const fieldBefore = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } });
const response = await callSignEnvelopeField(page, {
token: recipient.token,
fieldId: textField.id,
value: 'TEXT',
});
expect(response.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } });
expect(fieldAfter.inserted).toBe(false);
expect(fieldAfter.customText).toBe(fieldBefore.customText);
});
test('[ENVELOPE_FIELD_SIGN]: accepts field insertion for a recipient within their signing window', async ({ page }) => {
// Positive control: proves the request format reaches the route, so the
// expired-recipient rejection above cannot pass vacuously.
const { recipient, textField } = await seedV2PendingDocumentWithTextField();
await prisma.recipient.update({
where: { id: recipient.id },
data: {
// Expires an hour from now.
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
const response = await callSignEnvelopeField(page, {
token: recipient.token,
fieldId: textField.id,
value: 'TEXT',
});
expect(response.ok()).toBeTruthy();
const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } });
expect(fieldAfter.inserted).toBe(true);
expect(fieldAfter.customText).toBe('TEXT');
});
@@ -0,0 +1,82 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { apiSeedDraftDocument } from '../../fixtures/api-seeds';
const API_BASE_URL = `${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta`;
/**
* `Recipient.signingOrder` is an Int column, but nothing constrained the input
* to an integer. Prisma does not reject a fraction — it truncates it (1.5 -> 1),
* so distinct orders could silently collapse onto the same value, which under
* signing groups means "same step". Zero and negatives were persisted as-is and
* sort ahead of everything, including the `?? 0` fallback in assistant scoping.
*/
const createRecipient = async (request: APIRequestContext, token: string, envelopeId: string, signingOrder: number) =>
await request.post(`${API_BASE_URL}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
data: [
{
email: `signing-order-${Date.now()}-${signingOrder}@documenso.com`,
name: 'Signing Order Test',
role: 'SIGNER',
signingOrder,
},
],
},
});
test('[SIGNING_ORDER_VALIDATION]: rejects a fractional signing order with a client error', async ({ request }) => {
const { envelope, token } = await apiSeedDraftDocument(request, { title: '[TEST] Signing order validation' });
const response = await createRecipient(request, token, envelope.id, 1.5);
expect(response.status()).toBe(400);
// Nothing may be written — in particular not a silently truncated `1`.
const recipients = await prisma.recipient.findMany({ where: { envelopeId: envelope.id } });
expect(recipients).toHaveLength(0);
});
test('[SIGNING_ORDER_VALIDATION]: rejects a zero signing order', async ({ request }) => {
const { envelope, token } = await apiSeedDraftDocument(request, { title: '[TEST] Signing order validation zero' });
const response = await createRecipient(request, token, envelope.id, 0);
expect(response.status()).toBe(400);
const persisted = await prisma.recipient.findMany({ where: { envelopeId: envelope.id, signingOrder: 0 } });
expect(persisted).toHaveLength(0);
});
test('[SIGNING_ORDER_VALIDATION]: rejects a negative signing order', async ({ request }) => {
const { envelope, token } = await apiSeedDraftDocument(request, {
title: '[TEST] Signing order validation negative',
});
const response = await createRecipient(request, token, envelope.id, -1);
expect(response.status()).toBe(400);
const persisted = await prisma.recipient.findMany({ where: { envelopeId: envelope.id, signingOrder: -1 } });
expect(persisted).toHaveLength(0);
});
test('[SIGNING_ORDER_VALIDATION]: still accepts a valid positive integer signing order', async ({ request }) => {
const { envelope, token } = await apiSeedDraftDocument(request, { title: '[TEST] Signing order validation valid' });
const response = await createRecipient(request, token, envelope.id, 2);
expect(response.ok(), await response.text()).toBeTruthy();
const persisted = await prisma.recipient.findMany({ where: { envelopeId: envelope.id, signingOrder: 2 } });
expect(persisted).toHaveLength(1);
});
@@ -0,0 +1,111 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { apiSeedDraftDocument } from '../../fixtures/api-seeds';
const API_BASE_URL = `${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta`;
/**
* AES/QES envelopes cannot contain signing groups: two recipients sharing a
* step sign in parallel, which breaks the per-recipient /ByteRange invariant
* TSP signatures depend on. That rule previously lived only in the editor's
* form schema, so the API would happily create the forbidden state.
*
* The signature level is seeded directly because `resolveSignatureLevel`
* coerces AES/QES down to SES on a non-CSC instance, so it cannot be requested
* through the API here.
*/
const createRecipients = async (
request: APIRequestContext,
token: string,
envelopeId: string,
recipients: Array<{ signingOrder?: number }>,
) =>
await request.post(`${API_BASE_URL}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
data: recipients.map((recipient, index) => ({
email: `tsp-grouping-${Date.now()}-${index}@documenso.com`,
name: `TSP Recipient ${index}`,
role: 'SIGNER',
...recipient,
})),
},
});
const seedEnvelopeAtSignatureLevel = async (request: APIRequestContext, signatureLevel: string) => {
const { envelope, token } = await apiSeedDraftDocument(request, { title: `[TEST] ${signatureLevel} grouping` });
await prisma.envelope.update({ where: { id: envelope.id }, data: { signatureLevel } });
return { envelopeId: envelope.id, token };
};
test('[TSP_GROUPING]: rejects two recipients sharing a signing order on an AES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES');
const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 1 }]);
expect(response.status()).toBe(400);
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
expect(recipients).toHaveLength(0);
});
test('[TSP_GROUPING]: rejects a second recipient joining an existing step on an AES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES');
const first = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }]);
expect(first.ok(), await first.text()).toBeTruthy();
// The payload alone looks fine — only the resulting set reveals the group.
const second = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }]);
expect(second.status()).toBe(400);
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
expect(recipients).toHaveLength(1);
});
test('[TSP_GROUPING]: rejects two recipients without a signing order on a QES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'QES');
// Both land in the same tail step, so they would sign in parallel.
const response = await createRecipients(request, token, envelopeId, [{}, {}]);
expect(response.status()).toBe(400);
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
expect(recipients).toHaveLength(0);
});
test('[TSP_GROUPING]: accepts distinct signing orders on an AES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES');
const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 2 }]);
expect(response.ok(), await response.text()).toBeTruthy();
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
expect(recipients).toHaveLength(2);
});
test('[TSP_GROUPING]: still allows signing groups on an SES envelope', async ({ request }) => {
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'SES');
const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 1 }]);
expect(response.ok(), await response.text()).toBeTruthy();
const recipients = await prisma.recipient.findMany({ where: { envelopeId } });
expect(recipients.map((recipient) => recipient.signingOrder)).toEqual([1, 1]);
});