feat(server): handle account deleting properly (#12399)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Users are now prevented from deleting their account if they own one or more team workspaces. A clear error message instructs users to transfer ownership or delete those workspaces first.
  - Disabled (banned) users are explicitly prevented from signing in or re-registering.
  - Added new error messages and translations to improve clarity around account deletion restrictions.

- **Bug Fixes**
  - Disabled users are now explicitly handled to prevent sign-in attempts.

- **Tests**
  - Introduced comprehensive end-to-end tests covering account deletion, banning, and re-registration scenarios.

- **Chores**
  - Improved event handling for user deletion and subscription cancellation.
  - Updated localization resources with new error messages.
  - Renamed payment event handler class for clarity.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
forehalo
2025-05-23 03:57:29 +00:00
parent f99b143bf9
commit f38b8fef4d
15 changed files with 235 additions and 23 deletions
@@ -154,12 +154,10 @@ export class TestingApp extends NestApplication {
}
async login(user: MockedUser) {
await this.POST('/api/auth/sign-in')
.send({
email: user.email,
password: user.password,
})
.expect(200);
return await this.POST('/api/auth/sign-in').send({
email: user.email,
password: user.password,
});
}
async switchUser(userOrId: string | { id: string }) {
@@ -0,0 +1,150 @@
import {
deleteAccountMutation,
disableUserMutation,
getCurrentUserQuery,
getWorkspaceQuery,
} from '@affine/graphql';
import { app, e2e, Mockers } from '../test';
const admin = await app.create(Mockers.User, {
feature: 'administrator',
});
e2e('should be able to delete account', async t => {
const user = await app.signup();
const user2 = await app.create(Mockers.User);
const ws = await app.create(Mockers.Workspace, {
owner: user,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: ws.id,
userId: user2.id,
});
await app.gql({
query: deleteAccountMutation,
});
// assert session removed
const { currentUser } = await app.gql({
query: getCurrentUserQuery,
});
t.is(currentUser, null);
// assert login failed
const res = await app.login(user);
t.is(res.status, 400);
t.like(res.body, {
message: `Wrong user email or password: ${user.email}`,
});
// assert workspace access deleted
await app.login(user2);
await t.throwsAsync(
app.gql({
query: getWorkspaceQuery,
variables: {
id: ws.id,
},
}),
{
message: `You do not have permission to access Space ${ws.id}.`,
}
);
});
e2e('should not delete account if is owner of team workspace', async t => {
const user = await app.signup();
const ws = await app.create(Mockers.Workspace, {
owner: user,
});
await app.create(Mockers.TeamWorkspace, {
id: ws.id,
});
await t.throwsAsync(
app.gql({
query: deleteAccountMutation,
}),
{
message:
'Cannot delete account. You are the owner of one or more team workspaces. Please transfer ownership or delete them first.',
}
);
});
e2e('should register deleted account again', async t => {
const user = await app.signup();
await app.gql({
query: deleteAccountMutation,
});
const res = await app.POST('/api/auth/sign-in').send({
email: user.email,
});
t.is(res.status, 200);
t.like(await app.mails.waitFor('SignUp'), {
to: user.email,
});
});
e2e('should ban account', async t => {
const user = await app.create(Mockers.User);
await app.login(admin);
const { banUser } = await app.gql({
query: disableUserMutation,
variables: {
id: user.id,
},
});
t.is(banUser.disabled, true);
});
e2e('should not login banned account', async t => {
const user = await app.create(Mockers.User);
await app.login(admin);
await app.gql({
query: disableUserMutation,
variables: {
id: user.id,
},
});
await app.logout();
const res = await app.login(user);
t.is(res.status, 400);
t.like(res.body, {
message: `Wrong user email or password: ${user.email}`,
});
});
e2e('should not signup banned account', async t => {
const user = await app.create(Mockers.User);
await app.login(admin);
await app.gql({
query: disableUserMutation,
variables: {
id: user.id,
},
});
const res = await app.POST('/api/auth/sign-in').send({
email: user.email,
});
t.is(res.status, 400);
t.like(res.body, {
message: `Wrong user email or password: ${user.email}`,
});
});
@@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker';
import { hashSync } from '@node-rs/argon2';
import type { Prisma, User } from '@prisma/client';
import type { UserFeatureName } from '../../models';
import { FeatureConfigs, type UserFeatureName } from '../../models';
import { Mocker } from './factory';
export type MockUserInput = Prisma.UserCreateInput & {
@@ -15,33 +15,37 @@ export type MockedUser = Omit<User, 'password'> & {
export class MockUser extends Mocker<MockUserInput, MockedUser> {
override async create(input?: Partial<MockUserInput>) {
const { feature, ...userInput } = input ?? {};
const password = input?.password ?? faker.internet.password();
const user = await this.db.user.create({
data: {
email: faker.internet.email(),
name: faker.person.fullName(),
password: password ? hashSync(password) : undefined,
...input,
...userInput,
},
});
if (input?.feature) {
const feature = await this.db.feature.findFirst({
if (feature) {
const featureRecord = await this.db.feature.findFirst({
where: {
name: input.feature,
name: feature,
},
});
if (!feature) {
if (!featureRecord) {
throw new Error(
`Feature ${input.feature} does not exist in DB. You might forgot to run data-migration first.`
`Feature ${feature} does not exist in DB. You might forgot to run data-migration first.`
);
}
const config = FeatureConfigs[feature];
await this.db.userFeature.create({
data: {
userId: user.id,
featureId: feature.id,
featureId: featureRecord.id,
name: feature,
type: config.type,
reason: 'test',
activated: true,
},