mirror of
https://github.com/documenso/documenso.git
synced 2026-07-14 23:07:13 +10:00
1058 lines
38 KiB
TypeScript
1058 lines
38 KiB
TypeScript
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, EnvelopeType, TeamMemberRole } from '@documenso/prisma/client';
|
|
import {
|
|
seedBlankDocument,
|
|
seedCompletedDocument,
|
|
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 { TFindEnvelopesResponse } from '@documenso/trpc/server/envelope-router/find-envelopes.types';
|
|
import { expect, test } from '@playwright/test';
|
|
import type { Team, User } from '@prisma/client';
|
|
|
|
import { apiSignin } from '../../fixtures/authentication';
|
|
|
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
|
|
|
test.describe.configure({
|
|
mode: 'parallel',
|
|
});
|
|
|
|
// Helper to make authenticated GET requests to the find envelopes endpoint.
|
|
const findEnvelopes = async (
|
|
request: import('@playwright/test').APIRequestContext,
|
|
token: string,
|
|
params: Record<string, string> = {},
|
|
) => {
|
|
const searchParams = new URLSearchParams(params);
|
|
const url = `${baseUrl}/envelope${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 TFindEnvelopesResponse) : null,
|
|
};
|
|
};
|
|
|
|
// ─── Expected Output Tests ───────────────────────────────────────────────────
|
|
|
|
test.describe('Find Envelopes API - Basic', () => {
|
|
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 envelopes exist', async ({ request }) => {
|
|
const { json } = await findEnvelopes(request, tokenA);
|
|
expect(json!.data).toHaveLength(0);
|
|
expect(json!.count).toBe(0);
|
|
expect(json!.currentPage).toBe(1);
|
|
expect(json!.totalPages).toBe(0);
|
|
});
|
|
|
|
test('should return only envelopes owned by the user and not the other user', async ({ request }) => {
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'UserA Doc' },
|
|
});
|
|
await seedDraftDocument(userB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'UserB Doc' },
|
|
});
|
|
|
|
const { json: jsonA } = await findEnvelopes(request, tokenA);
|
|
expect(jsonA!.data).toHaveLength(1);
|
|
expect(jsonA!.data[0].title).toBe('UserA Doc');
|
|
|
|
const { json: jsonB } = await findEnvelopes(request, tokenB);
|
|
expect(jsonB!.data).toHaveLength(1);
|
|
expect(jsonB!.data[0].title).toBe('UserB Doc');
|
|
});
|
|
|
|
test('should NOT leak envelopes between unrelated users', async ({ request }) => {
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Secret A1' },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Secret A2' },
|
|
});
|
|
await seedDraftDocument(userB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'Secret B1' },
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, tokenB);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).not.toContain('Secret A1');
|
|
expect(titles).not.toContain('Secret A2');
|
|
expect(titles).toContain('Secret B1');
|
|
expect(json!.count).toBe(1);
|
|
});
|
|
|
|
test('should filter by status correctly', async ({ request }) => {
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Draft Doc' },
|
|
});
|
|
await seedPendingDocument(userA, teamA.id, [userB], {
|
|
createDocumentOptions: { title: 'Pending Doc' },
|
|
});
|
|
await seedCompletedDocument(userA, teamA.id, [userB], {
|
|
createDocumentOptions: { title: 'Completed Doc' },
|
|
});
|
|
|
|
// DRAFT only
|
|
const { json: draftJson } = await findEnvelopes(request, tokenA, { status: 'DRAFT' });
|
|
expect(draftJson!.data.every((d) => d.status === DocumentStatus.DRAFT)).toBe(true);
|
|
expect(draftJson!.data.some((d) => d.title === 'Draft Doc')).toBe(true);
|
|
expect(draftJson!.data.some((d) => d.title === 'Pending Doc')).toBe(false);
|
|
|
|
// PENDING only
|
|
const { json: pendingJson } = await findEnvelopes(request, tokenA, { status: 'PENDING' });
|
|
expect(pendingJson!.data.every((d) => d.status === DocumentStatus.PENDING)).toBe(true);
|
|
|
|
// COMPLETED only
|
|
const { json: completedJson } = await findEnvelopes(request, tokenA, { status: 'COMPLETED' });
|
|
expect(completedJson!.data.every((d) => d.status === DocumentStatus.COMPLETED)).toBe(true);
|
|
});
|
|
|
|
test('should filter by type (DOCUMENT vs TEMPLATE)', async ({ request }) => {
|
|
await seedBlankDocument(userA, teamA.id, {
|
|
createDocumentOptions: { title: 'A Document', type: EnvelopeType.DOCUMENT },
|
|
});
|
|
await seedBlankDocument(userA, teamA.id, {
|
|
createDocumentOptions: { title: 'A Template', type: EnvelopeType.TEMPLATE },
|
|
});
|
|
|
|
const { json: docJson } = await findEnvelopes(request, tokenA, { type: 'DOCUMENT' });
|
|
expect(docJson!.data.every((d) => d.type === EnvelopeType.DOCUMENT)).toBe(true);
|
|
expect(docJson!.data.some((d) => d.title === 'A Document')).toBe(true);
|
|
expect(docJson!.data.some((d) => d.title === 'A Template')).toBe(false);
|
|
|
|
const { json: templateJson } = await findEnvelopes(request, tokenA, { type: 'TEMPLATE' });
|
|
expect(templateJson!.data.every((d) => d.type === EnvelopeType.TEMPLATE)).toBe(true);
|
|
expect(templateJson!.data.some((d) => d.title === 'A Template')).toBe(true);
|
|
expect(templateJson!.data.some((d) => d.title === 'A Document')).toBe(false);
|
|
});
|
|
|
|
test('should paginate correctly', async ({ request }) => {
|
|
// Create 5 docs, paginate with perPage=2
|
|
for (let i = 1; i <= 5; i++) {
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: `Paginated Doc ${i}` },
|
|
});
|
|
}
|
|
|
|
const { json: page1 } = await findEnvelopes(request, tokenA, {
|
|
page: '1',
|
|
perPage: '2',
|
|
});
|
|
expect(page1!.data).toHaveLength(2);
|
|
expect(page1!.count).toBe(5);
|
|
expect(page1!.totalPages).toBe(3);
|
|
expect(page1!.currentPage).toBe(1);
|
|
|
|
const { json: page3 } = await findEnvelopes(request, tokenA, {
|
|
page: '3',
|
|
perPage: '2',
|
|
});
|
|
expect(page3!.data).toHaveLength(1);
|
|
expect(page3!.currentPage).toBe(3);
|
|
|
|
// Page beyond total
|
|
const { json: pageBeyond } = await findEnvelopes(request, tokenA, {
|
|
page: '10',
|
|
perPage: '2',
|
|
});
|
|
expect(pageBeyond!.data).toHaveLength(0);
|
|
});
|
|
|
|
test('should search by title', async ({ request }) => {
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Annual Budget Report' },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Quarterly Review' },
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, tokenA, { query: 'Budget' });
|
|
expect(json!.data).toHaveLength(1);
|
|
expect(json!.data[0].title).toBe('Annual Budget Report');
|
|
});
|
|
|
|
test('should search by externalId', async ({ request }) => {
|
|
await seedBlankDocument(userA, teamA.id, {
|
|
createDocumentOptions: { title: 'External Doc', externalId: 'ext-abc-123' },
|
|
});
|
|
await seedBlankDocument(userA, teamA.id, {
|
|
createDocumentOptions: { title: 'Other Doc', externalId: 'ext-xyz-789' },
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, tokenA, { query: 'abc-123' });
|
|
expect(json!.data).toHaveLength(1);
|
|
expect(json!.data[0].title).toBe('External Doc');
|
|
});
|
|
|
|
test('should search by recipient email and name', async ({ request }) => {
|
|
const { user: recipientUser } = await seedUser();
|
|
|
|
await seedPendingDocument(userA, teamA.id, [recipientUser], {
|
|
createDocumentOptions: { title: 'Doc with Specific Recipient' },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Doc without Recipients' },
|
|
});
|
|
|
|
// Search by recipient email
|
|
const { json: emailSearch } = await findEnvelopes(request, tokenA, {
|
|
query: recipientUser.email,
|
|
});
|
|
expect(emailSearch!.data).toHaveLength(1);
|
|
expect(emailSearch!.data[0].title).toBe('Doc with Specific Recipient');
|
|
|
|
// Search by recipient name
|
|
if (recipientUser.name) {
|
|
const { json: nameSearch } = await findEnvelopes(request, tokenA, {
|
|
query: recipientUser.name,
|
|
});
|
|
expect(nameSearch!.data.some((d) => d.title === 'Doc with Specific Recipient')).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('should search case-insensitively', async ({ request }) => {
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'UPPERCASE TITLE' },
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, tokenA, { query: 'uppercase title' });
|
|
expect(json!.data).toHaveLength(1);
|
|
expect(json!.data[0].title).toBe('UPPERCASE TITLE');
|
|
});
|
|
|
|
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' },
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, tokenA);
|
|
expect(json!.data).toHaveLength(2);
|
|
expect(json!.data[0].title).toBe('Second Created');
|
|
expect(json!.data[1].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' },
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, tokenA, {
|
|
orderByColumn: 'createdAt',
|
|
orderByDirection: 'asc',
|
|
});
|
|
expect(json!.data[0].title).toBe('First Created');
|
|
expect(json!.data[1].title).toBe('Second Created');
|
|
});
|
|
|
|
test('should filter by folderId and show root-level when no folderId', 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: 'In Folder', folderId: folder.id },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'At Root' },
|
|
});
|
|
|
|
// No folderId → root-level only (folderId: null)
|
|
const { json: rootJson } = await findEnvelopes(request, tokenA);
|
|
const rootTitles = rootJson!.data.map((d) => d.title);
|
|
expect(rootTitles).toContain('At Root');
|
|
expect(rootTitles).not.toContain('In Folder');
|
|
|
|
// With folderId → only docs in that folder
|
|
const { json: folderJson } = await findEnvelopes(request, tokenA, { folderId: folder.id });
|
|
expect(folderJson!.data).toHaveLength(1);
|
|
expect(folderJson!.data[0].title).toBe('In Folder');
|
|
});
|
|
|
|
test('should not return deleted envelopes', async ({ request }) => {
|
|
const doc = await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Deleted Doc' },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Active Doc' },
|
|
});
|
|
|
|
// Soft-delete
|
|
await prisma.envelope.update({
|
|
where: { id: doc.id },
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, tokenA);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Active Doc');
|
|
expect(titles).not.toContain('Deleted Doc');
|
|
});
|
|
|
|
test('should return correct response schema fields', async ({ request }) => {
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Schema Check' },
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, tokenA);
|
|
const envelope = json!.data[0];
|
|
|
|
// Pagination fields
|
|
expect(json!.count).toBeGreaterThan(0);
|
|
expect(json!.currentPage).toBe(1);
|
|
expect(json!.perPage).toBe(10);
|
|
expect(json!.totalPages).toBeGreaterThan(0);
|
|
|
|
// Envelope fields
|
|
expect(envelope.id).toBeDefined();
|
|
expect(envelope.title).toBe('Schema Check');
|
|
expect(envelope.status).toBe(DocumentStatus.DRAFT);
|
|
expect(envelope.type).toBe(EnvelopeType.DOCUMENT);
|
|
expect(envelope.createdAt).toBeDefined();
|
|
expect(envelope.updatedAt).toBeDefined();
|
|
expect(envelope.deletedAt).toBeNull();
|
|
|
|
// Included relations
|
|
expect(envelope.user).toBeDefined();
|
|
expect(envelope.user.id).toBe(userA.id);
|
|
expect(envelope.user.email).toBe(userA.email);
|
|
expect(envelope.recipients).toBeDefined();
|
|
expect(envelope.team).toBeDefined();
|
|
});
|
|
|
|
test('should reject unauthenticated requests', async ({ request }) => {
|
|
const { res } = await findEnvelopes(request, '');
|
|
expect(res.ok()).toBeFalsy();
|
|
});
|
|
|
|
test('should reject invalid API tokens', async ({ request }) => {
|
|
const { res } = await findEnvelopes(request, 'invalid-token-abc123');
|
|
expect(res.ok()).toBeFalsy();
|
|
});
|
|
});
|
|
|
|
// ─── Token Masking ───────────────────────────────────────────────────────────
|
|
|
|
test.describe('Find Envelopes API - Token Masking', () => {
|
|
test('owner should see all recipient tokens on their envelopes', async ({ request }) => {
|
|
const { user: owner, team } = await seedUser();
|
|
const { user: recipient } = await seedUser();
|
|
|
|
const { token } = await createApiToken({
|
|
userId: owner.id,
|
|
teamId: team.id,
|
|
tokenName: 'ownerToken',
|
|
expiresIn: null,
|
|
});
|
|
|
|
await seedPendingDocument(owner, team.id, [recipient], {
|
|
createDocumentOptions: { title: 'Owner Token Test' },
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, token);
|
|
const doc = json!.data.find((d) => d.title === 'Owner Token Test');
|
|
expect(doc).toBeDefined();
|
|
expect(doc!.recipients.length).toBeGreaterThan(0);
|
|
// Owner sees actual token values (non-empty)
|
|
doc!.recipients.forEach((r) => {
|
|
expect(r.token).not.toBe('');
|
|
});
|
|
});
|
|
|
|
test('non-owner team member should have recipient tokens masked', async ({ request }) => {
|
|
const { team, owner } = await seedTeam();
|
|
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
|
const { user: recipient } = await seedUser();
|
|
|
|
await seedPendingDocument(owner, team.id, [recipient], {
|
|
createDocumentOptions: { title: 'Masked Token Test' },
|
|
});
|
|
|
|
const { token: memberToken } = await createApiToken({
|
|
userId: member.id,
|
|
teamId: team.id,
|
|
tokenName: 'memberToken',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, memberToken);
|
|
const doc = json!.data.find((d) => d.title === 'Masked Token Test');
|
|
expect(doc).toBeDefined();
|
|
expect(doc!.recipients.length).toBeGreaterThan(0);
|
|
// Non-owner should see masked tokens (empty string)
|
|
doc!.recipients.forEach((r) => {
|
|
expect(r.token).toBe('');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Team Context & Visibility ───────────────────────────────────────────────
|
|
|
|
test.describe('Find Envelopes API - Team Context', () => {
|
|
// Regression test: findEnvelopes previously had `{ userId }` as a top-level OR branch with no
|
|
// teamId constraint, so personal docs leaked into team context. Fixed in the Kysely refactor.
|
|
test('should return team envelopes for team members and exclude personal docs', async ({ request }) => {
|
|
const { team, owner } = await seedTeam();
|
|
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
|
|
|
// Team docs
|
|
await seedDraftDocument(owner, team.id, [], {
|
|
createDocumentOptions: { title: 'Team Doc by Owner' },
|
|
});
|
|
await seedDraftDocument(member, team.id, [], {
|
|
createDocumentOptions: { title: 'Team Doc by Member' },
|
|
});
|
|
|
|
// Member's personal doc — should NOT appear in team context
|
|
const memberOrg = await prisma.organisation.findFirstOrThrow({
|
|
where: { ownerUserId: member.id },
|
|
include: { teams: true },
|
|
});
|
|
const memberPersonalTeamId = memberOrg.teams[0].id;
|
|
await seedDraftDocument(member, memberPersonalTeamId, [], {
|
|
createDocumentOptions: { title: 'Member Personal Doc' },
|
|
});
|
|
|
|
const { token: memberToken } = await createApiToken({
|
|
userId: member.id,
|
|
teamId: team.id,
|
|
tokenName: 'memberTeamToken',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, memberToken);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Team Doc by Owner');
|
|
expect(titles).toContain('Team Doc by Member');
|
|
expect(titles).not.toContain('Member Personal Doc');
|
|
});
|
|
|
|
test('should NOT show envelopes from other teams', async ({ request }) => {
|
|
const { team: teamX, owner: ownerX } = await seedTeam();
|
|
const { team: teamY, owner: ownerY } = await seedTeam();
|
|
|
|
await seedDraftDocument(ownerX, teamX.id, [], {
|
|
createDocumentOptions: { title: 'TeamX Doc' },
|
|
});
|
|
await seedDraftDocument(ownerY, teamY.id, [], {
|
|
createDocumentOptions: { title: 'TeamY Doc' },
|
|
});
|
|
|
|
const { token: tokenX } = await createApiToken({
|
|
userId: ownerX.id,
|
|
teamId: teamX.id,
|
|
tokenName: 'tokenX',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, tokenX);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('TeamX Doc');
|
|
expect(titles).not.toContain('TeamY Doc');
|
|
});
|
|
|
|
test('should NOT leak team envelopes to non-members', async ({ request }) => {
|
|
const { team, owner } = await seedTeam();
|
|
const { user: outsider, team: outsiderTeam } = await seedUser();
|
|
|
|
await seedDraftDocument(owner, team.id, [], {
|
|
createDocumentOptions: { title: 'Team Secret Doc' },
|
|
});
|
|
|
|
const { token: outsiderToken } = await createApiToken({
|
|
userId: outsider.id,
|
|
teamId: outsiderTeam.id,
|
|
tokenName: 'outsiderToken',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, outsiderToken);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).not.toContain('Team Secret Doc');
|
|
});
|
|
|
|
test('should enforce visibility: ADMIN sees all levels', async ({ request }) => {
|
|
const { team, owner } = await seedTeam();
|
|
const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
|
|
|
await seedDraftDocument(owner, team.id, [], {
|
|
createDocumentOptions: { title: 'Admin Vis', visibility: DocumentVisibility.ADMIN },
|
|
});
|
|
await seedDraftDocument(owner, team.id, [], {
|
|
createDocumentOptions: {
|
|
title: 'Manager Vis',
|
|
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
|
},
|
|
});
|
|
await seedDraftDocument(owner, team.id, [], {
|
|
createDocumentOptions: { title: 'Everyone Vis', visibility: DocumentVisibility.EVERYONE },
|
|
});
|
|
|
|
const { token: adminToken } = await createApiToken({
|
|
userId: admin.id,
|
|
teamId: team.id,
|
|
tokenName: 'adminToken',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, adminToken);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Admin Vis');
|
|
expect(titles).toContain('Manager Vis');
|
|
expect(titles).toContain('Everyone Vis');
|
|
});
|
|
|
|
test('should enforce visibility: MANAGER cannot see ADMIN-only docs', async ({ request }) => {
|
|
const { team, owner } = await seedTeam();
|
|
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
|
|
|
await seedDraftDocument(owner, team.id, [], {
|
|
createDocumentOptions: { title: 'Admin Only', visibility: DocumentVisibility.ADMIN },
|
|
});
|
|
await seedDraftDocument(owner, team.id, [], {
|
|
createDocumentOptions: {
|
|
title: 'Manager Visible',
|
|
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
|
},
|
|
});
|
|
await seedDraftDocument(owner, team.id, [], {
|
|
createDocumentOptions: {
|
|
title: 'Everyone Visible',
|
|
visibility: DocumentVisibility.EVERYONE,
|
|
},
|
|
});
|
|
|
|
const { token: managerToken } = await createApiToken({
|
|
userId: manager.id,
|
|
teamId: team.id,
|
|
tokenName: 'managerToken',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, managerToken);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).not.toContain('Admin Only');
|
|
expect(titles).toContain('Manager Visible');
|
|
expect(titles).toContain('Everyone Visible');
|
|
});
|
|
|
|
test('document owner should see their doc regardless of visibility', async ({ request }) => {
|
|
const { team, owner } = await seedTeam();
|
|
|
|
// Another user creates an ADMIN-only doc — owner shouldn't see it
|
|
const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
|
await seedDraftDocument(admin, team.id, [], {
|
|
createDocumentOptions: {
|
|
title: 'Admin Created Doc',
|
|
visibility: DocumentVisibility.ADMIN,
|
|
},
|
|
});
|
|
|
|
// Owner creates their own ADMIN-only doc — should see it because they own it
|
|
await seedDraftDocument(owner, team.id, [], {
|
|
createDocumentOptions: { title: 'Owner ADMIN Doc', visibility: DocumentVisibility.ADMIN },
|
|
});
|
|
|
|
// Owner is implicitly ADMIN of their own team, so let's test with a MANAGER member
|
|
// who owns an ADMIN-visibility doc
|
|
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
|
await seedDraftDocument(manager, team.id, [], {
|
|
createDocumentOptions: {
|
|
title: 'Manager Own ADMIN Doc',
|
|
visibility: DocumentVisibility.ADMIN,
|
|
},
|
|
});
|
|
|
|
const { token: managerToken } = await createApiToken({
|
|
userId: manager.id,
|
|
teamId: team.id,
|
|
tokenName: 'managerToken',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, managerToken);
|
|
const titles = json!.data.map((d) => d.title);
|
|
// Manager should see their own ADMIN doc (owner override)
|
|
expect(titles).toContain('Manager Own ADMIN Doc');
|
|
// Manager should NOT see admin's ADMIN doc (visibility restricted)
|
|
expect(titles).not.toContain('Admin Created Doc');
|
|
});
|
|
|
|
test('being a recipient does not override ADMIN visibility in the API', async ({ request }) => {
|
|
const { team, owner } = await seedTeam();
|
|
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
|
|
|
// Owner creates an ADMIN-only doc with manager as recipient
|
|
await seedPendingDocument(owner, team.id, [manager], {
|
|
createDocumentOptions: {
|
|
title: 'ADMIN Doc With Manager Recipient',
|
|
visibility: DocumentVisibility.ADMIN,
|
|
},
|
|
});
|
|
|
|
// Owner creates another ADMIN-only doc WITHOUT manager as recipient
|
|
await seedDraftDocument(owner, team.id, [], {
|
|
createDocumentOptions: {
|
|
title: 'ADMIN Doc Without Manager',
|
|
visibility: DocumentVisibility.ADMIN,
|
|
},
|
|
});
|
|
|
|
const { token: managerToken } = await createApiToken({
|
|
userId: manager.id,
|
|
teamId: team.id,
|
|
tokenName: 'managerToken',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, managerToken);
|
|
const titles = json!.data.map((d) => d.title);
|
|
// Unlike findDocuments (UI), the API does not let recipient status bypass visibility
|
|
expect(titles).not.toContain('ADMIN Doc With Manager Recipient');
|
|
expect(titles).not.toContain('ADMIN Doc Without Manager');
|
|
});
|
|
});
|
|
|
|
// ─── Team with Team Email ────────────────────────────────────────────────────
|
|
|
|
test.describe('Find Envelopes API - Team Email', () => {
|
|
test('should include envelopes received by team email from external senders', async ({ request }) => {
|
|
const { team, owner } = await seedTeam();
|
|
const teamEmailAddr = `team-find-env-${team.id}@test.documenso.com`;
|
|
await seedTeamEmail({ email: teamEmailAddr, teamId: team.id });
|
|
|
|
const { user: externalUser, team: externalTeam } = await seedUser();
|
|
|
|
// Regular team doc (should be included)
|
|
await seedDraftDocument(owner, team.id, [], {
|
|
createDocumentOptions: { title: 'Regular Team Doc' },
|
|
});
|
|
|
|
// Doc sent TO team email from external user (should be included — recipient match)
|
|
await seedPendingDocument(externalUser, externalTeam.id, [teamEmailAddr], {
|
|
createDocumentOptions: { title: 'Received by Team Email' },
|
|
});
|
|
|
|
// External noise (should NOT be included)
|
|
const { user: externalUser2 } = await seedUser();
|
|
await seedPendingDocument(externalUser, externalTeam.id, [externalUser2], {
|
|
createDocumentOptions: { title: 'External Noise Doc' },
|
|
});
|
|
|
|
const { token } = await createApiToken({
|
|
userId: owner.id,
|
|
teamId: team.id,
|
|
tokenName: 'ownerToken',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, token);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Regular Team Doc');
|
|
expect(titles).toContain('Received by Team Email');
|
|
expect(titles).not.toContain('External Noise Doc');
|
|
});
|
|
|
|
test('should NOT include external noise from other teams when team has team email', async ({ request }) => {
|
|
const { team: teamA, owner: ownerA } = await seedTeam();
|
|
const teamEmailAddr = `team-noise-${teamA.id}@test.documenso.com`;
|
|
await seedTeamEmail({ email: teamEmailAddr, teamId: teamA.id });
|
|
|
|
const { team: teamB, owner: ownerB } = await seedTeam();
|
|
|
|
await seedDraftDocument(ownerA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'TeamA Doc' },
|
|
});
|
|
await seedDraftDocument(ownerB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'TeamB External Doc' },
|
|
});
|
|
|
|
const { token: tokenA } = await createApiToken({
|
|
userId: ownerA.id,
|
|
teamId: teamA.id,
|
|
tokenName: 'tokenA',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, tokenA);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('TeamA Doc');
|
|
expect(titles).not.toContain('TeamB External Doc');
|
|
});
|
|
|
|
test('team email received docs bypass visibility for managers', async ({ request }) => {
|
|
const { team, owner } = await seedTeam();
|
|
const teamEmailAddr = `team-vis-env-${team.id}@test.documenso.com`;
|
|
await seedTeamEmail({ email: teamEmailAddr, teamId: team.id });
|
|
|
|
const { user: externalUser, team: externalTeam } = await seedUser();
|
|
|
|
// External user sends ADMIN-visibility doc to team email
|
|
await seedPendingDocument(externalUser, externalTeam.id, [teamEmailAddr], {
|
|
createDocumentOptions: {
|
|
title: 'External ADMIN to Team Email',
|
|
visibility: DocumentVisibility.ADMIN,
|
|
},
|
|
});
|
|
|
|
// External user sends EVERYONE-visibility doc to team email
|
|
await seedPendingDocument(externalUser, externalTeam.id, [teamEmailAddr], {
|
|
createDocumentOptions: {
|
|
title: 'External EVERYONE to Team Email',
|
|
visibility: DocumentVisibility.EVERYONE,
|
|
},
|
|
});
|
|
|
|
// Regular team doc with ADMIN visibility (manager shouldn't see via visibility filter)
|
|
await seedDraftDocument(owner, team.id, [], {
|
|
createDocumentOptions: {
|
|
title: 'Team ADMIN Doc',
|
|
visibility: DocumentVisibility.ADMIN,
|
|
},
|
|
});
|
|
|
|
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
|
const { token: managerToken } = await createApiToken({
|
|
userId: manager.id,
|
|
teamId: team.id,
|
|
tokenName: 'managerToken',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, managerToken);
|
|
const titles = json!.data.map((d) => d.title);
|
|
|
|
// Team email recipient filter bypasses visibility — both docs visible
|
|
expect(titles).toContain('External EVERYONE to Team Email');
|
|
expect(titles).toContain('External ADMIN to Team Email');
|
|
// Regular ADMIN doc should NOT be visible to manager (no bypass)
|
|
expect(titles).not.toContain('Team ADMIN Doc');
|
|
});
|
|
});
|
|
|
|
// ─── Adversarial / Parameter Manipulation Tests ──────────────────────────────
|
|
|
|
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 Envelopes 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 }) => {
|
|
const { team: teamA, owner: ownerA } = await seedTeam();
|
|
const { team: teamB, owner: ownerB } = await seedTeam();
|
|
|
|
await seedDraftDocument(ownerA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'Secret TeamA Envelope' },
|
|
});
|
|
await seedDraftDocument(ownerB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'TeamB Envelope' },
|
|
});
|
|
|
|
// Sign in as ownerB (NOT a member of teamA)
|
|
await apiSignin({ page, email: ownerB.email });
|
|
|
|
// Spoof x-team-id to teamA
|
|
const res = await trpcQuery(page, 'envelope.find', teamA.id, {
|
|
page: 1,
|
|
perPage: 100,
|
|
});
|
|
|
|
expect(res.ok()).toBeFalsy();
|
|
expect(res.status()).toBe(404);
|
|
});
|
|
|
|
test('should return only own team data when user provides 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 Envelope' },
|
|
});
|
|
await seedDraftDocument(ownerB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'TeamB Legit Envelope' },
|
|
});
|
|
|
|
await apiSignin({ page, email: ownerA.email });
|
|
|
|
const res = await trpcQuery(page, 'envelope.find', 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 Envelope');
|
|
expect(titles).not.toContain('TeamB Legit Envelope');
|
|
});
|
|
|
|
test('member of TeamA should not access TeamB via x-team-id', async ({ page }) => {
|
|
const { team: teamA } = 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 apiSignin({ page, email: member.email });
|
|
|
|
const res = await trpcQuery(page, 'envelope.find', teamB.id);
|
|
|
|
expect(res.ok()).toBeFalsy();
|
|
expect(res.status()).toBe(404);
|
|
});
|
|
});
|
|
|
|
test.describe('Find Envelopes API - Adversarial: Cross-Team folderId', () => {
|
|
test('should NOT return envelopes from another team when folderId belongs to that team', async ({ request }) => {
|
|
const { user: userA, team: teamA } = await seedUser();
|
|
const { user: userB, team: teamB } = await seedUser();
|
|
|
|
const folderA = await prisma.folder.create({
|
|
data: {
|
|
name: 'TeamA Folder',
|
|
teamId: teamA.id,
|
|
userId: userA.id,
|
|
type: 'DOCUMENT',
|
|
},
|
|
});
|
|
|
|
const folderB = await prisma.folder.create({
|
|
data: {
|
|
name: 'TeamB Folder',
|
|
teamId: teamB.id,
|
|
userId: userB.id,
|
|
type: 'DOCUMENT',
|
|
},
|
|
});
|
|
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'TeamA Folder Env', folderId: folderA.id },
|
|
});
|
|
await seedDraftDocument(userB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'TeamB Folder Env', folderId: folderB.id },
|
|
});
|
|
|
|
const { token: tokenA } = await createApiToken({
|
|
userId: userA.id,
|
|
teamId: teamA.id,
|
|
tokenName: 'tokenA',
|
|
expiresIn: null,
|
|
});
|
|
|
|
// UserA tries teamB's folderId — should return empty
|
|
const { json } = await findEnvelopes(request, tokenA, { folderId: folderB.id });
|
|
expect(json!.data).toHaveLength(0);
|
|
expect(json!.count).toBe(0);
|
|
|
|
// Positive control: own folderId works
|
|
const { json: ownFolder } = await findEnvelopes(request, tokenA, { folderId: folderA.id });
|
|
expect(ownFolder!.data).toHaveLength(1);
|
|
expect(ownFolder!.data[0].title).toBe('TeamA Folder Env');
|
|
});
|
|
|
|
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: 'Target Folder Env', folderId: folderB.id },
|
|
});
|
|
|
|
await apiSignin({ page, email: ownerA.email });
|
|
|
|
const res = await trpcQuery(page, 'envelope.find', 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 Envelopes API - Adversarial: Cross-Team templateId', () => {
|
|
test('should NOT return envelopes from another team when filtering by their templateId', async ({ request }) => {
|
|
const { user: userA, team: teamA } = await seedUser();
|
|
const { user: userB, team: teamB } = await seedUser();
|
|
|
|
const fakeTemplateId = 888777;
|
|
const ownTemplateId = 888666;
|
|
|
|
await seedDraftDocument(userB, teamB.id, [], {
|
|
createDocumentOptions: { title: 'TeamB Template Env', templateId: fakeTemplateId },
|
|
});
|
|
await seedDraftDocument(userA, teamA.id, [], {
|
|
createDocumentOptions: { title: 'TeamA Template Env', templateId: ownTemplateId },
|
|
});
|
|
|
|
const { token: tokenA } = await createApiToken({
|
|
userId: userA.id,
|
|
teamId: teamA.id,
|
|
tokenName: 'tokenA',
|
|
expiresIn: null,
|
|
});
|
|
|
|
// UserA tries teamB's templateId — should return empty
|
|
const { json } = await findEnvelopes(request, tokenA, {
|
|
templateId: String(fakeTemplateId),
|
|
});
|
|
expect(json!.data).toHaveLength(0);
|
|
expect(json!.count).toBe(0);
|
|
|
|
// Positive control: own templateId works
|
|
const { json: ownTemplate } = await findEnvelopes(request, tokenA, {
|
|
templateId: String(ownTemplateId),
|
|
});
|
|
expect(ownTemplate!.data).toHaveLength(1);
|
|
expect(ownTemplate!.data[0].title).toBe('TeamA Template Env');
|
|
});
|
|
});
|
|
|
|
// ─── Personal vs Team Isolation ──────────────────────────────────────────────
|
|
|
|
test.describe('Find Envelopes API - Cross-User Isolation', () => {
|
|
test('other users personal envelopes should never appear regardless of context', async ({ request }) => {
|
|
const { team } = await seedTeam();
|
|
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
|
const { user: outsider, team: outsiderTeam } = await seedUser();
|
|
|
|
// Outsider creates personal doc
|
|
await seedDraftDocument(outsider, outsiderTeam.id, [], {
|
|
createDocumentOptions: { title: 'Outsider Personal Env' },
|
|
});
|
|
|
|
// Member creates team doc
|
|
await seedDraftDocument(member, team.id, [], {
|
|
createDocumentOptions: { title: 'Team Env' },
|
|
});
|
|
|
|
// Query with team token — outsider's doc should never appear
|
|
const { token: teamToken } = await createApiToken({
|
|
userId: member.id,
|
|
teamId: team.id,
|
|
tokenName: 'teamToken',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, teamToken);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Team Env');
|
|
expect(titles).not.toContain('Outsider Personal Env');
|
|
});
|
|
|
|
// Regression test: Same { userId } OR clause issue — user's team docs leaked into personal
|
|
// context. Fixed in the Kysely refactor.
|
|
test('team envelopes should not appear in personal context', async ({ request }) => {
|
|
const { team: orgTeam } = await seedTeam();
|
|
// Add a member — seedTeamMember creates a separate user with their own personal team
|
|
const member = await seedTeamMember({ teamId: orgTeam.id, role: TeamMemberRole.ADMIN });
|
|
|
|
// Find the member's personal team
|
|
const memberOrg = await prisma.organisation.findFirstOrThrow({
|
|
where: { ownerUserId: member.id },
|
|
include: { teams: true },
|
|
});
|
|
const memberPersonalTeamId = memberOrg.teams[0].id;
|
|
|
|
// Member creates a doc on the org team
|
|
await seedDraftDocument(member, orgTeam.id, [], {
|
|
createDocumentOptions: { title: 'Member Org Team Env' },
|
|
});
|
|
|
|
// Member creates a doc on their personal team
|
|
await seedDraftDocument(member, memberPersonalTeamId, [], {
|
|
createDocumentOptions: { title: 'Member Personal Env' },
|
|
});
|
|
|
|
// Query with member's personal team token
|
|
const { token: personalToken } = await createApiToken({
|
|
userId: member.id,
|
|
teamId: memberPersonalTeamId,
|
|
tokenName: 'personalToken',
|
|
expiresIn: null,
|
|
});
|
|
|
|
const { json } = await findEnvelopes(request, personalToken);
|
|
const titles = json!.data.map((d) => d.title);
|
|
expect(titles).toContain('Member Personal Env');
|
|
// Org team doc should NOT appear in personal context
|
|
expect(titles).not.toContain('Member Org Team Env');
|
|
});
|
|
});
|