feat: bulk download documents (#2711)

This commit is contained in:
Ephraim Duncan
2026-08-03 20:55:43 +10:00
committed by GitHub
parent 29020bcbed
commit b3c609a549
14 changed files with 1091 additions and 65 deletions
@@ -0,0 +1,118 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
const downloadUrl = (envelopeId: string, envelopeItemId: string, version: 'original' | 'signed' | 'pending') =>
`${WEBAPP_BASE_URL}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`;
const seedOwnerWithDraft = async () => {
const owner = await seedUser();
const draft = await seedDraftDocument(owner.user, owner.team.id, [], {
createDocumentOptions: { title: 'File Download Auth Test' },
});
return { owner, draft, draftItem: draft.envelopeItems[0] };
};
test.describe('Envelope item file download endpoint authorization', () => {
test('rejects an unauthenticated download request', async ({ request }) => {
const { draft, draftItem } = await seedOwnerWithDraft();
const res = await request.get(downloadUrl(draft.id, draftItem.id, 'original'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a download request from a user outside the organisation', async ({ page }) => {
const { draft, draftItem } = await seedOwnerWithDraft();
const { user: outsider } = await seedUser();
await apiSignin({ page, email: outsider.email });
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(403);
});
test('returns 404 for a nonexistent envelope', async ({ page }) => {
const { user } = await seedUser();
await apiSignin({ page, email: user.email });
const res = await page.request.get(
downloadUrl('envelope_does_not_exist', 'envelope_item_does_not_exist', 'original'),
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('rejects a pending version download for a draft envelope', async ({ page }) => {
const { owner, draft, draftItem } = await seedOwnerWithDraft();
await apiSignin({ page, email: owner.user.email });
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'pending'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('rejects a pending version download for a legacy envelope', async ({ page }) => {
const owner = await seedUser();
const { user: recipient } = await seedUser();
// Default internalVersion is 1 (legacy).
const pendingDocument = await seedPendingDocument(owner.user, owner.team.id, [recipient], {
createDocumentOptions: { title: 'Legacy Pending Download Test' },
});
const envelopeItem = pendingDocument.envelopeItems[0];
await apiSignin({ page, email: owner.user.email });
const res = await page.request.get(downloadUrl(pendingDocument.id, envelopeItem.id, 'pending'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows the owner to download their own document', async ({ page }) => {
const { owner, draft, draftItem } = await seedOwnerWithDraft();
await apiSignin({ page, email: owner.user.email });
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
expect(res.ok()).toBeTruthy();
expect(res.headers()['content-type']).toContain('application/pdf');
const body = await res.body();
// %PDF magic bytes.
expect(Array.from(body.subarray(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
});
test('rejects a recipient-token download with an invalid token', async ({ request }) => {
const { draftItem } = await seedOwnerWithDraft();
const res = await request.get(
`${WEBAPP_BASE_URL}/api/files/token/invalid-token-12345/envelopeItem/${draftItem.id}/download/original`,
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
});
@@ -1,3 +1,5 @@
import fs from 'node:fs';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { prisma } from '@documenso/prisma';
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
@@ -5,6 +7,7 @@ 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 { unzipSync } from 'fflate';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
@@ -50,10 +53,10 @@ test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ p
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
await expect(page.getByText(/2\s*selected/)).toBeVisible();
});
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
@@ -67,7 +70,7 @@ test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ p
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
await expect(page.getByText(new RegExp(`${documents.length}\\s*selected`))).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
@@ -80,11 +83,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
await page.getByLabel('Clear selection').click();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
@@ -98,13 +101,13 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -113,6 +116,122 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
});
test('[BULK_ACTIONS]: selection does not leak between teams', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
const teamBUrl = `team-b-${Date.now()}`;
await createTeam({
userId: sender.user.id,
teamName: 'Team B',
teamUrl: teamBUrl,
organisationId: sender.organisation.id,
inheritMembers: true,
});
const teamB = await prisma.team.findFirstOrThrow({
where: { url: teamBUrl },
});
await seedDraftDocument(sender.user, teamB.id, [], {
createDocumentOptions: { title: 'Team B Doc' },
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
// The selection made in team A must not appear in team B.
await page.goto(`/t/${teamBUrl}/documents`);
await expect(page.getByRole('link', { name: 'Team B Doc' })).toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
// Returning to team A restores its selection.
await page.goto(`/t/${sender.team.url}/documents`);
await expect(page.getByText(/1\s*selected/)).toBeVisible();
});
test('[BULK_ACTIONS]: escape clears selection unless a dialog is open', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
// Escape while a dialog is open should close the dialog but keep the selection.
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
// Escape with no dialog open should clear the selection.
await page.keyboard.press('Escape');
await expect(page.getByText(/1\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can bulk download multiple documents as a zip', async ({ page }) => {
const { sender, documents } = await seedBulkActionsTestRequirements();
const [doc1, doc2] = documents;
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Download Documents')).toBeVisible();
await expect(dialog.getByText('Bulk Test Doc 1')).toBeVisible();
await expect(dialog.getByText('Bulk Test Doc 2')).toBeVisible();
await expect(dialog.getByText('Draft').first()).toBeVisible();
const downloadPromise = page.waitForEvent('download', { timeout: 10_000 });
await dialog.getByRole('button', { name: 'Download' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^documenso-documents-\d{4}-\d{2}-\d{2}\.zip$/);
const downloadPath = await download.path();
const zipContents = unzipSync(new Uint8Array(fs.readFileSync(downloadPath)));
// Each envelope's files are nested inside an `envelopeId_title` folder.
expect(Object.keys(zipContents).sort()).toEqual(
[`${doc1.id}_Bulk Test Doc 1/Bulk Test Doc 1.pdf`, `${doc2.id}_Bulk Test Doc 2/Bulk Test Doc 2.pdf`].sort(),
);
// Each entry should be a valid non-empty PDF (%PDF magic bytes).
for (const entry of Object.values(zipContents)) {
expect(Array.from(entry.slice(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
}
await expectToastTextToBeVisible(page, 'Documents downloaded');
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
@@ -152,14 +271,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
@@ -172,13 +291,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Documents deleted');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
@@ -199,7 +318,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
@@ -236,14 +355,14 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -49,10 +49,10 @@ test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ p
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
await expect(page.getByText(/2\s*selected/)).toBeVisible();
});
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
@@ -66,7 +66,7 @@ test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ p
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
await expect(page.getByText(new RegExp(`${templates.length}\\s*selected`))).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
@@ -79,11 +79,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
await page.getByLabel('Clear selection').click();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
@@ -97,13 +97,13 @@ test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page })
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -151,14 +151,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
@@ -171,13 +171,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Templates deleted');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
@@ -199,7 +199,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
@@ -236,14 +236,14 @@ test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ p
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');