mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 17:35:05 +10:00
Merge branch 'main' into fix/cc-recipient-order-last
This commit is contained in:
+242
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
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';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
@@ -250,3 +253,147 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
|
||||
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Bulk cancel ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('[BULK_ACTIONS]: can cancel multiple pending documents', async ({ page }) => {
|
||||
const sender = await seedUser({ setTeamEmailAsOwner: true });
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const [pending1, pending2] = await Promise.all([
|
||||
seedPendingDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Bulk Cancel Pending 1' },
|
||||
}),
|
||||
seedPendingDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Bulk Cancel Pending 2' },
|
||||
}),
|
||||
]);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Cancel Pending 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Cancel Pending 2' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
|
||||
// The bulk action bar Cancel button (distinct from the dialog's confirm button).
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading', { name: 'Cancel Documents' })).toBeVisible();
|
||||
await expect(dialog.getByText('You are about to cancel 2 documents')).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents cancelled');
|
||||
|
||||
// Selection clears after a successful cancel.
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
|
||||
// Both documents are now cancelled in the database.
|
||||
for (const document of [pending1, pending2]) {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true, deletedAt: true },
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
expect(envelope.deletedAt).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: bulk cancel only affects pending documents', async ({ page }) => {
|
||||
const sender = await seedUser({ setTeamEmailAsOwner: true });
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const pending = await seedPendingDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Mixed Cancel Pending' },
|
||||
});
|
||||
const draft = await seedDraftDocument(sender.user, sender.team.id, [], {
|
||||
createDocumentOptions: { title: 'Mixed Cancel Draft' },
|
||||
});
|
||||
const completed = await seedCompletedDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Mixed Cancel Completed' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
await expect(page.getByText('3 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
// Only one of the three was pending, so this is a partial result.
|
||||
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
|
||||
|
||||
const pendingEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: pending.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(pendingEnvelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
|
||||
// The draft and completed documents are untouched.
|
||||
const draftEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: draft.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(draftEnvelope.status).toBe(DocumentStatus.DRAFT);
|
||||
|
||||
const completedEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: completed.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(completedEnvelope.status).toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: a MEMBER cannot bulk cancel documents they do not own', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const ownerDocument = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Member Cannot Cancel This', visibility: 'EVERYONE' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Member Cannot Cancel This' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
// The server rejects the cancellation for a document the MEMBER does not own,
|
||||
// so it reports zero cancelled (a partial result with the document in failedIds).
|
||||
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
|
||||
|
||||
// The document remains pending.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: ownerDocument.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(envelope.status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user