mirror of
https://github.com/documenso/documenso.git
synced 2026-10-03 10:13:52 +10:00
feat: add inbox filters (#3372)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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<Envelope, 'envelope'>;
|
||||
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,
|
||||
|
||||
@@ -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<typeof ZInboxStatusSchema>;
|
||||
|
||||
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<typeof ZFindInboxRequestSchema>;
|
||||
export type TFindInboxResponse = z.infer<typeof ZFindInboxResponseSchema>;
|
||||
|
||||
Reference in New Issue
Block a user