mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
396 lines
15 KiB
TypeScript
396 lines
15 KiB
TypeScript
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.describe.configure({ mode: 'parallel' });
|
|
|
|
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(email);
|
|
|
|
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled();
|
|
await page.getByRole('button', { name: 'Confirm Deletion' }).click();
|
|
|
|
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
|
|
};
|
|
|
|
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();
|
|
});
|