import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { createTeam } from '@documenso/lib/server-only/team/create-team'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { seedTeamMember } from '@documenso/prisma/seed/teams'; import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; import { seedUser } from '@documenso/prisma/seed/users'; import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { TemplateType } from '@prisma/client'; import { customAlphabet } from 'nanoid'; import { apiSignin, apiSignout } from '../fixtures/authentication'; const nanoid = customAlphabet('1234567890abcdef', 10); const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); test.describe.configure({ mode: 'parallel', }); /** * Helper to set up the standard two-team-one-org scenario: * * - One organisation with two teams (teamA and teamB). * - ownerA owns both the org and teamA. * - memberB is only a member of teamB (no relation to teamA). * - An ORGANISATION template is created on teamA. */ const seedOrgTemplateScenario = async () => { const { user: ownerA, organisation, team: teamA } = await seedUser(); const teamBUrl = `team-b-${nanoid()}`; await createTeam({ userId: ownerA.id, teamName: `Team B ${teamBUrl}`, teamUrl: teamBUrl, organisationId: organisation.id, inheritMembers: false, }); const teamB = await prisma.team.findFirstOrThrow({ where: { url: teamBUrl }, }); // memberB is only added to teamB, not teamA. const memberB = await seedTeamMember({ teamId: teamB.id, role: 'MEMBER', }); const orgTemplate = await seedBlankTemplate(ownerA, teamA.id, { createTemplateOptions: { title: `Org Template ${nanoid()}`, templateType: TemplateType.ORGANISATION, }, }); return { ownerA, organisation, teamA, teamB, memberB, orgTemplate }; }; /** * Helper to make tRPC queries via the authenticated page context. */ const trpcQuery = async (page: Page, procedure: string, input: Record, teamId?: number) => { const inputParam = encodeURIComponent(JSON.stringify({ json: input })); const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`; const headers: Record = {}; if (teamId) { headers['x-team-id'] = teamId.toString(); } const res = await page.context().request.get(url, { headers }); return { res, json: res.ok() ? await res.json() : null }; }; const trpcMutation = async (page: Page, procedure: string, input: Record, teamId?: number) => { const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}`; const headers: Record = { 'content-type': 'application/json', }; if (teamId) { headers['x-team-id'] = teamId.toString(); } const res = await page.context().request.post(url, { data: JSON.stringify({ json: input }), headers, }); return { res, json: res.ok() ? await res.json() : null }; }; // ─── UI: Tab Visibility ────────────────────────────────────────────────────── test.describe('Organisation Templates - UI Tabs', () => { test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => { const { ownerA, teamA } = await seedOrgTemplateScenario(); await apiSignin({ page, email: ownerA.email, redirectPath: `/t/${teamA.url}/templates`, }); await expect(page.getByTestId('template-tab-team')).toBeVisible(); await expect(page.getByTestId('template-tab-organisation')).toBeVisible(); }); test('should not show tabs for personal organisations', async ({ page }) => { const { user, team } = await seedUser({ isPersonalOrganisation: true }); await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/templates`, }); await expect(page.getByTestId('template-tab-team')).not.toBeVisible(); await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible(); }); }); // ─── UI: Listing Organisation Templates ────────────────────────────────────── test.describe('Organisation Templates - Listing', () => { test('should list org templates from other teams under the Organisation tab', async ({ page }) => { const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario(); await apiSignin({ page, email: memberB.email, redirectPath: `/t/${teamB.url}/templates`, }); // Team tab should show 0 (memberB has no templates on teamB). await expect(page.getByTestId('template-tab-team')).toBeVisible(); // Switch to Organisation tab. await page.getByTestId('template-tab-organisation').click(); // Should see the org template from teamA. await expect(page.getByText(orgTemplate.title)).toBeVisible(); }); test('should not show private templates from other teams under Organisation tab', async ({ page }) => { const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario(); // Create a private template on teamA — should NOT appear in org tab. const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, { createTemplateOptions: { title: `Private Template ${nanoid()}`, templateType: TemplateType.PRIVATE, }, }); await apiSignin({ page, email: memberB.email, redirectPath: `/t/${teamB.url}/templates?view=organisation`, }); await expect(page.getByText(privateTemplate.title)).not.toBeVisible(); }); }); // ─── UI: Organisation Template Detail Page ─────────────────────────────────── test.describe('Organisation Templates - Detail Page', () => { test('should show org template detail but hide edit/delete actions', async ({ page }) => { const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario(); await apiSignin({ page, email: memberB.email, redirectPath: `/t/${teamB.url}/templates?view=organisation`, }); // Click into the org template. await page.getByText(orgTemplate.title).click(); // Should see the template title. await expect(page.getByRole('heading', { name: orgTemplate.title })).toBeVisible(); // Should see the Use button. await expect(page.getByRole('button', { name: 'Use' })).toBeVisible(); // Should NOT see the Edit Template button. await expect(page.getByRole('link', { name: 'Edit Template' })).not.toBeVisible(); }); }); // ─── API: findOrganisationTemplates ────────────────────────────────────────── test.describe('Organisation Templates - findOrganisationTemplates API', () => { test('should return org templates for a member of a sibling team', async ({ page }) => { const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario(); await apiSignin({ page, email: memberB.email }); const { res, json } = await trpcQuery( page, 'template.findOrganisationTemplates', { page: 1, perPage: 50 }, teamB.id, ); expect(res.ok()).toBeTruthy(); const titles = json.result.data.json.data.map((t: { title: string }) => t.title); expect(titles).toContain(orgTemplate.title); }); test('should not return private templates from sibling teams', async ({ page }) => { const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario(); const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, { createTemplateOptions: { title: `Private No Show ${nanoid()}`, templateType: TemplateType.PRIVATE, }, }); await apiSignin({ page, email: memberB.email }); const { json } = await trpcQuery(page, 'template.findOrganisationTemplates', { page: 1, perPage: 50 }, teamB.id); const titles = json.result.data.json.data.map((t: { title: string }) => t.title); expect(titles).not.toContain(privateTemplate.title); }); test('should not return org templates to a user outside the organisation', async ({ page }) => { const { orgTemplate } = await seedOrgTemplateScenario(); // Create a completely separate user with their own org. const { user: outsider, team: outsiderTeam } = await seedUser(); await apiSignin({ page, email: outsider.email }); const { json } = await trpcQuery( page, 'template.findOrganisationTemplates', { page: 1, perPage: 50 }, outsiderTeam.id, ); const titles = json.result.data.json.data.map((t: { title: string }) => t.title); expect(titles).not.toContain(orgTemplate.title); }); test('should respect document visibility based on the viewer team role', async ({ page }) => { const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario(); const everyoneTemplate = await seedBlankTemplate(ownerA, teamA.id, { createTemplateOptions: { title: `Visibility Everyone ${nanoid()}`, templateType: TemplateType.ORGANISATION, visibility: 'EVERYONE', }, }); const adminOnlyTemplate = await seedBlankTemplate(ownerA, teamA.id, { createTemplateOptions: { title: `Visibility Admin ${nanoid()}`, templateType: TemplateType.ORGANISATION, visibility: 'ADMIN', }, }); // memberB has role MEMBER on teamB — should only see EVERYONE visibility. await apiSignin({ page, email: memberB.email }); const { json } = await trpcQuery(page, 'template.findOrganisationTemplates', { page: 1, perPage: 50 }, teamB.id); const titles = json.result.data.json.data.map((t: { title: string }) => t.title); expect(titles).toContain(everyoneTemplate.title); expect(titles).not.toContain(adminOnlyTemplate.title); await apiSignout({ page }); // ownerA has role ADMIN on teamA — should see both. await apiSignin({ page, email: ownerA.email }); const { json: adminJson } = await trpcQuery( page, 'template.findOrganisationTemplates', { page: 1, perPage: 50 }, teamA.id, ); const adminTitles = adminJson.result.data.json.data.map((t: { title: string }) => t.title); expect(adminTitles).toContain(everyoneTemplate.title); expect(adminTitles).toContain(adminOnlyTemplate.title); }); }); // ─── API: getOrganisationTemplateById ──────────────────────────────────────── test.describe('Organisation Templates - getOrganisationTemplateById API', () => { test('should allow a sibling team member to fetch an org template', async ({ page }) => { const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario(); await apiSignin({ page, email: memberB.email }); const { res, json } = await trpcQuery( page, 'template.getOrganisationTemplateById', { envelopeId: orgTemplate.id }, teamB.id, ); expect(res.ok()).toBeTruthy(); expect(json.result.data.json.title).toBe(orgTemplate.title); }); test('should reject access from a user outside the organisation', async ({ page }) => { const { orgTemplate } = await seedOrgTemplateScenario(); const { user: outsider, team: outsiderTeam } = await seedUser(); await apiSignin({ page, email: outsider.email }); const { res } = await trpcQuery( page, 'template.getOrganisationTemplateById', { envelopeId: orgTemplate.id }, outsiderTeam.id, ); // Should fail — outsider is not in the same org. expect(res.ok()).toBeFalsy(); }); test('should reject fetching a private template via the org endpoint', async ({ page }) => { const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario(); const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, { createTemplateOptions: { title: `Private ${nanoid()}`, templateType: TemplateType.PRIVATE, }, }); await apiSignin({ page, email: memberB.email }); const { res } = await trpcQuery( page, 'template.getOrganisationTemplateById', { envelopeId: privateTemplate.id }, teamB.id, ); // Should fail — template is PRIVATE, not ORGANISATION. expect(res.ok()).toBeFalsy(); }); }); // ─── API: createDocumentFromTemplate with org template ─────────────────────── test.describe('Organisation Templates - Use from different team', () => { test('should allow creating a document from an org template owned by a sibling team', async ({ page }) => { const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario(); // Add a recipient to the org template so we can use it. await prisma.recipient.create({ data: { email: 'recipient@test.documenso.com', name: 'Recipient', token: Math.random().toString().slice(2, 7), envelopeId: orgTemplate.id, }, }); const orgTemplateWithRecipients = await prisma.envelope.findFirstOrThrow({ where: { id: orgTemplate.id }, include: { recipients: true }, }); await apiSignin({ page, email: memberB.email }); const templateId = mapSecondaryIdToTemplateId(orgTemplateWithRecipients.secondaryId); const { res } = await trpcMutation( page, 'template.createDocumentFromTemplate', { templateId, recipients: orgTemplateWithRecipients.recipients.map((r) => ({ id: r.id, email: r.email, name: r.name ?? '', })), }, teamB.id, ); expect(res.ok()).toBeTruthy(); }); }); // ─── Adversarial: Cross-organisation access ────────────────────────────────── test.describe('Organisation Templates - Adversarial', () => { test('should not allow accessing org template from a different organisation via getEnvelopeById', async ({ page, }) => { const { orgTemplate } = await seedOrgTemplateScenario(); const { user: outsider, team: outsiderTeam } = await seedUser(); await apiSignin({ page, email: outsider.email }); // Try to fetch via the standard envelope.get endpoint. const { res } = await trpcQuery(page, 'envelope.get', { envelopeId: orgTemplate.id }, outsiderTeam.id); expect(res.ok()).toBeFalsy(); }); test('should not allow a sibling team member to fetch a private template via org endpoint', async ({ page }) => { const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario(); const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, { createTemplateOptions: { title: `Adversarial Private ${nanoid()}`, templateType: TemplateType.PRIVATE, }, }); await apiSignin({ page, email: memberB.email }); // Attempt 1: Try via org template endpoint. const { res: orgRes } = await trpcQuery( page, 'template.getOrganisationTemplateById', { envelopeId: privateTemplate.id }, teamB.id, ); expect(orgRes.ok()).toBeFalsy(); // Attempt 2: Try via standard envelope endpoint. const { res: envelopeRes } = await trpcQuery(page, 'envelope.get', { envelopeId: privateTemplate.id }, teamB.id); expect(envelopeRes.ok()).toBeFalsy(); }); test('should not list org templates from a completely unrelated organisation', async ({ page }) => { // Create scenario A. const { orgTemplate: orgTemplateA } = await seedOrgTemplateScenario(); // Create scenario B (separate org, separate teams). const { memberB: memberFromOrgB, teamB: teamFromOrgB } = await seedOrgTemplateScenario(); await apiSignin({ page, email: memberFromOrgB.email }); const { json } = await trpcQuery( page, 'template.findOrganisationTemplates', { page: 1, perPage: 50 }, teamFromOrgB.id, ); const titles = json.result.data.json.data.map((t: { title: string }) => t.title); expect(titles).not.toContain(orgTemplateA.title); }); test('should not allow unauthenticated access to org template endpoints', async ({ page }) => { const { orgTemplate, teamB } = await seedOrgTemplateScenario(); // No apiSignin — unauthenticated. const { res: findRes } = await trpcQuery( page, 'template.findOrganisationTemplates', { page: 1, perPage: 50 }, teamB.id, ); expect(findRes.ok()).toBeFalsy(); expect(findRes.status()).toBe(401); const { res: getRes } = await trpcQuery( page, 'template.getOrganisationTemplateById', { envelopeId: orgTemplate.id }, teamB.id, ); expect(getRes.ok()).toBeFalsy(); expect(getRes.status()).toBe(401); }); test('should not return org template data via findTemplates (team endpoint)', async ({ page }) => { const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario(); await apiSignin({ page, email: memberB.email }); // The standard findTemplates endpoint should NOT include org templates from other teams. const { json } = await trpcQuery(page, 'template.findTemplates', { page: 1, perPage: 50 }, teamB.id); const titles = json.result.data.json.data.map((t: { title: string }) => t.title); expect(titles).not.toContain(orgTemplate.title); }); }); // ─── API: envelope.item.getManyByToken (org template fallback) ─────────────── test.describe('Organisation Templates - envelope.item.getManyByToken API', () => { test('should allow a sibling team member to fetch envelope items for an org template', async ({ page }) => { const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario(); await apiSignin({ page, email: memberB.email }); const { res, json } = await trpcQuery( page, 'envelope.item.getManyByToken', { envelopeId: orgTemplate.id, access: { type: 'user' } }, teamB.id, ); expect(res.ok()).toBeTruthy(); const items = json.result.data.json.data; expect(Array.isArray(items)).toBe(true); expect(items.length).toBeGreaterThan(0); expect(items[0].envelopeId).toBe(orgTemplate.id); }); test('should allow the owning team member to fetch envelope items (own-team path)', async ({ page }) => { const { ownerA, teamA, orgTemplate } = await seedOrgTemplateScenario(); await apiSignin({ page, email: ownerA.email }); const { res, json } = await trpcQuery( page, 'envelope.item.getManyByToken', { envelopeId: orgTemplate.id, access: { type: 'user' } }, teamA.id, ); expect(res.ok()).toBeTruthy(); const items = json.result.data.json.data; expect(items.length).toBeGreaterThan(0); expect(items[0].envelopeId).toBe(orgTemplate.id); }); test('should reject a user outside the organisation', async ({ page }) => { const { orgTemplate } = await seedOrgTemplateScenario(); const { user: outsider, team: outsiderTeam } = await seedUser(); await apiSignin({ page, email: outsider.email }); const { res } = await trpcQuery( page, 'envelope.item.getManyByToken', { envelopeId: orgTemplate.id, access: { type: 'user' } }, outsiderTeam.id, ); expect(res.ok()).toBeFalsy(); }); test('should reject fetching items for a PRIVATE template from a sibling team', async ({ page }) => { const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario(); const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, { createTemplateOptions: { title: `Private Items ${nanoid()}`, templateType: TemplateType.PRIVATE, }, }); await apiSignin({ page, email: memberB.email }); const { res } = await trpcQuery( page, 'envelope.item.getManyByToken', { envelopeId: privateTemplate.id, access: { type: 'user' } }, teamB.id, ); expect(res.ok()).toBeFalsy(); }); test('should respect document visibility for the viewer team role', async ({ page }) => { const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario(); const adminOnlyTemplate = await seedBlankTemplate(ownerA, teamA.id, { createTemplateOptions: { title: `Items Admin Only ${nanoid()}`, templateType: TemplateType.ORGANISATION, visibility: 'ADMIN', }, }); // memberB is a MEMBER on teamB — must not be able to read items for an ADMIN-only template. await apiSignin({ page, email: memberB.email }); const { res: memberRes } = await trpcQuery( page, 'envelope.item.getManyByToken', { envelopeId: adminOnlyTemplate.id, access: { type: 'user' } }, teamB.id, ); expect(memberRes.ok()).toBeFalsy(); await apiSignout({ page }); // ownerA is ADMIN on teamA — should succeed via the own-team path. await apiSignin({ page, email: ownerA.email }); const { res: adminRes, json: adminJson } = await trpcQuery( page, 'envelope.item.getManyByToken', { envelopeId: adminOnlyTemplate.id, access: { type: 'user' } }, teamA.id, ); expect(adminRes.ok()).toBeTruthy(); expect(adminJson.result.data.json.data.length).toBeGreaterThan(0); }); test('should reject unauthenticated callers using the user access type', async ({ page }) => { const { orgTemplate, teamB } = await seedOrgTemplateScenario(); // No apiSignin — unauthenticated. const { res } = await trpcQuery( page, 'envelope.item.getManyByToken', { envelopeId: orgTemplate.id, access: { type: 'user' } }, teamB.id, ); expect(res.ok()).toBeFalsy(); }); });