mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
4f346d3c2d
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.
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
import { prisma } from '@documenso/prisma';
|
|
import { seedCancelledDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
|
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
|
import { seedUser } from '@documenso/prisma/seed/users';
|
|
import { expect, type Page, test } from '@playwright/test';
|
|
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
|
|
|
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
|
import { checkDocumentTabCount } from '../fixtures/documents';
|
|
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
|
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
const seedCancelDocumentsTestRequirements = async () => {
|
|
const [sender, recipientA, recipientB] = await Promise.all([
|
|
seedUser({ setTeamEmailAsOwner: true }),
|
|
seedUser({ setTeamEmailAsOwner: true }),
|
|
seedUser({ setTeamEmailAsOwner: true }),
|
|
]);
|
|
|
|
const pendingDocument = await seedPendingDocument(sender.user, sender.team.id, [recipientA.user, recipientB.user], {
|
|
createDocumentOptions: { title: 'Document 1 - Pending' },
|
|
});
|
|
|
|
return {
|
|
sender,
|
|
recipients: [recipientA, recipientB],
|
|
pendingDocument,
|
|
};
|
|
};
|
|
|
|
const cancelDocumentViaUi = async (page: Page, documentTitle: string, reason?: string) => {
|
|
const documentActionBtn = page.locator('tr', { hasText: documentTitle }).getByTestId('document-table-action-btn');
|
|
|
|
await openDropdownMenu(page, documentActionBtn);
|
|
|
|
await expect(page.getByRole('menuitem', { name: 'Cancel' })).toBeVisible();
|
|
await page.getByRole('menuitem', { name: 'Cancel' }).click();
|
|
|
|
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
|
|
|
|
if (reason) {
|
|
await page.getByPlaceholder('Add an optional reason for cancelling this document').fill(reason);
|
|
}
|
|
|
|
await page.getByRole('button', { name: 'Cancel document' }).click();
|
|
};
|
|
|
|
test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard as cancelled', async ({ page }) => {
|
|
const { sender, pendingDocument } = await seedCancelDocumentsTestRequirements();
|
|
|
|
await apiSignin({
|
|
page,
|
|
email: sender.user.email,
|
|
redirectPath: `/t/${sender.team.url}/documents`,
|
|
});
|
|
|
|
await cancelDocumentViaUi(page, 'Document 1 - Pending', 'No longer required');
|
|
|
|
await expectToastTextToBeVisible(page, 'Document cancelled');
|
|
|
|
// The document must remain in the dashboard, unlike deleting a pending document.
|
|
await checkDocumentTabCount(page, 'Inbox', 0);
|
|
await checkDocumentTabCount(page, 'Pending', 0);
|
|
await checkDocumentTabCount(page, 'Cancelled', 1);
|
|
await checkDocumentTabCount(page, 'All', 1);
|
|
|
|
// The cancelled document is still listed.
|
|
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
|
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
|
|
|
// The envelope status is persisted as CANCELLED.
|
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
|
where: {
|
|
id: pendingDocument.id,
|
|
},
|
|
select: {
|
|
status: true,
|
|
completedAt: true,
|
|
deletedAt: true,
|
|
},
|
|
});
|
|
|
|
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
|
expect(envelope.completedAt).not.toBeNull();
|
|
expect(envelope.deletedAt).toBeNull();
|
|
});
|
|
|
|
test('[DOCUMENTS]: cancelling a pending document retains it for recipients', async ({ page }) => {
|
|
const { sender, recipients } = await seedCancelDocumentsTestRequirements();
|
|
|
|
await apiSignin({
|
|
page,
|
|
email: sender.user.email,
|
|
redirectPath: `/t/${sender.team.url}/documents`,
|
|
});
|
|
|
|
await cancelDocumentViaUi(page, 'Document 1 - Pending');
|
|
|
|
await expectToastTextToBeVisible(page, 'Document cancelled');
|
|
|
|
await apiSignout({ page });
|
|
|
|
// Recipients should still be able to see the document as a record of distribution.
|
|
for (const recipient of recipients) {
|
|
await apiSignin({
|
|
page,
|
|
email: recipient.user.email,
|
|
redirectPath: `/t/${recipient.team.url}/documents`,
|
|
});
|
|
|
|
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
|
|
|
await apiSignout({ page });
|
|
}
|
|
});
|
|
|
|
test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner without removing it', async ({
|
|
page,
|
|
}) => {
|
|
const { sender, recipients, pendingDocument } = await seedCancelDocumentsTestRequirements();
|
|
|
|
await apiSignin({
|
|
page,
|
|
email: sender.user.email,
|
|
redirectPath: `/t/${sender.team.url}/documents`,
|
|
});
|
|
|
|
await cancelDocumentViaUi(page, 'Document 1 - Pending');
|
|
await expectToastTextToBeVisible(page, 'Document cancelled');
|
|
|
|
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
|
|
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
|
|
|
const documentActionBtn = page
|
|
.locator('tr', { hasText: 'Document 1 - Pending' })
|
|
.getByTestId('document-table-action-btn');
|
|
await openDropdownMenu(page, documentActionBtn);
|
|
|
|
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
|
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
|
await page.getByRole('button', { name: 'Delete' }).click();
|
|
|
|
await page.waitForTimeout(2500);
|
|
|
|
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
|
|
|
// The envelope is soft deleted, not hard deleted.
|
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
|
where: {
|
|
id: pendingDocument.id,
|
|
},
|
|
select: {
|
|
status: true,
|
|
deletedAt: true,
|
|
},
|
|
});
|
|
|
|
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
|
expect(envelope.deletedAt).not.toBeNull();
|
|
|
|
await apiSignout({ page });
|
|
|
|
// Recipients should still retain the document after the owner deletes it.
|
|
await apiSignin({
|
|
page,
|
|
email: recipients[0].user.email,
|
|
redirectPath: `/t/${recipients[0].team.url}/documents`,
|
|
});
|
|
|
|
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
|
});
|
|
|
|
// ─── Visibility: a cancelled document must respect team document visibility ───
|
|
|
|
test('[DOCUMENTS]: cancelled document with ADMIN visibility is hidden from a MEMBER', async ({ page }) => {
|
|
const { team, owner } = await seedTeam();
|
|
|
|
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
|
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
|
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
|
|
|
await seedCancelledDocument(owner, team.id, [], {
|
|
createDocumentOptions: {
|
|
visibility: 'ADMIN',
|
|
title: 'Cancelled Admin Only Document',
|
|
},
|
|
});
|
|
|
|
// The MEMBER must NOT see the ADMIN-visibility cancelled document on any tab.
|
|
await apiSignin({
|
|
page,
|
|
email: memberUser.email,
|
|
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
|
});
|
|
|
|
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
|
|
|
|
// Also confirm it doesn't leak via the ALL tab.
|
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents`);
|
|
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
|
|
|
|
await apiSignout({ page });
|
|
|
|
// The MANAGER must NOT see an ADMIN-visibility document either.
|
|
await apiSignin({
|
|
page,
|
|
email: managerUser.email,
|
|
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
|
});
|
|
|
|
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
|
|
|
|
await apiSignout({ page });
|
|
|
|
// The ADMIN must see it.
|
|
await apiSignin({
|
|
page,
|
|
email: adminUser.email,
|
|
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
|
});
|
|
|
|
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).toBeVisible();
|
|
});
|
|
|
|
test('[DOCUMENTS]: cancelled document with MANAGER_AND_ABOVE visibility is hidden from a MEMBER', async ({ page }) => {
|
|
const { team, owner } = await seedTeam();
|
|
|
|
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
|
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
|
|
|
await seedCancelledDocument(owner, team.id, [], {
|
|
createDocumentOptions: {
|
|
visibility: 'MANAGER_AND_ABOVE',
|
|
title: 'Cancelled Manager Document',
|
|
},
|
|
});
|
|
|
|
// The MEMBER must NOT see it.
|
|
await apiSignin({
|
|
page,
|
|
email: memberUser.email,
|
|
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
|
});
|
|
|
|
await expect(page.getByRole('link', { name: 'Cancelled Manager Document', exact: true })).not.toBeVisible();
|
|
|
|
await apiSignout({ page });
|
|
|
|
// The MANAGER must see it.
|
|
await apiSignin({
|
|
page,
|
|
email: managerUser.email,
|
|
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
|
});
|
|
|
|
await expect(page.getByRole('link', { name: 'Cancelled Manager Document', exact: true })).toBeVisible();
|
|
});
|
|
|
|
test('[DOCUMENTS]: a recipient sees a cancelled document regardless of restricted visibility', async ({ page }) => {
|
|
const { team, owner } = await seedTeam();
|
|
|
|
// A MEMBER who is also a recipient on an ADMIN-visibility document.
|
|
const memberRecipient = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
|
|
|
await seedCancelledDocument(owner, team.id, [memberRecipient], {
|
|
createDocumentOptions: {
|
|
visibility: 'ADMIN',
|
|
title: 'Cancelled Admin Doc With Recipient',
|
|
},
|
|
});
|
|
|
|
// Even though the document is ADMIN-only, the MEMBER is a recipient, so they
|
|
// must still see it (proof of distribution), matching completed-document behaviour.
|
|
await apiSignin({
|
|
page,
|
|
email: memberRecipient.email,
|
|
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
|
});
|
|
|
|
await expect(page.getByRole('link', { name: 'Cancelled Admin Doc With Recipient', exact: true })).toBeVisible();
|
|
});
|
|
|
|
// ─── UI gating: only privileged members see the Cancel action ────────────────
|
|
|
|
test('[DOCUMENTS]: a MEMBER does not see the Cancel action on a pending document', async ({ page }) => {
|
|
const { team, owner } = await seedTeam();
|
|
|
|
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
|
|
|
const { user: recipient } = await seedUser();
|
|
|
|
await seedPendingDocument(owner, team.id, [recipient], {
|
|
createDocumentOptions: { title: 'Member Gating Pending Document', visibility: 'EVERYONE' },
|
|
});
|
|
|
|
await apiSignin({
|
|
page,
|
|
email: memberUser.email,
|
|
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
|
});
|
|
|
|
const documentActionBtn = page
|
|
.locator('tr', { hasText: 'Member Gating Pending Document' })
|
|
.getByTestId('document-table-action-btn');
|
|
await openDropdownMenu(page, documentActionBtn);
|
|
|
|
// The dropdown must render (Edit is always there) but Cancel must be absent.
|
|
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
|
|
await expect(page.getByRole('menuitem', { name: 'Cancel' })).not.toBeVisible();
|
|
});
|
|
|
|
test('[DOCUMENTS]: a team ADMIN sees and can use the Cancel action on a document they do not own', async ({ page }) => {
|
|
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], {
|
|
createDocumentOptions: { title: 'Admin Cancellable Document', visibility: 'EVERYONE' },
|
|
});
|
|
|
|
await apiSignin({
|
|
page,
|
|
email: adminUser.email,
|
|
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
|
});
|
|
|
|
await cancelDocumentViaUi(page, 'Admin Cancellable Document');
|
|
|
|
await expectToastTextToBeVisible(page, 'Document cancelled');
|
|
|
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
|
where: { id: document.id },
|
|
select: { status: true },
|
|
});
|
|
|
|
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
|
});
|