mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
1596 lines
54 KiB
TypeScript
1596 lines
54 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import type { Team, User } from '@prisma/client';
|
|
|
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
|
import { prisma } from '@documenso/prisma';
|
|
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
|
import {
|
|
seedBlankDocument,
|
|
seedCompletedDocument,
|
|
seedDocuments,
|
|
seedDraftDocument,
|
|
seedPendingDocument,
|
|
} from '@documenso/prisma/seed/documents';
|
|
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
|
|
import { seedUser } from '@documenso/prisma/seed/users';
|
|
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types';
|
|
|
|
import { apiSignin } from '../../fixtures/authentication';
|
|
|
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
|
|
|
|
test.describe.configure({
|
|
mode: 'parallel',
|
|
});
|
|
|
|
// Helper to make authenticated GET requests to the find documents endpoint.
|
|
const findDocuments = async (
|
|
request: import('@playwright/test').APIRequestContext,
|
|
token: string,
|
|
params: Record<string, string> = {},
|
|
) => {
|
|
const searchParams = new URLSearchParams(params);
|
|
const url = `${baseUrl}/document${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
|
|
|
const res = await request.get(url, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
return {
|
|
res,
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
json: res.ok() ? ((await res.json()) as TFindDocumentsResponse) : null,
|
|
};
|
|
};
|
|
|
|
test.describe('Find Documents API - Personal Context', () => {
|
|
let userA: User, teamA: Team, tokenA: string;
|
|
let userB: User, teamB: Team, tokenB: string;
|
|
|
|
test.beforeEach(async () => {
|
|
({ user: userA, team: teamA } = await seedUser());
|
|
({ token: tokenA } = await createApiToken({
|
|
userId: userA.id,
|
|
teamId: teamA.id,
|
|
tokenName: 'tokenA',
|
|
expiresIn: null,
|
|
}));
|
|
|
|
({ user: userB, team: teamB } = await seedUser());
|
|
({ token: tokenB } = await createApiToken({
|
|
userId: userB.id,
|
|
teamId: teamB.id,
|
|
tokenName: 'tokenB',
|
|
expiresIn: null,
|
|
}));
|
|
});
|
|
|
|
test('should return empty results when no documents exist', async ({ request }) => {
|
|
const { res, json } = await findDocuments(request, tokenA);
|
|
|
|
expect(res.ok()).toBeTruthy();
|
|
expect(json).toBeDefined();
|
|
expect(json!.data).toHaveLength(0);
|
|
expect(json!.count).toBe(0);
|
|
expect(json!.currentPage).toBe(1);
|
|
expect(json!.totalPages).toBe(0);
|
|
});
|
|
|
|
test('should return only documents owned by the user and not the other user', async ({
|
|
request,
|
|
}) => {
|
|
// The v2 API token scopes to a team. A personal team token only returns
|
|
// docs belonging to that team — cross-team received docs are NOT included.
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'UserA Draft 1' },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'UserA Draft 2' },
|
|
});
|
|
await seedPendingDocument(userA, teamA.id, [userB], {
|
|
createDocumentOptions: { title: 'UserA Pending' },
|
|
});
|
|
|
|
await seedDraftDocument(userB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'UserB Draft 1' },
|
|
});
|
|
await seedPendingDocument(userB, teamB.id, [userA], {
|
|
createDocumentOptions: { title: 'UserB Pending' },
|
|
});
|
|
await seedCompletedDocument(userB, teamB.id, [userA], {
|
|
createDocumentOptions: { title: 'UserB Completed' },
|
|
});
|
|
|
|
const { json: jsonA } = await findDocuments(request, tokenA);
|
|
const { json: jsonB } = await findDocuments(request, tokenB);
|
|
|
|
const titlesA = jsonA!.data.map((d) => d.title);
|
|
// UserA sees only their own team's docs
|
|
expect(titlesA).toContain('UserA Draft 1');
|
|
expect(titlesA).toContain('UserA Draft 2');
|
|
expect(titlesA).toContain('UserA Pending');
|
|
// Cross-team received docs are NOT visible via personal team token
|
|
expect(titlesA).not.toContain('UserB Pending');
|
|
expect(titlesA).not.toContain('UserB Completed');
|
|
expect(titlesA).not.toContain('UserB Draft 1');
|
|
expect(jsonA!.count).toBe(3);
|
|
|
|
const titlesB = jsonB!.data.map((d) => d.title);
|
|
expect(titlesB).toContain('UserB Draft 1');
|
|
expect(titlesB).toContain('UserB Pending');
|
|
expect(titlesB).toContain('UserB Completed');
|
|
expect(titlesB).not.toContain('UserA Draft 1');
|
|
expect(titlesB).not.toContain('UserA Draft 2');
|
|
expect(titlesB).not.toContain('UserA Pending');
|
|
expect(jsonB!.count).toBe(3);
|
|
});
|
|
|
|
test('should only return documents belonging to the personal team, not cross-team received docs', async ({
|
|
request,
|
|
}) => {
|
|
// The v2 API scopes to a team. Cross-team docs where the user is a recipient
|
|
// are NOT returned — they belong to the sender's team, not the recipient's.
|
|
await seedPendingDocument(userB, teamB.id, [userA], {
|
|
createDocumentOptions: { title: 'Pending for A from B Team' },
|
|
});
|
|
await seedCompletedDocument(userB, teamB.id, [userA], {
|
|
createDocumentOptions: { title: 'Completed for A from B Team' },
|
|
});
|
|
|
|
// UserA's own docs (should be returned)
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'UserA Own Draft' },
|
|
});
|
|
await seedPendingDocument(userA, teamA.id, [userB], {
|
|
createDocumentOptions: { title: 'UserA Own Pending' },
|
|
});
|
|
|
|
const { json } = await findDocuments(request, tokenA);
|
|
const titles = json!.data.map((d) => d.title);
|
|
|
|
expect(titles).toContain('UserA Own Draft');
|
|
expect(titles).toContain('UserA Own Pending');
|
|
// Cross-team received docs NOT visible
|
|
expect(titles).not.toContain('Pending for A from B Team');
|
|
expect(titles).not.toContain('Completed for A from B Team');
|
|
expect(json!.count).toBe(2);
|
|
});
|
|
|
|
test('should NOT leak documents between unrelated users', async ({ request }) => {
|
|
const { user: userC, team: teamC } = await seedUser();
|
|
|
|
// Each user has their own docs
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'UserA Own Doc' },
|
|
});
|
|
|
|
await seedDraftDocument(userB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'UserB Own Doc' },
|
|
});
|
|
|
|
await seedDraftDocument(userC, teamC.id, [], {
|
|
createDocumentOptions: { title: 'UserC Private Draft' },
|
|
});
|
|
await seedPendingDocument(userC, teamC.id, [userC], {
|
|
createDocumentOptions: { title: 'UserC Pending' },
|
|
});
|
|
await seedCompletedDocument(userC, teamC.id, [userC], {
|
|
createDocumentOptions: { title: 'UserC Completed' },
|
|
});
|
|
|
|
const { json: jsonA } = await findDocuments(request, tokenA);
|
|
const { json: jsonB } = await findDocuments(request, tokenB);
|
|
|
|
// UserA should see only their own doc
|
|
expect(jsonA!.data).toHaveLength(1);
|
|
expect(jsonA!.data[0].title).toBe('UserA Own Doc');
|
|
|
|
// UserB should see only their own doc
|
|
expect(jsonB!.data).toHaveLength(1);
|
|
expect(jsonB!.data[0].title).toBe('UserB Own Doc');
|
|
});
|
|
|
|
test('should filter by status correctly across all statuses', async ({ request }) => {
|
|
const { user: userC } = await seedUser();
|
|
|
|
// Seed all three statuses with 2 docs each plus noise from received docs
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Draft 1' },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Draft 2' },
|
|
});
|
|
await seedPendingDocument(userA, teamA.id, [userB], {
|
|
createDocumentOptions: { title: 'Pending 1' },
|
|
});
|
|
await seedPendingDocument(userA, teamA.id, [userC], {
|
|
createDocumentOptions: { title: 'Pending 2' },
|
|
});
|
|
await seedCompletedDocument(userA, teamA.id, [userB], {
|
|
createDocumentOptions: { title: 'Completed 1' },
|
|
});
|
|
await seedCompletedDocument(userA, teamA.id, [userC], {
|
|
createDocumentOptions: { title: 'Completed 2' },
|
|
});
|
|
|
|
const { json: draftResults } = await findDocuments(request, tokenA, { status: 'DRAFT' });
|
|
expect(draftResults!.data).toHaveLength(2);
|
|
expect(draftResults!.data.every((d) => d.status === DocumentStatus.DRAFT)).toBe(true);
|
|
|
|
const { json: pendingResults } = await findDocuments(request, tokenA, { status: 'PENDING' });
|
|
expect(pendingResults!.data).toHaveLength(2);
|
|
expect(pendingResults!.data.every((d) => d.status === DocumentStatus.PENDING)).toBe(true);
|
|
|
|
const { json: completedResults } = await findDocuments(request, tokenA, {
|
|
status: 'COMPLETED',
|
|
});
|
|
expect(completedResults!.data).toHaveLength(2);
|
|
expect(completedResults!.data.every((d) => d.status === DocumentStatus.COMPLETED)).toBe(true);
|
|
});
|
|
|
|
test('should paginate correctly', async ({ request }) => {
|
|
// Create 5 documents
|
|
for (let i = 0; i < 5; i++) {
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: `Paginated Doc ${i}` },
|
|
});
|
|
}
|
|
|
|
// Also seed noise docs for userB to ensure isolation across pages
|
|
for (let i = 0; i < 3; i++) {
|
|
await seedDraftDocument(userB, teamB.id, [], {
|
|
createDocumentOptions: { title: `UserB Noise Doc ${i}` },
|
|
});
|
|
}
|
|
|
|
const { json: page1 } = await findDocuments(request, tokenA, { page: '1', perPage: '2' });
|
|
expect(page1!.data).toHaveLength(2);
|
|
expect(page1!.count).toBe(5);
|
|
expect(page1!.currentPage).toBe(1);
|
|
expect(page1!.totalPages).toBe(3);
|
|
expect(page1!.perPage).toBe(2);
|
|
|
|
const { json: page2 } = await findDocuments(request, tokenA, { page: '2', perPage: '2' });
|
|
expect(page2!.data).toHaveLength(2);
|
|
expect(page2!.currentPage).toBe(2);
|
|
|
|
const { json: page3 } = await findDocuments(request, tokenA, { page: '3', perPage: '2' });
|
|
expect(page3!.data).toHaveLength(1);
|
|
expect(page3!.currentPage).toBe(3);
|
|
|
|
// Ensure no duplicates across pages and no B docs leaked
|
|
const allTitles = [
|
|
...page1!.data.map((d) => d.title),
|
|
...page2!.data.map((d) => d.title),
|
|
...page3!.data.map((d) => d.title),
|
|
];
|
|
const uniqueTitles = new Set(allTitles);
|
|
expect(uniqueTitles.size).toBe(5);
|
|
expect(allTitles.every((t) => t.startsWith('Paginated Doc'))).toBe(true);
|
|
});
|
|
|
|
test('should search by document title and exclude non-matching docs', async ({ request }) => {
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Quarterly Report 2024' },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Annual Budget Plan' },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Monthly Summary' },
|
|
});
|
|
|
|
const { json } = await findDocuments(request, tokenA, { query: 'Quarterly' });
|
|
expect(json!.data).toHaveLength(1);
|
|
expect(json!.data[0].title).toBe('Quarterly Report 2024');
|
|
});
|
|
|
|
test('should search by recipient email and not return docs with different recipients', async ({
|
|
request,
|
|
}) => {
|
|
const { user: userC } = await seedUser();
|
|
|
|
await seedPendingDocument(userA, teamA.id, [userB], {
|
|
createDocumentOptions: { title: 'Doc with Recipient B' },
|
|
});
|
|
await seedPendingDocument(userA, teamA.id, [userC], {
|
|
createDocumentOptions: { title: 'Doc with Recipient C' },
|
|
});
|
|
await seedPendingDocument(userA, teamA.id, [userB, userC], {
|
|
createDocumentOptions: { title: 'Doc with Both Recipients' },
|
|
});
|
|
|
|
const { json } = await findDocuments(request, tokenA, { query: userB.email });
|
|
// Should find the doc with B and the doc with both, but not the doc with only C
|
|
expect(json!.data).toHaveLength(2);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Doc with Recipient B');
|
|
expect(titles).toContain('Doc with Both Recipients');
|
|
expect(titles).not.toContain('Doc with Recipient C');
|
|
});
|
|
|
|
test('should search case-insensitively', async ({ request }) => {
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Important Contract' },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Other Document' },
|
|
});
|
|
|
|
const { json: lowerCase } = await findDocuments(request, tokenA, {
|
|
query: 'important contract',
|
|
});
|
|
expect(lowerCase!.data).toHaveLength(1);
|
|
expect(lowerCase!.data[0].title).toBe('Important Contract');
|
|
|
|
const { json: upperCase } = await findDocuments(request, tokenA, {
|
|
query: 'IMPORTANT CONTRACT',
|
|
});
|
|
expect(upperCase!.data).toHaveLength(1);
|
|
expect(upperCase!.data[0].title).toBe('Important Contract');
|
|
});
|
|
|
|
test('should order by createdAt descending by default', async ({ request }) => {
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'First Created' },
|
|
});
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 50);
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Second Created' },
|
|
});
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 50);
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Third Created' },
|
|
});
|
|
|
|
const { json } = await findDocuments(request, tokenA);
|
|
expect(json!.data).toHaveLength(3);
|
|
expect(json!.data[0].title).toBe('Third Created');
|
|
expect(json!.data[1].title).toBe('Second Created');
|
|
expect(json!.data[2].title).toBe('First Created');
|
|
});
|
|
|
|
test('should support ascending order', async ({ request }) => {
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'First Created' },
|
|
});
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 50);
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Second Created' },
|
|
});
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 50);
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Third Created' },
|
|
});
|
|
|
|
const { json } = await findDocuments(request, tokenA, {
|
|
orderByColumn: 'createdAt',
|
|
orderByDirection: 'asc',
|
|
});
|
|
|
|
expect(json!.data[0].title).toBe('First Created');
|
|
expect(json!.data[1].title).toBe('Second Created');
|
|
expect(json!.data[2].title).toBe('Third Created');
|
|
});
|
|
|
|
test('owner should see all recipient tokens on their documents', async ({ request }) => {
|
|
// Full token masking (non-owner sees masked tokens) can't be tested via API
|
|
// since only ADMIN/MANAGER can create tokens and they have full visibility.
|
|
// This test verifies the owner sees all tokens; masking is tested in the UI file.
|
|
const { user: recipient1 } = await seedUser();
|
|
const { user: recipient2 } = await seedUser();
|
|
|
|
await seedPendingDocument(userA, teamA.id, [recipient1, recipient2], {
|
|
createDocumentOptions: { title: 'Token Visibility Test' },
|
|
});
|
|
|
|
const { json } = await findDocuments(request, tokenA);
|
|
const doc = json!.data.find((d) => d.title === 'Token Visibility Test');
|
|
expect(doc).toBeDefined();
|
|
expect(doc!.recipients.length).toBe(2);
|
|
// Owner should see all recipient tokens (not masked)
|
|
for (const r of doc!.recipients) {
|
|
expect(r.token).not.toBe('');
|
|
}
|
|
});
|
|
|
|
test('should only show root-level documents when no folderId is provided', async ({
|
|
request,
|
|
}) => {
|
|
const folder = await prisma.folder.create({
|
|
data: {
|
|
name: 'Test Folder',
|
|
teamId: teamA.id,
|
|
userId: userA.id,
|
|
type: 'DOCUMENT',
|
|
},
|
|
});
|
|
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Root Document 1', folderId: null },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Root Document 2', folderId: null },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Foldered Document 1', folderId: folder.id },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Foldered Document 2', folderId: folder.id },
|
|
});
|
|
|
|
const { json } = await findDocuments(request, tokenA);
|
|
expect(json!.count).toBe(2);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Root Document 1');
|
|
expect(titles).toContain('Root Document 2');
|
|
expect(titles).not.toContain('Foldered Document 1');
|
|
expect(titles).not.toContain('Foldered Document 2');
|
|
});
|
|
|
|
test('should filter by folderId and not show root or other folder docs', async ({ request }) => {
|
|
const folder1 = await prisma.folder.create({
|
|
data: {
|
|
name: 'Folder 1',
|
|
teamId: teamA.id,
|
|
userId: userA.id,
|
|
type: 'DOCUMENT',
|
|
},
|
|
});
|
|
const folder2 = await prisma.folder.create({
|
|
data: {
|
|
name: 'Folder 2',
|
|
teamId: teamA.id,
|
|
userId: userA.id,
|
|
type: 'DOCUMENT',
|
|
},
|
|
});
|
|
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Root Document', folderId: null },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Folder1 Document', folderId: folder1.id },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Folder2 Document', folderId: folder2.id },
|
|
});
|
|
|
|
const { json } = await findDocuments(request, tokenA, { folderId: folder1.id });
|
|
expect(json!.data).toHaveLength(1);
|
|
expect(json!.data[0].title).toBe('Folder1 Document');
|
|
});
|
|
|
|
test('should return correct response schema fields', async ({ request }) => {
|
|
await seedPendingDocument(userA, teamA.id, [userB], {
|
|
createDocumentOptions: { title: 'Schema Check Doc' },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Schema Check Draft' },
|
|
});
|
|
|
|
const { json } = await findDocuments(request, tokenA);
|
|
expect(json!.count).toBe(2);
|
|
expect(json!.currentPage).toBeDefined();
|
|
expect(json!.perPage).toBeDefined();
|
|
expect(json!.totalPages).toBeDefined();
|
|
|
|
const doc = json!.data.find((d) => d.title === 'Schema Check Doc')!;
|
|
expect(doc.id).toBeDefined();
|
|
expect(doc.status).toBe(DocumentStatus.PENDING);
|
|
expect(doc.createdAt).toBeDefined();
|
|
expect(doc.updatedAt).toBeDefined();
|
|
expect(doc.userId).toBe(userA.id);
|
|
expect(doc.teamId).toBe(teamA.id);
|
|
expect(doc.user).toBeDefined();
|
|
expect(doc.user.id).toBe(userA.id);
|
|
expect(doc.user.email).toBe(userA.email);
|
|
expect(doc.recipients).toHaveLength(1);
|
|
expect(doc.recipients[0].email).toBe(userB.email);
|
|
});
|
|
|
|
test('should not return deleted documents but should return non-deleted ones', async ({
|
|
request,
|
|
}) => {
|
|
const deletedDoc = await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Deleted Document' },
|
|
});
|
|
await prisma.envelope.update({
|
|
where: { id: deletedDoc.id },
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Active Document 1' },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Active Document 2' },
|
|
});
|
|
|
|
const { json } = await findDocuments(request, tokenA);
|
|
expect(json!.count).toBe(2);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Active Document 1');
|
|
expect(titles).toContain('Active Document 2');
|
|
expect(titles).not.toContain('Deleted Document');
|
|
});
|
|
|
|
test('should search by externalId', async ({ request }) => {
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: {
|
|
title: 'External ID Doc',
|
|
externalId: 'EXT-12345-UNIQUE',
|
|
},
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Other Doc 1' },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Other Doc 2' },
|
|
});
|
|
|
|
const { json } = await findDocuments(request, tokenA, { query: 'EXT-12345-UNIQUE' });
|
|
expect(json!.data).toHaveLength(1);
|
|
expect(json!.data[0].title).toBe('External ID Doc');
|
|
});
|
|
|
|
test('should search by recipient name', async ({ request }) => {
|
|
const { user: recipient } = await seedUser({ name: 'Unique Recipient Name' });
|
|
const { user: otherRecipient } = await seedUser({ name: 'Other Person' });
|
|
|
|
await seedPendingDocument(userA, teamA.id, [recipient], {
|
|
createDocumentOptions: { title: 'Doc for Unique Person' },
|
|
});
|
|
await seedPendingDocument(userA, teamA.id, [otherRecipient], {
|
|
createDocumentOptions: { title: 'Doc for Other Person' },
|
|
});
|
|
|
|
const { json } = await findDocuments(request, tokenA, { query: 'Unique Recipient' });
|
|
expect(json!.data).toHaveLength(1);
|
|
expect(json!.data[0].title).toBe('Doc for Unique Person');
|
|
});
|
|
});
|
|
|
|
test.describe('Find Documents API - Team Context', () => {
|
|
test('should return team documents for team members and exclude non-team docs', async ({
|
|
request,
|
|
}) => {
|
|
const { team, owner } = await seedTeam();
|
|
|
|
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
|
const { user: outsideUser, team: outsideTeam } = await seedUser();
|
|
|
|
// Team docs
|
|
await seedDocuments([
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [outsideUser],
|
|
type: DocumentStatus.PENDING,
|
|
documentOptions: { title: 'Team Doc 1' },
|
|
},
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [],
|
|
type: DocumentStatus.DRAFT,
|
|
documentOptions: { title: 'Team Doc 2' },
|
|
},
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [outsideUser],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: { title: 'Team Doc 3' },
|
|
},
|
|
]);
|
|
|
|
// Non-team docs (noise - should NOT appear)
|
|
await seedDocuments([
|
|
{
|
|
sender: outsideUser,
|
|
teamId: outsideTeam.id,
|
|
recipients: [],
|
|
type: DocumentStatus.DRAFT,
|
|
documentOptions: { title: 'Outside Draft' },
|
|
},
|
|
{
|
|
sender: outsideUser,
|
|
teamId: outsideTeam.id,
|
|
recipients: [member],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: { title: 'Outside Completed with Member as Recipient' },
|
|
},
|
|
]);
|
|
|
|
const { token: memberToken } = await createApiToken({
|
|
userId: member.id,
|
|
teamId: team.id,
|
|
tokenName: 'member-token',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findDocuments(request, memberToken);
|
|
expect(json!.data.length).toBe(3);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Team Doc 1');
|
|
expect(titles).toContain('Team Doc 2');
|
|
expect(titles).toContain('Team Doc 3');
|
|
expect(titles).not.toContain('Outside Draft');
|
|
});
|
|
|
|
test('should NOT leak team documents to non-members', async ({ request }) => {
|
|
const { team, owner } = await seedTeam();
|
|
const { user: outsideUser, team: outsideTeam } = await seedUser();
|
|
|
|
// Team docs
|
|
await seedDocuments([
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [],
|
|
type: DocumentStatus.DRAFT,
|
|
documentOptions: { title: 'Secret Team Draft' },
|
|
},
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: { title: 'Secret Team Completed' },
|
|
},
|
|
]);
|
|
|
|
// Outside user's own docs (positive control)
|
|
await seedDraftDocument(outsideUser, outsideTeam.id, [], {
|
|
createDocumentOptions: { title: 'Outside Own Doc' },
|
|
});
|
|
|
|
const { token: outsideToken } = await createApiToken({
|
|
userId: outsideUser.id,
|
|
teamId: outsideTeam.id,
|
|
tokenName: 'outside-token',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findDocuments(request, outsideToken);
|
|
const titles = json!.data.map((d) => d.title);
|
|
// Outside user should see their own doc but not team docs
|
|
expect(titles).toContain('Outside Own Doc');
|
|
expect(titles).not.toContain('Secret Team Draft');
|
|
expect(titles).not.toContain('Secret Team Completed');
|
|
});
|
|
|
|
test('should NOT show documents from other teams', async ({ request }) => {
|
|
const { team: teamX, owner: ownerX } = await seedTeam();
|
|
const { team: teamY, owner: ownerY } = await seedTeam();
|
|
|
|
// Multiple docs in each team
|
|
await seedDocuments([
|
|
{
|
|
sender: ownerX,
|
|
teamId: teamX.id,
|
|
recipients: [],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: { title: 'Team X Completed' },
|
|
},
|
|
{
|
|
sender: ownerX,
|
|
teamId: teamX.id,
|
|
recipients: [],
|
|
type: DocumentStatus.DRAFT,
|
|
documentOptions: { title: 'Team X Draft' },
|
|
},
|
|
{
|
|
sender: ownerY,
|
|
teamId: teamY.id,
|
|
recipients: [],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: { title: 'Team Y Completed' },
|
|
},
|
|
{
|
|
sender: ownerY,
|
|
teamId: teamY.id,
|
|
recipients: [],
|
|
type: DocumentStatus.DRAFT,
|
|
documentOptions: { title: 'Team Y Draft' },
|
|
},
|
|
]);
|
|
|
|
const { token: tokenX } = await createApiToken({
|
|
userId: ownerX.id,
|
|
teamId: teamX.id,
|
|
tokenName: 'teamX-token',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findDocuments(request, tokenX);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Team X Completed');
|
|
expect(titles).toContain('Team X Draft');
|
|
expect(titles).not.toContain('Team Y Completed');
|
|
expect(titles).not.toContain('Team Y Draft');
|
|
expect(json!.count).toBe(2);
|
|
});
|
|
|
|
test('should enforce visibility across admin and manager levels with adequate data', async ({
|
|
request,
|
|
}) => {
|
|
// Note: MEMBER role cannot create API tokens (requires MANAGE_TEAM permission).
|
|
// MEMBER visibility is tested in the UI test file instead.
|
|
const { team, owner } = await seedTeam();
|
|
|
|
const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
|
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
|
|
|
// Seed 2 docs per visibility level (6 total)
|
|
await seedDocuments([
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: { title: 'Everyone Doc 1', visibility: DocumentVisibility.EVERYONE },
|
|
},
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: { title: 'Everyone Doc 2', visibility: DocumentVisibility.EVERYONE },
|
|
},
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: {
|
|
title: 'Manager Doc 1',
|
|
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
|
},
|
|
},
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: {
|
|
title: 'Manager Doc 2',
|
|
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
|
},
|
|
},
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: { title: 'Admin Doc 1', visibility: DocumentVisibility.ADMIN },
|
|
},
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: { title: 'Admin Doc 2', visibility: DocumentVisibility.ADMIN },
|
|
},
|
|
]);
|
|
|
|
// Admin sees all 6
|
|
const { token: adminToken } = await createApiToken({
|
|
userId: admin.id,
|
|
teamId: team.id,
|
|
tokenName: 'admin-token',
|
|
expiresIn: null,
|
|
});
|
|
const { json: adminJson } = await findDocuments(request, adminToken);
|
|
expect(adminJson!.count).toBe(6);
|
|
|
|
// Manager sees 4 (Everyone + Manager)
|
|
const { token: managerToken } = await createApiToken({
|
|
userId: manager.id,
|
|
teamId: team.id,
|
|
tokenName: 'manager-token',
|
|
expiresIn: null,
|
|
});
|
|
const { json: managerJson } = await findDocuments(request, managerToken);
|
|
expect(managerJson!.count).toBe(4);
|
|
const managerTitles = managerJson!.data.map((d) => d.title);
|
|
expect(managerTitles).toContain('Everyone Doc 1');
|
|
expect(managerTitles).toContain('Manager Doc 1');
|
|
expect(managerTitles).not.toContain('Admin Doc 1');
|
|
});
|
|
|
|
test('document owner should see their document regardless of visibility even when other restricted docs are hidden', async ({
|
|
request,
|
|
}) => {
|
|
const { team, owner } = await seedTeam();
|
|
|
|
// Use MANAGER (can create tokens but can't see ADMIN-vis docs by default)
|
|
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
|
|
|
// Manager creates an ADMIN-only doc (they should still see it as owner)
|
|
// Owner creates an ADMIN-only doc (manager should NOT see this one)
|
|
await seedDocuments([
|
|
{
|
|
sender: manager,
|
|
teamId: team.id,
|
|
recipients: [],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: {
|
|
title: 'Manager Owned Admin Vis',
|
|
visibility: DocumentVisibility.ADMIN,
|
|
},
|
|
},
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: {
|
|
title: 'Owner Admin Vis (hidden from manager)',
|
|
visibility: DocumentVisibility.ADMIN,
|
|
},
|
|
},
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: {
|
|
title: 'Everyone Vis Control',
|
|
visibility: DocumentVisibility.EVERYONE,
|
|
},
|
|
},
|
|
]);
|
|
|
|
const { token: managerToken } = await createApiToken({
|
|
userId: manager.id,
|
|
teamId: team.id,
|
|
tokenName: 'manager-token',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findDocuments(request, managerToken);
|
|
const titles = json!.data.map((d) => d.title);
|
|
// Manager sees their own ADMIN doc + EVERYONE + MANAGER_AND_ABOVE docs, but NOT the owner's ADMIN doc
|
|
expect(titles).toContain('Manager Owned Admin Vis');
|
|
expect(titles).toContain('Everyone Vis Control');
|
|
expect(titles).not.toContain('Owner Admin Vis (hidden from manager)');
|
|
});
|
|
|
|
test('recipient should see document regardless of visibility even when other restricted docs are hidden', async ({
|
|
request,
|
|
}) => {
|
|
const { team, owner } = await seedTeam();
|
|
|
|
// Use MANAGER (can create tokens but can't see ADMIN-vis docs by default)
|
|
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
|
|
|
// Admin doc with manager as recipient (manager should see despite ADMIN visibility)
|
|
// Admin doc WITHOUT manager as recipient (manager should NOT see)
|
|
await seedDocuments([
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [manager],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: {
|
|
title: 'Admin Doc with Manager Recipient',
|
|
visibility: DocumentVisibility.ADMIN,
|
|
},
|
|
},
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: {
|
|
title: 'Admin Doc without Manager',
|
|
visibility: DocumentVisibility.ADMIN,
|
|
},
|
|
},
|
|
{
|
|
sender: owner,
|
|
teamId: team.id,
|
|
recipients: [],
|
|
type: DocumentStatus.COMPLETED,
|
|
documentOptions: {
|
|
title: 'Everyone Doc Control',
|
|
visibility: DocumentVisibility.EVERYONE,
|
|
},
|
|
},
|
|
]);
|
|
|
|
const { token: managerToken } = await createApiToken({
|
|
userId: manager.id,
|
|
teamId: team.id,
|
|
tokenName: 'manager-token',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findDocuments(request, managerToken);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Admin Doc with Manager Recipient');
|
|
expect(titles).toContain('Everyone Doc Control');
|
|
expect(titles).not.toContain('Admin Doc without Manager');
|
|
});
|
|
});
|
|
|
|
test.describe('Find Documents API - Team with Team Email', () => {
|
|
test('should show documents sent by team email and received by team email, but not external noise', async ({
|
|
request,
|
|
}) => {
|
|
const { team, owner } = await seedTeam();
|
|
|
|
const teamEmail = `team-email-${team.id}@test.documenso.com`;
|
|
await seedTeamEmail({ email: teamEmail, teamId: team.id });
|
|
|
|
const { user: externalUser, team: externalTeam } = await seedUser();
|
|
const { user: externalUser2, team: externalTeam2 } = await seedUser();
|
|
|
|
// Doc owned by team
|
|
await seedPendingDocument(owner, team.id, [externalUser], {
|
|
createDocumentOptions: { title: 'Team Owned Pending' },
|
|
});
|
|
|
|
// Doc sent TO team email (external sender)
|
|
await seedPendingDocument(externalUser, externalTeam.id, [teamEmail], {
|
|
createDocumentOptions: { title: 'Received by Team Email' },
|
|
});
|
|
await seedCompletedDocument(externalUser, externalTeam.id, [teamEmail], {
|
|
createDocumentOptions: { title: 'Completed for Team Email' },
|
|
});
|
|
|
|
// Draft sent to team email (should NOT show)
|
|
await seedDraftDocument(externalUser2, externalTeam2.id, [teamEmail], {
|
|
createDocumentOptions: { title: 'Draft To Team Email (hidden)' },
|
|
});
|
|
|
|
// Doc between two external users (noise)
|
|
await seedPendingDocument(externalUser, externalTeam.id, [externalUser2], {
|
|
createDocumentOptions: { title: 'External Noise Doc' },
|
|
});
|
|
|
|
const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
|
const { token: adminToken } = await createApiToken({
|
|
userId: admin.id,
|
|
teamId: team.id,
|
|
tokenName: 'admin-token',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findDocuments(request, adminToken);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Team Owned Pending');
|
|
expect(titles).toContain('Received by Team Email');
|
|
expect(titles).toContain('Completed for Team Email');
|
|
expect(titles).not.toContain('Draft To Team Email (hidden)');
|
|
expect(titles).not.toContain('External Noise Doc');
|
|
});
|
|
|
|
test('team email documents should respect visibility rules with adequate controls', async ({
|
|
request,
|
|
}) => {
|
|
const { team } = await seedTeam();
|
|
|
|
const teamEmail = `team-vis-email-${team.id}@test.documenso.com`;
|
|
await seedTeamEmail({ email: teamEmail, teamId: team.id });
|
|
|
|
const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
|
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
|
const { user: externalUser, team: externalTeam } = await seedUser();
|
|
|
|
// External user sends admin-only doc to team email
|
|
await seedPendingDocument(externalUser, externalTeam.id, [teamEmail], {
|
|
createDocumentOptions: {
|
|
title: 'Admin Email Doc',
|
|
visibility: DocumentVisibility.ADMIN,
|
|
},
|
|
});
|
|
|
|
// External user sends everyone-visible doc to team email
|
|
await seedPendingDocument(externalUser, externalTeam.id, [teamEmail], {
|
|
createDocumentOptions: {
|
|
title: 'Everyone Email Doc',
|
|
visibility: DocumentVisibility.EVERYONE,
|
|
},
|
|
});
|
|
|
|
// Admin should see both
|
|
const { token: adminToken } = await createApiToken({
|
|
userId: admin.id,
|
|
teamId: team.id,
|
|
tokenName: 'admin-token',
|
|
expiresIn: null,
|
|
});
|
|
const { json: adminJson } = await findDocuments(request, adminToken);
|
|
const adminTitles = adminJson!.data.map((d) => d.title);
|
|
expect(adminTitles).toContain('Admin Email Doc');
|
|
expect(adminTitles).toContain('Everyone Email Doc');
|
|
|
|
// Manager should see both (Everyone + Manager_and_above, and ADMIN email doc should not be visible)
|
|
const { token: managerToken } = await createApiToken({
|
|
userId: manager.id,
|
|
teamId: team.id,
|
|
tokenName: 'manager-token',
|
|
expiresIn: null,
|
|
});
|
|
const { json: managerJson } = await findDocuments(request, managerToken);
|
|
const managerTitles = managerJson!.data.map((d) => d.title);
|
|
expect(managerTitles).toContain('Everyone Email Doc');
|
|
expect(managerTitles).not.toContain('Admin Email Doc');
|
|
});
|
|
});
|
|
|
|
test.describe('Find Documents API - Deleted Document Handling', () => {
|
|
test('should not show soft-deleted documents for owner but show non-deleted ones', async ({
|
|
request,
|
|
}) => {
|
|
const { user, team } = await seedUser();
|
|
|
|
const deletedDoc = await seedPendingDocument(user, team.id, [], {
|
|
createDocumentOptions: { title: 'Soft Deleted by Owner' },
|
|
});
|
|
await prisma.envelope.update({
|
|
where: { id: deletedDoc.id },
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
|
|
await seedPendingDocument(user, team.id, [], {
|
|
createDocumentOptions: { title: 'Still Active Doc 1' },
|
|
});
|
|
await seedDraftDocument(user, team.id, [], {
|
|
createDocumentOptions: { title: 'Still Active Doc 2' },
|
|
});
|
|
|
|
const { token } = await createApiToken({
|
|
userId: user.id,
|
|
teamId: team.id,
|
|
tokenName: 'token',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findDocuments(request, token);
|
|
expect(json!.count).toBe(2);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Still Active Doc 1');
|
|
expect(titles).toContain('Still Active Doc 2');
|
|
expect(titles).not.toContain('Soft Deleted by Owner');
|
|
});
|
|
|
|
test('should not show documents where owner soft-deleted their copy in personal context', async ({
|
|
request,
|
|
}) => {
|
|
// In personal context, documentDeletedAt on recipient hides the doc for that user.
|
|
// Note: the v2 API scopes to a team, so we test this by having the owner
|
|
// soft-delete a doc from their own personal team.
|
|
const { user, team } = await seedUser();
|
|
|
|
const deletedDoc = await seedDraftDocument(user, team.id, [], {
|
|
createDocumentOptions: { title: 'Owner Soft Deleted' },
|
|
});
|
|
await prisma.envelope.update({
|
|
where: { id: deletedDoc.id },
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
|
|
// Non-deleted doc (positive control)
|
|
await seedDraftDocument(user, team.id, [], {
|
|
createDocumentOptions: { title: 'Owner Active Doc' },
|
|
});
|
|
|
|
const { token } = await createApiToken({
|
|
userId: user.id,
|
|
teamId: team.id,
|
|
tokenName: 'token',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findDocuments(request, token);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Owner Active Doc');
|
|
expect(titles).not.toContain('Owner Soft Deleted');
|
|
});
|
|
|
|
test('should not show deleted team documents for any team member but show non-deleted ones', async ({
|
|
request,
|
|
}) => {
|
|
const { team, owner } = await seedTeam();
|
|
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
|
|
|
const deletedDoc = await seedBlankDocument(owner, team.id, {
|
|
createDocumentOptions: { title: 'Deleted Team Doc' },
|
|
});
|
|
await prisma.envelope.update({
|
|
where: { id: deletedDoc.id },
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
|
|
await seedBlankDocument(owner, team.id, {
|
|
createDocumentOptions: { title: 'Active Team Doc 1' },
|
|
});
|
|
await seedBlankDocument(owner, team.id, {
|
|
createDocumentOptions: { title: 'Active Team Doc 2' },
|
|
});
|
|
|
|
const { token: memberToken } = await createApiToken({
|
|
userId: member.id,
|
|
teamId: team.id,
|
|
tokenName: 'member-token',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findDocuments(request, memberToken);
|
|
expect(json!.count).toBe(2);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Active Team Doc 1');
|
|
expect(titles).toContain('Active Team Doc 2');
|
|
expect(titles).not.toContain('Deleted Team Doc');
|
|
});
|
|
});
|
|
|
|
test.describe('Find Documents API - Edge Cases', () => {
|
|
test('should handle empty search query gracefully', async ({ request }) => {
|
|
const { user, team } = await seedUser();
|
|
|
|
await seedDraftDocument(user, team.id, [], {
|
|
createDocumentOptions: { title: 'Test Doc 1' },
|
|
});
|
|
await seedDraftDocument(user, team.id, [], {
|
|
createDocumentOptions: { title: 'Test Doc 2' },
|
|
});
|
|
|
|
const { token } = await createApiToken({
|
|
userId: user.id,
|
|
teamId: team.id,
|
|
tokenName: 'token',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findDocuments(request, token, { query: '' });
|
|
expect(json!.data).toHaveLength(2);
|
|
});
|
|
|
|
test('should handle page beyond total pages', async ({ request }) => {
|
|
const { user, team } = await seedUser();
|
|
|
|
await seedDraftDocument(user, team.id, [], {
|
|
createDocumentOptions: { title: 'Single Doc' },
|
|
});
|
|
|
|
const { token } = await createApiToken({
|
|
userId: user.id,
|
|
teamId: team.id,
|
|
tokenName: 'token',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findDocuments(request, token, { page: '999', perPage: '10' });
|
|
expect(json!.data).toHaveLength(0);
|
|
expect(json!.count).toBe(1);
|
|
expect(json!.totalPages).toBe(1);
|
|
});
|
|
|
|
test('should reject unauthenticated requests', async ({ request }) => {
|
|
const res = await request.get(`${baseUrl}/document`, {
|
|
headers: {},
|
|
});
|
|
|
|
expect(res.ok()).toBeFalsy();
|
|
expect(res.status()).toBe(401);
|
|
});
|
|
|
|
test('should reject invalid API tokens', async ({ request }) => {
|
|
const res = await request.get(`${baseUrl}/document`, {
|
|
headers: { Authorization: 'Bearer invalid_token_here' },
|
|
});
|
|
|
|
expect(res.ok()).toBeFalsy();
|
|
});
|
|
|
|
test('personal documents should not appear in team context even with adequate team data', async ({
|
|
request,
|
|
}) => {
|
|
const { team, owner } = await seedTeam();
|
|
|
|
const teamMember = await seedTeamMember({
|
|
teamId: team.id,
|
|
role: TeamMemberRole.ADMIN,
|
|
});
|
|
|
|
// Find member's personal team (seedUser creates an org with ownerUserId set)
|
|
const teamMemberOrg = await prisma.organisation.findFirstOrThrow({
|
|
where: {
|
|
ownerUserId: teamMember.id,
|
|
},
|
|
include: {
|
|
teams: true,
|
|
},
|
|
});
|
|
|
|
const memberPersonalTeamId = teamMemberOrg.teams[0].id;
|
|
|
|
// Multiple personal docs
|
|
await seedDraftDocument(teamMember, memberPersonalTeamId, [], {
|
|
createDocumentOptions: { title: 'Personal Doc 1' },
|
|
});
|
|
await seedDraftDocument(teamMember, memberPersonalTeamId, [], {
|
|
createDocumentOptions: { title: 'Personal Doc 2' },
|
|
});
|
|
|
|
// Multiple team docs
|
|
await seedDraftDocument(teamMember, team.id, [], {
|
|
createDocumentOptions: { title: 'Team Doc by Member 1' },
|
|
});
|
|
await seedDraftDocument(owner, team.id, [], {
|
|
createDocumentOptions: { title: 'Team Doc by Owner' },
|
|
});
|
|
|
|
const { token: teamToken } = await createApiToken({
|
|
userId: owner.id,
|
|
teamId: team.id,
|
|
tokenName: 'team-token',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findDocuments(request, teamToken);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Team Doc by Member 1');
|
|
expect(titles).toContain('Team Doc by Owner');
|
|
expect(titles).not.toContain('Personal Doc 1');
|
|
expect(titles).not.toContain('Personal Doc 2');
|
|
expect(json!.count).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ─── Adversarial / Parameter Manipulation Tests ──────────────────────────────
|
|
// These tests target attack vectors where an authenticated user attempts to
|
|
// access data they shouldn't by manipulating request parameters.
|
|
// Session-based tests hit the tRPC endpoint with GET (queries require GET, not POST)
|
|
// and pass the x-team-id header to simulate header spoofing.
|
|
|
|
const trpcQuery = async (
|
|
page: import('@playwright/test').Page,
|
|
route: string,
|
|
teamId: number,
|
|
input: Record<string, unknown> = {},
|
|
) => {
|
|
const inputParam = encodeURIComponent(JSON.stringify({ json: input }));
|
|
const url = `${WEBAPP_BASE_URL}/api/trpc/${route}?input=${inputParam}`;
|
|
|
|
return page.context().request.get(url, {
|
|
headers: {
|
|
'x-team-id': String(teamId),
|
|
},
|
|
});
|
|
};
|
|
|
|
test.describe('Find Documents API - Adversarial: x-team-id Header Spoofing', () => {
|
|
test('should reject request when user spoofs x-team-id to a team they do not belong to', async ({
|
|
page,
|
|
}) => {
|
|
// Setup: two separate teams with documents
|
|
const { team: teamA, owner: ownerA } = await seedTeam();
|
|
const { team: teamB, owner: ownerB } = await seedTeam();
|
|
|
|
await seedDraftDocument(ownerA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Secret TeamA Doc 1' },
|
|
});
|
|
await seedDraftDocument(ownerA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Secret TeamA Doc 2' },
|
|
});
|
|
await seedDraftDocument(ownerB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'TeamB Own Doc' },
|
|
});
|
|
|
|
// Sign in as ownerB (who has NO access to teamA)
|
|
await apiSignin({ page, email: ownerB.email });
|
|
|
|
// Attempt to query findDocumentsInternal with x-team-id pointing to teamA
|
|
const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, {
|
|
page: 1,
|
|
perPage: 100,
|
|
});
|
|
|
|
// Should be rejected — ownerB is not a member of teamA
|
|
expect(res.ok()).toBeFalsy();
|
|
expect(res.status()).toBe(404);
|
|
});
|
|
|
|
test('should return only own team data when user provides their legitimate x-team-id (positive control)', async ({
|
|
page,
|
|
}) => {
|
|
const { team: teamA, owner: ownerA } = await seedTeam();
|
|
const { team: teamB, owner: ownerB } = await seedTeam();
|
|
|
|
await seedDraftDocument(ownerA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'TeamA Legit Doc' },
|
|
});
|
|
await seedDraftDocument(ownerB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'TeamB Legit Doc' },
|
|
});
|
|
|
|
// Sign in as ownerA
|
|
await apiSignin({ page, email: ownerA.email });
|
|
|
|
// Query with legitimate x-team-id
|
|
const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, {
|
|
page: 1,
|
|
perPage: 100,
|
|
});
|
|
|
|
expect(res.ok()).toBeTruthy();
|
|
const data = await res.json();
|
|
const docs = data.result.data.json.data;
|
|
const titles = docs.map((d: { title: string }) => d.title);
|
|
expect(titles).toContain('TeamA Legit Doc');
|
|
expect(titles).not.toContain('TeamB Legit Doc');
|
|
});
|
|
|
|
test('team member should not access another team via x-team-id even if they belong to a different team', async ({
|
|
page,
|
|
}) => {
|
|
// User belongs to teamA but NOT teamB — tries to access teamB via header
|
|
const { team: teamA, owner: ownerA } = await seedTeam();
|
|
const { team: teamB, owner: ownerB } = await seedTeam();
|
|
|
|
const member = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.ADMIN });
|
|
|
|
await seedDraftDocument(ownerB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'TeamB Secret' },
|
|
});
|
|
await seedDraftDocument(ownerA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'TeamA Doc' },
|
|
});
|
|
|
|
// Sign in as member (belongs to teamA only)
|
|
await apiSignin({ page, email: member.email });
|
|
|
|
// Attempt to access teamB
|
|
const res = await trpcQuery(page, 'document.findDocumentsInternal', teamB.id);
|
|
|
|
expect(res.ok()).toBeFalsy();
|
|
expect(res.status()).toBe(404);
|
|
});
|
|
});
|
|
|
|
test.describe('Find Documents API - Adversarial: Cross-Team folderId', () => {
|
|
test('should NOT return documents from another team when folderId belongs to that team', async ({
|
|
request,
|
|
}) => {
|
|
// Setup: two teams each with a folder and documents
|
|
const { user: userA, team: teamA } = await seedUser();
|
|
const { user: userB, team: teamB } = await seedUser();
|
|
|
|
const folderA = await prisma.folder.create({
|
|
data: {
|
|
name: 'Team A Folder',
|
|
teamId: teamA.id,
|
|
userId: userA.id,
|
|
type: 'DOCUMENT',
|
|
},
|
|
});
|
|
|
|
const folderB = await prisma.folder.create({
|
|
data: {
|
|
name: 'Team B Folder',
|
|
teamId: teamB.id,
|
|
userId: userB.id,
|
|
type: 'DOCUMENT',
|
|
},
|
|
});
|
|
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'TeamA Folder Doc', folderId: folderA.id },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'TeamA Root Doc' },
|
|
});
|
|
await seedDraftDocument(userB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'TeamB Folder Doc', folderId: folderB.id },
|
|
});
|
|
|
|
const { token: tokenA } = await createApiToken({
|
|
userId: userA.id,
|
|
teamId: teamA.id,
|
|
tokenName: 'tokenA',
|
|
expiresIn: null,
|
|
});
|
|
|
|
// UserA tries to query with teamB's folderId — should return empty, not teamB's docs
|
|
const { json } = await findDocuments(request, tokenA, { folderId: folderB.id });
|
|
expect(json!.data).toHaveLength(0);
|
|
expect(json!.count).toBe(0);
|
|
|
|
// Positive control: querying own folder works
|
|
const { json: ownFolder } = await findDocuments(request, tokenA, { folderId: folderA.id });
|
|
expect(ownFolder!.data).toHaveLength(1);
|
|
expect(ownFolder!.data[0].title).toBe('TeamA Folder Doc');
|
|
});
|
|
|
|
test('cross-team folderId via session/tRPC should also return empty', async ({ page }) => {
|
|
const { team: teamA, owner: ownerA } = await seedTeam();
|
|
const { team: teamB, owner: ownerB } = await seedTeam();
|
|
|
|
const folderB = await prisma.folder.create({
|
|
data: {
|
|
name: 'Target Folder',
|
|
teamId: teamB.id,
|
|
userId: ownerB.id,
|
|
type: 'DOCUMENT',
|
|
},
|
|
});
|
|
|
|
await seedDraftDocument(ownerB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'Folder Target Doc', folderId: folderB.id },
|
|
});
|
|
|
|
// Sign in as ownerA, request with own team but teamB's folderId
|
|
await apiSignin({ page, email: ownerA.email });
|
|
|
|
const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, {
|
|
folderId: folderB.id,
|
|
page: 1,
|
|
perPage: 100,
|
|
});
|
|
|
|
expect(res.ok()).toBeTruthy();
|
|
const data = await res.json();
|
|
const docs = data.result.data.json.data;
|
|
expect(docs).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
test.describe('Find Documents API - Adversarial: Cross-Team senderIds', () => {
|
|
test('should NOT return documents when senderIds contains users from another team', async ({
|
|
page,
|
|
}) => {
|
|
const { team: teamA, owner: ownerA } = await seedTeam();
|
|
const { team: teamB, owner: ownerB } = await seedTeam();
|
|
|
|
// Both teams have documents
|
|
await seedDraftDocument(ownerA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'TeamA Doc by OwnerA' },
|
|
});
|
|
await seedDraftDocument(ownerB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'TeamB Doc by OwnerB' },
|
|
});
|
|
|
|
// Sign in as ownerA, try to use senderIds with ownerB's userId
|
|
await apiSignin({ page, email: ownerA.email });
|
|
|
|
const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, {
|
|
senderIds: [ownerB.id],
|
|
page: 1,
|
|
perPage: 100,
|
|
});
|
|
|
|
expect(res.ok()).toBeTruthy();
|
|
const data = await res.json();
|
|
const docs = data.result.data.json.data;
|
|
// senderIds narrows within team scope — ownerB is not on teamA, so no results
|
|
expect(docs).toHaveLength(0);
|
|
|
|
// Positive control: senderIds with own userId returns own docs
|
|
const res2 = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, {
|
|
senderIds: [ownerA.id],
|
|
page: 1,
|
|
perPage: 100,
|
|
});
|
|
|
|
expect(res2.ok()).toBeTruthy();
|
|
const data2 = await res2.json();
|
|
const docs2 = data2.result.data.json.data;
|
|
expect(docs2).toHaveLength(1);
|
|
expect(docs2[0].title).toBe('TeamA Doc by OwnerA');
|
|
});
|
|
|
|
test('senderIds with mix of valid and cross-team userIds should only return matching team docs', async ({
|
|
page,
|
|
}) => {
|
|
const { team, owner } = await seedTeam();
|
|
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
|
const { user: outsider } = await seedUser();
|
|
|
|
await seedDraftDocument(owner, team.id, [], {
|
|
createDocumentOptions: { title: 'Owner Doc' },
|
|
});
|
|
await seedDraftDocument(member, team.id, [], {
|
|
createDocumentOptions: { title: 'Member Doc' },
|
|
});
|
|
|
|
// Find outsider's personal team
|
|
const outsiderOrg = await prisma.organisation.findFirstOrThrow({
|
|
where: { ownerUserId: outsider.id },
|
|
include: { teams: true },
|
|
});
|
|
const outsiderTeamId = outsiderOrg.teams[0].id;
|
|
|
|
await seedDraftDocument(outsider, outsiderTeamId, [], {
|
|
createDocumentOptions: { title: 'Outsider Doc' },
|
|
});
|
|
|
|
await apiSignin({ page, email: owner.email });
|
|
|
|
// Include outsider.id in senderIds — should be silently ignored (no results from them)
|
|
const res = await trpcQuery(page, 'document.findDocumentsInternal', team.id, {
|
|
senderIds: [member.id, outsider.id],
|
|
page: 1,
|
|
perPage: 100,
|
|
});
|
|
|
|
expect(res.ok()).toBeTruthy();
|
|
const data = await res.json();
|
|
const docs = data.result.data.json.data;
|
|
const titles = docs.map((d: { title: string }) => d.title);
|
|
// Only member doc should appear — outsider's docs are on a different team
|
|
expect(titles).toContain('Member Doc');
|
|
expect(titles).not.toContain('Owner Doc'); // owner not in senderIds
|
|
expect(titles).not.toContain('Outsider Doc');
|
|
expect(docs).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
test.describe('Find Documents API - Adversarial: Cross-Team templateId', () => {
|
|
test('should NOT return documents from another team when filtering by their templateId', async ({
|
|
request,
|
|
}) => {
|
|
const { user: userA, team: teamA } = await seedUser();
|
|
const { user: userB, team: teamB } = await seedUser();
|
|
|
|
// Use a shared templateId integer (simulates a template that exists on teamB)
|
|
const fakeTemplateId = 999888;
|
|
|
|
// Create a doc in teamB with this templateId
|
|
await seedDraftDocument(userB, teamB.id, [], {
|
|
createDocumentOptions: {
|
|
title: 'TeamB Doc from Template',
|
|
templateId: fakeTemplateId,
|
|
},
|
|
});
|
|
|
|
// Create a doc in teamA with a different templateId (positive control)
|
|
const teamATemplateId = 999777;
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: {
|
|
title: 'TeamA Doc from Template',
|
|
templateId: teamATemplateId,
|
|
},
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'TeamA Regular Doc' },
|
|
});
|
|
|
|
const { token: tokenA } = await createApiToken({
|
|
userId: userA.id,
|
|
teamId: teamA.id,
|
|
tokenName: 'tokenA',
|
|
expiresIn: null,
|
|
});
|
|
|
|
// UserA tries to filter by teamB's templateId — should return empty, not teamB's docs
|
|
const { json } = await findDocuments(request, tokenA, {
|
|
templateId: String(fakeTemplateId),
|
|
});
|
|
expect(json!.data).toHaveLength(0);
|
|
expect(json!.count).toBe(0);
|
|
|
|
// Positive control: own templateId returns own docs
|
|
const { json: ownTemplate } = await findDocuments(request, tokenA, {
|
|
templateId: String(teamATemplateId),
|
|
});
|
|
expect(ownTemplate!.data).toHaveLength(1);
|
|
expect(ownTemplate!.data[0].title).toBe('TeamA Doc from Template');
|
|
});
|
|
});
|