mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 18:35:05 +10:00
chore: merge main, resolve biome formatting conflicts
Merge origin/main into feat/external-2fa-codes. Resolve formatting conflicts caused by biome rollout; preserve both feature streams: PR's external 2FA token + signing-session 2FA proof additions plus main's RateLimit/RecipientExpired/signingReminders/date-auto-insert. In complete-document-with-token.ts, drop the duplicate early field-fetching block introduced when main moved that logic later with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH check using derivedRecipientActionAuth.
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { FolderType } from '@documenso/prisma/client';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentDataType, TeamMemberRole } from '@prisma/client';
|
||||
import path from 'path';
|
||||
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentDataType, TeamMemberRole } from '@prisma/client';
|
||||
import path from 'path';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const EXAMPLE_PDF_PATH = path.join(__dirname, '../../../../assets/example.pdf');
|
||||
const FIELD_ALIGNMENT_TEST_PDF_PATH = path.join(
|
||||
__dirname,
|
||||
'../../../../assets/field-font-alignment.pdf',
|
||||
);
|
||||
const FIELD_ALIGNMENT_TEST_PDF_PATH = path.join(__dirname, '../../../../assets/field-font-alignment.pdf');
|
||||
|
||||
/**
|
||||
* 1. Create a template with all settings filled out
|
||||
@@ -230,9 +226,7 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
|
||||
* This test verifies that we can create a document from a template using a custom document
|
||||
* instead of the template's default document.
|
||||
*/
|
||||
test('[TEMPLATE]: should create a document from a template with custom document', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEMPLATE]: should create a document from a template with custom document', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
@@ -267,13 +261,11 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
// Upload document.
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page
|
||||
.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`)
|
||||
.evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
page.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`).evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(FIELD_ALIGNMENT_TEST_PDF_PATH);
|
||||
@@ -305,9 +297,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
const firstDocumentData = document.envelopeItems[0].documentData;
|
||||
|
||||
const expectedDocumentDataType =
|
||||
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3'
|
||||
? DocumentDataType.S3_PATH
|
||||
: DocumentDataType.BYTES_64;
|
||||
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3' ? DocumentDataType.S3_PATH : DocumentDataType.BYTES_64;
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_WITH_CUSTOM_DOC');
|
||||
expect(firstDocumentData.type).toEqual(expectedDocumentDataType);
|
||||
@@ -316,9 +306,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
// Todo: Doesn't really work due to normalization of the PDF which won't let us directly compare the data.
|
||||
// Probably need to do a pixel match
|
||||
expect(firstDocumentData.data).not.toEqual(template.envelopeItems[0].documentData.data);
|
||||
expect(firstDocumentData.initialData).not.toEqual(
|
||||
template.envelopeItems[0].documentData.initialData,
|
||||
);
|
||||
expect(firstDocumentData.initialData).not.toEqual(template.envelopeItems[0].documentData.initialData);
|
||||
} else {
|
||||
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
|
||||
expect(firstDocumentData.data).toBeTruthy();
|
||||
@@ -330,9 +318,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
* This test verifies that we can create a team document from a template using a custom document
|
||||
* instead of the template's default document.
|
||||
*/
|
||||
test('[TEMPLATE]: should create a team document from a template with custom document', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEMPLATE]: should create a team document from a template with custom document', async ({ page }) => {
|
||||
const { team, owner, organisation } = await seedTeam({
|
||||
createTeamMembers: 2,
|
||||
});
|
||||
@@ -370,13 +356,11 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
||||
// Upload document.
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page
|
||||
.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`)
|
||||
.evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
page.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`).evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(FIELD_ALIGNMENT_TEST_PDF_PATH);
|
||||
@@ -406,9 +390,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
||||
});
|
||||
|
||||
const expectedDocumentDataType =
|
||||
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3'
|
||||
? DocumentDataType.S3_PATH
|
||||
: DocumentDataType.BYTES_64;
|
||||
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3' ? DocumentDataType.S3_PATH : DocumentDataType.BYTES_64;
|
||||
|
||||
const firstDocumentData = document.envelopeItems[0].documentData;
|
||||
|
||||
@@ -420,9 +402,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
||||
// Todo: Doesn't really work due to normalization of the PDF which won't let us directly compare the data.
|
||||
// Probably need to do a pixel match
|
||||
expect(firstDocumentData.data).not.toEqual(template.envelopeItems[0].documentData.data);
|
||||
expect(firstDocumentData.initialData).not.toEqual(
|
||||
template.envelopeItems[0].documentData.initialData,
|
||||
);
|
||||
expect(firstDocumentData.initialData).not.toEqual(template.envelopeItems[0].documentData.initialData);
|
||||
} else {
|
||||
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
|
||||
expect(firstDocumentData.data).toBeTruthy();
|
||||
@@ -505,18 +485,12 @@ test('[TEMPLATE]: should create a document from a template using template docume
|
||||
});
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_WITH_ORIGINAL_DOC');
|
||||
expect(firstDocumentData.initialData).toEqual(
|
||||
templateWithData.envelopeItems[0].documentData.data,
|
||||
);
|
||||
expect(firstDocumentData.initialData).toEqual(
|
||||
templateWithData.envelopeItems[0].documentData.initialData,
|
||||
);
|
||||
expect(firstDocumentData.initialData).toEqual(templateWithData.envelopeItems[0].documentData.data);
|
||||
expect(firstDocumentData.initialData).toEqual(templateWithData.envelopeItems[0].documentData.initialData);
|
||||
expect(firstDocumentData.type).toEqual(templateWithData.envelopeItems[0].documentData.type);
|
||||
});
|
||||
|
||||
test('[TEMPLATE]: should persist document visibility when creating from template', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[TEMPLATE]: should persist document visibility when creating from template', async ({ page }) => {
|
||||
const { team, owner, organisation } = await seedTeam({
|
||||
createTeamMembers: 2,
|
||||
});
|
||||
@@ -533,9 +507,7 @@ test('[TEMPLATE]: should persist document visibility when creating from template
|
||||
await page.getByLabel('Title').fill('TEMPLATE_WITH_VISIBILITY');
|
||||
await page.getByTestId('documentVisibilitySelectValue').click();
|
||||
await page.getByLabel('Managers and above').click();
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
|
||||
'Managers and above',
|
||||
);
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Managers and above');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
@@ -10,6 +6,9 @@ import { prisma } from '@documenso/prisma';
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
@@ -139,9 +138,7 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
|
||||
},
|
||||
});
|
||||
|
||||
const directTemplatePath = formatDirectTemplatePath(
|
||||
directTemplateWithAuth.directLink?.token || '',
|
||||
);
|
||||
const directTemplatePath = formatDirectTemplatePath(directTemplateWithAuth.directLink?.token || '');
|
||||
|
||||
await page.goto(directTemplatePath);
|
||||
|
||||
@@ -181,9 +178,7 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page })
|
||||
},
|
||||
});
|
||||
|
||||
const directTemplatePath = formatDirectTemplatePath(
|
||||
directTemplateWithAuth.directLink?.token || '',
|
||||
);
|
||||
const directTemplatePath = formatDirectTemplatePath(directTemplateWithAuth.directLink?.token || '');
|
||||
|
||||
await page.goto(directTemplatePath);
|
||||
|
||||
@@ -312,9 +307,7 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
|
||||
},
|
||||
});
|
||||
|
||||
const updatedSecondRecipient = createdEnvelopeRecipients.find(
|
||||
(recipient) => recipient.signingOrder === 2,
|
||||
);
|
||||
const updatedSecondRecipient = createdEnvelopeRecipients.find((recipient) => recipient.signingOrder === 2);
|
||||
|
||||
expect(updatedSecondRecipient?.name).toBe(newName);
|
||||
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
|
||||
@@ -399,9 +392,7 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
|
||||
},
|
||||
});
|
||||
|
||||
const updatedSecondRecipient = createdEnvelopeRecipients.find(
|
||||
(recipient) => recipient.signingOrder === 2,
|
||||
);
|
||||
const updatedSecondRecipient = createdEnvelopeRecipients.find((recipient) => recipient.signingOrder === 2);
|
||||
|
||||
expect(updatedSecondRecipient?.name).toBe(newName);
|
||||
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
@@ -117,7 +116,13 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
|
||||
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
await expect(page.getByText('Template duplicated').first()).toBeVisible();
|
||||
await expect(page.getByText('Template Duplicated').first()).toBeVisible();
|
||||
|
||||
// The dialog should navigate to the new template's edit page.
|
||||
await page.waitForURL(/\/templates\/.*\/edit/);
|
||||
|
||||
// Navigate back to the templates list and verify the count is now 2.
|
||||
await page.goto(`/t/${team.url}/templates`);
|
||||
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
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<string, unknown>, teamId?: number) => {
|
||||
const inputParam = encodeURIComponent(JSON.stringify({ json: input }));
|
||||
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
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<string, unknown>, teamId?: number) => {
|
||||
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'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();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user