feat: add cancellable document status (#2992)

Adds a CANCELLED envelope status that privileged members (owner or team
admin/manager) can move a pending document into. Sending recipient
notifications via a background job while retaining the document in the
dashboard as proof of distribution.

Includes a dedicated Cancelled tab, single and bulk cancel actions,
the ENVELOPE_CANCELLED mutability guard, and e2e coverage for
permissions
and visibility.
This commit is contained in:
Lucas Smith
2026-06-18 13:52:35 +10:00
committed by GitHub
parent d5ce222482
commit 4f346d3c2d
45 changed files with 1806 additions and 45 deletions
@@ -0,0 +1,242 @@
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 { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
const createTokenForUser = async (userId: number, teamId: number, tokenName: string) => {
const { token } = await createApiToken({
userId,
teamId,
tokenName,
expiresIn: null,
});
return token;
};
test.describe('Envelope cancel endpoint authorization', () => {
test('hides the document from an outsider attempting to cancel it', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const { user: outsider, team: outsiderTeam } = await seedUser();
const outsiderToken = await createTokenForUser(outsider.id, outsiderTeam.id, 'outsider');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${outsiderToken}` },
data: { envelopeId: document.id },
});
// Outsiders must not be able to determine whether the envelope exists.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// The document must be untouched.
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.PENDING);
});
test('hides the document from a recipient attempting to cancel it', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient, team: recipientTeam } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const recipientToken = await createTokenForUser(recipient.id, recipientTeam.id, 'recipient');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${recipientToken}` },
data: { envelopeId: document.id },
});
// A recipient is not a member of the document's team, so they must not be
// able to determine whether it exists via this endpoint.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.PENDING);
});
// Note: a non-privileged MEMBER cannot obtain an API token at all (token
// creation requires the MANAGE_TEAM permission), so the MEMBER cancellation
// restriction is covered through the UI tests in cancel-documents.spec.ts
// rather than at the API layer.
test('allows the document owner to cancel a pending document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true, completedAt: true, deletedAt: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
expect(documentInDb.completedAt).not.toBeNull();
expect(documentInDb.deletedAt).toBeNull();
});
test('allows a team ADMIN to cancel a pending document they do not own', async ({ request }) => {
const { team, owner } = await seedTeam();
const adminUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.ADMIN,
});
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const adminToken = await createTokenForUser(adminUser.id, team.id, 'admin');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
});
test('allows a team MANAGER to cancel a pending document they do not own', async ({ request }) => {
const { team, owner } = await seedTeam();
const managerUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MANAGER,
});
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const managerToken = await createTokenForUser(managerUser.id, team.id, 'manager');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${managerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
});
test('rejects cancelling a draft document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const document = await seedDraftDocument(owner, team.id, []);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-draft');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.DRAFT);
});
test('rejects cancelling a completed document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedCompletedDocument(owner, team.id, [recipient]);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-completed');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.COMPLETED);
});
test('rejects double cancellation of an already cancelled document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-double');
const firstRes = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(firstRes.status()).toBe(200);
const secondRes = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(secondRes.ok()).toBeFalsy();
expect(secondRes.status()).toBe(400);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
});
});
@@ -0,0 +1,102 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../../../assets/example.pdf'));
test.describe.configure({
mode: 'parallel',
});
const createPresignTokenForUser = async (userId: number, teamId: number) => {
const { token: apiToken } = await createApiToken({
userId,
teamId,
tokenName: 'file-upload-test',
expiresIn: null,
});
const { token: presignToken } = await createEmbeddingPresignToken({ apiToken });
return presignToken;
};
const buildPdfFormData = () => {
const formData = new FormData();
formData.append('file', new File([examplePdf], 'test.pdf', { type: 'application/pdf' }));
return formData;
};
test.describe('File upload endpoint authorization', () => {
test('rejects an unauthenticated upload-pdf request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/upload-pdf`, {
multipart: buildPdfFormData(),
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: { 'Content-Type': 'application/json' },
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer not-a-real-token',
},
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${presignToken}`,
},
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
});
// Authenticated, but the content type is not on the allow-list.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/upload-pdf`, {
headers: { Authorization: `Bearer ${presignToken}` },
multipart: buildPdfFormData(),
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.id).toBeDefined();
});
});