mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 08:54:20 +10:00
Merge remote-tracking branch 'origin/main' into pr-2711
# Conflicts: # apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx # apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx # packages/app-tests/e2e/documents/bulk-document-actions.spec.ts
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
import { encryptEmailTransportConfig } from '@documenso/lib/server-only/email/email-transport-config';
|
||||
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Locator, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Transports seeded by the current test, deleted afterwards. Deleting a transport
|
||||
// referenced by a claim is safe: the FK is `onDelete: SetNull`.
|
||||
const transportIdsToCleanup: string[] = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (transportIdsToCleanup.length > 0) {
|
||||
await prisma.emailTransport.deleteMany({ where: { id: { in: transportIdsToCleanup } } });
|
||||
transportIdsToCleanup.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const seedTransport = async (label: string) => {
|
||||
const transport = await prisma.emailTransport.create({
|
||||
data: {
|
||||
id: generateDatabaseId('email_transport'),
|
||||
name: `e2e-transport-${label}-${nanoid()}`,
|
||||
type: 'RESEND',
|
||||
fromName: 'Seeded Transport',
|
||||
fromAddress: 'seeded@example.com',
|
||||
config: encryptEmailTransportConfig({ type: 'RESEND', apiKey: `re_${nanoid()}` }),
|
||||
},
|
||||
});
|
||||
|
||||
transportIdsToCleanup.push(transport.id);
|
||||
|
||||
return transport;
|
||||
};
|
||||
|
||||
const seedSubscriptionClaim = (name: string) =>
|
||||
prisma.subscriptionClaim.create({
|
||||
data: {
|
||||
name,
|
||||
teamCount: 1,
|
||||
memberCount: 1,
|
||||
envelopeItemCount: 10,
|
||||
recipientCount: 10,
|
||||
flags: {},
|
||||
documentRateLimits: [],
|
||||
emailRateLimits: [],
|
||||
apiRateLimits: [],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Seeds an organisation whose `OrganisationClaim` is descended (via
|
||||
* `originalSubscriptionClaimId`) from the supplied subscription claim. This is
|
||||
* the relationship the backport `updateMany` keys on.
|
||||
*/
|
||||
const seedOrgForClaim = async (subscriptionClaimId: string) => {
|
||||
const { organisation } = await seedUser();
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
data: {
|
||||
originalSubscriptionClaimId: subscriptionClaimId,
|
||||
emailTransportId: null,
|
||||
},
|
||||
});
|
||||
|
||||
return organisation;
|
||||
};
|
||||
|
||||
const openClaimUpdateDialog = async (page: Page, claimName: string) => {
|
||||
// The update dialog lives inside the table row. Wait for the debounced search
|
||||
// refetch to land BEFORE opening it, otherwise the table re-renders mid-flow
|
||||
// and unmounts the dialog.
|
||||
const searchSettled = page
|
||||
.waitForResponse((r) => r.url().includes('claims.find') && r.url().includes(claimName), { timeout: 15_000 })
|
||||
.catch(() => undefined);
|
||||
|
||||
await page.getByPlaceholder('Search by claim ID or name').fill(claimName);
|
||||
await searchSettled;
|
||||
|
||||
const row = page.getByRole('row', { name: claimName });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// The actions dropdown trigger is the last button in the row (the first is the
|
||||
// ID copy button).
|
||||
await row.getByRole('button').last().click();
|
||||
await page.getByRole('menuitem', { name: 'Update' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('heading', { name: 'Update Subscription Claim' })).toBeVisible();
|
||||
|
||||
return dialog;
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks an option from an open Radix Select listbox. The email-transport list is
|
||||
* populated by a `find` query that can keep re-rendering (it loads up to 100
|
||||
* transports), so the target option's box may still be shifting — wait for it,
|
||||
* best-effort scroll it into view, then force the click.
|
||||
*/
|
||||
const chooseOption = async (page: Page, name: string) => {
|
||||
const option = page.getByRole('option', { name });
|
||||
await option.waitFor({ state: 'visible' });
|
||||
await option.scrollIntoViewIfNeeded().catch(() => undefined);
|
||||
await option.click({ force: true });
|
||||
};
|
||||
|
||||
const selectEmailTransport = async (page: Page, dialog: Locator, transportName: string) => {
|
||||
await dialog.getByRole('combobox').filter({ hasText: 'Default (system mailer)' }).click();
|
||||
await chooseOption(page, transportName);
|
||||
};
|
||||
|
||||
// ─── Subscription claim: NO backport ─────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: updating a subscription claim WITHOUT backport does not touch organisation claims', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const transport = await seedTransport('no-backport');
|
||||
const claimName = `e2e-claim-no-backport-${nanoid()}`;
|
||||
const claim = await seedSubscriptionClaim(claimName);
|
||||
const organisation = await seedOrgForClaim(claim.id);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/claims' });
|
||||
|
||||
const dialog = await openClaimUpdateDialog(page, claimName);
|
||||
|
||||
await selectEmailTransport(page, dialog, transport.name);
|
||||
|
||||
// Backport checkbox left UNCHECKED.
|
||||
await expect(dialog.getByRole('checkbox', { name: 'Backport email transport' })).not.toBeChecked();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Update Claim' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
// The subscription claim itself was updated (proves the mutation ran).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const updated = await prisma.subscriptionClaim.findUniqueOrThrow({ where: { id: claim.id } });
|
||||
return updated.emailTransportId;
|
||||
})
|
||||
.toBe(transport.id);
|
||||
|
||||
// The organisation claim was NOT backported.
|
||||
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
});
|
||||
expect(orgClaim.emailTransportId).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Subscription claim: WITH backport ───────────────────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: updating a subscription claim WITH backport propagates to organisation claims', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const transport = await seedTransport('backport');
|
||||
const claimName = `e2e-claim-backport-${nanoid()}`;
|
||||
const claim = await seedSubscriptionClaim(claimName);
|
||||
const organisation = await seedOrgForClaim(claim.id);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/claims' });
|
||||
|
||||
const dialog = await openClaimUpdateDialog(page, claimName);
|
||||
|
||||
await selectEmailTransport(page, dialog, transport.name);
|
||||
|
||||
// Enable backporting.
|
||||
const backportCheckbox = dialog.getByRole('checkbox', { name: 'Backport email transport' });
|
||||
await backportCheckbox.click();
|
||||
await expect(backportCheckbox).toBeChecked();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Update Claim' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
// Both the subscription claim AND the descendant organisation claim are updated.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const updated = await prisma.subscriptionClaim.findUniqueOrThrow({ where: { id: claim.id } });
|
||||
return updated.emailTransportId;
|
||||
})
|
||||
.toBe(transport.id);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
});
|
||||
return orgClaim.emailTransportId;
|
||||
})
|
||||
.toBe(transport.id);
|
||||
});
|
||||
|
||||
// ─── Organisation claim transport (set directly on the org page) ─────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: setting the email transport on an organisation claim persists', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const transport = await seedTransport('org-claim');
|
||||
const { organisation } = await seedUser();
|
||||
|
||||
// Ensure a known starting point.
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
data: { emailTransportId: null },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: `/admin/organisations/${organisation.id}` });
|
||||
|
||||
// Scope to the billing/claims form (the one containing the "Email transport" field);
|
||||
// the page has a second form (name/url) with its own "Update" button.
|
||||
const billingForm = page.locator('form', { has: page.getByText('Email transport', { exact: true }) });
|
||||
|
||||
await billingForm.getByRole('combobox').filter({ hasText: 'Default (system mailer)' }).click();
|
||||
await chooseOption(page, transport.name);
|
||||
|
||||
await billingForm.getByRole('button', { name: 'Update', exact: true }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
});
|
||||
return orgClaim.emailTransportId;
|
||||
})
|
||||
.toBe(transport.id);
|
||||
});
|
||||
|
||||
// ─── Organisation claim transport can be reset to the system mailer ──────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: clearing an organisation claim transport resets it to the system mailer', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const transport = await seedTransport('org-clear');
|
||||
const { organisation } = await seedUser();
|
||||
|
||||
// Start with the transport already assigned.
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
data: { emailTransportId: transport.id },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: `/admin/organisations/${organisation.id}` });
|
||||
|
||||
const billingForm = page.locator('form', { has: page.getByText('Email transport', { exact: true }) });
|
||||
|
||||
// The select currently shows the transport name; switch back to the default.
|
||||
await billingForm.getByRole('combobox').filter({ hasText: transport.name }).click();
|
||||
await chooseOption(page, 'Default (system mailer)');
|
||||
|
||||
await billingForm.getByRole('button', { name: 'Update', exact: true }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
});
|
||||
return orgClaim.emailTransportId;
|
||||
})
|
||||
.toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { decryptEmailTransportConfig } from '@documenso/lib/server-only/email/email-transport-config';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Locator, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Transport names created by the current test, deleted afterwards so the global
|
||||
// email-transports table doesn't accumulate rows across runs.
|
||||
const transportNamesToCleanup: string[] = [];
|
||||
|
||||
const trackTransport = (name: string) => {
|
||||
transportNamesToCleanup.push(name);
|
||||
return name;
|
||||
};
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (transportNamesToCleanup.length > 0) {
|
||||
await prisma.emailTransport.deleteMany({ where: { name: { in: transportNamesToCleanup } } });
|
||||
transportNamesToCleanup.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const getTransportFromDbOrThrow = async (name: string) => {
|
||||
await expect
|
||||
.poll(async () => prisma.emailTransport.findFirst({ where: { name }, select: { id: true } }), {
|
||||
message: `transport "${name}" was not persisted in time`,
|
||||
timeout: 10_000,
|
||||
intervals: [200, 400, 800],
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
return prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
};
|
||||
|
||||
const openCreateDialog = async (page: Page) => {
|
||||
await page.getByRole('button', { name: 'Add transport' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('heading', { name: 'Add Email Transport' })).toBeVisible();
|
||||
|
||||
return dialog;
|
||||
};
|
||||
|
||||
const selectTransportType = async (page: Page, dialog: Locator, optionName: string) => {
|
||||
// The transport-type Select is the only combobox inside the create/edit dialog.
|
||||
await dialog.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: optionName, exact: true }).click();
|
||||
};
|
||||
|
||||
const searchForTransport = async (page: Page, name: string) => {
|
||||
// The row-level Edit/Delete dialogs live inside the table row. Wait for the
|
||||
// debounced search refetch to land before interacting, otherwise a late
|
||||
// re-render can unmount a freshly-opened dialog.
|
||||
const searchSettled = page
|
||||
.waitForResponse((r) => r.url().includes('emailTransport.find') && r.url().includes(name), { timeout: 15_000 })
|
||||
.catch(() => undefined);
|
||||
|
||||
await page.getByPlaceholder('Search by name or from address').fill(name);
|
||||
await searchSettled;
|
||||
|
||||
await expect(page.getByRole('row', { name })).toBeVisible();
|
||||
};
|
||||
|
||||
const openRowAction = async (page: Page, name: string, action: 'Edit' | 'Send test' | 'Delete') => {
|
||||
await searchForTransport(page, name);
|
||||
// The transports table row has exactly one button: the actions dropdown trigger.
|
||||
await page.getByRole('row', { name }).getByRole('button').click();
|
||||
await page.getByRole('menuitem', { name: action }).click();
|
||||
};
|
||||
|
||||
// ─── Create: RESEND (round-trips the secret through encrypt/decrypt) ─────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: create a RESEND transport encrypts the secret and round-trips correctly', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-resend-${nanoid()}`);
|
||||
const apiKey = `re_${nanoid()}`;
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
const dialog = await openCreateDialog(page);
|
||||
|
||||
await dialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await dialog.getByLabel('From name', { exact: true }).fill('Acme Mailer');
|
||||
await dialog.getByLabel('From address', { exact: true }).fill('sender@example.com');
|
||||
await selectTransportType(page, dialog, 'Resend');
|
||||
await dialog.getByLabel('API key', { exact: true }).fill(apiKey);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
const row = await getTransportFromDbOrThrow(name);
|
||||
|
||||
// The stored blob must NOT contain the plaintext secret.
|
||||
expect(row.config).not.toContain(apiKey);
|
||||
expect(row.type).toBe('RESEND');
|
||||
expect(row.fromName).toBe('Acme Mailer');
|
||||
expect(row.fromAddress).toBe('sender@example.com');
|
||||
|
||||
// Decrypting yields the original config (proves encrypt → store → decrypt works).
|
||||
const config = decryptEmailTransportConfig(row.config);
|
||||
expect(config).toEqual({ type: 'RESEND', apiKey });
|
||||
});
|
||||
|
||||
// ─── Create: SMTP_AUTH (non-secret + secret fields) ─────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: create an SMTP_AUTH transport stores host/port/username and encrypts the password', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-smtp-${nanoid()}`);
|
||||
const password = `pw_${nanoid()}`;
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
const dialog = await openCreateDialog(page);
|
||||
|
||||
await dialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await dialog.getByLabel('From name', { exact: true }).fill('SMTP Sender');
|
||||
await dialog.getByLabel('From address', { exact: true }).fill('smtp-sender@example.com');
|
||||
// Default type is SMTP_AUTH, so the host/port/username/password fields are already shown.
|
||||
await dialog.getByLabel('Host', { exact: true }).fill('smtp.example.com');
|
||||
await dialog.getByLabel('Port', { exact: true }).fill('587');
|
||||
await dialog.getByLabel('Username', { exact: true }).fill('smtp-user');
|
||||
await dialog.getByLabel('Password', { exact: true }).fill(password);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
const row = await getTransportFromDbOrThrow(name);
|
||||
|
||||
expect(row.config).not.toContain(password);
|
||||
|
||||
const config = decryptEmailTransportConfig(row.config);
|
||||
expect(config).toEqual({
|
||||
type: 'SMTP_AUTH',
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
ignoreTLS: false,
|
||||
username: 'smtp-user',
|
||||
password,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Update without a secret preserves the existing secret ───────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: updating without a secret keeps the existing secret intact', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-keep-${nanoid()}`);
|
||||
const originalApiKey = `re_keep_${nanoid()}`;
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
// Create the transport with a secret.
|
||||
const createDialog = await openCreateDialog(page);
|
||||
await createDialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await createDialog.getByLabel('From name', { exact: true }).fill('Keep Original');
|
||||
await createDialog.getByLabel('From address', { exact: true }).fill('keep@example.com');
|
||||
await selectTransportType(page, createDialog, 'Resend');
|
||||
await createDialog.getByLabel('API key', { exact: true }).fill(originalApiKey);
|
||||
await createDialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(createDialog).not.toBeVisible();
|
||||
|
||||
await getTransportFromDbOrThrow(name);
|
||||
|
||||
// Edit: change a non-secret field, leave the API key blank.
|
||||
await openRowAction(page, name, 'Edit');
|
||||
|
||||
const editDialog = page.getByRole('dialog');
|
||||
await expect(editDialog.getByRole('heading', { name: 'Edit Email Transport' })).toBeVisible();
|
||||
|
||||
// The secret field stays blank (we never re-enter it).
|
||||
await expect(editDialog.getByLabel('API key', { exact: true })).toHaveValue('');
|
||||
await editDialog.getByLabel('From name', { exact: true }).fill('Renamed Sender');
|
||||
await editDialog.getByRole('button', { name: 'Save changes' }).click();
|
||||
await expect(editDialog).not.toBeVisible();
|
||||
|
||||
// The update ran (fromName changed) but the original secret is preserved.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
return row.fromName;
|
||||
})
|
||||
.toBe('Renamed Sender');
|
||||
|
||||
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
const config = decryptEmailTransportConfig(row.config);
|
||||
expect(config).toEqual({ type: 'RESEND', apiKey: originalApiKey });
|
||||
});
|
||||
|
||||
// ─── Update with a new secret correctly replaces it ──────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: updating with a new secret replaces the stored secret', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-replace-${nanoid()}`);
|
||||
const originalApiKey = `re_old_${nanoid()}`;
|
||||
const newApiKey = `re_new_${nanoid()}`;
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
const createDialog = await openCreateDialog(page);
|
||||
await createDialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await createDialog.getByLabel('From name', { exact: true }).fill('Replace Secret');
|
||||
await createDialog.getByLabel('From address', { exact: true }).fill('replace@example.com');
|
||||
await selectTransportType(page, createDialog, 'Resend');
|
||||
await createDialog.getByLabel('API key', { exact: true }).fill(originalApiKey);
|
||||
await createDialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(createDialog).not.toBeVisible();
|
||||
|
||||
await getTransportFromDbOrThrow(name);
|
||||
|
||||
await openRowAction(page, name, 'Edit');
|
||||
|
||||
const editDialog = page.getByRole('dialog');
|
||||
await editDialog.getByLabel('API key', { exact: true }).fill(newApiKey);
|
||||
await editDialog.getByRole('button', { name: 'Save changes' }).click();
|
||||
await expect(editDialog).not.toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
const config = decryptEmailTransportConfig(row.config);
|
||||
return config.type === 'RESEND' ? config.apiKey : null;
|
||||
})
|
||||
.toBe(newApiKey);
|
||||
|
||||
// And it definitely no longer decrypts to the old secret.
|
||||
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
expect(row.config).not.toContain(originalApiKey);
|
||||
});
|
||||
|
||||
// ─── Delete ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: delete removes the transport', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-delete-${nanoid()}`);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
const createDialog = await openCreateDialog(page);
|
||||
await createDialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await createDialog.getByLabel('From name', { exact: true }).fill('To Delete');
|
||||
await createDialog.getByLabel('From address', { exact: true }).fill('delete@example.com');
|
||||
await selectTransportType(page, createDialog, 'Resend');
|
||||
await createDialog.getByLabel('API key', { exact: true }).fill(`re_${nanoid()}`);
|
||||
await createDialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(createDialog).not.toBeVisible();
|
||||
|
||||
const row = await getTransportFromDbOrThrow(name);
|
||||
|
||||
await openRowAction(page, name, 'Delete');
|
||||
|
||||
const deleteDialog = page.getByRole('dialog');
|
||||
await expect(deleteDialog.getByRole('heading', { name: 'Delete Email Transport' })).toBeVisible();
|
||||
await deleteDialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(deleteDialog).not.toBeVisible();
|
||||
|
||||
await expect.poll(async () => prisma.emailTransport.findUnique({ where: { id: row.id } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Access control ──────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: a non-admin cannot access the email transports page', async ({ page }) => {
|
||||
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
|
||||
|
||||
await apiSignin({ page, email: nonAdminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Add transport' })).not.toBeVisible();
|
||||
});
|
||||
@@ -526,7 +526,7 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
|
||||
// Should be able to access organisation settings
|
||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Update organisation' })).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
|
||||
|
||||
// Should have delete permissions
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
|
||||
import type { Organisation, Team, User } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Dynamic organisation rate-limit & quota tests — API **v1** edition.
|
||||
*
|
||||
* This is the v1 counterpart to `../v2/organisation-rate-limits.spec.ts`. It
|
||||
* covers the feature added in `feat: add dynamic rate limits`:
|
||||
* - Three counters: `api`, `document`, `email`.
|
||||
* - Two enforcement stages per counter:
|
||||
* 1. Windowed rate limits (`*RateLimits`) — a 429 distinguished by its
|
||||
* message. NOTE: in v2 this 429 also carries `X-RateLimit-*` headers, but
|
||||
* v1 does NOT surface them (the ts-rest handler drops the headers the
|
||||
* middleware returns — see the windowed test), so v1 tells the windowed
|
||||
* stage apart from the quota stage by the MESSAGE alone.
|
||||
* 2. Monthly quota (`*Quota`) — 429 WITHOUT rate-limit headers; a `null`
|
||||
* quota means unlimited and a `0` quota is a hard block.
|
||||
*
|
||||
* --- WHAT THIS V1 SUITE COVERS (and what it intentionally does NOT) ---
|
||||
* api -> every authenticated v1 request (get-api-token-by-token). Ported
|
||||
* 1:1 from the v2 suite against `GET /api/v1/documents`.
|
||||
* email -> resend (`POST /api/v1/documents/:id/resend`) consumes
|
||||
* `recipientsToRemind.length` SYNCHRONOUSLY (resend-document), so we
|
||||
* can assert on the HTTP response rather than racing async jobs.
|
||||
* IMPORTANT V1 DIVERGENCE: the v1 `resendDocument` handler catches
|
||||
* EVERY error and returns a generic HTTP 500
|
||||
* (`{ message: 'An error has occured while resending the document' }`)
|
||||
* — it does NOT surface the org limiter's 429 / `X-RateLimit-*`
|
||||
* headers like the v2 `redistribute` endpoint does. These tests
|
||||
* therefore assert the v1 reality: a blocked resend returns 500 and
|
||||
* the monthly counter advances exactly as documented.
|
||||
* document -> INTENTIONALLY OMITTED. v1's `POST /api/v1/documents` create path
|
||||
* requires S3 upload transport (createEnvelope), which the local E2E
|
||||
* environment generally does not provide, so it cannot be exercised
|
||||
* deterministically here. Document-counter enforcement is covered by
|
||||
* the v2 suite (envelope/create).
|
||||
*
|
||||
* --- WHY THIS TEST IS SKIPPED IN CI ---
|
||||
* CI runs E2E with `DANGEROUS_BYPASS_RATE_LIMITS=true`, which short-circuits BOTH
|
||||
* the per-org assertion and the global IP limiter, making every assertion here
|
||||
* meaningless. The test therefore skips itself in that mode and is intended to be
|
||||
* run deliberately and locally with the bypass OFF.
|
||||
*
|
||||
* --- GLOBAL LIMIT AWARENESS ---
|
||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
|
||||
* apiV1RateLimit = 100 requests / 1 minute (action `api.v1`, see rate-limits.ts).
|
||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||
* 429 is shaped `{ message }` — `expectOrgLimited()` asserts the 429 status AND
|
||||
* that we hit the org limiter rather than the global one.
|
||||
*/
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
|
||||
|
||||
// Run serially: all workers share one IP, and the global /api/v1 limiter is
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||
// bypass flag, so skip there; run it locally with the bypass turned off.
|
||||
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Test skipped because bypass rate limits is enabled.');
|
||||
|
||||
const WINDOWED_LIMIT_MESSAGE = /contact support if you require higher limits/i;
|
||||
const NO_QUOTA_MESSAGE = /request could not be completed at this time/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claim / usage control (direct Prisma) — mirrors recipient-count-limit.spec.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RateLimitEntry = { window: `${number}${'s' | 'm' | 'h' | 'd'}`; max: number };
|
||||
|
||||
type ClaimLimits = {
|
||||
apiRateLimits?: RateLimitEntry[];
|
||||
apiQuota?: number | null;
|
||||
documentRateLimits?: RateLimitEntry[];
|
||||
documentQuota?: number | null;
|
||||
emailRateLimits?: RateLimitEntry[];
|
||||
emailQuota?: number | null;
|
||||
};
|
||||
|
||||
const currentMonthlyPeriod = (): string => {
|
||||
const now = new Date();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
|
||||
return `${now.getUTCFullYear()}-${month}`;
|
||||
};
|
||||
|
||||
const getOrganisationClaim = async (team: Team) =>
|
||||
prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { organisation: { id: team.organisationId } },
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply a clean set of limits to the org's claim. Any counter not provided is
|
||||
* reset to "unlimited" (empty windows + null quota) so scenarios never leak into
|
||||
* each other.
|
||||
*/
|
||||
const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
|
||||
const claim = await getOrganisationClaim(team);
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: claim.id },
|
||||
data: {
|
||||
apiRateLimits: limits.apiRateLimits ?? [],
|
||||
apiQuota: limits.apiQuota === undefined ? null : limits.apiQuota,
|
||||
documentRateLimits: limits.documentRateLimits ?? [],
|
||||
documentQuota: limits.documentQuota === undefined ? null : limits.documentQuota,
|
||||
emailRateLimits: limits.emailRateLimits ?? [],
|
||||
emailQuota: limits.emailQuota === undefined ? null : limits.emailQuota,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the monthly quota counters, the org windowed rate-limit buckets AND the
|
||||
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
|
||||
*
|
||||
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
||||
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 100/min
|
||||
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
|
||||
* Across the suite (and especially across repeated local runs within the same
|
||||
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
||||
* limit under test, producing a `{ error }` 429 instead of the org `{ message }`
|
||||
* one. Since this suite runs deliberately in isolation (it skips in CI), we
|
||||
* clear that bucket here so the global limiter never masks the org assertion.
|
||||
*/
|
||||
const resetUsage = async (organisation: Organisation) => {
|
||||
const period = currentMonthlyPeriod();
|
||||
|
||||
await prisma.organisationMonthlyStat.updateMany({
|
||||
where: { organisationId: organisation.id, period },
|
||||
data: {
|
||||
documentCount: 0,
|
||||
emailCount: 0,
|
||||
apiCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.rateLimit.deleteMany({
|
||||
where: {
|
||||
OR: [{ key: `ip:org:${organisation.id}` }, { action: 'api.v1' }],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type MonthlyCounter = 'documentCount' | 'emailCount' | 'apiCount';
|
||||
|
||||
const getMonthlyStat = async (organisation: Organisation) =>
|
||||
prisma.organisationMonthlyStat.findUnique({
|
||||
where: {
|
||||
organisationId_period: { organisationId: organisation.id, period: currentMonthlyPeriod() },
|
||||
},
|
||||
select: { documentCount: true, emailCount: true, apiCount: true },
|
||||
});
|
||||
|
||||
/**
|
||||
* Assert the live OrganisationMonthlyStat counter equals `expected`.
|
||||
*
|
||||
* The DB counter is the source of truth for quota enforcement, so checking its
|
||||
* exact value (not just the HTTP response) proves the documented increment
|
||||
* semantics in check-monthly-quota.ts:
|
||||
* - quota === null -> unlimited: never blocks, but the request is STILL
|
||||
* counted (the upsert now runs before the null return)
|
||||
* - quota === 0 -> throws BEFORE increment (stays 0)
|
||||
* - quota > 0 -> incremented by `count` BEFORE the over-quota check, so
|
||||
* even the request that gets rejected still advances it
|
||||
* - windowed limit -> trips BEFORE the quota stage, so the counter is untouched
|
||||
*/
|
||||
const expectMonthlyCounter = async (organisation: Organisation, counter: MonthlyCounter, expected: number) => {
|
||||
const stat = await getMonthlyStat(organisation);
|
||||
|
||||
expect(stat?.[counter] ?? 0, `${counter} should be exactly ${expected}`).toBe(expected);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sleep until just after the next windowed-limit bucket boundary.
|
||||
*
|
||||
* The limiter (createRateLimit -> getBucket) buckets time as
|
||||
* `now - (now % windowMs)` aligned to the epoch. A windowed exhaustion test must
|
||||
* land all of its MAX+1 requests inside ONE bucket; if the requests straddle a
|
||||
* boundary the counter resets mid-test and the expected 429 never happens. We
|
||||
* share the server's clock (same host), so aligning to a fresh bucket here makes
|
||||
* the exhaustion deterministic.
|
||||
*/
|
||||
const alignToFreshWindowBucket = async (windowSeconds: number) => {
|
||||
const windowMs = windowSeconds * 1000;
|
||||
const msUntilNextBucket = windowMs - (Date.now() % windowMs);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, msUntilNextBucket + 100));
|
||||
};
|
||||
|
||||
/**
|
||||
* Guarantee at least `requiredHeadroomMs` remain in the current bucket so a burst
|
||||
* of MAX+1 requests completes inside ONE window. Without this, a burst that
|
||||
* happens to cross a bucket boundary would have its count reset mid-test and the
|
||||
* expected 429 would never fire. Unlike `alignToFreshWindowBucket`, this only
|
||||
* sleeps when we are actually near a boundary, so for long (e.g. 1m) windows it
|
||||
* is almost always a no-op.
|
||||
*/
|
||||
const ensureWindowHeadroom = async (windowSeconds: number, requiredHeadroomMs: number) => {
|
||||
const windowMs = windowSeconds * 1000;
|
||||
const msLeftInBucket = windowMs - (Date.now() % windowMs);
|
||||
|
||||
if (msLeftInBucket < requiredHeadroomMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, msLeftInBucket + 100));
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ApiErrorBody = { message?: string; error?: string };
|
||||
|
||||
/**
|
||||
* Non-throwing predicate: true when the response is an ORG-level 429
|
||||
* (`{ message }`), not the global IP 429 (`{ error }`). Used by the preflight,
|
||||
* which needs a boolean to decide whether to skip rather than fail.
|
||||
*/
|
||||
const isOrgLimited = async (res: APIResponse): Promise<boolean> => {
|
||||
if (res.status() !== 429) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = (await res.json().catch(() => ({}))) as ApiErrorBody;
|
||||
|
||||
// Global limiter returns `{ error }`; org limiter returns `{ message }`.
|
||||
return body.message !== undefined && body.error === undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert the response is an ORG-level 429 and return its parsed body.
|
||||
*
|
||||
* Checks the status code EXPLICITLY so a wrong 200/4xx fails with a clear
|
||||
* "Expected 429, got <status>: <body>" message instead of an opaque
|
||||
* `expected true, received false`. Also asserts the body is the org limiter's
|
||||
* `{ message }` shape and not the global limiter's `{ error }` shape, so a
|
||||
* global-IP 429 can never be mistaken for the org limit under test.
|
||||
*/
|
||||
const expectOrgLimited = async (res: APIResponse): Promise<ApiErrorBody> => {
|
||||
const bodyText = await res.text();
|
||||
|
||||
expect(res.status(), `Expected an org 429 but got ${res.status()} with body: ${bodyText}`).toBe(429);
|
||||
|
||||
let body: ApiErrorBody = {};
|
||||
|
||||
try {
|
||||
body = JSON.parse(bodyText) as ApiErrorBody;
|
||||
} catch {
|
||||
throw new Error(`Expected a JSON error body, got: ${bodyText}`);
|
||||
}
|
||||
|
||||
expect(
|
||||
body.message !== undefined && body.error === undefined,
|
||||
`429 should be the ORG limiter ({ message }), not the global limiter ({ error }). Got: ${bodyText}`,
|
||||
).toBeTruthy();
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert NO org rate-limit header was surfaced — the GLOBAL /api/v1 middleware
|
||||
* still stamps a single `X-RateLimit-Limit: 100`, so "no org header" means the
|
||||
* value is either absent or exactly the lone global `100` (i.e. it does not
|
||||
* contain a second, org-specific entry).
|
||||
*
|
||||
* In v1 this holds for BOTH stages: quota rejections intentionally omit
|
||||
* rate-limit headers, AND windowed rejections lose theirs because the ts-rest
|
||||
* handler ignores the `headers` the middleware returns (see the windowed test).
|
||||
*/
|
||||
const expectNoOrgRateLimitHeader = (res: APIResponse) => {
|
||||
const header = res.headers()['x-ratelimit-limit'];
|
||||
|
||||
if (header === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = header.split(',').map((v) => v.trim());
|
||||
|
||||
expect(values, `Quota rejection should not add an org X-RateLimit-Limit, got "${header}"`).toEqual(['100']);
|
||||
};
|
||||
|
||||
/** Guard against the global limiter silently masking an org assertion. */
|
||||
const expectNotGlobalLimited = async (res: APIResponse) => {
|
||||
if (res.status() === 429) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
expect(
|
||||
'error' in body && !('message' in body),
|
||||
'Hit the GLOBAL /api/v1 IP limiter, not the org limiter. Re-run this suite in isolation.',
|
||||
).toBeFalsy();
|
||||
}
|
||||
};
|
||||
|
||||
/** Cheap read endpoint — consumes exactly one `api` counter, no document/email. */
|
||||
const findDocuments = (request: APIRequestContext, token: string): Promise<APIResponse> =>
|
||||
request.get(`${baseUrl}/documents?page=1&perPage=1`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
/**
|
||||
* Resend (remind) the given recipients. This runs the SYNCHRONOUS email assertion
|
||||
* in resend-document with `count = recipients.length`.
|
||||
*
|
||||
* NOTE: unlike the v2 `redistribute` endpoint, the v1 `resendDocument` handler
|
||||
* wraps everything in a try/catch and returns a generic HTTP 500 on ANY error
|
||||
* (including the org limiter's TOO_MANY_REQUESTS AppError). So when the email
|
||||
* limit/quota is exceeded this resolves to a 500, NOT a 429.
|
||||
*/
|
||||
const resendDocument = (
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
documentId: number,
|
||||
recipientIds: number[],
|
||||
): Promise<APIResponse> =>
|
||||
request.post(`${baseUrl}/documents/${documentId}/resend`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { recipients: recipientIds },
|
||||
});
|
||||
|
||||
/**
|
||||
* Assert a resend was blocked by the org email limiter.
|
||||
*
|
||||
* v1's handler masks the limiter's 429 as a generic HTTP 500 (see `resendDocument`
|
||||
* above), so the only signal available on the HTTP layer is the 500 status. The
|
||||
* accompanying `expectMonthlyCounter` assertions in each test prove WHICH stage
|
||||
* blocked it (windowed leaves the counter untouched; quota advances it).
|
||||
*/
|
||||
const expectResendBlocked = async (res: APIResponse) => {
|
||||
const bodyText = await res.text();
|
||||
|
||||
expect(
|
||||
res.status(),
|
||||
`Expected the v1 resend to be blocked (masked as HTTP 500) but got ${res.status()} with body: ${bodyText}`,
|
||||
).toBe(500);
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed a PENDING document with `recipientCount` NOT_SIGNED signer recipients (each
|
||||
* carrying a signature field) created directly via Prisma — so no async signing
|
||||
* emails are fanned out and the monthly email counter starts clean. Returns the
|
||||
* legacy document id (for the resend endpoint) and the recipient ids to remind.
|
||||
*/
|
||||
const seedRemindableDocument = async ({
|
||||
owner,
|
||||
team,
|
||||
recipientCount,
|
||||
}: {
|
||||
owner: User;
|
||||
team: Team;
|
||||
recipientCount: number;
|
||||
}): Promise<{ documentId: number; recipientIds: number[] }> => {
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner,
|
||||
teamId: team.id,
|
||||
recipients: Array.from(
|
||||
{ length: recipientCount },
|
||||
(_, i) => `rl-${Date.now()}-${i}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
|
||||
),
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
return {
|
||||
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
|
||||
recipientIds: recipients.map((recipient) => recipient.id),
|
||||
};
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
test.describe('Organisation dynamic rate limits & quotas (API v1)', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let organisation: Organisation;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
const seeded = await seedUser();
|
||||
user = seeded.user;
|
||||
team = seeded.team;
|
||||
organisation = seeded.organisation;
|
||||
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-org-rate-limits',
|
||||
expiresIn: null,
|
||||
}));
|
||||
|
||||
// Preflight: the `test.skip` above only sees the PLAYWRIGHT process env. The
|
||||
// value that actually matters is the env the SERVER was started with — if the
|
||||
// server has `DANGEROUS_BYPASS_RATE_LIMITS=true`, every assertion here would
|
||||
// fail confusingly instead of skipping. Prove enforcement is live by setting a
|
||||
// quota of 0 (instant hard block) and confirming the server rejects. If it
|
||||
// doesn't, the server is bypassing limits, so skip with a clear message.
|
||||
await setClaimLimits(team, { apiQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const preflight = await findDocuments(request, token);
|
||||
const enforced = await isOrgLimited(preflight);
|
||||
|
||||
// Reset back to a clean slate before the real scenario runs.
|
||||
await setClaimLimits(team, {});
|
||||
await resetUsage(organisation);
|
||||
|
||||
test.skip(
|
||||
!enforced,
|
||||
'Server is not enforcing organisation rate limits (likely started with DANGEROUS_BYPASS_RATE_LIMITS=true). Restart the server with the flag unset/false to run this suite.',
|
||||
);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// API counter — windowed rate limit
|
||||
// =========================================================================
|
||||
|
||||
test.describe('api rate limit (windowed)', () => {
|
||||
test('allows requests up to the limit then 429s with rate-limit headers', async ({ request }) => {
|
||||
const MAX = 4;
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure the MAX+1 request burst lands inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
// Each request (including these GETs) consumes one api counter.
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||
}
|
||||
|
||||
// The next request is over the windowed limit.
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
// The windowed limit uses a message distinct from the global limiter — and
|
||||
// in v1 the MESSAGE is the only signal we get (see note below), so it is how
|
||||
// we tell a windowed rejection apart from a quota one.
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
|
||||
// V1 DIVERGENCE: unlike v2, v1's ts-rest handler does not propagate the org
|
||||
// limiter's `X-RateLimit-*` headers. `authenticatedMiddleware` returns them
|
||||
// on the body object (`headers: err.headers`), which `@ts-rest/serverless`
|
||||
// ignores (custom headers must be written to the `responseHeaders` Headers
|
||||
// object). So only the global middleware's lone `X-RateLimit-Limit: 100`
|
||||
// survives — the org `max` and `Retry-After`/`Remaining` never reach the
|
||||
// client. We therefore assert no org-specific header is surfaced.
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// The windowed stage blocks the (MAX+1)th request before the quota upsert,
|
||||
// but each of the MAX allowed requests still records usage (null quota now
|
||||
// tracks instead of skipping), so the counter equals MAX.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', MAX);
|
||||
});
|
||||
|
||||
test('a single allowed request succeeds when the limit is 1', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: 1 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure both requests land inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
const okRes = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(okRes);
|
||||
expect(okRes.status()).toBe(200);
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
|
||||
// The one allowed request is counted (null quota still tracks); the blocked
|
||||
// request trips the window before the quota upsert, so the counter is 1.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 1);
|
||||
});
|
||||
|
||||
test('the windowed limit RESETS once the window elapses (429 -> wait -> 200)', async ({ request }) => {
|
||||
const MAX = 2;
|
||||
const WINDOW_SECONDS = 3;
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: `${WINDOW_SECONDS}s`, max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Land at the start of a fresh bucket so all MAX+1 requests below fall in
|
||||
// the SAME window (otherwise a mid-exhaustion boundary would reset the count).
|
||||
await alignToFreshWindowBucket(WINDOW_SECONDS);
|
||||
|
||||
// Exhaust the window.
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||
}
|
||||
|
||||
// The next request is blocked by the window.
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// Wait out the window using the server-provided Retry-After (plus a small
|
||||
// buffer to be sure we've crossed into the next time bucket). Crucially we
|
||||
// do NOT reset usage here — the limiter must recover on its own as the
|
||||
// bucket rolls over.
|
||||
const retryAfterHeader = limitedRes.headers()['retry-after'] ?? String(WINDOW_SECONDS);
|
||||
const retryAfterSeconds = Number.parseInt(retryAfterHeader.split(',')[0]?.trim() ?? '', 10) || WINDOW_SECONDS;
|
||||
await new Promise((resolve) => setTimeout(resolve, (retryAfterSeconds + 1) * 1000));
|
||||
|
||||
// Window has elapsed: the same org can make requests again without any
|
||||
// manual intervention — the bucket rolled over on its own.
|
||||
const afterReset = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(afterReset);
|
||||
expect(afterReset.status(), 'request after the window elapsed should be allowed').toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// API counter — monthly quota
|
||||
// =========================================================================
|
||||
|
||||
test.describe('api quota (monthly)', () => {
|
||||
test('null quota allows unlimited requests', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: null });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// A null quota means "unlimited" (never blocks), but every request is now
|
||||
// recorded so usage is visible on unlimited plans — so the counter is 6.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 6);
|
||||
});
|
||||
|
||||
test('exhausting the quota 429s without rate-limit headers and keeps counting', async ({ request }) => {
|
||||
const QUOTA = 3;
|
||||
await setClaimLimits(team, { apiQuota: QUOTA });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be within quota`).toBe(200);
|
||||
}
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// Quota rejections deliberately omit rate-limit headers (it isn't a window).
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// The atomic increment runs even on the rejected request: QUOTA allowed
|
||||
// requests + the one rejected request = exactly QUOTA + 1.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||
});
|
||||
|
||||
test('quota of exactly 1 allows one request then blocks', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: 1 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const okRes = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(okRes);
|
||||
expect(okRes.status()).toBe(200);
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// One allowed + one rejected, both incremented => exactly 2.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 2);
|
||||
});
|
||||
|
||||
test('quota of 0 is a hard block with a "no quota available" message', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Email counter — windowed rate limit (via synchronous resend)
|
||||
// =========================================================================
|
||||
|
||||
test.describe('email rate limit (windowed)', () => {
|
||||
test('resend is allowed when recipient count is within the email window', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 2 });
|
||||
|
||||
// Window allows 5/min; reminding 2 recipients is fine. Reset usage so the
|
||||
// seeding above doesn't count against this window.
|
||||
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 5 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `resend should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
// The windowed pass is now recorded even though the quota is null, so the
|
||||
// counter advances by the batch size (recipientIds.length).
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('resend is blocked when recipient count exceeds the email window', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 3 });
|
||||
|
||||
// Window only allows 2 emails per minute; reminding 3 at once exceeds it.
|
||||
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 2 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
// v1 masks the org 429 as a generic HTTP 500.
|
||||
await expectResendBlocked(res);
|
||||
|
||||
// Windowed limit trips BEFORE the quota stage, so the counter is untouched.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Email counter — monthly quota (via synchronous resend)
|
||||
// =========================================================================
|
||||
|
||||
test.describe('email quota (monthly)', () => {
|
||||
test('resend within the remaining email quota succeeds', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 2 });
|
||||
|
||||
await setClaimLimits(team, { emailQuota: 10 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `resend should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
// The synchronous assertion consumed exactly `recipientIds.length` of quota.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('resend that would exceed the email quota is blocked', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 3 });
|
||||
|
||||
// Quota of 2 but reminding 3 recipients in one synchronous call.
|
||||
await setClaimLimits(team, { emailQuota: 2 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
// v1 masks the org 429 as a generic HTTP 500.
|
||||
await expectResendBlocked(res);
|
||||
|
||||
// The count (3) is added BEFORE the over-quota check throws, so the counter
|
||||
// advances by the full batch even though the request was rejected.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('email quota of 0 hard-blocks reminders', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 1 });
|
||||
|
||||
await setClaimLimits(team, { emailQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
// v1 masks the org 429 as a generic HTTP 500.
|
||||
await expectResendBlocked(res);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Stage interaction — quota binds before a looser window
|
||||
// =========================================================================
|
||||
|
||||
test.describe('stage interaction', () => {
|
||||
test('the quota trips before a looser windowed limit', async ({ request }) => {
|
||||
const WINDOW_MAX = 50; // generous window
|
||||
const QUOTA = 2; // strict quota — should bind first
|
||||
await setClaimLimits(team, {
|
||||
apiRateLimits: [{ window: '1m', max: WINDOW_MAX }],
|
||||
apiQuota: QUOTA,
|
||||
});
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
|
||||
// It must be the QUOTA that bound, not the window: the message is the quota
|
||||
// one (not the windowed-limit message) and there are no rate-limit headers.
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
expect(String(body.message)).not.toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// Quota bound at QUOTA + 1; the looser window (50) was never the limiter.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,917 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
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 { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
|
||||
import type { Organisation, Team, User } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Dynamic organisation rate-limit & quota tests.
|
||||
*
|
||||
* Covers the feature added in `feat: add dynamic rate limits`:
|
||||
* - Three counters: `api`, `document`, `email`.
|
||||
* - Two enforcement stages per counter:
|
||||
* 1. Windowed rate limits (`*RateLimits`) — 429 WITH `X-RateLimit-*` headers.
|
||||
* 2. Monthly quota (`*Quota`) — 429 WITHOUT rate-limit headers; a `null`
|
||||
* quota means unlimited and a `0` quota is a hard block.
|
||||
*
|
||||
* Where each counter is consumed:
|
||||
* api -> every authenticated v2 request (get-api-token-by-token).
|
||||
* document -> envelope create where type === DOCUMENT (count 1).
|
||||
* email -> redistribute/remind consumes `recipientsToRemind.length`
|
||||
* SYNCHRONOUSLY (resend-document), so we can assert on the HTTP
|
||||
* response rather than racing async signing-email jobs.
|
||||
*
|
||||
* --- WHY THIS TEST IS SKIPPED IN CI ---
|
||||
* CI runs E2E with `DANGEROUS_BYPASS_RATE_LIMITS=true`, which short-circuits BOTH
|
||||
* the per-org assertion and the global IP limiter, making every assertion here
|
||||
* meaningless. The test therefore skips itself in that mode and is intended to be
|
||||
* run deliberately and locally with the bypass OFF.
|
||||
*
|
||||
* --- GLOBAL LIMIT AWARENESS ---
|
||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
|
||||
* apiV2RateLimit = 100 requests / 1 minute (see rate-limits.ts).
|
||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||
* 429 is shaped `{ message }` — `expectOrgLimited()` asserts the 429 status AND
|
||||
* that we hit the org limiter rather than the global one.
|
||||
*/
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
// Run serially: all workers share one IP, and the global /api/v2 limiter is
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||
// bypass flag, so skip there; run it locally with the bypass turned off.
|
||||
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Test skipped because bypass rate limits is enabled.');
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
|
||||
|
||||
const WINDOWED_LIMIT_MESSAGE = /contact support if you require higher limits/i;
|
||||
const NO_QUOTA_MESSAGE = /request could not be completed at this time/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claim / usage control (direct Prisma) — mirrors recipient-count-limit.spec.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RateLimitEntry = { window: `${number}${'s' | 'm' | 'h' | 'd'}`; max: number };
|
||||
|
||||
type ClaimLimits = {
|
||||
apiRateLimits?: RateLimitEntry[];
|
||||
apiQuota?: number | null;
|
||||
documentRateLimits?: RateLimitEntry[];
|
||||
documentQuota?: number | null;
|
||||
emailRateLimits?: RateLimitEntry[];
|
||||
emailQuota?: number | null;
|
||||
};
|
||||
|
||||
const currentMonthlyPeriod = (): string => {
|
||||
const now = new Date();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
|
||||
return `${now.getUTCFullYear()}-${month}`;
|
||||
};
|
||||
|
||||
const getOrganisationClaim = async (team: Team) =>
|
||||
prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { organisation: { id: team.organisationId } },
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply a clean set of limits to the org's claim. Any counter not provided is
|
||||
* reset to "unlimited" (empty windows + null quota) so scenarios never leak into
|
||||
* each other.
|
||||
*/
|
||||
const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
|
||||
const claim = await getOrganisationClaim(team);
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: claim.id },
|
||||
data: {
|
||||
apiRateLimits: limits.apiRateLimits ?? [],
|
||||
apiQuota: limits.apiQuota === undefined ? null : limits.apiQuota,
|
||||
documentRateLimits: limits.documentRateLimits ?? [],
|
||||
documentQuota: limits.documentQuota === undefined ? null : limits.documentQuota,
|
||||
emailRateLimits: limits.emailRateLimits ?? [],
|
||||
emailQuota: limits.emailQuota === undefined ? null : limits.emailQuota,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the monthly quota counters, the org windowed rate-limit buckets AND the
|
||||
* GLOBAL /api/v2 IP bucket so a fresh scenario starts from zero.
|
||||
*
|
||||
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
||||
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV2RateLimit, 100/min
|
||||
* per IP, action `api.v2`) is shared by EVERY v2 request from this test client.
|
||||
* Across the suite (and especially across repeated local runs within the same
|
||||
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
||||
* limit under test, producing a `{ error }` 429 instead of the org `{ message }`
|
||||
* one. Since this suite runs deliberately in isolation (it skips in CI), we
|
||||
* clear that bucket here so the global limiter never masks the org assertion.
|
||||
*/
|
||||
const resetUsage = async (organisation: Organisation) => {
|
||||
const period = currentMonthlyPeriod();
|
||||
|
||||
await prisma.organisationMonthlyStat.updateMany({
|
||||
where: { organisationId: organisation.id, period },
|
||||
data: {
|
||||
documentCount: 0,
|
||||
emailCount: 0,
|
||||
apiCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.rateLimit.deleteMany({
|
||||
where: {
|
||||
OR: [{ key: `ip:org:${organisation.id}` }, { action: 'api.v2' }],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type MonthlyCounter = 'documentCount' | 'emailCount' | 'apiCount';
|
||||
|
||||
const getMonthlyStat = async (organisation: Organisation) =>
|
||||
prisma.organisationMonthlyStat.findUnique({
|
||||
where: {
|
||||
organisationId_period: { organisationId: organisation.id, period: currentMonthlyPeriod() },
|
||||
},
|
||||
select: { documentCount: true, emailCount: true, apiCount: true },
|
||||
});
|
||||
|
||||
/**
|
||||
* Assert the live OrganisationMonthlyStat counter equals `expected`.
|
||||
*
|
||||
* The DB counter is the source of truth for quota enforcement, so checking its
|
||||
* exact value (not just the HTTP response) proves the documented increment
|
||||
* semantics in check-monthly-quota.ts:
|
||||
* - quota === null -> unlimited: never blocks, but the request is STILL
|
||||
* counted (the upsert now runs before the null return)
|
||||
* - quota === 0 -> throws BEFORE increment (stays 0)
|
||||
* - quota > 0 -> incremented by `count` BEFORE the over-quota check, so
|
||||
* even the request that gets rejected still advances it
|
||||
* - windowed limit -> trips BEFORE the quota stage, so the counter is untouched
|
||||
*/
|
||||
const expectMonthlyCounter = async (organisation: Organisation, counter: MonthlyCounter, expected: number) => {
|
||||
const stat = await getMonthlyStat(organisation);
|
||||
|
||||
expect(stat?.[counter] ?? 0, `${counter} should be exactly ${expected}`).toBe(expected);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait until a monthly counter reaches `atLeast` and then stops changing.
|
||||
*
|
||||
* `distribute` fans out one async signing-request email job per recipient (the
|
||||
* local job runner fires them via fire-and-forget HTTP, so they complete after
|
||||
* the call returns). Each job increments emailCount. We poll until the counter
|
||||
* has reached the expected floor AND is stable across consecutive reads, which
|
||||
* guarantees no late job will increment the counter after the caller resets
|
||||
* usage — making the subsequent (synchronous) redistribute assertions exact.
|
||||
*/
|
||||
const waitForCounterToSettle = async (
|
||||
organisation: Organisation,
|
||||
counter: MonthlyCounter,
|
||||
atLeast: number,
|
||||
timeoutMs = 20_000,
|
||||
): Promise<number> => {
|
||||
const start = Date.now();
|
||||
let previous = -1;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const stat = await getMonthlyStat(organisation);
|
||||
const current = stat?.[counter] ?? 0;
|
||||
|
||||
if (current >= atLeast && current === previous) {
|
||||
return current;
|
||||
}
|
||||
|
||||
previous = current;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${counter} to settle at >= ${atLeast}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sleep until just after the next windowed-limit bucket boundary.
|
||||
*
|
||||
* The limiter (createRateLimit -> getBucket) buckets time as
|
||||
* `now - (now % windowMs)` aligned to the epoch. A windowed exhaustion test must
|
||||
* land all of its MAX+1 requests inside ONE bucket; if the requests straddle a
|
||||
* boundary the counter resets mid-test and the expected 429 never happens. We
|
||||
* share the server's clock (same host), so aligning to a fresh bucket here makes
|
||||
* the exhaustion deterministic.
|
||||
*/
|
||||
const alignToFreshWindowBucket = async (windowSeconds: number) => {
|
||||
const windowMs = windowSeconds * 1000;
|
||||
const msUntilNextBucket = windowMs - (Date.now() % windowMs);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, msUntilNextBucket + 100));
|
||||
};
|
||||
|
||||
/**
|
||||
* Guarantee at least `requiredHeadroomMs` remain in the current bucket so a burst
|
||||
* of MAX+1 requests completes inside ONE window. Without this, a burst that
|
||||
* happens to cross a bucket boundary would have its count reset mid-test and the
|
||||
* expected 429 would never fire. Unlike `alignToFreshWindowBucket`, this only
|
||||
* sleeps when we are actually near a boundary, so for long (e.g. 1m) windows it
|
||||
* is almost always a no-op.
|
||||
*/
|
||||
const ensureWindowHeadroom = async (windowSeconds: number, requiredHeadroomMs: number) => {
|
||||
const windowMs = windowSeconds * 1000;
|
||||
const msLeftInBucket = windowMs - (Date.now() % windowMs);
|
||||
|
||||
if (msLeftInBucket < requiredHeadroomMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, msLeftInBucket + 100));
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ApiErrorBody = { message?: string; error?: string };
|
||||
|
||||
/**
|
||||
* Non-throwing predicate: true when the response is an ORG-level 429
|
||||
* (`{ message }`), not the global IP 429 (`{ error }`). Used by the preflight,
|
||||
* which needs a boolean to decide whether to skip rather than fail.
|
||||
*/
|
||||
const isOrgLimited = async (res: APIResponse): Promise<boolean> => {
|
||||
if (res.status() !== 429) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = (await res.json().catch(() => ({}))) as ApiErrorBody;
|
||||
|
||||
// Global limiter returns `{ error }`; org limiter returns `{ message }`.
|
||||
return body.message !== undefined && body.error === undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert the response is an ORG-level 429 and return its parsed body.
|
||||
*
|
||||
* Checks the status code EXPLICITLY so a wrong 200/4xx fails with a clear
|
||||
* "Expected 429, got <status>: <body>" message instead of an opaque
|
||||
* `expected true, received false`. Also asserts the body is the org limiter's
|
||||
* `{ message }` shape and not the global limiter's `{ error }` shape, so a
|
||||
* global-IP 429 can never be mistaken for the org limit under test.
|
||||
*/
|
||||
const expectOrgLimited = async (res: APIResponse): Promise<ApiErrorBody> => {
|
||||
const bodyText = await res.text();
|
||||
|
||||
expect(res.status(), `Expected an org 429 but got ${res.status()} with body: ${bodyText}`).toBe(429);
|
||||
|
||||
let body: ApiErrorBody = {};
|
||||
|
||||
try {
|
||||
body = JSON.parse(bodyText) as ApiErrorBody;
|
||||
} catch {
|
||||
throw new Error(`Expected a JSON error body, got: ${bodyText}`);
|
||||
}
|
||||
|
||||
expect(
|
||||
body.message !== undefined && body.error === undefined,
|
||||
`429 should be the ORG limiter ({ message }), not the global limiter ({ error }). Got: ${bodyText}`,
|
||||
).toBeTruthy();
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert the org windowed-limit value is present in `X-RateLimit-Limit`.
|
||||
*
|
||||
* Two limiters set this header: the GLOBAL /api/v2 middleware (max 100) sets it
|
||||
* first, then the org limiter's AppError sets it to the org `max`. Playwright
|
||||
* surfaces duplicate headers joined by ", " (e.g. "100, 4"), so we assert the
|
||||
* org value is one of the comma-separated entries rather than an exact match.
|
||||
*/
|
||||
const expectRateLimitHeaderToInclude = (res: APIResponse, expectedMax: number) => {
|
||||
const header = res.headers()['x-ratelimit-limit'] ?? '';
|
||||
const values = header.split(',').map((v) => v.trim());
|
||||
|
||||
expect(values, `X-RateLimit-Limit "${header}" should include the org max ${expectedMax}`).toContain(
|
||||
String(expectedMax),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert NO org rate-limit header was added — used for quota rejections, which
|
||||
* intentionally omit rate-limit headers (a quota isn't a window). The GLOBAL
|
||||
* middleware still stamps a single `X-RateLimit-Limit: 100`, so "no org header"
|
||||
* means the value is either absent or exactly the lone global `100` (i.e. it does
|
||||
* not contain a second, org-specific entry).
|
||||
*/
|
||||
const expectNoOrgRateLimitHeader = (res: APIResponse) => {
|
||||
const header = res.headers()['x-ratelimit-limit'];
|
||||
|
||||
if (header === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = header.split(',').map((v) => v.trim());
|
||||
|
||||
expect(values, `Quota rejection should not add an org X-RateLimit-Limit, got "${header}"`).toEqual(['100']);
|
||||
};
|
||||
|
||||
/** Guard against the global limiter silently masking an org assertion. */
|
||||
const expectNotGlobalLimited = async (res: APIResponse) => {
|
||||
if (res.status() === 429) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
expect(
|
||||
'error' in body && !('message' in body),
|
||||
'Hit the GLOBAL /api/v2 IP limiter, not the org limiter. Re-run this suite in isolation.',
|
||||
).toBeFalsy();
|
||||
}
|
||||
};
|
||||
|
||||
/** Cheap read endpoint — consumes exactly one `api` counter, no document/email. */
|
||||
const findEnvelopes = (request: APIRequestContext, token: string): Promise<APIResponse> =>
|
||||
request.get(`${baseUrl}/envelope?perPage=1`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a DOCUMENT envelope. Consumes one `api` counter and, when
|
||||
* `type === DOCUMENT`, one `document` counter. Optionally seeds SIGNER recipients
|
||||
* (each with a signature field) so the envelope can later be distributed.
|
||||
*/
|
||||
const createEnvelope = async (
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
options: { recipientCount?: number } = {},
|
||||
): Promise<APIResponse> => {
|
||||
const { recipientCount = 0 } = options;
|
||||
|
||||
const payload: TCreateEnvelopePayload = {
|
||||
title: `Rate limit test ${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
recipients:
|
||||
recipientCount > 0
|
||||
? Array.from({ length: recipientCount }, (_, i) => ({
|
||||
email: `rl-${Date.now()}-${i}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
|
||||
name: `Recipient ${i}`,
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: i + 1,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
fields: [
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
fieldMeta: { type: 'signature', overflow: 'crop' },
|
||||
identifier: 0,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 80,
|
||||
width: 20,
|
||||
height: 5,
|
||||
},
|
||||
],
|
||||
}))
|
||||
: undefined,
|
||||
meta: {
|
||||
subject: 'Rate limit test',
|
||||
message: 'Automated rate-limit test. Ignore.',
|
||||
distributionMethod: 'EMAIL',
|
||||
},
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
return request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
};
|
||||
|
||||
/** Distribute an envelope to all of its recipients via EMAIL. */
|
||||
const distributeEnvelope = (request: APIRequestContext, token: string, envelopeId: string): Promise<APIResponse> =>
|
||||
request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId,
|
||||
meta: { distributionMethod: 'EMAIL', subject: 'Rate limit test', message: 'Rate limit test' },
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Redistribute (remind) the given recipients. This runs the SYNCHRONOUS email
|
||||
* assertion in resend-document with `count = recipients.length`, returning a 429
|
||||
* directly when the email limit/quota is exceeded.
|
||||
*/
|
||||
const redistributeEnvelope = (
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
envelopeId: string,
|
||||
recipientIds: number[],
|
||||
): Promise<APIResponse> =>
|
||||
request.post(`${baseUrl}/envelope/redistribute`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId, recipients: recipientIds },
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a fully-distributed envelope and return its NOT_SIGNED recipient IDs so a
|
||||
* subsequent redistribute can exercise the synchronous email assertion.
|
||||
*
|
||||
* Setup uses a GENEROUS email quota so the async signing-request emails fanned out
|
||||
* by `distribute` are counted, then waits for that counter to settle. This drains
|
||||
* the background jobs BEFORE the caller resets usage, so they can't pollute
|
||||
* emailCount mid-test. The caller then configures the email limit/quota under test
|
||||
* and resets usage, so only the (synchronous, deterministic) redistribute counts.
|
||||
*/
|
||||
const seedDistributedEnvelope = async ({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount,
|
||||
}: {
|
||||
request: APIRequestContext;
|
||||
token: string;
|
||||
team: Team;
|
||||
organisation: Organisation;
|
||||
recipientCount: number;
|
||||
}): Promise<{ envelopeId: string; recipientIds: number[] }> => {
|
||||
await setClaimLimits(team, { emailQuota: 1000 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const createRes = await createEnvelope(request, token, { recipientCount });
|
||||
expect(createRes.ok(), `create failed: ${await createRes.text()}`).toBeTruthy();
|
||||
const { id: envelopeId } = (await createRes.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
const distributeRes = await distributeEnvelope(request, token, envelopeId);
|
||||
expect(distributeRes.ok(), `distribute failed: ${await distributeRes.text()}`).toBeTruthy();
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Drain the async signing-request email jobs (one per recipient) so a late job
|
||||
// cannot increment emailCount after the caller's resetUsage.
|
||||
await waitForCounterToSettle(organisation, 'emailCount', recipientCount);
|
||||
|
||||
return { envelopeId, recipientIds: recipients.map((r) => r.id) };
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
test.describe('Organisation dynamic rate limits & quotas', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let organisation: Organisation;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
const seeded = await seedUser();
|
||||
user = seeded.user;
|
||||
team = seeded.team;
|
||||
organisation = seeded.organisation;
|
||||
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-org-rate-limits',
|
||||
expiresIn: null,
|
||||
}));
|
||||
|
||||
// Preflight: the `test.skip` above only sees the PLAYWRIGHT process env. The
|
||||
// value that actually matters is the env the SERVER was started with — if the
|
||||
// server has `DANGEROUS_BYPASS_RATE_LIMITS=true`, every assertion here would
|
||||
// fail confusingly instead of skipping. Prove enforcement is live by setting a
|
||||
// quota of 0 (instant hard block) and confirming the server rejects. If it
|
||||
// doesn't, the server is bypassing limits, so skip with a clear message.
|
||||
await setClaimLimits(team, { apiQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const preflight = await findEnvelopes(request, token);
|
||||
const enforced = await isOrgLimited(preflight);
|
||||
|
||||
// Reset back to a clean slate before the real scenario runs.
|
||||
await setClaimLimits(team, {});
|
||||
await resetUsage(organisation);
|
||||
|
||||
test.skip(
|
||||
!enforced,
|
||||
'Server is not enforcing organisation rate limits (likely started with DANGEROUS_BYPASS_RATE_LIMITS=true). Restart the server with the flag unset/false to run this suite.',
|
||||
);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// API counter — windowed rate limit
|
||||
// =========================================================================
|
||||
|
||||
test.describe('api rate limit (windowed)', () => {
|
||||
test('allows requests up to the limit then 429s with rate-limit headers', async ({ request }) => {
|
||||
const MAX = 4;
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure the MAX+1 request burst lands inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
// Each request (including these GETs) consumes one api counter.
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||
}
|
||||
|
||||
// The next request is over the windowed limit.
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
// The windowed limit uses a message distinct from the global limiter.
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectRateLimitHeaderToInclude(limitedRes, MAX);
|
||||
expect(limitedRes.headers()['x-ratelimit-remaining']).toContain('0');
|
||||
expect(limitedRes.headers()['retry-after']).toBeTruthy();
|
||||
|
||||
// The windowed stage blocks the (MAX+1)th request before the quota upsert,
|
||||
// but each of the MAX allowed requests still records usage (null quota now
|
||||
// tracks instead of skipping), so the counter equals MAX.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', MAX);
|
||||
});
|
||||
|
||||
test('a single allowed request succeeds when the limit is 1', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: 1 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure both requests land inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
const okRes = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(okRes);
|
||||
expect(okRes.status()).toBe(200);
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
|
||||
// The one allowed request is counted (null quota still tracks); the blocked
|
||||
// request trips the window before the quota upsert, so the counter is 1.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 1);
|
||||
});
|
||||
|
||||
test('the windowed limit RESETS once the window elapses (429 -> wait -> 200)', async ({ request }) => {
|
||||
const MAX = 2;
|
||||
const WINDOW_SECONDS = 3;
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: `${WINDOW_SECONDS}s`, max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Land at the start of a fresh bucket so all MAX+1 requests below fall in
|
||||
// the SAME window (otherwise a mid-exhaustion boundary would reset the count).
|
||||
await alignToFreshWindowBucket(WINDOW_SECONDS);
|
||||
|
||||
// Exhaust the window.
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||
}
|
||||
|
||||
// The next request is blocked by the window.
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// Wait out the window using the server-provided Retry-After (plus a small
|
||||
// buffer to be sure we've crossed into the next time bucket). Crucially we
|
||||
// do NOT reset usage here — the limiter must recover on its own as the
|
||||
// bucket rolls over.
|
||||
const retryAfterHeader = limitedRes.headers()['retry-after'] ?? String(WINDOW_SECONDS);
|
||||
const retryAfterSeconds = Number.parseInt(retryAfterHeader.split(',')[0]?.trim() ?? '', 10) || WINDOW_SECONDS;
|
||||
await new Promise((resolve) => setTimeout(resolve, (retryAfterSeconds + 1) * 1000));
|
||||
|
||||
// Window has elapsed: the same org can make requests again without any
|
||||
// manual intervention — the bucket rolled over on its own.
|
||||
const afterReset = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(afterReset);
|
||||
expect(afterReset.status(), 'request after the window elapsed should be allowed').toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// API counter — monthly quota
|
||||
// =========================================================================
|
||||
|
||||
test.describe('api quota (monthly)', () => {
|
||||
test('null quota allows unlimited requests', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: null });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// A null quota means "unlimited" (never blocks), but every request is now
|
||||
// recorded so usage is visible on unlimited plans — so the counter is 6.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 6);
|
||||
});
|
||||
|
||||
test('exhausting the quota 429s without rate-limit headers and keeps counting', async ({ request }) => {
|
||||
const QUOTA = 3;
|
||||
await setClaimLimits(team, { apiQuota: QUOTA });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be within quota`).toBe(200);
|
||||
}
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// Quota rejections deliberately omit rate-limit headers (it isn't a window).
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// The atomic increment runs even on the rejected request: QUOTA allowed
|
||||
// requests + the one rejected request = exactly QUOTA + 1.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||
});
|
||||
|
||||
test('quota of exactly 1 allows one request then blocks', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: 1 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const okRes = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(okRes);
|
||||
expect(okRes.status()).toBe(200);
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// One allowed + one rejected, both incremented => exactly 2.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 2);
|
||||
});
|
||||
|
||||
test('quota of 0 is a hard block with a "no quota available" message', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Document counter — windowed rate limit
|
||||
// =========================================================================
|
||||
|
||||
test.describe('document rate limit (windowed)', () => {
|
||||
test('allows creates up to the limit then 429s with headers', async ({ request }) => {
|
||||
const MAX = 3;
|
||||
// Keep api unlimited so only the document stage can trip.
|
||||
await setClaimLimits(team, { documentRateLimits: [{ window: '1m', max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure the MAX+1 create burst lands inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await createEnvelope(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `create #${i + 1} should succeed`).toBeTruthy();
|
||||
}
|
||||
|
||||
const limitedRes = await createEnvelope(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectRateLimitHeaderToInclude(limitedRes, MAX);
|
||||
expect(limitedRes.headers()['retry-after']).toBeTruthy();
|
||||
|
||||
// The (MAX+1)th create trips the window before the quota upsert, but each of
|
||||
// the MAX allowed creates still records usage (null quota now tracks).
|
||||
await expectMonthlyCounter(organisation, 'documentCount', MAX);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Document counter — monthly quota
|
||||
// =========================================================================
|
||||
|
||||
test.describe('document quota (monthly)', () => {
|
||||
test('exhausting the document quota blocks further creates', async ({ request }) => {
|
||||
const QUOTA = 2;
|
||||
await setClaimLimits(team, { documentQuota: QUOTA });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await createEnvelope(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `create #${i + 1} should be within quota`).toBeTruthy();
|
||||
}
|
||||
|
||||
const limitedRes = await createEnvelope(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// QUOTA successful creates + the rejected one (incremented before throwing).
|
||||
await expectMonthlyCounter(organisation, 'documentCount', QUOTA + 1);
|
||||
});
|
||||
|
||||
test('document quota of 0 hard-blocks creation', async ({ request }) => {
|
||||
await setClaimLimits(team, { documentQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const limitedRes = await createEnvelope(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'documentCount', 0);
|
||||
});
|
||||
|
||||
test('null document quota allows creation', async ({ request }) => {
|
||||
await setClaimLimits(team, { documentQuota: null });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await createEnvelope(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
// A null quota is unlimited (never blocks) but is now still recorded, so the
|
||||
// single create advances the counter to 1.
|
||||
await expectMonthlyCounter(organisation, 'documentCount', 1);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Email counter — windowed rate limit (via synchronous redistribute)
|
||||
// =========================================================================
|
||||
|
||||
test.describe('email rate limit (windowed)', () => {
|
||||
test('redistribute is allowed when recipient count is within the email window', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 2,
|
||||
});
|
||||
|
||||
// Window allows 5/min; reminding 2 recipients is fine. Reset usage so the
|
||||
// create/distribute consumption above doesn't count against this window.
|
||||
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 5 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
// The windowed pass is now recorded even though the quota is null, so the
|
||||
// counter advances by the batch size (recipientIds.length).
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('redistribute is blocked when recipient count exceeds the email window', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 3,
|
||||
});
|
||||
|
||||
// Window only allows 2 emails per minute; reminding 3 at once exceeds it.
|
||||
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 2 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
const body = await expectOrgLimited(res);
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectRateLimitHeaderToInclude(res, 2);
|
||||
|
||||
// Windowed limit trips BEFORE the quota stage, so the counter is untouched.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Email counter — monthly quota (via synchronous redistribute)
|
||||
// =========================================================================
|
||||
|
||||
test.describe('email quota (monthly)', () => {
|
||||
test('redistribute within the remaining email quota succeeds', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 2,
|
||||
});
|
||||
|
||||
await setClaimLimits(team, { emailQuota: 10 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
// The synchronous assertion consumed exactly `recipientIds.length` of quota.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('redistribute that would exceed the email quota is blocked', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 3,
|
||||
});
|
||||
|
||||
// Quota of 2 but reminding 3 recipients in one synchronous call.
|
||||
await setClaimLimits(team, { emailQuota: 2 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
const body = await expectOrgLimited(res);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// Quota rejection carries no rate-limit headers.
|
||||
expectNoOrgRateLimitHeader(res);
|
||||
|
||||
// The count (3) is added BEFORE the over-quota check throws, so the counter
|
||||
// advances by the full batch even though the request was rejected.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('email quota of 0 hard-blocks reminders', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 1,
|
||||
});
|
||||
|
||||
await setClaimLimits(team, { emailQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
const body = await expectOrgLimited(res);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Stage interaction — quota binds before a looser window
|
||||
// =========================================================================
|
||||
|
||||
test.describe('stage interaction', () => {
|
||||
test('the quota trips before a looser windowed limit', async ({ request }) => {
|
||||
const WINDOW_MAX = 50; // generous window
|
||||
const QUOTA = 2; // strict quota — should bind first
|
||||
await setClaimLimits(team, {
|
||||
apiRateLimits: [{ window: '1m', max: WINDOW_MAX }],
|
||||
apiQuota: QUOTA,
|
||||
});
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
|
||||
// It must be the QUOTA that bound, not the window: the message is the quota
|
||||
// one (not the windowed-limit message) and there are no rate-limit headers.
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
expect(String(body.message)).not.toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// Quota bound at QUOTA + 1; the looser window (50) was never the limiter.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
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, EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
|
||||
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
|
||||
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
|
||||
|
||||
/**
|
||||
* Set the `recipientCount` limit on the organisation that owns the seeded team.
|
||||
*
|
||||
* A value of `0` means unlimited recipients are allowed.
|
||||
*/
|
||||
const setOrganisationRecipientCount = async (team: Team, recipientCount: number) => {
|
||||
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: {
|
||||
organisation: {
|
||||
id: team.organisationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
recipientCount,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
|
||||
const payload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Recipient Count Limit Test',
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return (await res.json()) as TCreateEnvelopeResponse;
|
||||
};
|
||||
|
||||
const getEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => {
|
||||
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return (await res.json()) as TGetEnvelopeResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an envelope with exactly `recipientCount` SIGNER recipients, each with
|
||||
* their own signature field, then attempt to distribute it.
|
||||
*
|
||||
* Returns the raw distribute response so the caller can assert on the status.
|
||||
*/
|
||||
const buildAndDistributeEnvelopeWithRecipients = async ({
|
||||
request,
|
||||
authToken,
|
||||
recipientCount,
|
||||
}: {
|
||||
request: APIRequestContext;
|
||||
authToken: string;
|
||||
recipientCount: number;
|
||||
}): Promise<{ envelopeId: string; distributeRes: APIResponse }> => {
|
||||
const envelope = await createEnvelope(request, authToken);
|
||||
|
||||
// Create N SIGNER recipients in a single request.
|
||||
const recipientData = Array.from({ length: recipientCount }).map((_, index) => ({
|
||||
email: `recipient-${index}-${Date.now()}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
|
||||
name: `Recipient ${index}`,
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
}));
|
||||
|
||||
const recipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: recipientData,
|
||||
} satisfies TCreateEnvelopeRecipientsRequest,
|
||||
});
|
||||
|
||||
expect(recipientsRes.ok()).toBeTruthy();
|
||||
|
||||
const recipients = (await recipientsRes.json()).data;
|
||||
|
||||
// Resolve the envelope item ID to place fields on.
|
||||
const envelopeData = await getEnvelope(request, authToken, envelope.id);
|
||||
const envelopeItemId = envelopeData.envelopeItems[0].id;
|
||||
|
||||
// Each SIGNER must have a signature field, otherwise distribution fails for
|
||||
// a reason unrelated to the recipient count.
|
||||
const fieldData = recipients.map((recipient: { id: number }) => ({
|
||||
recipientId: recipient.id,
|
||||
envelopeItemId,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 100,
|
||||
width: 50,
|
||||
height: 50,
|
||||
}));
|
||||
|
||||
const fieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: fieldData,
|
||||
},
|
||||
});
|
||||
|
||||
expect(fieldsRes.ok()).toBeTruthy();
|
||||
|
||||
// Attempt to distribute the envelope.
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
} satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
return { envelopeId: envelope.id, distributeRes };
|
||||
};
|
||||
|
||||
const expectEnvelopeStatus = async (envelopeId: string, status: DocumentStatus) => {
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelopeId },
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(status);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Recipient count limit on distribute', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user, team } = await seedUser());
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-recipient-count-limit',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Limit = 3. Edge cases around the boundary: 2 (under), 3 (at), 4 (over).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should allow distribution when recipient count is below the limit', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 3);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 2,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('should allow distribution when recipient count is exactly at the limit', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 3);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 3,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('should deny distribution when recipient count is one over the limit', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 3);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 4,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeFalsy();
|
||||
expect(distributeRes.status()).toBe(400);
|
||||
|
||||
// The envelope must remain a DRAFT — distribution was rejected.
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Limit = 1. The smallest non-unlimited boundary.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should allow distribution with a single recipient when the limit is 1', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 1);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 1,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('should deny distribution with two recipients when the limit is 1', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 1);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 2,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeFalsy();
|
||||
expect(distributeRes.status()).toBe(400);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Limit = 0 means unlimited recipients are allowed.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should allow distribution with many recipients when the limit is 0 (unlimited)', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 0);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 10,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
test.describe('Redistribute updates recipient send status', () => {
|
||||
let user: User, team: Team, token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user, team } = await seedUser());
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('marks a NOT_SENT signer as SENT after a successful resend', async ({ request }) => {
|
||||
const document = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const [recipient] = document.recipients;
|
||||
|
||||
// Simulate a recipient that is stuck at NOT_SENT on a pending document
|
||||
// (e.g. the initial send did not dispatch an email for them).
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: {
|
||||
sendStatus: SendStatus.NOT_SENT,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
sentAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${baseUrl}/document/redistribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
|
||||
recipients: [recipient.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
const updatedRecipient = await prisma.recipient.findFirstOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.sendStatus).toBe(SendStatus.SENT);
|
||||
expect(updatedRecipient.sentAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
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 { DocumentVisibility, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { TRejectEnvelopeRecipientOnBehalfOfRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const rejectRecipient = (
|
||||
request: APIRequestContext,
|
||||
authToken: string,
|
||||
envelopeId: string,
|
||||
recipientId: number,
|
||||
reason: string,
|
||||
actAsEmail?: string,
|
||||
) => {
|
||||
return request.post(`${baseUrl}/envelope/recipient/${recipientId}/reject`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId,
|
||||
recipientId,
|
||||
reason,
|
||||
actAsEmail,
|
||||
} satisfies TRejectEnvelopeRecipientOnBehalfOfRequest,
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('Reject recipient on behalf of', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user, team } = await seedUser());
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-reject-recipient',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('should reject a recipient and record an external rejection audit log', async ({ request }) => {
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band');
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.signingStatus).toBe(SigningStatus.REJECTED);
|
||||
expect(updatedRecipient.rejectionReason).toBe('Declined out of band');
|
||||
|
||||
const auditLog = await prisma.documentAuditLog.findFirst({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: 'DOCUMENT_RECIPIENT_REJECTED',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(auditLog).not.toBeNull();
|
||||
|
||||
const auditData = auditLog!.data as Record<string, unknown>;
|
||||
|
||||
expect(auditData.recipientId).toBe(recipient.id);
|
||||
expect(auditData.recipientEmail).toBe(recipient.email);
|
||||
expect(auditData.reason).toBe('Declined out of band');
|
||||
expect(auditData.isExternal).toBe(true);
|
||||
|
||||
// No actAsEmail supplied - the rejection defaults to the API user.
|
||||
expect(auditLog!.userId).toBe(user.id);
|
||||
expect(auditLog!.email).toBe(user.email);
|
||||
expect(auditData.onBehalfOfUserEmail).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should attribute the rejection to the elected team member when actAsEmail is supplied', async ({ request }) => {
|
||||
const member = await seedTeamMember({ teamId: team.id });
|
||||
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band', member.email);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const auditLog = await prisma.documentAuditLog.findFirstOrThrow({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: 'DOCUMENT_RECIPIENT_REJECTED',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// The audit log actor must be the elected member, not the API user.
|
||||
expect(auditLog.userId).toBe(member.id);
|
||||
expect(auditLog.email).toBe(member.email);
|
||||
|
||||
const auditData = auditLog.data as Record<string, unknown>;
|
||||
|
||||
expect(auditData.isExternal).toBe(true);
|
||||
expect(auditData.onBehalfOfUserEmail).toBe(member.email);
|
||||
});
|
||||
|
||||
test('should reject when actAsEmail is not a member of the team', async ({ request }) => {
|
||||
// A user that exists but belongs to a different team.
|
||||
const { user: outsider } = await seedUser();
|
||||
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(
|
||||
request,
|
||||
token,
|
||||
envelope.id,
|
||||
recipient.id,
|
||||
'Declined out of band',
|
||||
outsider.email,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
|
||||
// The recipient must remain untouched.
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
test('should deny rejecting a recipient that has already actioned the document', async ({ request }) => {
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
// Reject once - succeeds.
|
||||
const firstRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'First rejection');
|
||||
expect(firstRes.ok()).toBeTruthy();
|
||||
|
||||
// Reject again - the recipient is no longer NOT_SIGNED.
|
||||
const secondRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'Second rejection');
|
||||
|
||||
expect(secondRes.ok()).toBeFalsy();
|
||||
expect(secondRes.status()).toBe(400);
|
||||
|
||||
// The original rejection reason must remain unchanged.
|
||||
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.rejectionReason).toBe('First rejection');
|
||||
});
|
||||
|
||||
test('should not allow rejecting a recipient in another team', async ({ request }) => {
|
||||
// Seed a separate team/user that owns the document.
|
||||
const { user: otherUser, team: otherTeam } = await seedUser();
|
||||
|
||||
const envelope = await seedPendingDocument(otherUser, otherTeam.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
// Use the original team's token - it must not be able to reject.
|
||||
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Should not work');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// The recipient must remain untouched.
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
test('should return 404 for a non-existent recipient', async ({ request }) => {
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await rejectRecipient(request, token, envelope.id, 999999999, 'No such recipient');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should return 404 when the recipient does not belong to the supplied envelope', async ({ request }) => {
|
||||
const targetEnvelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const otherEnvelope = await seedPendingDocument(user, team.id, ['other-recipient@test.documenso.com']);
|
||||
|
||||
const recipient = targetEnvelope.recipients[0];
|
||||
|
||||
// Valid recipient ID, but paired with the wrong envelope ID.
|
||||
const res = await rejectRecipient(request, token, otherEnvelope.id, recipient.id, 'Mismatched envelope');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// The recipient must remain untouched.
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
test('should enforce document visibility: manager cannot reject on an ADMIN-only document', async ({ request }) => {
|
||||
// The API token belongs to a MANAGER, who cannot see ADMIN-visibility docs.
|
||||
const { team: visTeam, owner } = await seedTeam();
|
||||
const manager = await seedTeamMember({ teamId: visTeam.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const { token: managerToken } = await createApiToken({
|
||||
userId: manager.id,
|
||||
teamId: visTeam.id,
|
||||
tokenName: 'manager-reject-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// ADMIN-visibility document owned by the team owner.
|
||||
const envelope = await seedPendingDocument(owner, visTeam.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(
|
||||
request,
|
||||
managerToken,
|
||||
envelope.id,
|
||||
recipient.id,
|
||||
'Should be hidden by visibility',
|
||||
);
|
||||
|
||||
// Visibility failure surfaces as not-found, matching the canonical checks.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
});
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
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 { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const createTokenForUser = async (userId: number, teamId: number, tokenName: string) => {
|
||||
const { token } = await createApiToken({
|
||||
userId,
|
||||
teamId,
|
||||
tokenName,
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
test.describe('Envelope cancel endpoint authorization', () => {
|
||||
test('hides the document from an outsider attempting to cancel it', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const { user: outsider, team: outsiderTeam } = await seedUser();
|
||||
const outsiderToken = await createTokenForUser(outsider.id, outsiderTeam.id, 'outsider');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${outsiderToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
// Outsiders must not be able to determine whether the envelope exists.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// The document must be untouched.
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('hides the document from a recipient attempting to cancel it', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient, team: recipientTeam } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const recipientToken = await createTokenForUser(recipient.id, recipientTeam.id, 'recipient');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${recipientToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
// A recipient is not a member of the document's team, so they must not be
|
||||
// able to determine whether it exists via this endpoint.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
// Note: a non-privileged MEMBER cannot obtain an API token at all (token
|
||||
// creation requires the MANAGE_TEAM permission), so the MEMBER cancellation
|
||||
// restriction is covered through the UI tests in cancel-documents.spec.ts
|
||||
// rather than at the API layer.
|
||||
|
||||
test('allows the document owner to cancel a pending document', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true, completedAt: true, deletedAt: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
|
||||
expect(documentInDb.completedAt).not.toBeNull();
|
||||
expect(documentInDb.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('allows a team ADMIN to cancel a pending document they do not own', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const adminUser = await seedTeamMember({
|
||||
teamId: team.id,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
});
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const adminToken = await createTokenForUser(adminUser.id, team.id, 'admin');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
|
||||
});
|
||||
|
||||
test('allows a team MANAGER to cancel a pending document they do not own', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const managerUser = await seedTeamMember({
|
||||
teamId: team.id,
|
||||
role: TeamMemberRole.MANAGER,
|
||||
});
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const managerToken = await createTokenForUser(managerUser.id, team.id, 'manager');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${managerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
|
||||
});
|
||||
|
||||
test('rejects cancelling a draft document', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const document = await seedDraftDocument(owner, team.id, []);
|
||||
|
||||
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-draft');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.DRAFT);
|
||||
});
|
||||
|
||||
test('rejects cancelling a completed document', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedCompletedDocument(owner, team.id, [recipient]);
|
||||
|
||||
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-completed');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('rejects double cancellation of an already cancelled document', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-double');
|
||||
|
||||
const firstRes = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(firstRes.status()).toBe(200);
|
||||
|
||||
const secondRes = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(secondRes.ok()).toBeFalsy();
|
||||
expect(secondRes.status()).toBe(400);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
|
||||
});
|
||||
});
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedCompletedDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
/**
|
||||
* Create an API token directly, bypassing the role check in `createApiToken`.
|
||||
*
|
||||
* This simulates a token that was minted while the user had permission, and which
|
||||
* survives a later downgrade to a lower team role (e.g. MEMBER). Such a token must
|
||||
* still respect document visibility at request time.
|
||||
*/
|
||||
const seedApiTokenForUser = async ({
|
||||
userId,
|
||||
teamId,
|
||||
tokenName,
|
||||
}: {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
tokenName: string;
|
||||
}) => {
|
||||
const token = `api_${alphaid(16)}`;
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: {
|
||||
name: tokenName,
|
||||
token: hashString(token),
|
||||
expires: null,
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
return { token };
|
||||
};
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const downloadAuditLogPdf = (request: APIRequestContext, envelopeId: string, authToken?: string) => {
|
||||
return request.get(`${API_BASE_URL}/envelope/${envelopeId}/audit-log/download`, {
|
||||
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
|
||||
});
|
||||
};
|
||||
|
||||
const downloadCertificatePdf = (request: APIRequestContext, envelopeId: string, authToken?: string) => {
|
||||
return request.get(`${API_BASE_URL}/envelope/${envelopeId}/certificate/download`, {
|
||||
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('Envelope certificate / audit log PDF download API V2 - access control', () => {
|
||||
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user: userA, team: teamA } = await seedUser());
|
||||
({ token: tokenA } = await createApiToken({
|
||||
userId: userA.id,
|
||||
teamId: teamA.id,
|
||||
tokenName: 'userA',
|
||||
expiresIn: null,
|
||||
}));
|
||||
|
||||
({ user: userB, team: teamB } = await seedUser());
|
||||
({ token: tokenB } = await createApiToken({
|
||||
userId: userB.id,
|
||||
teamId: teamB.id,
|
||||
tokenName: 'userB',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('should reject audit log download without an API token', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should reject certificate download without an API token', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should reject audit log download from a user in a different team', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, tokenB);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should reject certificate download from a user in a different team', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, tokenB);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should reject a disabled user downloading the audit log', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userA.id },
|
||||
data: { disabled: true },
|
||||
});
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, tokenA);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should reject a disabled user downloading the certificate', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userA.id },
|
||||
data: { disabled: true },
|
||||
});
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, tokenA);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should return 404 for a non-existent envelope id', async ({ request }) => {
|
||||
const auditLogRes = await downloadAuditLogPdf(request, 'envelope_doesnotexist', tokenA);
|
||||
expect(auditLogRes.status()).toBe(404);
|
||||
|
||||
const certificateRes = await downloadCertificatePdf(request, 'envelope_doesnotexist', tokenA);
|
||||
expect(certificateRes.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope certificate / audit log PDF download API V2 - document visibility', () => {
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
test('should hide an ADMIN-only document from a downgraded member (audit log)', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({
|
||||
userId: member.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'member-audit-log-token',
|
||||
});
|
||||
|
||||
// ADMIN-visibility document owned by the team owner - a member must not see it.
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, memberToken);
|
||||
|
||||
// Visibility failure surfaces as not-found, matching the canonical access checks.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should hide an ADMIN-only document from a downgraded member (certificate)', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({
|
||||
userId: member.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'member-certificate-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, memberToken);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should hide a MANAGER_AND_ABOVE document from a downgraded member (audit log)', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({
|
||||
userId: member.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'member-manager-vis-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.MANAGER_AND_ABOVE },
|
||||
});
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, memberToken);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should hide a MANAGER_AND_ABOVE document from a downgraded member (certificate)', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({
|
||||
userId: member.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'member-manager-vis-cert-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.MANAGER_AND_ABOVE },
|
||||
});
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, memberToken);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should hide an ADMIN-only document from a downgraded manager (certificate)', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const { token: managerToken } = await seedApiTokenForUser({
|
||||
userId: manager.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'manager-admin-vis-cert-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, managerToken);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow a member to download an EVERYONE-visibility document (audit log)', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({
|
||||
userId: member.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'member-everyone-vis-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.EVERYONE },
|
||||
});
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, memberToken);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.headers()['content-type']).toContain('application/pdf');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../../../assets/example.pdf'));
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const createPresignTokenForUser = async (userId: number, teamId: number) => {
|
||||
const { token: apiToken } = await createApiToken({
|
||||
userId,
|
||||
teamId,
|
||||
tokenName: 'file-upload-test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const { token: presignToken } = await createEmbeddingPresignToken({ apiToken });
|
||||
|
||||
return presignToken;
|
||||
};
|
||||
|
||||
const buildPdfFormData = () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File([examplePdf], 'test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
test.describe('File upload endpoint authorization', () => {
|
||||
test('rejects an unauthenticated upload-pdf request', async ({ request }) => {
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/upload-pdf`, {
|
||||
multipart: buildPdfFormData(),
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer not-a-real-token',
|
||||
},
|
||||
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${presignToken}`,
|
||||
},
|
||||
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
|
||||
});
|
||||
|
||||
// Authenticated, but the content type is not on the allow-list.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/upload-pdf`, {
|
||||
headers: { Authorization: `Bearer ${presignToken}` },
|
||||
multipart: buildPdfFormData(),
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { type Download, expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
@@ -287,3 +290,147 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
|
||||
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Bulk cancel ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('[BULK_ACTIONS]: can cancel multiple pending documents', async ({ page }) => {
|
||||
const sender = await seedUser({ setTeamEmailAsOwner: true });
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const [pending1, pending2] = await Promise.all([
|
||||
seedPendingDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Bulk Cancel Pending 1' },
|
||||
}),
|
||||
seedPendingDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Bulk Cancel Pending 2' },
|
||||
}),
|
||||
]);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Cancel Pending 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Cancel Pending 2' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
|
||||
// The bulk action bar Cancel button (distinct from the dialog's confirm button).
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading', { name: 'Cancel Documents' })).toBeVisible();
|
||||
await expect(dialog.getByText('You are about to cancel 2 documents')).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents cancelled');
|
||||
|
||||
// Selection clears after a successful cancel.
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
|
||||
// Both documents are now cancelled in the database.
|
||||
for (const document of [pending1, pending2]) {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true, deletedAt: true },
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
expect(envelope.deletedAt).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: bulk cancel only affects pending documents', async ({ page }) => {
|
||||
const sender = await seedUser({ setTeamEmailAsOwner: true });
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const pending = await seedPendingDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Mixed Cancel Pending' },
|
||||
});
|
||||
const draft = await seedDraftDocument(sender.user, sender.team.id, [], {
|
||||
createDocumentOptions: { title: 'Mixed Cancel Draft' },
|
||||
});
|
||||
const completed = await seedCompletedDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Mixed Cancel Completed' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
await expect(page.getByText('3 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
// Only one of the three was pending, so this is a partial result.
|
||||
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
|
||||
|
||||
const pendingEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: pending.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(pendingEnvelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
|
||||
// The draft and completed documents are untouched.
|
||||
const draftEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: draft.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(draftEnvelope.status).toBe(DocumentStatus.DRAFT);
|
||||
|
||||
const completedEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: completed.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(completedEnvelope.status).toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: a MEMBER cannot bulk cancel documents they do not own', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const ownerDocument = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Member Cannot Cancel This', visibility: 'EVERYONE' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Member Cannot Cancel This' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
// The server rejects the cancellation for a document the MEMBER does not own,
|
||||
// so it reports zero cancelled (a partial result with the document in failedIds).
|
||||
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
|
||||
|
||||
// The document remains pending.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: ownerDocument.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(envelope.status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedCancelledDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedCancelDocumentsTestRequirements = async () => {
|
||||
const [sender, recipientA, recipientB] = await Promise.all([
|
||||
seedUser({ setTeamEmailAsOwner: true }),
|
||||
seedUser({ setTeamEmailAsOwner: true }),
|
||||
seedUser({ setTeamEmailAsOwner: true }),
|
||||
]);
|
||||
|
||||
const pendingDocument = await seedPendingDocument(sender.user, sender.team.id, [recipientA.user, recipientB.user], {
|
||||
createDocumentOptions: { title: 'Document 1 - Pending' },
|
||||
});
|
||||
|
||||
return {
|
||||
sender,
|
||||
recipients: [recipientA, recipientB],
|
||||
pendingDocument,
|
||||
};
|
||||
};
|
||||
|
||||
const cancelDocumentViaUi = async (page: Page, documentTitle: string, reason?: string) => {
|
||||
const documentActionBtn = page.locator('tr', { hasText: documentTitle }).getByTestId('document-table-action-btn');
|
||||
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Cancel' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Cancel' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
|
||||
|
||||
if (reason) {
|
||||
await page.getByPlaceholder('Add an optional reason for cancelling this document').fill(reason);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel document' }).click();
|
||||
};
|
||||
|
||||
test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard as cancelled', async ({ page }) => {
|
||||
const { sender, pendingDocument } = await seedCancelDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending', 'No longer required');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// The document must remain in the dashboard, unlike deleting a pending document.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Cancelled', 1);
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
|
||||
// The cancelled document is still listed.
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
// The envelope status is persisted as CANCELLED.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: pendingDocument.id,
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
completedAt: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
expect(envelope.completedAt).not.toBeNull();
|
||||
expect(envelope.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: cancelling a pending document retains it for recipients', async ({ page }) => {
|
||||
const { sender, recipients } = await seedCancelDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// Recipients should still be able to see the document as a record of distribution.
|
||||
for (const recipient of recipients) {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipient.user.email,
|
||||
redirectPath: `/t/${recipient.team.url}/documents`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner without removing it', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { sender, recipients, pendingDocument } = await seedCancelDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// The envelope is soft deleted, not hard deleted.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: pendingDocument.id,
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
expect(envelope.deletedAt).not.toBeNull();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// Recipients should still retain the document after the owner deletes it.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipients[0].user.email,
|
||||
redirectPath: `/t/${recipients[0].team.url}/documents`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Visibility: a cancelled document must respect team document visibility ───
|
||||
|
||||
test('[DOCUMENTS]: cancelled document with ADMIN visibility is hidden from a MEMBER', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
||||
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await seedCancelledDocument(owner, team.id, [], {
|
||||
createDocumentOptions: {
|
||||
visibility: 'ADMIN',
|
||||
title: 'Cancelled Admin Only Document',
|
||||
},
|
||||
});
|
||||
|
||||
// The MEMBER must NOT see the ADMIN-visibility cancelled document on any tab.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
|
||||
|
||||
// Also confirm it doesn't leak via the ALL tab.
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents`);
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// The MANAGER must NOT see an ADMIN-visibility document either.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: managerUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// The ADMIN must see it.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: cancelled document with MANAGER_AND_ABOVE visibility is hidden from a MEMBER', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await seedCancelledDocument(owner, team.id, [], {
|
||||
createDocumentOptions: {
|
||||
visibility: 'MANAGER_AND_ABOVE',
|
||||
title: 'Cancelled Manager Document',
|
||||
},
|
||||
});
|
||||
|
||||
// The MEMBER must NOT see it.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Manager Document', exact: true })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// The MANAGER must see it.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: managerUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Manager Document', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: a recipient sees a cancelled document regardless of restricted visibility', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
// A MEMBER who is also a recipient on an ADMIN-visibility document.
|
||||
const memberRecipient = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await seedCancelledDocument(owner, team.id, [memberRecipient], {
|
||||
createDocumentOptions: {
|
||||
visibility: 'ADMIN',
|
||||
title: 'Cancelled Admin Doc With Recipient',
|
||||
},
|
||||
});
|
||||
|
||||
// Even though the document is ADMIN-only, the MEMBER is a recipient, so they
|
||||
// must still see it (proof of distribution), matching completed-document behaviour.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberRecipient.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Doc With Recipient', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── UI gating: only privileged members see the Cancel action ────────────────
|
||||
|
||||
test('[DOCUMENTS]: a MEMBER does not see the Cancel action on a pending document', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Member Gating Pending Document', visibility: 'EVERYONE' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Member Gating Pending Document' })
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
// The dropdown must render (Edit is always there) but Cancel must be absent.
|
||||
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Cancel' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: a team ADMIN sees and can use the Cancel action on a document they do not own', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Admin Cancellable Document', visibility: 'EVERYONE' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await cancelDocumentViaUi(page, 'Admin Cancellable Document');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
|
||||
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const requestMetadata = {
|
||||
auth: null,
|
||||
requestMetadata: {},
|
||||
source: 'app' as const,
|
||||
};
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const canReadEnvelope = async (envelopeId: string, userId: number, teamId: number) => {
|
||||
try {
|
||||
await getEnvelopeWhereInput({
|
||||
id: { type: 'envelopeId', id: envelopeId },
|
||||
userId,
|
||||
teamId,
|
||||
type: null,
|
||||
}).then(({ envelopeWhereInput }) => prisma.envelope.findFirstOrThrow({ where: envelopeWhereInput }));
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
test('[DOCUMENTS]: a member cannot delete a document with restricted visibility', async () => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: {
|
||||
visibility: DocumentVisibility.ADMIN,
|
||||
status: DocumentStatus.DRAFT,
|
||||
},
|
||||
});
|
||||
|
||||
// The member cannot read an ADMIN-visibility document, so they must not be
|
||||
// able to delete it either.
|
||||
expect(await canReadEnvelope(envelope.id, member.id, team.id)).toBe(false);
|
||||
|
||||
await expect(
|
||||
deleteDocument({
|
||||
id: { type: 'envelopeId', id: envelope.id },
|
||||
userId: member.id,
|
||||
teamId: team.id,
|
||||
requestMetadata,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const stillExists = await prisma.envelope.findUnique({ where: { id: envelope.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: a manager cannot cancel a document with restricted visibility', async () => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: {
|
||||
visibility: DocumentVisibility.ADMIN,
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
// A manager outranks a member but still cannot read an ADMIN-visibility
|
||||
// document, so cancellation must be blocked despite the sufficient role.
|
||||
expect(await canReadEnvelope(envelope.id, manager.id, team.id)).toBe(false);
|
||||
|
||||
await expect(
|
||||
cancelDocument({
|
||||
id: { type: 'envelopeId', id: envelope.id },
|
||||
userId: manager.id,
|
||||
teamId: team.id,
|
||||
reason: 'test-cancel',
|
||||
requestMetadata,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const after = await prisma.envelope.findUnique({ where: { id: envelope.id } });
|
||||
expect(after?.status).toBe(DocumentStatus.PENDING);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
|
||||
const token = `api_${alphaid(16)}`;
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: { name: 'attachment-url-test', token: hashString(token), expires: null, userId, teamId },
|
||||
});
|
||||
|
||||
return { token };
|
||||
};
|
||||
|
||||
/**
|
||||
* Attachment URLs are rendered as link hrefs, so they must be restricted to
|
||||
* http(s). The API must reject any other scheme.
|
||||
*/
|
||||
const NON_HTTP_URLS = [
|
||||
'javascript:alert(document.cookie)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'vbscript:msgbox(1)',
|
||||
'file:///etc/passwd',
|
||||
];
|
||||
|
||||
test('[ATTACHMENTS]: rejects attachment URLs with a non-http(s) protocol', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id);
|
||||
|
||||
for (const url of NON_HTTP_URLS) {
|
||||
const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'attachment', data: url } },
|
||||
});
|
||||
|
||||
expect(res.ok(), `expected ${url} to be rejected`).toBe(false);
|
||||
}
|
||||
|
||||
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
|
||||
expect(attachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[ATTACHMENTS]: accepts attachment URLs with an http(s) protocol', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id);
|
||||
|
||||
const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'safe', data: 'https://example.com/file.pdf' } },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
|
||||
expect(attachments).toHaveLength(1);
|
||||
expect(attachments[0].data).toBe('https://example.com/file.pdf');
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
|
||||
const token = `api_${alphaid(16)}`;
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: { name: 'attachment-access-test', token: hashString(token), expires: null, userId, teamId },
|
||||
});
|
||||
|
||||
return { token };
|
||||
};
|
||||
|
||||
const canReadEnvelope = async (request: APIRequestContext, token: string, envelopeId: string) => {
|
||||
const res = await request.get(`${API_BASE_URL}/envelope/${envelopeId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
return res.ok();
|
||||
};
|
||||
|
||||
/**
|
||||
* Attachment create/update/delete/list must enforce document visibility, not
|
||||
* just team membership. A member whose visibility tier excludes a restricted
|
||||
* envelope must not be able to read or mutate its attachments.
|
||||
*/
|
||||
test('[ATTACHMENTS]: a member cannot create or delete attachments on a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
|
||||
|
||||
const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'attachment', data: 'https://example.com' } },
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBe(false);
|
||||
|
||||
// No attachment should have been created.
|
||||
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
|
||||
expect(attachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[ATTACHMENTS]: a member cannot update an attachment on a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
// The owner (who can see the document) creates the attachment.
|
||||
const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'original', data: 'https://example.com/original' } },
|
||||
});
|
||||
expect(createRes.ok()).toBe(true);
|
||||
const attachment = await createRes.json();
|
||||
|
||||
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
|
||||
|
||||
const updateRes = await request.post(`${API_BASE_URL}/envelope/attachment/update`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' },
|
||||
data: { id: attachment.id, data: { label: 'tampered', data: 'https://example.com/tampered' } },
|
||||
});
|
||||
|
||||
expect(updateRes.ok()).toBe(false);
|
||||
|
||||
const persisted = await prisma.envelopeAttachment.findUnique({ where: { id: attachment.id } });
|
||||
expect(persisted?.label).toBe('original');
|
||||
});
|
||||
|
||||
test('[ATTACHMENTS]: a member cannot list attachments on a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'restricted', data: 'https://example.com/restricted' } },
|
||||
});
|
||||
|
||||
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
|
||||
|
||||
const findRes = await request.get(`${API_BASE_URL}/envelope/attachment?envelopeId=${envelope.id}`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}` },
|
||||
});
|
||||
|
||||
expect(findRes.ok()).toBe(false);
|
||||
|
||||
const body = findRes.ok() ? await findRes.json() : null;
|
||||
const attachments = body?.data ?? [];
|
||||
expect(attachments).toHaveLength(0);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import path from 'node:path';
|
||||
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 { seedDraftDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
@@ -302,6 +303,95 @@ test.describe('document editor', () => {
|
||||
expect(envelopes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('duplicate document without recipients excludes recipients and fields', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Seed a draft document that has a recipient with a field.
|
||||
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
|
||||
key: `dup-exclude-recipients-${Date.now()}`,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// Open the duplicate dialog.
|
||||
await page.locator('button[title="Duplicate Envelope"]').click();
|
||||
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
|
||||
|
||||
// Uncheck "Include Recipients" — this also disables and unchecks "Include Fields".
|
||||
await page.getByLabel('Include Recipients').click();
|
||||
await expect(page.getByLabel('Include Fields')).toBeDisabled();
|
||||
|
||||
// Duplicate.
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
await expectToastTextToBeVisible(page, 'Document Duplicated');
|
||||
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
|
||||
|
||||
// The duplicate should have neither recipients nor fields.
|
||||
const duplicate = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
id: { not: document.id },
|
||||
},
|
||||
include: { recipients: true, fields: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(duplicate.recipients).toHaveLength(0);
|
||||
expect(duplicate.fields).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('duplicate document without fields keeps recipients but excludes fields', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Seed a draft document that has a recipient with a field.
|
||||
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
|
||||
key: `dup-exclude-fields-${Date.now()}`,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// Open the duplicate dialog.
|
||||
await page.locator('button[title="Duplicate Envelope"]').click();
|
||||
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
|
||||
|
||||
// Uncheck only "Include Fields" (recipients stay included).
|
||||
await page.getByLabel('Include Fields').click();
|
||||
|
||||
// Duplicate.
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
await expectToastTextToBeVisible(page, 'Document Duplicated');
|
||||
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
|
||||
|
||||
// The duplicate should keep the recipient but have no fields.
|
||||
const duplicate = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
id: { not: document.id },
|
||||
},
|
||||
include: { recipients: true, fields: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(duplicate.recipients).toHaveLength(1);
|
||||
expect(duplicate.fields).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('download PDF dialog shows envelope items', async ({ page }) => {
|
||||
await openDocumentEnvelopeEditor(page);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
import { getKonvaElementCountForPage } from '../fixtures/konva';
|
||||
import { getKonvaElementCountForPage, getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
|
||||
|
||||
type TFieldFlowResult = {
|
||||
externalId: string;
|
||||
@@ -46,6 +46,7 @@ const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: str
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
await surface.root.getByTestId('toast-close').click();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,6 +99,17 @@ const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number
|
||||
await canvas.click({ position, force: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Shift+click a field on the canvas to toggle it in/out of the current multi-selection.
|
||||
*/
|
||||
const shiftClickFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => {
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await root.waitForTimeout(300);
|
||||
// Use force:true to bypass any floating action toolbar buttons that may intercept clicks.
|
||||
await canvas.click({ position, modifiers: ['Shift'], force: true });
|
||||
};
|
||||
|
||||
const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
|
||||
const externalId = `e2e-fields-${nanoid()}`;
|
||||
|
||||
@@ -621,9 +633,245 @@ const assertDuplicateDeleteFieldPersistedInDatabase = async ({
|
||||
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
|
||||
};
|
||||
|
||||
// --- Change field type flow ---
|
||||
|
||||
type TChangeFieldTypeFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const FIELD_A_POSITION = { x: 150, y: 150 };
|
||||
const FIELD_B_POSITION = { x: 150, y: 250 };
|
||||
|
||||
const changeFieldTypeViaToolbar = async (root: Page, newTypeLabel: FieldButtonName) => {
|
||||
await expect(root.locator('button[title="Change Field Type"]')).toBeVisible();
|
||||
await root.locator('button[title="Change Field Type"]').click();
|
||||
|
||||
// The CommandDialog uses role="option" for items; sidebar palette buttons use role="button".
|
||||
const option = root.getByRole('option', { name: newTypeLabel, exact: true });
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
|
||||
// Wait for the CommandDialog to close (selection persists so the toolbar remains).
|
||||
await expect(root.getByRole('dialog')).toHaveCount(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Multi-select fields on the konva canvas by drawing a marquee selection rectangle.
|
||||
*
|
||||
* The editor's stage mousedown/mousemove/mouseup handlers create a Konva selection
|
||||
* rectangle when the user drags on empty stage area. All field groups that intersect
|
||||
* the rectangle are selected at once. This is the canonical multi-select gesture.
|
||||
*/
|
||||
const marqueeSelectFieldsOnCanvas = async (
|
||||
root: Page,
|
||||
start: { x: number; y: number },
|
||||
end: { x: number; y: number },
|
||||
) => {
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
if (!box) {
|
||||
throw new Error('Canvas bounding box not available for marquee selection.');
|
||||
}
|
||||
|
||||
// The marquee gesture must start on empty stage (not on a field) and pass through
|
||||
// intermediate points so the editor's mousemove handler can grow the rectangle.
|
||||
await root.mouse.move(box.x + start.x, box.y + start.y);
|
||||
await root.mouse.down();
|
||||
await root.mouse.move(box.x + (start.x + end.x) / 2, box.y + (start.y + end.y) / 2, { steps: 5 });
|
||||
await root.mouse.move(box.x + end.x, box.y + end.y, { steps: 5 });
|
||||
await root.mouse.up();
|
||||
};
|
||||
|
||||
const runChangeFieldTypeFlow = async (surface: TEnvelopeEditorSurface): Promise<TChangeFieldTypeFlowResult> => {
|
||||
const externalId = `e2e-change-type-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
// Place two fields of different types: Signature (A) and Name (B).
|
||||
await placeFieldOnPdf(root, 'Signature', FIELD_A_POSITION);
|
||||
await placeFieldOnPdf(root, 'Name', FIELD_B_POSITION);
|
||||
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// --- Phase 1: single field type change ---
|
||||
// Select field A (Signature) and change it to Text via the toolbar.
|
||||
await selectFieldOnCanvas(root, FIELD_A_POSITION);
|
||||
await changeFieldTypeViaToolbar(root, 'Text');
|
||||
|
||||
// Field count must remain stable -- changing type doesn't add/remove fields.
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// Navigate away and back to verify the change is persisted in local state.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// --- Phase 2: multi-field type change ---
|
||||
// Use a marquee drag-selection rectangle to capture both fields at once.
|
||||
// Fields are at (150, 150) and (150, 250) with default dims ~90x30; drag from
|
||||
// (50, 100) to (260, 290) encloses both with margin.
|
||||
await marqueeSelectFieldsOnCanvas(root, { x: 50, y: 100 }, { x: 260, y: 290 });
|
||||
|
||||
// With mixed-type selection (Text + Name), change both to Date.
|
||||
await changeFieldTypeViaToolbar(root, 'Date');
|
||||
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// Navigate away and back to verify persistence.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertChangeFieldTypePersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
// Started with Signature + Name, then both were converted to Date.
|
||||
// Use sorted .map() in the assertion so any failure prints which types were found.
|
||||
const actualTypes = envelope.fields.map((field) => field.type).sort();
|
||||
const expectedTypes = [FieldType.DATE, FieldType.DATE];
|
||||
|
||||
expect(envelope.fields).toHaveLength(2);
|
||||
expect(actualTypes).toEqual(expectedTypes);
|
||||
|
||||
// Each field's meta must have been reset to the new type's defaults.
|
||||
const actualMetaTypes = envelope.fields.map((field) => getFieldMetaType(field.fieldMeta)).sort();
|
||||
expect(actualMetaTypes).toEqual(['date', 'date']);
|
||||
};
|
||||
|
||||
// --- Shift+click multi-select flow ---
|
||||
|
||||
type TShiftClickFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const SHIFT_CLICK_FIELD_POSITIONS = {
|
||||
signature: { x: 150, y: 120 },
|
||||
text: { x: 150, y: 260 },
|
||||
name: { x: 150, y: 400 },
|
||||
};
|
||||
|
||||
const runShiftClickMultiSelectFlow = async (surface: TEnvelopeEditorSurface): Promise<TShiftClickFlowResult> => {
|
||||
const externalId = `e2e-shift-click-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
// Place three fields, spaced far enough apart that their action toolbars don't
|
||||
// overlap a neighbouring field's click target.
|
||||
await placeFieldOnPdf(root, 'Signature', SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await placeFieldOnPdf(root, 'Text', SHIFT_CLICK_FIELD_POSITIONS.text);
|
||||
await placeFieldOnPdf(root, 'Name', SHIFT_CLICK_FIELD_POSITIONS.name);
|
||||
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
|
||||
|
||||
// A plain click selects exactly one field.
|
||||
await selectFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
|
||||
// Shift+click a second field ADDS it to the selection (the new behaviour).
|
||||
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.text);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
|
||||
|
||||
// Shift+click an already-selected field REMOVES it from the selection.
|
||||
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
|
||||
// Shift+click it again RE-ADDS it, leaving Signature + Text selected and Name excluded.
|
||||
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
|
||||
|
||||
// Delete the two selected fields via the floating action toolbar. Only the
|
||||
// un-selected Name field should remain -- proving the multi-selection contained
|
||||
// exactly the two Shift-clicked fields.
|
||||
await expect(root.locator('button[title="Remove"]')).toBeVisible();
|
||||
await root.locator('button[title="Remove"]').click();
|
||||
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
|
||||
|
||||
// Navigate away and back to verify persistence.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertShiftClickMultiSelectPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
// Signature + Text were multi-selected via Shift+click and deleted; only Name remains.
|
||||
expect(envelope.fields).toHaveLength(1);
|
||||
expect(envelope.fields[0].type).toBe(FieldType.NAME);
|
||||
};
|
||||
|
||||
// --- Test describe blocks ---
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
@@ -663,9 +911,29 @@ test.describe('document editor', () => {
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runChangeFieldTypeFlow(surface);
|
||||
|
||||
await assertChangeFieldTypePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
@@ -705,9 +973,34 @@ test.describe('template editor', () => {
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runChangeFieldTypeFlow(surface);
|
||||
|
||||
await assertChangeFieldTypePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-shift-click',
|
||||
});
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
@@ -767,9 +1060,40 @@ test.describe('embedded create', () => {
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-change-type',
|
||||
});
|
||||
const result = await runChangeFieldTypeFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertChangeFieldTypePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-shift-click',
|
||||
});
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
@@ -833,4 +1157,20 @@ test.describe('embedded edit', () => {
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-change-type',
|
||||
});
|
||||
const result = await runChangeFieldTypeFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertChangeFieldTypePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,6 +115,21 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
|
||||
|
||||
await root.locator('input[name="externalId"]').fill(externalId);
|
||||
await root.locator('input[name="meta.redirectUrl"]').fill(TEST_SETTINGS_VALUES.redirectUrl);
|
||||
await root.getByRole('button', { name: 'Notifications' }).click();
|
||||
// Fill email-content fields and toggle recipient-facing notification checkboxes
|
||||
// while distributionMethod is still EMAIL. After it flips to NONE below, these
|
||||
// controls are disabled because no email is sent to recipients.
|
||||
await root.locator('input[name="meta.subject"]').fill(TEST_SETTINGS_VALUES.subject);
|
||||
await root.locator('textarea[name="meta.message"]').fill(TEST_SETTINGS_VALUES.message);
|
||||
await root.locator('input[name="meta.emailReplyTo"]').fill(TEST_SETTINGS_VALUES.replyTo);
|
||||
await root.locator('#recipientSigned').click();
|
||||
await root.locator('#recipientSigningRequest').click();
|
||||
await root.locator('#recipientRemoved').click();
|
||||
await root.locator('#documentPending').click();
|
||||
await root.locator('#documentCompleted').click();
|
||||
await root.locator('#documentDeleted').click();
|
||||
|
||||
await root.getByRole('button', { name: 'General' }).click();
|
||||
|
||||
await root.locator('[data-testid="documentDistributionMethodSelectValue"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.distributionMethod }).click();
|
||||
@@ -190,19 +205,35 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.getByRole('button', { name: 'Email' }).click();
|
||||
await root.locator('#recipientSigned').click();
|
||||
await root.locator('#recipientSigningRequest').click();
|
||||
await root.locator('#recipientRemoved').click();
|
||||
await root.locator('#documentPending').click();
|
||||
await root.locator('#documentCompleted').click();
|
||||
await root.locator('#documentDeleted').click();
|
||||
await root.getByRole('button', { name: 'Notifications' }).click();
|
||||
|
||||
// Distribution is NONE: email-content fields stay rendered but disabled,
|
||||
// recipient-facing checkboxes are hidden entirely and replaced by an alert,
|
||||
// owner-facing checkboxes stay editable so we toggle them here.
|
||||
await expect(root.locator('input[name="meta.subject"]')).toBeDisabled();
|
||||
await expect(root.locator('textarea[name="meta.message"]')).toBeDisabled();
|
||||
await expect(root.locator('input[name="meta.emailReplyTo"]')).toBeDisabled();
|
||||
await expect(root.locator('#recipientSigned')).toHaveCount(0);
|
||||
await expect(root.locator('#recipientSigningRequest')).toHaveCount(0);
|
||||
await expect(root.locator('#recipientRemoved')).toHaveCount(0);
|
||||
await expect(root.locator('#documentPending')).toHaveCount(0);
|
||||
await expect(root.locator('#documentCompleted')).toHaveCount(0);
|
||||
await expect(root.locator('#documentDeleted')).toHaveCount(0);
|
||||
await expect(root.getByText(/Email distribution needs to be enabled/)).toBeVisible();
|
||||
|
||||
// Email Sender select only renders when the org has the emailDomains feature
|
||||
// flag plus allowConfigureEmailSender, so the assertion is conditional.
|
||||
const emailSenderSelect = getComboboxByLabel(root, 'Email Sender');
|
||||
const hasEmailSenderSelect = (await emailSenderSelect.count()) > 0;
|
||||
|
||||
if (hasEmailSenderSelect) {
|
||||
await expect(emailSenderSelect).toBeDisabled();
|
||||
}
|
||||
|
||||
await expect(root.locator('#ownerDocumentCompleted')).toBeEnabled();
|
||||
await root.locator('#ownerDocumentCompleted').click();
|
||||
await root.locator('#ownerRecipientExpired').click();
|
||||
await root.locator('#ownerDocumentCreated').click();
|
||||
await root.locator('input[name="meta.emailReplyTo"]').fill(TEST_SETTINGS_VALUES.replyTo);
|
||||
await root.locator('input[name="meta.subject"]').fill(TEST_SETTINGS_VALUES.subject);
|
||||
await root.locator('textarea[name="meta.message"]').fill(TEST_SETTINGS_VALUES.message);
|
||||
|
||||
await root.getByRole('button', { name: 'Security' }).click();
|
||||
await selectMultiSelectOption(root, 'documentAccessSelectValue', TEST_SETTINGS_VALUES.accessAuth);
|
||||
@@ -264,13 +295,17 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
|
||||
TEST_SETTINGS_VALUES.reminderRepeatUnit,
|
||||
);
|
||||
|
||||
await root.getByRole('button', { name: 'Email' }).click();
|
||||
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#recipientSigningRequest')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#recipientRemoved')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentPending')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentCompleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentDeleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await root.getByRole('button', { name: 'Notifications' }).click();
|
||||
// Distribution persisted as NONE: recipient-facing checkboxes are hidden, owner-facing
|
||||
// checkboxes remain visible and persist their stored values. Email-content fields are
|
||||
// still rendered (disabled) and persist their stored values.
|
||||
await expect(root.locator('#recipientSigned')).toHaveCount(0);
|
||||
await expect(root.locator('#recipientSigningRequest')).toHaveCount(0);
|
||||
await expect(root.locator('#recipientRemoved')).toHaveCount(0);
|
||||
await expect(root.locator('#documentPending')).toHaveCount(0);
|
||||
await expect(root.locator('#documentCompleted')).toHaveCount(0);
|
||||
await expect(root.locator('#documentDeleted')).toHaveCount(0);
|
||||
await expect(root.getByText(/Email distribution needs to be enabled/)).toBeVisible();
|
||||
await expect(root.locator('#ownerDocumentCompleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#ownerRecipientExpired')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#ownerDocumentCreated')).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
@@ -270,7 +270,7 @@ test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) =>
|
||||
await page.getByLabel('test.documenso.com').first().click();
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({
|
||||
await expect(page.getByText('Document resent', { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
|
||||
});
|
||||
|
||||
// Wait for the form to load.
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// Change the amount to 2.
|
||||
const amountInput = page.getByTestId('envelope-expiration-amount');
|
||||
@@ -35,7 +35,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
|
||||
await unitTrigger.click();
|
||||
await page.getByRole('option', { name: 'Weeks' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
@@ -57,14 +57,14 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// Find the mode select (shows "Custom duration") and change to "Never expires".
|
||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||
await modeTrigger.click();
|
||||
await page.getByRole('option', { name: 'Never expires' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
@@ -109,7 +109,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
|
||||
redirectPath: `/t/${team.url}/settings/document`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// The expiration picker mode select should show "Inherit from organisation" by default.
|
||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||
@@ -128,7 +128,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
|
||||
await unitTrigger.click();
|
||||
await page.getByRole('option', { name: 'Days' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify team setting is overridden.
|
||||
|
||||
@@ -324,10 +324,7 @@ test.describe('Signing Certificate Tests', () => {
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /Update/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
@@ -347,10 +344,7 @@ test.describe('Signing Certificate Tests', () => {
|
||||
.getByRole('combobox')
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'Yes' }).click();
|
||||
await page
|
||||
.getByRole('button', { name: /Update/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
|
||||
@@ -16,3 +16,35 @@ export const getKonvaElementCountForPage = async (page: Page, pageNumber: number
|
||||
{ pageNumber, elementSelector },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns how many field groups are currently attached to the page's Konva
|
||||
* transformer, i.e. the size of the active canvas selection. Used to assert
|
||||
* multi-select behaviour (marquee drag and Shift+click).
|
||||
*/
|
||||
export const getKonvaTransformerNodeCountForPage = async (page: Page, pageNumber: number) => {
|
||||
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
|
||||
|
||||
return await page.evaluate(
|
||||
({ pageNumber }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
|
||||
|
||||
const stage = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`);
|
||||
|
||||
if (!stage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const transformer = stage.find('Transformer')[0];
|
||||
|
||||
if (!transformer) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return (transformer as Konva.Transformer).nodes().length;
|
||||
},
|
||||
{ pageNumber },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,7 +60,7 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => {
|
||||
await page.getByLabel('Organisation URL*').clear();
|
||||
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
|
||||
|
||||
await page.getByRole('button', { name: 'Update organisation' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||
|
||||
// Check we have been redirected to the new organisation URL and the name is updated.
|
||||
await page.waitForURL(`/o/${updatedOrganisationId}/settings/general`);
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { OrganisationGroupType, type OrganisationMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
/**
|
||||
* Calls a tRPC mutation directly using the cookies of whoever is currently
|
||||
* signed in on the page context. This deliberately bypasses the UI: the
|
||||
* authorisation checks under test live on the server, and the UI may simply
|
||||
* hide a button rather than reject the request, which would mask a backend gap.
|
||||
*/
|
||||
const trpcMutation = async (page: Page, procedure: string, input: Record<string, unknown>) => {
|
||||
return await page.request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: JSON.stringify({ json: input }),
|
||||
});
|
||||
};
|
||||
|
||||
const getOrganisationMember = async (userId: number, organisationId: string) => {
|
||||
return await prisma.organisationMember.findFirstOrThrow({
|
||||
where: {
|
||||
userId,
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createCustomGroup = async (organisationId: string, organisationRole: OrganisationMemberRole) => {
|
||||
return await prisma.organisationGroup.create({
|
||||
data: {
|
||||
id: generateDatabaseId('org_group'),
|
||||
organisationId,
|
||||
name: `custom-${organisationRole}-${nanoid()}`,
|
||||
type: OrganisationGroupType.CUSTOM,
|
||||
organisationRole,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createPendingInvite = async (organisationId: string, organisationRole: OrganisationMemberRole) => {
|
||||
return await prisma.organisationMemberInvite.create({
|
||||
data: {
|
||||
id: generateDatabaseId('member_invite'),
|
||||
email: `invite-${nanoid()}@test.documenso.com`,
|
||||
token: nanoid(32),
|
||||
organisationId,
|
||||
organisationRole,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: member deletion', () => {
|
||||
test('a manager cannot delete an admin via member.delete', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser, adminUser] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{ name: 'Manager', organisationRole: 'MANAGER' },
|
||||
{ name: 'Admin', organisationRole: 'ADMIN' },
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const adminMember = await getOrganisationMember(adminUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.delete', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberId: adminMember.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
// The admin must still be a member of the organisation.
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: adminMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a manager cannot delete an admin via member.deleteMany', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser, adminUser] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{ name: 'Manager', organisationRole: 'MANAGER' },
|
||||
{ name: 'Admin', organisationRole: 'ADMIN' },
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const adminMember = await getOrganisationMember(adminUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberIds: [adminMember.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: adminMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a manager cannot delete the organisation owner', async ({ page }) => {
|
||||
const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberIds: [ownerMember.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: ownerMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('an admin cannot delete the organisation owner', async ({ page }) => {
|
||||
const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [adminUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Admin', organisationRole: 'ADMIN' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberIds: [ownerMember.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: ownerMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a manager can still delete a regular member (positive control)', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser, memberUser] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{ name: 'Manager', organisationRole: 'MANAGER' },
|
||||
{ name: 'Member', organisationRole: 'MEMBER' },
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const member = await getOrganisationMember(memberUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberIds: [member.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const deleted = await prisma.organisationMember.findFirst({
|
||||
where: { id: member.id },
|
||||
});
|
||||
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group deletion', () => {
|
||||
test('a manager cannot delete an admin-role group', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const adminGroup = await createCustomGroup(organisation.id, 'ADMIN');
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.group.delete', {
|
||||
organisationId: organisation.id,
|
||||
groupId: adminGroup.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationGroup.findFirst({
|
||||
where: { id: adminGroup.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a manager can delete a member-role group (positive control)', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const memberGroup = await createCustomGroup(organisation.id, 'MEMBER');
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.group.delete', {
|
||||
organisationId: organisation.id,
|
||||
groupId: memberGroup.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const deleted = await prisma.organisationGroup.findFirst({
|
||||
where: { id: memberGroup.id },
|
||||
});
|
||||
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: invite resend', () => {
|
||||
test('a manager cannot resend an admin-role invite', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const adminInvite = await createPendingInvite(organisation.id, 'ADMIN');
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.invite.resend', {
|
||||
organisationId: organisation.id,
|
||||
invitationId: adminInvite.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('a manager can resend a member-role invite (positive control)', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const memberInvite = await createPendingInvite(organisation.id, 'MEMBER');
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.invite.resend', {
|
||||
organisationId: organisation.id,
|
||||
invitationId: memberInvite.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: leaving an organisation', () => {
|
||||
test('the owner cannot leave without transferring ownership first', async ({ page }) => {
|
||||
const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: ownerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.leave', {
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: ownerMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a non-owner member can still leave (positive control)', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [memberUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Member', organisationRole: 'MEMBER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const member = await getOrganisationMember(memberUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: memberUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.leave', {
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const deleted = await prisma.organisationMember.findFirst({
|
||||
where: { id: member.id },
|
||||
});
|
||||
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group membership scoping', () => {
|
||||
test('cannot add a member from another organisation to a group', async ({ page }) => {
|
||||
// Organisation A, where the actor is the owner/admin.
|
||||
const { user: actor, organisation: organisationA } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// A separate organisation B with a member the actor has no authority over.
|
||||
const { organisation: organisationB } = await seedUser({ isPersonalOrganisation: false });
|
||||
const [foreignUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Foreign', organisationRole: 'MEMBER' }],
|
||||
organisationId: organisationB.id,
|
||||
});
|
||||
|
||||
const foreignMember = await getOrganisationMember(foreignUser.id, organisationB.id);
|
||||
|
||||
// A custom group the actor legitimately controls in organisation A.
|
||||
const groupA = await createCustomGroup(organisationA.id, 'MEMBER');
|
||||
|
||||
await apiSignin({ page, email: actor.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.group.update', {
|
||||
id: groupA.id,
|
||||
memberIds: [foreignMember.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const injectedMembership = await prisma.organisationGroupMember.findFirst({
|
||||
where: { groupId: groupA.id, organisationMemberId: foreignMember.id },
|
||||
});
|
||||
|
||||
expect(injectedMembership).toBeNull();
|
||||
});
|
||||
|
||||
test('can add a member from the same organisation to a group (positive control)', async ({ page }) => {
|
||||
const { user: actor, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [memberUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Member', organisationRole: 'MEMBER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const member = await getOrganisationMember(memberUser.id, organisation.id);
|
||||
|
||||
const group = await createCustomGroup(organisation.id, 'MEMBER');
|
||||
|
||||
await apiSignin({ page, email: actor.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.group.update', {
|
||||
id: group.id,
|
||||
memberIds: [member.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const membership = await prisma.organisationGroupMember.findFirst({
|
||||
where: { groupId: group.id, organisationMemberId: member.id },
|
||||
});
|
||||
|
||||
expect(membership).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const BANNER_EXCEEDED_TEXT = 'Your organisation has exceeded a fair use limit';
|
||||
const BANNER_NEARING_TEXT = 'Your organisation is approaching a fair use limit';
|
||||
|
||||
type SeedQuotaStateOptions = {
|
||||
organisationId: string;
|
||||
organisationClaimId: string;
|
||||
/**
|
||||
* The `originalSubscriptionClaimId` to set on the claim. The banner is suppressed
|
||||
* for `INTERNAL_CLAIM_ID.FREE`, so use a non-free value to make it render.
|
||||
*/
|
||||
subscriptionClaimId: string;
|
||||
documentQuota: number | null;
|
||||
documentCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Point the organisation's document quota and current-period usage at a known
|
||||
* state. Only the document counter is touched; email/api quotas stay `null`
|
||||
* (unlimited) so the document counter is the sole driver of the banner.
|
||||
*/
|
||||
const seedQuotaState = async ({
|
||||
organisationId,
|
||||
organisationClaimId,
|
||||
subscriptionClaimId,
|
||||
documentQuota,
|
||||
documentCount,
|
||||
}: SeedQuotaStateOptions) => {
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisationClaimId,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: subscriptionClaimId,
|
||||
documentQuota,
|
||||
},
|
||||
});
|
||||
|
||||
const period = currentMonthlyPeriod();
|
||||
|
||||
await prisma.organisationMonthlyStat.upsert({
|
||||
where: {
|
||||
organisationId_period: {
|
||||
organisationId,
|
||||
period,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
documentCount,
|
||||
},
|
||||
create: {
|
||||
id: generateDatabaseId('org_monthly_stat'),
|
||||
organisationId,
|
||||
period,
|
||||
documentCount,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test('[QUOTA BANNER]: shows the approaching state when a quota is nearing', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// ceil(10 * 0.8) = 8 → nearing, but not yet exceeded.
|
||||
await seedQuotaState({
|
||||
organisationId: organisation.id,
|
||||
organisationClaimId: organisation.organisationClaim.id,
|
||||
subscriptionClaimId: INTERNAL_CLAIM_ID.TEAM,
|
||||
documentQuota: 10,
|
||||
documentCount: 8,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
await expect(page.getByText(BANNER_NEARING_TEXT)).toBeVisible();
|
||||
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeHidden();
|
||||
});
|
||||
|
||||
test('[QUOTA BANNER]: shows the exceeded state when a quota is reached', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// usage >= quota → exceeded.
|
||||
await seedQuotaState({
|
||||
organisationId: organisation.id,
|
||||
organisationClaimId: organisation.organisationClaim.id,
|
||||
subscriptionClaimId: INTERNAL_CLAIM_ID.TEAM,
|
||||
documentQuota: 10,
|
||||
documentCount: 10,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeVisible();
|
||||
await expect(page.getByText(BANNER_NEARING_TEXT)).toBeHidden();
|
||||
});
|
||||
|
||||
test('[QUOTA BANNER]: learn more dialog lists the affected counter', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
await seedQuotaState({
|
||||
organisationId: organisation.id,
|
||||
organisationClaimId: organisation.organisationClaim.id,
|
||||
subscriptionClaimId: INTERNAL_CLAIM_ID.TEAM,
|
||||
documentQuota: 10,
|
||||
documentCount: 10,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Learn more' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
await expect(dialog.getByText('Fair use limit exceeded')).toBeVisible();
|
||||
await expect(dialog.getByText('Document creation has been temporarily paused')).toBeVisible();
|
||||
await expect(dialog.getByRole('link', { name: 'support' })).toHaveAttribute('href', /^mailto:/);
|
||||
});
|
||||
|
||||
test('[QUOTA BANNER]: is hidden for free-claim organisations', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// Usage is exceeded, but a free-claim organisation must never see the banner.
|
||||
await seedQuotaState({
|
||||
organisationId: organisation.id,
|
||||
organisationClaimId: organisation.organisationClaim.id,
|
||||
subscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
|
||||
documentQuota: 10,
|
||||
documentCount: 10,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
// Anchor on a stable element so banner-absence is meaningful (page fully loaded).
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||
|
||||
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Learn more' })).toBeHidden();
|
||||
});
|
||||
@@ -39,7 +39,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
await page.getByTestId('include-signing-certificate-trigger').click();
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const teamSettings = await getTeamSettings({
|
||||
@@ -73,7 +73,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
await page.getByTestId('document-date-format-trigger').click();
|
||||
await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const updatedTeamSettings = await getTeamSettings({
|
||||
@@ -128,7 +128,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
|
||||
await page.getByRole('textbox', { name: 'Brand Details' }).click();
|
||||
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const teamSettings = await getTeamSettings({
|
||||
@@ -150,7 +150,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com');
|
||||
await page.getByRole('textbox', { name: 'Brand Details' }).click();
|
||||
await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails');
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const updatedTeamSettings = await getTeamSettings({
|
||||
@@ -165,7 +165,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||
// Test inheritance by setting team back to inherit from organisation
|
||||
await page.getByTestId('enable-branding').click();
|
||||
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
@@ -208,7 +208,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('checkbox', { name: 'Email the signer if the document is still pending' }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' }).uncheck();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const teamSettings = await getTeamSettings({
|
||||
@@ -245,7 +245,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Email the owner when the document is completed' }).uncheck();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const updatedTeamSettings = await getTeamSettings({
|
||||
@@ -292,7 +292,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
|
||||
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedCompletedDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
|
||||
const token = `api_${alphaid(16)}`;
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: { name: 'recipient-access-test', token: hashString(token), expires: null, userId, teamId },
|
||||
});
|
||||
|
||||
return { token };
|
||||
};
|
||||
|
||||
/**
|
||||
* Reading a recipient exposes its signing token (a bearer credential), so the
|
||||
* recipient read must enforce document visibility — a member who cannot read a
|
||||
* restricted document must not be able to read its recipients either. This
|
||||
* mirrors the field read, which is asserted as a control below.
|
||||
*/
|
||||
test('[RECIPIENT]: a member cannot read a recipient of a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.findFirstOrThrow({ where: { envelopeId: document.id } });
|
||||
|
||||
const res = await request.get(`${API_BASE_URL}/envelope/recipient/${recipient.id}`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}` },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
const body = res.ok() ? await res.json() : null;
|
||||
expect(body?.token).toBeUndefined();
|
||||
});
|
||||
|
||||
test('[RECIPIENT]: a member cannot read a field of a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const field = await prisma.field.findFirst({ where: { envelopeId: document.id } });
|
||||
|
||||
test.skip(!field, 'No field seeded on completed document');
|
||||
|
||||
const res = await request.get(`${API_BASE_URL}/envelope/field/${field!.id}`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}` },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const getEmailReports = async (organisationId: string) => {
|
||||
const stat = await prisma.organisationMonthlyStat.findUnique({
|
||||
where: {
|
||||
organisationId_period: {
|
||||
organisationId,
|
||||
period: currentMonthlyPeriod(),
|
||||
},
|
||||
},
|
||||
select: { emailReports: true },
|
||||
});
|
||||
|
||||
return stat?.emailReports ?? 0;
|
||||
};
|
||||
|
||||
test('[REPORT_SENDER]: only reports the sender after the button is clicked', async ({ page }) => {
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(user, team.id, ['recipient@documenso.com']);
|
||||
const token = document.recipients[0].token;
|
||||
|
||||
expect(await getEmailReports(organisation.id)).toBe(0);
|
||||
|
||||
await page.goto(`/report/${token}`);
|
||||
|
||||
// Visiting the page (GET) must not register a report.
|
||||
await expect(page.getByRole('heading', { name: 'Report this sender?' })).toBeVisible();
|
||||
expect(await getEmailReports(organisation.id)).toBe(0);
|
||||
|
||||
await page.getByRole('button', { name: 'Report sender' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
|
||||
|
||||
expect(await getEmailReports(organisation.id)).toBe(1);
|
||||
});
|
||||
|
||||
test('[REPORT_SENDER]: does not double count within the rate limit window', async ({ page }) => {
|
||||
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Rate limits are bypassed');
|
||||
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(user, team.id, ['recipient@documenso.com']);
|
||||
const token = document.recipients[0].token;
|
||||
|
||||
await page.goto(`/report/${token}`);
|
||||
await page.getByRole('button', { name: 'Report sender' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
|
||||
|
||||
await page.goto(`/report/${token}`);
|
||||
await page.getByRole('button', { name: 'Report sender' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
|
||||
|
||||
expect(await getEmailReports(organisation.id)).toBe(1);
|
||||
});
|
||||
|
||||
test('[REPORT_SENDER]: returns 404 for an invalid token', async ({ page }) => {
|
||||
const response = await page.goto('/report/not-a-real-token');
|
||||
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { DocumentDataType, FieldType } from '@prisma/client';
|
||||
|
||||
const BRANDING_URL = 'https://brand.example/signing?source=documenso';
|
||||
const PDF_PAGE_SELECTOR = 'img[data-page-number]';
|
||||
|
||||
const readBrandingLogo = async () => {
|
||||
const logo = await fs.readFile(path.join(__dirname, '../../assets/logo.png'));
|
||||
|
||||
return JSON.stringify({
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: logo.toString('base64'),
|
||||
});
|
||||
};
|
||||
|
||||
const enableOrganisationBranding = async ({
|
||||
organisationGlobalSettingsId,
|
||||
brandingUrl = BRANDING_URL,
|
||||
}: {
|
||||
organisationGlobalSettingsId: string;
|
||||
brandingUrl?: string;
|
||||
}) => {
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisationGlobalSettingsId },
|
||||
data: {
|
||||
brandingEnabled: true,
|
||||
brandingLogo: await readBrandingLogo(),
|
||||
brandingUrl,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* On signing surfaces the custom branding logo must render as a plain image.
|
||||
* It must not be wrapped in any link, and the Brand Website must never appear
|
||||
* as a link on these pages.
|
||||
*/
|
||||
const expectPlainBrandingLogo = async (page: Page, logoName: string) => {
|
||||
const logo = page.getByRole('img', { name: logoName });
|
||||
|
||||
await expect(logo).toBeVisible();
|
||||
|
||||
// The custom logo must not be wrapped in a link.
|
||||
await expect(page.getByRole('link', { name: logoName })).toHaveCount(0);
|
||||
|
||||
// The Brand Website must never be rendered as a link on signing pages.
|
||||
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
|
||||
};
|
||||
|
||||
test('[SIGNING_BRANDING]: V1 normal signing renders custom logo as a plain image', async ({ page }) => {
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
await enableOrganisationBranding({
|
||||
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
|
||||
});
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['v1-branding-signer@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipients[0].token}`);
|
||||
|
||||
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
|
||||
});
|
||||
|
||||
test('[SIGNING_BRANDING]: V2 signing renders custom logo as a plain image', async ({ page }) => {
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
await enableOrganisationBranding({
|
||||
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
|
||||
});
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['v2-branding-signer@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
|
||||
const directTemplate = await seedDirectTemplate({
|
||||
title: 'V2 Branding Direct Template',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipients[0].token}`);
|
||||
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
|
||||
|
||||
await page.goto(formatDirectTemplatePath(directTemplate.directLink?.token || ''));
|
||||
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
|
||||
});
|
||||
|
||||
test('[SIGNING_BRANDING]: V2 signing keeps internal link for the Documenso fallback logo', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['v2-fallback-signer@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipients[0].token}`);
|
||||
|
||||
const fallbackLogoLink = page.locator('a[href="/"]').first();
|
||||
|
||||
await expect(fallbackLogoLink).toBeVisible();
|
||||
});
|
||||
|
||||
test('[SIGNING_BRANDING]: embedded signing does not render custom logo Brand Website links', async ({ page }) => {
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
await enableOrganisationBranding({
|
||||
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
|
||||
});
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['embed-branding-signer@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
|
||||
await page.goto(`/embed/sign/${recipients[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0);
|
||||
});
|
||||
@@ -66,7 +66,7 @@ test('[TEAMS]: update team', async ({ page }) => {
|
||||
await page.getByLabel('Team URL*').clear();
|
||||
await page.getByLabel('Team URL*').fill(updatedTeamId);
|
||||
|
||||
await page.getByRole('button', { name: 'Update team' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||
|
||||
// Check we have been redirected to the new team URL and the name is updated.
|
||||
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`);
|
||||
|
||||
@@ -238,7 +238,7 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||
await page.getByLabel('test.documenso.com').first().click();
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document re-sent');
|
||||
await expectToastTextToBeVisible(page, 'Document resent');
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete draft team document', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
/**
|
||||
* Calls a team-group tRPC mutation directly, bypassing the UI.
|
||||
*
|
||||
* The UI only ever surfaces CUSTOM / INTERNAL_ORGANISATION groups, so these
|
||||
* authorisation rules must be enforced on the server - a crafted request can
|
||||
* target any `teamGroupId`, including the system-managed INTERNAL_TEAM groups.
|
||||
*/
|
||||
const callTeamGroupMutation = (
|
||||
page: Page,
|
||||
procedure: 'team.group.delete' | 'team.group.update',
|
||||
teamId: number,
|
||||
input: Record<string, unknown>,
|
||||
) =>
|
||||
page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
|
||||
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
|
||||
data: JSON.stringify({ json: input }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Every team is created with three system-managed INTERNAL_TEAM groups
|
||||
* (admin/manager/member). They are the backbone of team-specific access and,
|
||||
* like organisation internal groups, must not be deletable - deleting them
|
||||
* silently strips team members of access while leaving the team row in place.
|
||||
*/
|
||||
test('[TEAMS]: internal team groups cannot be deleted via the API', async ({ page }) => {
|
||||
// Member inheritance OFF: membership is granted exclusively through the team's
|
||||
// INTERNAL_TEAM groups, so removing them is what causes the access loss.
|
||||
const { user: owner, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
// A direct team member whose access depends on the INTERNAL_TEAM member group.
|
||||
const directMember = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const internalTeamGroups = await prisma.teamGroup.findMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
|
||||
},
|
||||
});
|
||||
|
||||
// admin + manager + member.
|
||||
expect(internalTeamGroups).toHaveLength(3);
|
||||
|
||||
for (const group of internalTeamGroups) {
|
||||
const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, {
|
||||
teamId: team.id,
|
||||
teamGroupId: group.id,
|
||||
});
|
||||
|
||||
expect(response.status(), `INTERNAL_TEAM ${group.teamRole} group must not be deletable`).not.toBe(200);
|
||||
}
|
||||
|
||||
// None of the internal groups were removed.
|
||||
const remaining = await prisma.teamGroup.count({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
|
||||
},
|
||||
});
|
||||
|
||||
expect(remaining).toBe(3);
|
||||
|
||||
// The direct member therefore keeps their team access.
|
||||
const memberStillHasAccess = await prisma.teamGroup.findFirst({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
organisationGroup: {
|
||||
type: OrganisationGroupType.INTERNAL_TEAM,
|
||||
organisationGroupMembers: {
|
||||
some: { organisationMember: { userId: directMember.id } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(memberStillHasAccess).not.toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Guards against over-blocking: user-created (CUSTOM) team groups are not
|
||||
* internal and must remain removable by team managers/admins.
|
||||
*/
|
||||
test('[TEAMS]: custom team groups can still be deleted', async ({ page }) => {
|
||||
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
const customGroup = await prisma.organisationGroup.create({
|
||||
data: {
|
||||
id: generateDatabaseId('org_group'),
|
||||
name: `custom-${team.url}`,
|
||||
type: OrganisationGroupType.CUSTOM,
|
||||
organisationRole: OrganisationMemberRole.MEMBER,
|
||||
organisationId: organisation.id,
|
||||
teamGroups: {
|
||||
create: {
|
||||
id: generateDatabaseId('team_group'),
|
||||
teamId: team.id,
|
||||
teamRole: TeamMemberRole.MEMBER,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { teamGroups: true },
|
||||
});
|
||||
|
||||
const customTeamGroup = customGroup.teamGroups[0];
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, {
|
||||
teamId: team.id,
|
||||
teamGroupId: customTeamGroup.id,
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const deleted = await prisma.teamGroup.findUnique({ where: { id: customTeamGroup.id } });
|
||||
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* The same root cause affects updates: an INTERNAL_TEAM group's role must not be
|
||||
* editable either, otherwise a team admin could rewrite the backbone roles
|
||||
* (e.g. promote the member group to admin).
|
||||
*/
|
||||
test('[TEAMS]: internal team groups cannot be updated via the API', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const internalMemberGroup = await prisma.teamGroup.findFirstOrThrow({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
teamRole: TeamMemberRole.MEMBER,
|
||||
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
|
||||
},
|
||||
});
|
||||
|
||||
const response = await callTeamGroupMutation(page, 'team.group.update', team.id, {
|
||||
id: internalMemberGroup.id,
|
||||
data: { teamRole: TeamMemberRole.ADMIN },
|
||||
});
|
||||
|
||||
expect(response.status()).not.toBe(200);
|
||||
|
||||
const reloaded = await prisma.teamGroup.findUniqueOrThrow({ where: { id: internalMemberGroup.id } });
|
||||
|
||||
expect(reloaded.teamRole).toBe(TeamMemberRole.MEMBER);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
/**
|
||||
* Reproduces the "Team has no internal team groups" bug.
|
||||
*
|
||||
* When a team has member inheritance turned OFF, organisation admins/managers are
|
||||
* still inherited into the team as team admins (shown with the "Group" source).
|
||||
* These members are not part of the team's INTERNAL_TEAM group, so they cannot be
|
||||
* removed via the team members page - attempting to do so threw a 500 ("Team has no
|
||||
* internal team groups").
|
||||
*
|
||||
* Instead of crashing, the delete dialog must explain why the inherited member can't
|
||||
* be removed and not offer a confirm button.
|
||||
*/
|
||||
test('[TEAMS]: explains why an inherited organisation member cannot be removed', async ({ page }) => {
|
||||
// Team created with member inheritance OFF.
|
||||
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
const inheritedAdminEmail = `inherited-admin-${team.url}@test.documenso.com`;
|
||||
|
||||
// A second organisation admin is inherited into the team as a team admin (source "Group").
|
||||
await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [
|
||||
{
|
||||
name: 'Inherited Admin',
|
||||
email: inheritedAdminEmail,
|
||||
organisationRole: OrganisationMemberRole.ADMIN,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/settings/members`,
|
||||
});
|
||||
|
||||
const inheritedMemberRow = page.getByRole('row').filter({ hasText: inheritedAdminEmail });
|
||||
|
||||
// Sanity check: the member is inherited from a group, not a direct team member.
|
||||
await expect(inheritedMemberRow).toBeVisible();
|
||||
await expect(inheritedMemberRow).toContainText('Group');
|
||||
|
||||
await openDropdownMenu(page, inheritedMemberRow.getByRole('button').last());
|
||||
|
||||
// The action stays enabled - opening it shows a dialog explaining why the inherited
|
||||
// member can't be removed, rather than triggering the 500.
|
||||
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
|
||||
await expect(removeMenuItem).toBeEnabled();
|
||||
await removeMenuItem.click();
|
||||
|
||||
await expect(page.getByText('inherited from a group').first()).toBeVisible();
|
||||
|
||||
// No confirm button is offered, so the broken removal can never be triggered.
|
||||
await expect(page.getByRole('button', { name: 'Remove' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Guards against over-disabling the remove action: a direct team member (one that
|
||||
* belongs to the team's INTERNAL_TEAM group) must still be removable.
|
||||
*/
|
||||
test('[TEAMS]: can remove a direct team member', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
const directMember = await seedTeamMember({
|
||||
teamId: team.id,
|
||||
name: 'Direct Member',
|
||||
role: TeamMemberRole.MEMBER,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/settings/members`,
|
||||
});
|
||||
|
||||
const directMemberRow = page.getByRole('row').filter({ hasText: directMember.email });
|
||||
|
||||
await expect(directMemberRow).toBeVisible();
|
||||
|
||||
await openDropdownMenu(page, directMemberRow.getByRole('button').last());
|
||||
|
||||
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
|
||||
|
||||
// The "Remove" action is enabled for direct members and removing them succeeds.
|
||||
await expect(removeMenuItem).toBeEnabled();
|
||||
await removeMenuItem.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
await expect(page.getByText('You have successfully removed this user from the team.').first()).toBeVisible();
|
||||
|
||||
// The member is actually gone after reloading the members list.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('row').filter({ hasText: owner.email })).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: directMember.email })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
/**
|
||||
* Editing the team public profile is a team-management action and must require
|
||||
* MANAGE_TEAM, consistent with renaming the team or changing its URL.
|
||||
*/
|
||||
test('[TEAMS]: a member cannot edit the team public profile', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await apiSignin({ page, email: member.email });
|
||||
|
||||
const profileRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, {
|
||||
headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() },
|
||||
data: JSON.stringify({
|
||||
json: {
|
||||
teamId: team.id,
|
||||
data: { profileEnabled: true, profileBio: 'edited-by-member' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(profileRes.status()).not.toBe(200);
|
||||
|
||||
const profile = await prisma.teamProfile.findUnique({ where: { teamId: team.id } });
|
||||
expect(profile?.enabled ?? false).toBe(false);
|
||||
expect(profile?.bio ?? '').not.toBe('edited-by-member');
|
||||
|
||||
// The name/url path of the same route is also management-gated.
|
||||
const nameRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, {
|
||||
headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() },
|
||||
data: JSON.stringify({
|
||||
json: { teamId: team.id, data: { name: 'renamed-by-member' } },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(nameRes.status()).not.toBe(200);
|
||||
|
||||
const reloaded = await prisma.team.findUnique({ where: { id: team.id } });
|
||||
expect(reloaded?.name).not.toBe('renamed-by-member');
|
||||
|
||||
expect(owner.id).toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test('[TEAMS]: settings save bar docks at the bottom of the form', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
await expect(page.getByLabel('Team Name*')).toBeVisible();
|
||||
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
|
||||
// Pristine: the docked Save button is present but disabled; no Undo, no floating notice.
|
||||
await expect(saveButton).toBeVisible();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0);
|
||||
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
|
||||
|
||||
// Make a change → Save enables and Undo appears.
|
||||
const updatedName = `team-${Date.now()}`;
|
||||
await page.getByLabel('Team Name*').clear();
|
||||
await page.getByLabel('Team Name*').fill(updatedName);
|
||||
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await expect(page.getByRole('button', { name: 'Undo' })).toBeVisible();
|
||||
|
||||
// Undo → value restored, Save disabled again, Undo gone.
|
||||
await page.getByRole('button', { name: 'Undo' }).click();
|
||||
await expect(page.getByLabel('Team Name*')).toHaveValue(team.name);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0);
|
||||
|
||||
// Change again → Save → success toast, returns to a pristine (disabled) state.
|
||||
await page.getByLabel('Team Name*').clear();
|
||||
await page.getByLabel('Team Name*').fill(updatedName);
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await saveButton.click();
|
||||
|
||||
await expect(page.getByText('Your team has been successfully updated.').first()).toBeVisible();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[ORGANISATIONS]: settings save bar floats when the form footer is off-screen', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
});
|
||||
|
||||
// Wait for the long document-preferences form to load.
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// Pristine: no floating notice even though the footer is below the fold.
|
||||
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
|
||||
|
||||
// Edit a field near the top → the footer is off-screen, so the floating pill appears.
|
||||
await page.getByTestId('document-language-trigger').click();
|
||||
await page.getByRole('option', { name: 'German' }).click();
|
||||
|
||||
await expect(page.getByText('You have unsaved changes')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
|
||||
|
||||
// Scroll to the footer → the floating pill merges into the docked buttons and the
|
||||
// notice disappears.
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
|
||||
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
|
||||
});
|
||||
@@ -75,7 +75,7 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
||||
await item.click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
// Wait for the update to complete
|
||||
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
@@ -140,7 +140,7 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
||||
await item.click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
// Wait for finish
|
||||
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
|
||||
@@ -1,23 +1,395 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { ORGANISATION_USER_ACCOUNT_TYPE } from '@documenso/lib/constants/organisations';
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, SubscriptionStatus } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test('[USER] delete account', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
await apiSignin({ page, email: user.email, redirectPath: '/settings' });
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
/**
|
||||
* The deleted-account service account is where orphaned DOCUMENT envelopes land
|
||||
* when the team/org they belong to is torn down. It is created by a migration so
|
||||
* it always exists in the test database.
|
||||
*/
|
||||
const getDeletedServiceAccount = async () => {
|
||||
const deletedAccount = await prisma.user.findFirstOrThrow({
|
||||
where: { email: { startsWith: 'deleted-account@' } },
|
||||
select: {
|
||||
id: true,
|
||||
ownedOrganisations: { select: { teams: { select: { id: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: deletedAccount.id,
|
||||
teamId: deletedAccount.ownedOrganisations[0].teams[0].id,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Drives the account deletion through the settings UI, exactly as a user would.
|
||||
* Returns once the app has redirected to the sign-in page (deletion is performed
|
||||
* synchronously by the `profile.deleteAccount` mutation before the redirect).
|
||||
*/
|
||||
const deleteAccountViaUi = async (page: Page, email: string) => {
|
||||
await apiSignin({ page, email, redirectPath: '/settings' });
|
||||
|
||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||
await page.getByLabel('Confirm Email').fill(user.email);
|
||||
await page.getByLabel('Confirm Email').fill(email);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Confirm Deletion' }).click();
|
||||
|
||||
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/signin`);
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
|
||||
};
|
||||
|
||||
// Verify that the user no longer exists in the database
|
||||
const seedDocumentWithStatus = async (sender: User, teamId: number, key: string, status: DocumentStatus) => {
|
||||
const document = await seedBlankDocument(sender, teamId, { key });
|
||||
|
||||
if (status !== DocumentStatus.DRAFT) {
|
||||
await prisma.envelope.update({
|
||||
where: { id: document.id },
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
|
||||
return document;
|
||||
};
|
||||
|
||||
const waitForOrganisationToBeGone = async (organisationId: string) => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const org = await prisma.organisation.findUnique({
|
||||
where: { id: organisationId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return org === null;
|
||||
},
|
||||
{
|
||||
message: `Organisation ${organisationId} was not removed after account deletion`,
|
||||
timeout: 15_000,
|
||||
intervals: [250, 500, 1000],
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
};
|
||||
|
||||
// ─── Happy path: the basic flow still works ──────────────────────────────────
|
||||
|
||||
test('[USER] delete account', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
|
||||
await deleteAccountViaUi(page, user.email);
|
||||
|
||||
// Verify that the user no longer exists in the database.
|
||||
await expect(getUserByEmail({ email: user.email })).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ─── Owned organisation: documents orphaned to the service account ───────────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: owned org docs are orphaned to service account, drafts and templates removed', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation, team } = await seedUser();
|
||||
|
||||
// Inflight/completed DOCUMENT envelopes that must survive as orphans.
|
||||
const completed = await seedDocumentWithStatus(user, team.id, 'owned-completed', DocumentStatus.COMPLETED);
|
||||
const pending = await seedDocumentWithStatus(user, team.id, 'owned-pending', DocumentStatus.PENDING);
|
||||
const rejected = await seedDocumentWithStatus(user, team.id, 'owned-rejected', DocumentStatus.REJECTED);
|
||||
|
||||
// A draft DOCUMENT — orphan only re-parents PENDING/REJECTED/COMPLETED, so it is hard-deleted.
|
||||
const draft = await seedDocumentWithStatus(user, team.id, 'owned-draft', DocumentStatus.DRAFT);
|
||||
|
||||
// A TEMPLATE — orphan only re-parents DOCUMENT envelopes, so it is hard-deleted.
|
||||
const template = await seedBlankDocument(user, team.id, { key: 'owned-template' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: template.id },
|
||||
data: { type: EnvelopeType.TEMPLATE },
|
||||
});
|
||||
|
||||
expect(await prisma.envelope.count({ where: { teamId: team.id } })).toBe(5);
|
||||
|
||||
await deleteAccountViaUi(page, user.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
const service = await getDeletedServiceAccount();
|
||||
|
||||
// Completed/pending/rejected: re-parented to the service account + soft-deleted.
|
||||
for (const original of [completed, pending, rejected]) {
|
||||
const after = await prisma.envelope.findUnique({
|
||||
where: { id: original.id },
|
||||
select: { id: true, teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
|
||||
expect(after, `envelope ${original.id} should survive as an orphan`).not.toBeNull();
|
||||
expect(after?.teamId).toBe(service.teamId);
|
||||
expect(after?.userId).toBe(service.id);
|
||||
expect(after?.deletedAt).not.toBeNull();
|
||||
}
|
||||
|
||||
// Draft + template are hard-deleted.
|
||||
expect(await prisma.envelope.findUnique({ where: { id: draft.id } })).toBeNull();
|
||||
expect(await prisma.envelope.findUnique({ where: { id: template.id } })).toBeNull();
|
||||
|
||||
// The owned org, its team, and the user are gone. Nothing references the old team.
|
||||
expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).toBeNull();
|
||||
expect(await prisma.team.findUnique({ where: { id: team.id } })).toBeNull();
|
||||
expect(await prisma.user.findUnique({ where: { id: user.id } })).toBeNull();
|
||||
expect(await prisma.envelope.count({ where: { teamId: team.id } })).toBe(0);
|
||||
});
|
||||
|
||||
// ─── Member of another org: documents transferred to the OWNER, not deleted ──
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: docs in orgs the user is a member of are transferred to the org owner', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Another org, owned by someone else, that the deleted user is merely a member of.
|
||||
const { user: ownerB, organisation: orgB, team: teamB } = await seedUser();
|
||||
|
||||
// The account being deleted. They own their own (personal) org too.
|
||||
const { user: userA, organisation: orgA, team: teamA } = await seedUser();
|
||||
|
||||
await seedOrganisationMembers({
|
||||
organisationId: orgB.id,
|
||||
members: [{ email: userA.email, name: userA.name ?? 'User A', organisationRole: 'MEMBER' }],
|
||||
});
|
||||
|
||||
// userA authors envelopes inside orgB's team (both completed and draft).
|
||||
const memberCompleted = await seedDocumentWithStatus(userA, teamB.id, 'member-completed', DocumentStatus.COMPLETED);
|
||||
const memberDraft = await seedDocumentWithStatus(userA, teamB.id, 'member-draft', DocumentStatus.DRAFT);
|
||||
|
||||
// userA also has a completed doc in their OWN org (should orphan to service account).
|
||||
const ownedCompleted = await seedDocumentWithStatus(userA, teamA.id, 'owned-completed', DocumentStatus.COMPLETED);
|
||||
|
||||
await deleteAccountViaUi(page, userA.email);
|
||||
|
||||
await waitForOrganisationToBeGone(orgA.id);
|
||||
|
||||
const service = await getDeletedServiceAccount();
|
||||
|
||||
// Member-org envelopes — regardless of status — are reassigned to orgB's owner,
|
||||
// stay in orgB's team, and are NOT soft-deleted.
|
||||
for (const original of [memberCompleted, memberDraft]) {
|
||||
const after = await prisma.envelope.findUnique({
|
||||
where: { id: original.id },
|
||||
select: { id: true, teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
|
||||
expect(after, `member envelope ${original.id} should be transferred, not deleted`).not.toBeNull();
|
||||
expect(after?.teamId).toBe(teamB.id);
|
||||
expect(after?.userId).toBe(ownerB.id);
|
||||
expect(after?.deletedAt).toBeNull();
|
||||
}
|
||||
|
||||
// The other org and its owner survive — only the deleted user's own org is removed.
|
||||
expect(await prisma.organisation.findUnique({ where: { id: orgB.id } })).not.toBeNull();
|
||||
expect(await prisma.user.findUnique({ where: { id: ownerB.id } })).not.toBeNull();
|
||||
|
||||
// The deleted user's own completed doc was orphaned to the service account.
|
||||
const ownedAfter = await prisma.envelope.findUnique({
|
||||
where: { id: ownedCompleted.id },
|
||||
select: { teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(ownedAfter, 'owned-org envelope should survive as an orphan').not.toBeNull();
|
||||
expect(ownedAfter?.teamId).toBe(service.teamId);
|
||||
expect(ownedAfter?.userId).toBe(service.id);
|
||||
expect(ownedAfter?.deletedAt).not.toBeNull();
|
||||
|
||||
// userA is gone.
|
||||
expect(await prisma.user.findUnique({ where: { id: userA.id } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Owned org with members: org torn down, members survive, their docs orphaned ─
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: deleting the owner removes the org but keeps members and orphans their docs', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: owner, organisation, team } = await seedUser();
|
||||
|
||||
const [member] = await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [{ organisationRole: 'MEMBER' }],
|
||||
});
|
||||
|
||||
// A member (not the owner) authored a completed doc inside the owned org's team.
|
||||
// The orphan logic filters by teamId only, so this must be orphaned too.
|
||||
const memberCompleted = await seedDocumentWithStatus(member, team.id, 'member-completed', DocumentStatus.COMPLETED);
|
||||
|
||||
await deleteAccountViaUi(page, owner.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
const service = await getDeletedServiceAccount();
|
||||
|
||||
const after = await prisma.envelope.findUnique({
|
||||
where: { id: memberCompleted.id },
|
||||
select: { teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(after, 'member-authored envelope should survive as an orphan').not.toBeNull();
|
||||
expect(after?.teamId).toBe(service.teamId);
|
||||
expect(after?.userId).toBe(service.id);
|
||||
expect(after?.deletedAt).not.toBeNull();
|
||||
|
||||
// The member user survives — only the org and its owner are removed.
|
||||
expect(await prisma.user.findUnique({ where: { id: member.id } })).not.toBeNull();
|
||||
expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).toBeNull();
|
||||
expect(await prisma.user.findUnique({ where: { id: owner.id } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Subscription cancellation is scheduled for owned orgs ───────────────────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: a cancel-subscription job is enqueued for an owned org that has a subscription', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation } = await seedUser();
|
||||
|
||||
const planId = `sub_e2e_${nanoid()}`;
|
||||
|
||||
await prisma.subscription.create({
|
||||
data: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
planId,
|
||||
priceId: `price_e2e_${nanoid()}`,
|
||||
customerId: `cus_e2e_${nanoid()}`,
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteAccountViaUi(page, user.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
// The deletion must schedule the Stripe subscription cancellation job with the
|
||||
// captured planId (the Subscription row itself cascades away with the org).
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.cancel-organisation-subscription',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (job.payload as { stripeSubscriptionId?: string }).stripeSubscriptionId ?? null;
|
||||
},
|
||||
{
|
||||
message: 'cancel-organisation-subscription job was not enqueued',
|
||||
timeout: 15_000,
|
||||
intervals: [250, 500, 1000],
|
||||
},
|
||||
)
|
||||
.toBe(planId);
|
||||
|
||||
// The local Subscription row cascades away with the organisation — which is
|
||||
// exactly why the planId has to be captured into the job payload beforehand.
|
||||
expect(await prisma.subscription.findUnique({ where: { planId } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Owned org account (SSO) rows are cleaned up, members survive ────────────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: org-linked account rows are removed when an owned org is torn down', async ({ page }) => {
|
||||
const { user: owner, organisation } = await seedUser();
|
||||
|
||||
const [member] = await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [{ organisationRole: 'MEMBER' }],
|
||||
});
|
||||
|
||||
// Simulate a member who linked their login through the organisation's SSO.
|
||||
// These rows are keyed by `provider = organisation.id` and have no foreign key
|
||||
// to the organisation, so they must be deleted explicitly during teardown.
|
||||
const orgAccount = await prisma.account.create({
|
||||
data: {
|
||||
userId: member.id,
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: organisation.id,
|
||||
providerAccountId: `oidc-${nanoid()}`,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteAccountViaUi(page, owner.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
// The org-linked account row is gone...
|
||||
expect(await prisma.account.findUnique({ where: { id: orgAccount.id } })).toBeNull();
|
||||
expect(
|
||||
await prisma.account.count({
|
||||
where: { type: ORGANISATION_USER_ACCOUNT_TYPE, provider: organisation.id },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// ...but the member user it belonged to survives (only the org + owner are removed).
|
||||
expect(await prisma.user.findUnique({ where: { id: member.id } })).not.toBeNull();
|
||||
expect(await prisma.user.findUnique({ where: { id: owner.id } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Sad path: no subscription means no cancel job is enqueued ────────────────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: no cancel-subscription job is enqueued when the owned org has no subscription', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation } = await seedUser();
|
||||
|
||||
await deleteAccountViaUi(page, user.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.cancel-organisation-subscription',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
|
||||
expect(job).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Sad path: a mismatched confirmation email leaves everything intact ───────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: a wrong confirmation email keeps the account, org and documents intact', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation, team } = await seedUser();
|
||||
|
||||
const completed = await seedDocumentWithStatus(user, team.id, 'kept-completed', DocumentStatus.COMPLETED);
|
||||
|
||||
await apiSignin({ page, email: user.email, redirectPath: '/settings' });
|
||||
|
||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||
await page.getByLabel('Confirm Email').fill('not-my-email@example.com');
|
||||
|
||||
// The confirm button stays disabled while the email does not match.
|
||||
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).toBeDisabled();
|
||||
|
||||
// Nothing was deleted or orphaned.
|
||||
expect(await prisma.user.findUnique({ where: { id: user.id } })).not.toBeNull();
|
||||
expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).not.toBeNull();
|
||||
|
||||
const docAfter = await prisma.envelope.findUnique({
|
||||
where: { id: completed.id },
|
||||
select: { teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(docAfter?.teamId).toBe(team.id);
|
||||
expect(docAfter?.userId).toBe(user.id);
|
||||
expect(docAfter?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user