diff --git a/apps/remix/app/components/tables/documents-table-status-filter.tsx b/apps/remix/app/components/tables/documents-table-status-filter.tsx index 25abc1164..0d5f23ddc 100644 --- a/apps/remix/app/components/tables/documents-table-status-filter.tsx +++ b/apps/remix/app/components/tables/documents-table-status-filter.tsx @@ -1,4 +1,4 @@ -import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { STATS_COUNT_CAP } from '@documenso/lib/constants/document'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types'; @@ -14,13 +14,26 @@ import { FilterPill } from '~/components/general/filter-pill'; import { documentsSearchParams } from '~/utils/documents-search-params'; type DocumentsTableStatusFilterProps = { - stats: TFindDocumentsInternalResponse['stats']; + /** + * Per-status document counts, shown next to each option. When omitted no + * counts are rendered. + */ + stats?: TFindDocumentsInternalResponse['stats']; + + /** + * The statuses available for selection. Defaults to every status that + * makes sense for the documents page. + */ + statuses?: ExtendedDocumentStatus[]; }; -export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => { +export const DocumentsTableStatusFilter = ({ + stats, + statuses = SELECTABLE_STATUSES, +}: DocumentsTableStatusFilterProps) => { const { _ } = useLingui(); - const organisation = useCurrentOrganisation(); + const organisation = useOptionalCurrentOrganisation(); const [{ status }, setSearchParams] = useQueryStates( { @@ -32,14 +45,14 @@ export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilter const selectableStatuses = useMemo( () => - SELECTABLE_STATUSES.filter((value) => { - if (organisation.type === OrganisationType.PERSONAL) { + statuses.filter((value) => { + if (organisation?.type === OrganisationType.PERSONAL) { return value !== ExtendedDocumentStatus.INBOX; } return true; }), - [organisation.type], + [organisation?.type, statuses], ); const selectedStatus = useMemo( @@ -65,20 +78,22 @@ export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilter options={selectableStatuses.map((value) => ({ value, label: , - trailing: formatStatsCount(stats[value]), + trailing: stats ? formatStatsCount(stats[value]) : undefined, }))} testId="documents-table-status-filter" /> {/* Visually hidden document counts, for screen readers and tests. */} - - {[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => ( - - {_(FRIENDLY_STATUS_MAP[value].label)}:{' '} - {stats[value]} - - ))} - + {stats && ( + + {[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => ( + + {_(FRIENDLY_STATUS_MAP[value].label)}:{' '} + {stats[value]} + + ))} + + )} ); }; diff --git a/apps/remix/app/components/tables/inbox-table.tsx b/apps/remix/app/components/tables/inbox-table.tsx index f958da66d..0aec5bf22 100644 --- a/apps/remix/app/components/tables/inbox-table.tsx +++ b/apps/remix/app/components/tables/inbox-table.tsx @@ -16,22 +16,17 @@ import { Trans } from '@lingui/react/macro'; import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } from '@prisma/client'; import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react'; import { DateTime } from 'luxon'; +import { useQueryStates } from 'nuqs'; import { useMemo, useTransition } from 'react'; -import { useSearchParams } from 'react-router'; import { match } from 'ts-pattern'; import { DocumentStatus } from '~/components/general/document/document-status'; import { useOptionalCurrentTeam } from '~/providers/team'; +import { inboxSearchParams, resolveInboxStatus } from '~/utils/inbox-search-params'; import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog'; import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip'; -export type DocumentsTableProps = { - data?: TFindInboxResponse; - isLoading?: boolean; - isLoadingError?: boolean; -}; - type DocumentsTableRow = TFindInboxResponse['data'][number]; export const InboxTable = () => { @@ -40,17 +35,24 @@ export const InboxTable = () => { const team = useOptionalCurrentTeam(); const [isPending, startTransition] = useTransition(); - const [searchParams] = useSearchParams(); const updateSearchParams = useUpdateSearchParams(); - const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined; - const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined; + const [findInboxSearchParams] = useQueryStates(inboxSearchParams, { + history: 'push', + }); + + const status = resolveInboxStatus(findInboxSearchParams.status); + const query = findInboxSearchParams.query ?? ''; const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery({ - page: page || 1, - perPage: perPage || 10, + page: Math.max(findInboxSearchParams.page ?? 1, 1), + perPage: Math.min(Math.max(findInboxSearchParams.perPage ?? 10, 1), 100), + query: query || undefined, + status, }); + const hasSearchQuery = query.trim().length > 0; + const columns = useMemo(() => { return [ { @@ -123,7 +125,20 @@ export const InboxTable = () => { emptyState={

- Documents that require your attention will appear here + {match({ hasSearchQuery, status }) + .with({ hasSearchQuery: true }, () => No documents match your search) + .with({ status: DocumentStatusEnum.COMPLETED }, () => ( + Documents that you have completed will appear here + )) + .with({ status: DocumentStatusEnum.REJECTED }, () => ( + Documents that have been rejected will appear here + )) + .with({ status: DocumentStatusEnum.CANCELLED }, () => ( + Documents that have been cancelled will appear here + )) + .otherwise(() => ( + Documents that require your attention will appear here + ))}

} diff --git a/apps/remix/app/routes/_authenticated+/inbox.tsx b/apps/remix/app/routes/_authenticated+/inbox.tsx index 3ec3b70b7..fe1b49b2e 100644 --- a/apps/remix/app/routes/_authenticated+/inbox.tsx +++ b/apps/remix/app/routes/_authenticated+/inbox.tsx @@ -2,8 +2,11 @@ import { msg } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import { InboxIcon } from 'lucide-react'; +import { DocumentSearch } from '~/components/general/document/document-search'; import { OrganisationInvitations } from '~/components/general/organisations/organisation-invitations'; +import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter'; import { InboxTable } from '~/components/tables/inbox-table'; +import { INBOX_SELECTABLE_STATUSES } from '~/utils/inbox-search-params'; import { appMetaTags } from '~/utils/meta'; export function meta() { @@ -26,6 +29,14 @@ export default function InboxPage() { +
+
+ +
+ + +
+ ); diff --git a/apps/remix/app/utils/inbox-search-params.ts b/apps/remix/app/utils/inbox-search-params.ts new file mode 100644 index 000000000..e2c900cbe --- /dev/null +++ b/apps/remix/app/utils/inbox-search-params.ts @@ -0,0 +1,33 @@ +import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; +import { INBOX_STATUSES, type TInboxStatus } from '@documenso/trpc/server/document-router/find-inbox.types'; + +import { documentsSearchParams } from './documents-search-params'; + +/** + * The statuses that can be selected from the inbox status filter. + */ +export const INBOX_SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [...INBOX_STATUSES]; + +/** + * Shared nuqs parsers for the inbox page URL state. + * + * Reuses the documents parsers so the shared filter components + * (`DocumentSearch`, `DocumentsTableStatusFilter`) read and write the same + * params on both pages. + */ +export const inboxSearchParams = { + status: documentsSearchParams.status, + page: documentsSearchParams.page, + perPage: documentsSearchParams.perPage, + query: documentsSearchParams.query, +}; + +/** + * Narrows the URL `status` param to a status supported by the inbox. + * + * Returns `undefined` when it is missing or not selectable, which shows every + * non-draft document. + */ +export const resolveInboxStatus = (status: ExtendedDocumentStatus | null): TInboxStatus | undefined => { + return INBOX_STATUSES.find((value) => value === status); +}; diff --git a/packages/app-tests/e2e/api/trpc/find-inbox.spec.ts b/packages/app-tests/e2e/api/trpc/find-inbox.spec.ts new file mode 100644 index 000000000..b5c47177e --- /dev/null +++ b/packages/app-tests/e2e/api/trpc/find-inbox.spec.ts @@ -0,0 +1,423 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; +import { + seedCancelledDocument, + seedCompletedDocument, + seedDraftDocument, + seedPendingDocument, +} from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { DocumentStatus, EnvelopeType, RecipientRole, TeamMemberRole } from '@prisma/client'; + +import { apiSignin, apiSignout } from '../../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ + mode: 'parallel', +}); + +type InboxFindInput = { + query?: string; + status?: string; + page?: number; + perPage?: number; +}; + +type InboxFindDocument = { + envelopeId: string; + title: string; + status: string; + recipients: Array<{ email: string; token: string }>; +}; + +/** + * Calls `document.inbox.find` directly, bypassing any UI level restrictions so + * we can assert the server rejects or ignores hostile input on its own. + */ +const trpcInboxFind = async (page: Page, input: InboxFindInput) => { + const inputParam = encodeURIComponent(JSON.stringify({ json: input })); + const url = `${WEBAPP_BASE_URL}/api/trpc/document.inbox.find?input=${inputParam}`; + + const res = await page.context().request.get(url); + + return { + res, + data: res.ok() + ? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + ((await res.json()).result.data.json as { data: InboxFindDocument[]; count: number }) + : null, + }; +}; + +const titlesOf = (data: { data: InboxFindDocument[] } | null) => (data?.data ?? []).map((doc) => doc.title); + +// ─── Recipient scoping ─────────────────────────────────────────────────────── + +test.describe('Inbox Find - Recipient Scoping', () => { + test('should not return documents the user is not a recipient of, even when searched by title', async ({ page }) => { + const { user: sender, team: senderTeam } = await seedUser(); + const { user: victim } = await seedUser(); + const { user: attacker } = await seedUser(); + + await seedPendingDocument(sender, senderTeam.id, [victim], { + createDocumentOptions: { title: 'Confidential Merger Agreement' }, + }); + + await seedCompletedDocument(sender, senderTeam.id, [victim], { + createDocumentOptions: { title: 'Confidential Severance Package' }, + }); + + // Positive control: the actual recipient can find them. + await apiSignin({ page, email: victim.email }); + + const victimPending = await trpcInboxFind(page, { query: 'Confidential', status: 'PENDING' }); + expect(titlesOf(victimPending.data)).toEqual(['Confidential Merger Agreement']); + + const victimCompleted = await trpcInboxFind(page, { query: 'Confidential', status: 'COMPLETED' }); + expect(titlesOf(victimCompleted.data)).toEqual(['Confidential Severance Package']); + + await apiSignout({ page }); + + // The attacker knows the exact title but is not a recipient. + await apiSignin({ page, email: attacker.email }); + + for (const status of ['PENDING', 'COMPLETED', undefined]) { + const { res, data } = await trpcInboxFind(page, { query: 'Confidential', status }); + + expect(res.ok()).toBeTruthy(); + expect(data?.count).toBe(0); + expect(titlesOf(data)).toEqual([]); + } + + await apiSignout({ page }); + }); + + test('should not return documents where the user is only a CC recipient', async ({ page }) => { + const { user: sender, team: senderTeam } = await seedUser(); + const { user: recipient } = await seedUser(); + + const ccDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'CC Only Contract' }, + }); + + await prisma.recipient.updateMany({ + where: { envelopeId: ccDocument.id, email: recipient.email }, + data: { role: RecipientRole.CC }, + }); + + // Positive control on the same account. + await seedPendingDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Signer Contract' }, + }); + + await apiSignin({ page, email: recipient.email }); + + const unfiltered = await trpcInboxFind(page, {}); + expect(titlesOf(unfiltered.data)).toEqual(['Signer Contract']); + + const searched = await trpcInboxFind(page, { query: 'CC Only' }); + expect(titlesOf(searched.data)).toEqual([]); + + await apiSignout({ page }); + }); + + test('should not expose team documents to team members who are not recipients', async ({ page }) => { + const { team, owner } = await seedTeam(); + const { user: outsideRecipient } = await seedUser(); + + // A team admin can see this document on the team documents page, but the + // inbox is strictly recipient scoped. + const teamAdmin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + + await seedPendingDocument(owner, team.id, [outsideRecipient], { + createDocumentOptions: { title: 'Team Payroll Summary' }, + }); + + await seedCompletedDocument(owner, team.id, [outsideRecipient], { + createDocumentOptions: { title: 'Team Board Minutes' }, + }); + + await apiSignin({ page, email: teamAdmin.email }); + + const pending = await trpcInboxFind(page, { query: 'Team', status: 'PENDING' }); + expect(titlesOf(pending.data)).toEqual([]); + + const completed = await trpcInboxFind(page, { query: 'Team', status: 'COMPLETED' }); + expect(titlesOf(completed.data)).toEqual([]); + + await apiSignout({ page }); + + // The document owner is also not a recipient, so it should not be in their inbox either. + await apiSignin({ page, email: owner.email }); + + const ownerResult = await trpcInboxFind(page, { query: 'Team' }); + expect(titlesOf(ownerResult.data)).toEqual([]); + + await apiSignout({ page }); + }); + + test('should mask signing tokens of other recipients', async ({ page }) => { + const { user: sender, team: senderTeam } = await seedUser(); + const { user: recipient } = await seedUser(); + const { user: otherRecipient } = await seedUser(); + + await seedPendingDocument(sender, senderTeam.id, [recipient, otherRecipient], { + createDocumentOptions: { title: 'Shared Token Document' }, + }); + + await apiSignin({ page, email: recipient.email }); + + const { data } = await trpcInboxFind(page, { query: 'Shared Token', status: 'PENDING' }); + + expect(data?.data).toHaveLength(1); + + const document = data?.data[0]; + + const ownRecipient = document?.recipients.find((r) => r.email === recipient.email); + const foreignRecipient = document?.recipients.find((r) => r.email === otherRecipient.email); + + expect(ownRecipient?.token).toBeTruthy(); + expect(foreignRecipient?.token).toBe(''); + + await apiSignout({ page }); + }); +}); + +// ─── Search hardening ──────────────────────────────────────────────────────── + +test.describe('Inbox Find - Search Hardening', () => { + test('should keep wildcard searches scoped to the recipient inbox', async ({ page }) => { + // SQL LIKE wildcards ("%" and "_") are intentionally passed through so + // users can do advanced searches. That must only ever widen the title + // match, never the recipient scoping. + const { user: sender, team: senderTeam } = await seedUser(); + const { user: victim } = await seedUser(); + const { user: attacker } = await seedUser(); + + await seedPendingDocument(sender, senderTeam.id, [victim], { + createDocumentOptions: { title: 'Victim Alpha Report' }, + }); + + await seedCompletedDocument(sender, senderTeam.id, [victim], { + createDocumentOptions: { title: 'Victim Beta Report' }, + }); + + // The attacker has one document of their own so we can prove wildcards + // return their inbox and nothing more. + await seedPendingDocument(sender, senderTeam.id, [attacker], { + createDocumentOptions: { title: 'Attacker Own Report' }, + }); + + const wildcardQueries = ['%', '_', '%%%', '%Report%', 'Victim%', 'Victim _lpha%', '\\', '%victim%']; + + // Positive control: wildcards work for the actual recipient. + await apiSignin({ page, email: victim.email }); + + const victimAll = await trpcInboxFind(page, { query: '%' }); + expect(titlesOf(victimAll.data).sort()).toEqual(['Victim Alpha Report', 'Victim Beta Report']); + + const victimPattern = await trpcInboxFind(page, { query: 'Victim _lpha%' }); + expect(titlesOf(victimPattern.data)).toEqual(['Victim Alpha Report']); + + await apiSignout({ page }); + + // The attacker gets exactly their own inbox for every wildcard, never the victim's. + await apiSignin({ page, email: attacker.email }); + + for (const query of wildcardQueries) { + const { res, data } = await trpcInboxFind(page, { query }); + + expect(res.ok(), `query "${query}"`).toBeTruthy(); + + const titles = titlesOf(data); + + expect(titles, `query "${query}"`).not.toContain('Victim Alpha Report'); + expect(titles, `query "${query}"`).not.toContain('Victim Beta Report'); + expect( + titles.every((title) => title === 'Attacker Own Report'), + `query "${query}"`, + ).toBe(true); + } + + // Wildcards combined with the status filter still cannot escape the scope. + for (const status of ['PENDING', 'COMPLETED', 'REJECTED', 'CANCELLED']) { + const { data } = await trpcInboxFind(page, { query: '%', status }); + + expect(titlesOf(data), `status "${status}"`).not.toContain('Victim Alpha Report'); + expect(titlesOf(data), `status "${status}"`).not.toContain('Victim Beta Report'); + } + + await apiSignout({ page }); + }); + + test('should only match against the document title', async ({ page }) => { + // Explicit names so the negative queries below are deterministic. + const { user: sender, team: senderTeam } = await seedUser({ name: 'Sender Person' }); + const { user: recipient } = await seedUser({ name: 'Recipient Person' }); + + await seedPendingDocument(sender, senderTeam.id, ['zebra-person@test.documenso.com', recipient], { + createDocumentOptions: { + title: 'Plain Title', + externalId: 'ext-hidden-identifier', + }, + }); + + await apiSignin({ page, email: recipient.email }); + + // Positive control. + const byTitle = await trpcInboxFind(page, { query: 'Plain' }); + expect(titlesOf(byTitle.data)).toEqual(['Plain Title']); + + // External IDs, sender details and other recipients must not be probeable + // through the inbox search. + for (const query of ['ext-hidden', sender.email, 'Sender Person', 'zebra-person']) { + const { data } = await trpcInboxFind(page, { query }); + + expect(titlesOf(data), `query "${query}"`).toEqual([]); + } + + await apiSignout({ page }); + }); + + test('should not surface deleted, draft or template envelopes through search', async ({ page }) => { + const { user: sender, team: senderTeam } = await seedUser(); + const { user: recipient } = await seedUser(); + + const deletedDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Hidden Deleted Document' }, + }); + + await prisma.envelope.update({ + where: { id: deletedDocument.id }, + data: { deletedAt: new Date() }, + }); + + await seedDraftDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Hidden Draft Document' }, + }); + + await seedPendingDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Hidden Template Envelope', type: EnvelopeType.TEMPLATE }, + }); + + // Positive control. + await seedPendingDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Hidden Visible Document' }, + }); + + await apiSignin({ page, email: recipient.email }); + + const unfiltered = await trpcInboxFind(page, { query: 'Hidden' }); + expect(titlesOf(unfiltered.data)).toEqual(['Hidden Visible Document']); + + for (const query of ['Hidden Deleted', 'Hidden Draft', 'Hidden Template']) { + const { data } = await trpcInboxFind(page, { query }); + expect(titlesOf(data)).toEqual([]); + } + + await apiSignout({ page }); + }); +}); + +// ─── Status filter hardening ───────────────────────────────────────────────── + +test.describe('Inbox Find - Status Filter Hardening', () => { + test('should reject draft and virtual status values', async ({ page }) => { + const { user: sender, team: senderTeam } = await seedUser(); + const { user: recipient } = await seedUser(); + + // A recipient on a draft must never be able to pull it out via the status filter. + await seedDraftDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Unsent Draft Document' }, + }); + + await apiSignin({ page, email: recipient.email }); + + for (const status of ['DRAFT', 'EXPIRED', 'INBOX', 'ALL', 'pending', 'draft', '']) { + const { res, data } = await trpcInboxFind(page, { status }); + + expect(res.status(), `status "${status}" should be rejected`).toBe(400); + expect(data).toBeNull(); + } + + // Sanity check that the valid filters, and the unfiltered view, never include the draft. + for (const status of ['PENDING', 'COMPLETED', 'REJECTED', 'CANCELLED', undefined]) { + const { res, data } = await trpcInboxFind(page, { status }); + + expect(res.ok(), `status "${status}" should be accepted`).toBeTruthy(); + expect(titlesOf(data)).toEqual([]); + } + + await apiSignout({ page }); + }); + + test('should scope each status filter to exactly that status', async ({ page }) => { + const { user: sender, team: senderTeam } = await seedUser(); + const { user: recipient } = await seedUser(); + + await seedPendingDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Scoped Pending Document' }, + }); + + await seedCompletedDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Scoped Completed Document' }, + }); + + await seedCancelledDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Scoped Cancelled Document' }, + }); + + await seedPendingDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Scoped Rejected Document', status: DocumentStatus.REJECTED }, + }); + + await apiSignin({ page, email: recipient.email }); + + const expectations = [ + { status: 'PENDING', expected: ['Scoped Pending Document'] }, + { status: 'COMPLETED', expected: ['Scoped Completed Document'] }, + { status: 'CANCELLED', expected: ['Scoped Cancelled Document'] }, + { status: 'REJECTED', expected: ['Scoped Rejected Document'] }, + ]; + + for (const { status, expected } of expectations) { + const { data } = await trpcInboxFind(page, { query: 'Scoped', status }); + + expect(titlesOf(data), `status "${status}"`).toEqual(expected); + } + + await apiSignout({ page }); + }); + + test('should reject pagination values outside of the allowed range', async ({ page }) => { + const { user } = await seedUser(); + + await apiSignin({ page, email: user.email }); + + const tooManyPerPage = await trpcInboxFind(page, { perPage: 101 }); + expect(tooManyPerPage.res.status()).toBe(400); + + const zeroPerPage = await trpcInboxFind(page, { perPage: 0 }); + expect(zeroPerPage.res.status()).toBe(400); + + const zeroPage = await trpcInboxFind(page, { page: 0 }); + expect(zeroPage.res.status()).toBe(400); + + await apiSignout({ page }); + }); +}); + +// ─── Authentication ────────────────────────────────────────────────────────── + +test.describe('Inbox Find - Authentication', () => { + test('should reject unauthenticated requests', async ({ page }) => { + const { res } = await trpcInboxFind(page, { query: 'anything', status: 'PENDING' }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + }); +}); diff --git a/packages/app-tests/e2e/documents/inbox.spec.ts b/packages/app-tests/e2e/documents/inbox.spec.ts new file mode 100644 index 000000000..e88027028 --- /dev/null +++ b/packages/app-tests/e2e/documents/inbox.spec.ts @@ -0,0 +1,335 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; +import { + seedCancelledDocument, + seedCompletedDocument, + seedDraftDocument, + seedPendingDocument, +} from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { DocumentStatus, RecipientRole, TeamMemberRole } from '@prisma/client'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ + mode: 'parallel', +}); + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +const DEFAULT_EMPTY_STATE = 'Documents that require your attention will appear here'; +const COMPLETED_EMPTY_STATE = 'Documents that you have completed will appear here'; +const SEARCH_EMPTY_STATE = 'No documents match your search'; + +const inboxRow = (page: Page, title: string) => page.getByRole('row').filter({ hasText: title }); + +const searchInbox = async (page: Page, query: string) => { + await page.getByPlaceholder('Search documents...').fill(query); + + // An empty search removes the param entirely. + await page.waitForURL((url) => (url.searchParams.get('query') ?? '') === query); +}; + +// Rendered labels come from the compiled English catalog, which uses the US +// spelling "Canceled" for the `Cancelled` source string. +const INBOX_STATUS_LABELS = { + [DocumentStatus.PENDING]: 'Pending', + [DocumentStatus.COMPLETED]: 'Completed', + [DocumentStatus.REJECTED]: 'Rejected', + [DocumentStatus.CANCELLED]: 'Canceled', +} as const; + +type InboxStatus = keyof typeof INBOX_STATUS_LABELS; + +const selectInboxStatus = async (page: Page, status: InboxStatus) => { + await page.getByTestId('documents-table-status-filter').click(); + await page.getByRole('option', { name: INBOX_STATUS_LABELS[status], exact: true }).click(); + await page.waitForURL((url) => url.searchParams.get('status') === status); +}; + +// ─── Behaviour ─────────────────────────────────────────────────────────────── + +test.describe('Inbox - Search & Status Filter', () => { + test('should show every non-draft document by default', async ({ page }) => { + const { user: sender, team: senderTeam } = await seedUser(); + const { user: recipient } = await seedUser(); + + await seedPendingDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Inbox Pending Document' }, + }); + + await seedCompletedDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Inbox Completed Document' }, + }); + + await seedCancelledDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Inbox Cancelled Document' }, + }); + + await seedDraftDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Inbox Draft Document' }, + }); + + await apiSignin({ page, email: recipient.email, redirectPath: '/inbox' }); + + // No status selected by default. + expect(new URL(page.url()).searchParams.get('status')).toBeNull(); + await expect(page.getByTestId('documents-table-status-filter')).toHaveText('Status'); + + await expect(inboxRow(page, 'Inbox Pending Document')).toBeVisible(); + await expect(inboxRow(page, 'Inbox Completed Document')).toBeVisible(); + await expect(inboxRow(page, 'Inbox Cancelled Document')).toBeVisible(); + await expect(inboxRow(page, 'Inbox Draft Document')).not.toBeVisible(); + + await selectInboxStatus(page, DocumentStatus.COMPLETED); + + await expect(page.getByTestId('documents-table-status-filter')).toContainText('Completed'); + await expect(inboxRow(page, 'Inbox Completed Document')).toBeVisible(); + await expect(inboxRow(page, 'Inbox Pending Document')).not.toBeVisible(); + await expect(inboxRow(page, 'Inbox Cancelled Document')).not.toBeVisible(); + + await selectInboxStatus(page, DocumentStatus.CANCELLED); + + await expect(inboxRow(page, 'Inbox Cancelled Document')).toBeVisible(); + await expect(inboxRow(page, 'Inbox Pending Document')).not.toBeVisible(); + await expect(inboxRow(page, 'Inbox Completed Document')).not.toBeVisible(); + + // Clearing the filter returns to every non-draft document. + await page.getByTestId('documents-table-status-filter').click(); + await page.getByRole('option', { name: 'Clear' }).click(); + await page.waitForURL((url) => url.searchParams.get('status') === null); + + await expect(page.getByTestId('documents-table-status-filter')).toHaveText('Status'); + await expect(inboxRow(page, 'Inbox Pending Document')).toBeVisible(); + await expect(inboxRow(page, 'Inbox Completed Document')).toBeVisible(); + await expect(inboxRow(page, 'Inbox Cancelled Document')).toBeVisible(); + await expect(inboxRow(page, 'Inbox Draft Document')).not.toBeVisible(); + }); + + test('should offer every status except draft', async ({ page }) => { + const { user } = await seedUser(); + + await apiSignin({ page, email: user.email, redirectPath: '/inbox' }); + + await page.getByTestId('documents-table-status-filter').click(); + + for (const visibleStatus of Object.values(INBOX_STATUS_LABELS)) { + await expect(page.getByRole('option', { name: visibleStatus, exact: true })).toBeVisible(); + } + + for (const hiddenStatus of ['Draft', 'Inbox', 'All', 'Expired']) { + await expect(page.getByRole('option', { name: hiddenStatus, exact: true })).not.toBeVisible(); + } + }); + + test('should filter documents by title', async ({ page }) => { + const { user: sender, team: senderTeam } = await seedUser(); + const { user: recipient } = await seedUser(); + + await seedPendingDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Alpha Agreement' }, + }); + + await seedPendingDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Beta Agreement' }, + }); + + await apiSignin({ page, email: recipient.email, redirectPath: '/inbox' }); + + await expect(inboxRow(page, 'Alpha Agreement')).toBeVisible(); + await expect(inboxRow(page, 'Beta Agreement')).toBeVisible(); + + await searchInbox(page, 'alpha'); + + await expect(inboxRow(page, 'Alpha Agreement')).toBeVisible(); + await expect(inboxRow(page, 'Beta Agreement')).not.toBeVisible(); + + await searchInbox(page, 'Gamma'); + + await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible(); + + // Search is combined with the status filter. + await selectInboxStatus(page, DocumentStatus.COMPLETED); + await searchInbox(page, 'Agreement'); + + await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible(); + await expect(inboxRow(page, 'Alpha Agreement')).not.toBeVisible(); + + // Clearing the search shows the status specific empty state. + await searchInbox(page, ''); + + await expect(page.getByText(COMPLETED_EMPTY_STATE)).toBeVisible(); + }); +}); + +// ─── Adversarial ───────────────────────────────────────────────────────────── + +test.describe('Inbox - Adversarial Access', () => { + test('should not leak another user documents through search', async ({ page }) => { + const { user: sender, team: senderTeam } = await seedUser(); + const { user: victim } = await seedUser(); + const { user: attacker } = await seedUser(); + + await seedPendingDocument(sender, senderTeam.id, [victim], { + createDocumentOptions: { title: 'Confidential Merger Agreement' }, + }); + + await seedCompletedDocument(sender, senderTeam.id, [victim], { + createDocumentOptions: { title: 'Confidential Severance Package' }, + }); + + // Attacker lands directly on a crafted URL with the exact title. + await apiSignin({ + page, + email: attacker.email, + redirectPath: '/inbox?query=Confidential%20Merger%20Agreement', + }); + + await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible(); + await expect(inboxRow(page, 'Confidential Merger Agreement')).not.toBeVisible(); + + await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Confidential&status=COMPLETED`); + + await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible(); + await expect(inboxRow(page, 'Confidential Severance Package')).not.toBeVisible(); + + // Wildcards must not widen the search to everything. + await page.goto(`${WEBAPP_BASE_URL}/inbox?query=%25`); + + await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible(); + await expect(page.getByText('Confidential', { exact: false })).not.toBeVisible(); + }); + + test('should ignore tampered status values', async ({ page }) => { + const { user: sender, team: senderTeam } = await seedUser(); + const { user: recipient } = await seedUser(); + + // The recipient is attached to a draft that has not been sent yet. It must + // never be reachable via the URL, regardless of the status requested. + await seedDraftDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Unsent Draft Document' }, + }); + + await seedCancelledDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Cancelled Document' }, + }); + + await seedPendingDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Legit Pending Document' }, + }); + + // Draft, virtual and derived statuses are ignored, showing the unfiltered + // non-draft view. Kept to a handful of full page loads per test since each + // one is a fresh navigation. + const expectUnfilteredView = async () => { + await expect(page.getByTestId('documents-table-status-filter')).toHaveText('Status'); + await expect(inboxRow(page, 'Legit Pending Document')).toBeVisible(); + await expect(inboxRow(page, 'Cancelled Document')).toBeVisible(); + await expect(inboxRow(page, 'Unsent Draft Document')).not.toBeVisible(); + }; + + await apiSignin({ page, email: recipient.email, redirectPath: '/inbox?status=DRAFT' }); + await expectUnfilteredView(); + + await page.goto(`${WEBAPP_BASE_URL}/inbox?status=ALL`); + await expectUnfilteredView(); + + await page.goto(`${WEBAPP_BASE_URL}/inbox?status=EXPIRED`); + await expectUnfilteredView(); + + // Searching by the exact title of the draft must not surface it either. + await searchInbox(page, 'Unsent Draft'); + await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible(); + + // Supported non-pending statuses are scoped to exactly that status. + await page.goto(`${WEBAPP_BASE_URL}/inbox?status=CANCELLED`); + + await expect(page.getByTestId('documents-table-status-filter')).toContainText( + INBOX_STATUS_LABELS[DocumentStatus.CANCELLED], + ); + await expect(inboxRow(page, 'Cancelled Document')).toBeVisible(); + await expect(inboxRow(page, 'Legit Pending Document')).not.toBeVisible(); + await expect(inboxRow(page, 'Unsent Draft Document')).not.toBeVisible(); + }); + + test('should not show deleted or CC documents even when searched by exact title', async ({ page }) => { + const { user: sender, team: senderTeam } = await seedUser(); + const { user: recipient } = await seedUser(); + + const deletedDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'Deleted Pending Document' }, + }); + + await prisma.envelope.update({ + where: { id: deletedDocument.id }, + data: { deletedAt: new Date() }, + }); + + const ccDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], { + createDocumentOptions: { title: 'CC Only Pending Document' }, + }); + + await prisma.recipient.updateMany({ + where: { envelopeId: ccDocument.id, email: recipient.email }, + data: { role: RecipientRole.CC }, + }); + + await apiSignin({ page, email: recipient.email, redirectPath: '/inbox' }); + + await expect(page.getByText(DEFAULT_EMPTY_STATE)).toBeVisible(); + + await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Deleted%20Pending`); + await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible(); + await expect(inboxRow(page, 'Deleted Pending Document')).not.toBeVisible(); + + await page.goto(`${WEBAPP_BASE_URL}/inbox?query=CC%20Only`); + await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible(); + await expect(inboxRow(page, 'CC Only Pending Document')).not.toBeVisible(); + }); + + test('should not show team documents to team members who are not recipients', async ({ page }) => { + const { team, owner } = await seedTeam(); + const { user: outsideRecipient } = await seedUser(); + + const teamAdmin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + + await seedPendingDocument(owner, team.id, [outsideRecipient], { + createDocumentOptions: { title: 'Team Payroll Summary' }, + }); + + await seedCompletedDocument(owner, team.id, [outsideRecipient], { + createDocumentOptions: { title: 'Team Board Minutes' }, + }); + + // A team admin sees these on the team documents page, but the personal + // inbox is strictly recipient scoped. + await apiSignin({ page, email: teamAdmin.email, redirectPath: '/inbox?query=Team' }); + + await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible(); + await expect(inboxRow(page, 'Team Payroll Summary')).not.toBeVisible(); + + await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Team&status=COMPLETED`); + + await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible(); + await expect(inboxRow(page, 'Team Board Minutes')).not.toBeVisible(); + + // Positive control: the actual recipient can find both. + await page.context().clearCookies(); + await apiSignin({ page, email: outsideRecipient.email, redirectPath: '/inbox?query=Team' }); + + await expect(inboxRow(page, 'Team Payroll Summary')).toBeVisible(); + + await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Team&status=COMPLETED`); + + await expect(inboxRow(page, 'Team Board Minutes')).toBeVisible(); + }); + + test('should redirect unauthenticated users away from the inbox', async ({ page }) => { + await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Confidential&status=COMPLETED`); + + await expect(page).toHaveURL(/\/signin/); + }); +}); diff --git a/packages/trpc/server/document-router/find-inbox.ts b/packages/trpc/server/document-router/find-inbox.ts index 13e360a1d..72d7a39a3 100644 --- a/packages/trpc/server/document-router/find-inbox.ts +++ b/packages/trpc/server/document-router/find-inbox.ts @@ -5,13 +5,13 @@ import type { Envelope, Prisma } from '@prisma/client'; import { DocumentStatus, EnvelopeType, RecipientRole } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; -import { ZFindInboxRequestSchema, ZFindInboxResponseSchema } from './find-inbox.types'; +import { type TInboxStatus, ZFindInboxRequestSchema, ZFindInboxResponseSchema } from './find-inbox.types'; export const findInboxRoute = authenticatedProcedure .input(ZFindInboxRequestSchema) .output(ZFindInboxResponseSchema) .query(async ({ input, ctx }) => { - const { page, perPage } = input; + const { page, perPage, query, status } = input; const userId = ctx.user.id; @@ -19,6 +19,8 @@ export const findInboxRoute = authenticatedProcedure userId, page, perPage, + query, + status, }); return { @@ -31,13 +33,22 @@ export type FindInboxOptions = { userId: number; page?: number; perPage?: number; + /** + * Case insensitive search against the document title. + */ + query?: string; + + /** + * Restrict results to a single status. When omitted, every non-draft status is returned. + */ + status?: TInboxStatus; orderBy?: { column: keyof Omit; direction: 'asc' | 'desc'; }; }; -export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: FindInboxOptions) => { +export const findInbox = async ({ userId, page = 1, perPage = 10, query = '', status, orderBy }: FindInboxOptions) => { const user = await prisma.user.findFirstOrThrow({ where: { id: userId, @@ -50,10 +61,11 @@ export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: Fin const orderByColumn = orderBy?.column ?? 'createdAt'; const orderByDirection = orderBy?.direction ?? 'desc'; + const searchQuery = query.trim(); const whereClause: Prisma.EnvelopeWhereInput = { type: EnvelopeType.DOCUMENT, - status: { + status: status ?? { not: DocumentStatus.DRAFT, }, deletedAt: null, @@ -67,6 +79,13 @@ export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: Fin }, }; + if (searchQuery.length > 0) { + whereClause.title = { + contains: searchQuery, + mode: 'insensitive', + }; + } + const [data, count] = await Promise.all([ prisma.envelope.findMany({ where: whereClause, diff --git a/packages/trpc/server/document-router/find-inbox.types.ts b/packages/trpc/server/document-router/find-inbox.types.ts index 76e1855a9..ddbcebb55 100644 --- a/packages/trpc/server/document-router/find-inbox.types.ts +++ b/packages/trpc/server/document-router/find-inbox.types.ts @@ -2,12 +2,33 @@ import { ZDocumentManySchema } from '@documenso/lib/types/document'; import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; -import type { z } from 'zod'; +import { DocumentStatus } from '@prisma/client'; +import { z } from 'zod'; -export const ZFindInboxRequestSchema = ZFindSearchParamsSchema; +/** + * The statuses that can be filtered by in the inbox. + * + * Every document status except DRAFT, since drafts have not been sent to + * recipients yet and must never be visible in the inbox. + */ +export const INBOX_STATUSES = [ + DocumentStatus.PENDING, + DocumentStatus.COMPLETED, + DocumentStatus.REJECTED, + DocumentStatus.CANCELLED, +] as const; + +export const ZInboxStatusSchema = z.enum(INBOX_STATUSES); + +export type TInboxStatus = z.infer; + +export const ZFindInboxRequestSchema = ZFindSearchParamsSchema.extend({ + status: ZInboxStatusSchema.describe('Filter the inbox by document status.').optional(), +}); export const ZFindInboxResponseSchema = ZFindResultResponse.extend({ data: ZDocumentManySchema.array(), }); +export type TFindInboxRequest = z.infer; export type TFindInboxResponse = z.infer;