From 1d7e141d01cdeb85139f1f73445963318b986cf4 Mon Sep 17 00:00:00 2001 From: eternal-flame-AD Date: Tue, 19 Nov 2024 19:22:54 -0600 Subject: [PATCH] Atomic AP account deletion Signed-off-by: eternal-flame-AD --- .forgejo/workflows/test-federation.yml | 59 +++++++++++++++++++ .../src/core/activitypub/ApInboxService.ts | 18 +++--- .../activitypub/models/ApPersonService.ts | 17 +++--- .../src/core/entities/UserEntityService.ts | 10 ++-- packages/backend/src/di-symbols.ts | 1 + .../backend/src/models/RepositoryModule.ts | 9 +++ packages/backend/src/models/User.ts | 11 +++- packages/backend/src/models/_.ts | 4 +- .../DeleteAccountProcessorService.ts | 23 +++++++- .../DeleteDriveFilesProcessorService.ts | 6 +- .../backend/src/server/api/endpoints/users.ts | 8 +-- .../src/server/api/endpoints/users/search.ts | 10 ++-- .../src/server/api/endpoints/users/show.ts | 10 ++-- .../backend/test-federation/test/user.test.ts | 48 +++++++++++++++ 14 files changed, 189 insertions(+), 45 deletions(-) create mode 100644 .forgejo/workflows/test-federation.yml diff --git a/.forgejo/workflows/test-federation.yml b/.forgejo/workflows/test-federation.yml new file mode 100644 index 0000000000..50a4c866eb --- /dev/null +++ b/.forgejo/workflows/test-federation.yml @@ -0,0 +1,59 @@ +name: Test (federation) + +on: + push: + branches: + - master + - develop + paths: + - packages/backend/** + - packages/misskey-js/** + - .forgejo/workflows/test-federation.yml + pull_request: + paths: + - packages/backend/** + - packages/misskey-js/** + - .forgejo/workflows/test-federation.yml + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [22.11.0] + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - name: Install pnpm + uses: pnpm/action-setup@v4 + - name: Install FFmpeg + uses: https://github.com/FedericoCarboni/setup-ffmpeg@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4.0.3 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: Build Misskey + run: | + corepack enable && corepack prepare + pnpm i --frozen-lockfile + pnpm build + - name: Setup + run: | + cd packages/backend/test-federation + bash ./setup.sh + sudo chmod 644 ./certificates/*.test.key + - name: Start servers + # https://github.com/docker/compose/issues/1294#issuecomment-374847206 + run: | + cd packages/backend/test-federation + docker compose up -d --scale tester=0 + - name: Test + run: | + cd packages/backend/test-federation + docker compose run --no-deps tester + - name: Stop servers + run: | + cd packages/backend/test-federation + docker compose down diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index f3aa46292e..14d778a3f6 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -24,7 +24,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; +import type { NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository, MiMeta, ActiveUsersRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import type { MiRemoteUser } from '@/models/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; @@ -58,8 +58,8 @@ export class ApInboxService { @Inject(DI.meta) private meta: MiMeta, - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + @Inject(DI.activeUsersRepository) + private activeUsersRepository: ActiveUsersRepository, @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -380,7 +380,7 @@ export class ApInboxService { return 'skip: ブロックしようとしているユーザーはローカルユーザーではありません'; } - await this.userBlockingService.block(await this.usersRepository.findOneByOrFail({ id: actor.id }), await this.usersRepository.findOneByOrFail({ id: blockee.id })); + await this.userBlockingService.block(await this.activeUsersRepository.findOneByOrFail({ id: actor.id }), await this.activeUsersRepository.findOneByOrFail({ id: blockee.id })); return 'ok'; } @@ -509,7 +509,7 @@ export class ApInboxService { return `skip: delete actor ${actor.uri} !== ${uri}`; } - const user = await this.usersRepository.findOneBy({ id: actor.id }); + const user = await this.activeUsersRepository.findOneBy({ id: actor.id }); if (user == null) { return 'skip: actor not found'; } else if (user.isDeleted) { @@ -518,10 +518,6 @@ export class ApInboxService { const job = await this.queueService.createDeleteAccountJob(actor); - await this.usersRepository.update(actor.id, { - isDeleted: true, - }); - this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: actor.id }); return `ok: queued ${job.name} ${job.id}`; @@ -561,7 +557,7 @@ export class ApInboxService { .filter(uri => uri.startsWith(this.config.url + '/users/')) .map(uri => uri.split('/').at(-1)) .filter(x => x != null); - const users = await this.usersRepository.findBy({ + const users = await this.activeUsersRepository.findBy({ id: In(userIds), }); if (users.length < 1) return 'skip'; @@ -715,7 +711,7 @@ export class ApInboxService { return 'skip: ブロック解除しようとしているユーザーはローカルユーザーではありません'; } - await this.userBlockingService.unblock(await this.usersRepository.findOneByOrFail({ id: actor.id }), blockee); + await this.userBlockingService.unblock(await this.activeUsersRepository.findOneByOrFail({ id: actor.id }), blockee); return 'ok'; } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 8c4e40c561..06f84b5bfd 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -8,7 +8,7 @@ import promiseLimit from 'promise-limit'; import { DataSource } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, InstancesRepository, MiMeta, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; +import type { ActiveUsersRepository, FollowingsRepository, InstancesRepository, MiMeta, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { MiUser } from '@/models/User.js'; @@ -91,6 +91,9 @@ export class ApPersonService implements OnModuleInit { @Inject(DI.usersRepository) private usersRepository: UsersRepository, + @Inject(DI.activeUsersRepository) + private activeUsersRepository: ActiveUsersRepository, + @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, @@ -211,13 +214,13 @@ export class ApPersonService implements OnModuleInit { // URIがこのサーバーを指しているならデータベースからフェッチ if (uri.startsWith(`${this.config.url}/`)) { const id = uri.split('/').pop(); - const u = await this.usersRepository.findOneBy({ id }) as MiLocalUser | null; + const u = await this.activeUsersRepository.findOneBy({ id }) as MiLocalUser | null; if (u) this.cacheService.uriPersonCache.set(uri, u); return u; } //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.usersRepository.findOneBy({ uri }) as MiLocalUser | MiRemoteUser | null; + const exist = await this.activeUsersRepository.findOneBy({ uri }) as MiLocalUser | MiRemoteUser | null; if (exist) { this.cacheService.uriPersonCache.set(uri, exist); @@ -401,7 +404,7 @@ export class ApPersonService implements OnModuleInit { // duplicate key error if (isDuplicateKeyValueError(e)) { // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 - const u = await this.usersRepository.findOneBy({ uri: person.id }); + const u = await this.activeUsersRepository.findOneBy({ uri: person.id }); if (u == null) throw new Error('already registered'); user = u as MiRemoteUser; @@ -560,7 +563,7 @@ export class ApPersonService implements OnModuleInit { await this.usersRepository.update(exist.id, updates); if (person.publicKey) { - await this.userPublickeysRepository.update({ userId: exist.id }, { + await this.usersRepository.update({ userId: exist.id }, { keyId: person.publicKey.id, keyPem: person.publicKey.publicKeyPem, }); @@ -662,7 +665,7 @@ export class ApPersonService implements OnModuleInit { @bindThis public async updateFeatured(userId: MiUser['id'], resolver?: Resolver): Promise { - const user = await this.usersRepository.findOneByOrFail({ id: userId }); + const user = await this.activeUsersRepository.findOneByOrFail({ id: userId }); if (!this.userEntityService.isRemoteUser(user)) return; if (!user.featured) return; @@ -720,7 +723,7 @@ export class ApPersonService implements OnModuleInit { if (dst && this.userEntityService.isLocalUser(dst)) { // targetがローカルユーザーだった場合データベースから引っ張ってくる - dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as MiLocalUser; + dst = await this.activeUsersRepository.findOneByOrFail({ uri: src.movedToUri }) as MiLocalUser; } else if (dst) { if (movePreventUris.includes(src.movedToUri)) return 'skip: circular move'; diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index d3c087a153..5e617311b7 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -24,6 +24,7 @@ import { passwordSchema, } from '@/models/User.js'; import type { + ActiveUsersRepository, BlockingsRepository, FollowingsRepository, FollowRequestsRepository, @@ -37,7 +38,6 @@ import type { UserNotePiningsRepository, UserProfilesRepository, UserSecurityKeysRepository, - UsersRepository, } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; @@ -101,8 +101,8 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + @Inject(DI.activeUsersRepository) + private activeUsersRepository: ActiveUsersRepository, @Inject(DI.userSecurityKeysRepository) private userSecurityKeysRepository: UserSecurityKeysRepository, @@ -410,7 +410,7 @@ export class UserEntityService implements OnModuleInit { includeSecrets: false, }, options); - const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src }); + const user = typeof src === 'object' ? src : await this.activeUsersRepository.findOneByOrFail({ id: src }); const isDetailed = opts.schema !== 'UserLite'; const meId = me ? me.id : null; @@ -662,7 +662,7 @@ export class UserEntityService implements OnModuleInit { const _users = users.filter((user): user is MiUser => typeof user !== 'string'); if (_users.length !== users.length) { _users.push( - ...await this.usersRepository.findBy({ + ...await this.activeUsersRepository.findBy({ id: In(users.filter((user): user is string => typeof user === 'string')), }), ); diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index e599fc7b37..aa81a8f7bd 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -16,6 +16,7 @@ export const DI = { //#region Repositories usersRepository: Symbol('usersRepository'), + activeUsersRepository: Symbol('activeUsersRepository'), notesRepository: Symbol('notesRepository'), announcementsRepository: Symbol('announcementsRepository'), announcementReadsRepository: Symbol('announcementReadsRepository'), diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index ea0f88baba..77e879156f 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -10,6 +10,7 @@ import { MiAbuseReportNotificationRecipient, MiAbuseUserReport, MiAccessToken, + MiActiveUser, MiAd, MiAnnouncement, MiAnnouncementRead, @@ -87,6 +88,12 @@ const $usersRepository: Provider = { inject: [DI.db], }; +const $activeUsersRepository: Provider = { + provide: DI.activeUsersRepository, + useFactory: (db: DataSource) => db.getRepository(MiActiveUser).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $notesRepository: Provider = { provide: DI.notesRepository, useFactory: (db: DataSource) => db.getRepository(MiNote).extend(miRepository as MiRepository), @@ -499,6 +506,7 @@ const $reversiGamesRepository: Provider = { imports: [], providers: [ $usersRepository, + $activeUsersRepository, $notesRepository, $announcementsRepository, $announcementReadsRepository, @@ -570,6 +578,7 @@ const $reversiGamesRepository: Provider = { ], exports: [ $usersRepository, + $activeUsersRepository, $notesRepository, $announcementsRepository, $announcementReadsRepository, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 5328131b93..8572899a8c 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; +import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn, ViewEntity } from 'typeorm'; import { id } from './util/id.js'; import { MiDriveFile } from './DriveFile.js'; @@ -286,6 +286,15 @@ export class MiUser { } } +@ViewEntity({ + expression: (db) => db.createQueryBuilder().from(MiUser, 'user').where({ + isDeleted: false, + }), + materialized: false, +}) +export class MiActiveUser extends MiUser { +} + export type MiLocalUser = MiUser & { host: null; uri: null; diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index c72bdaa727..c62f778541 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -57,7 +57,7 @@ import { MiRelay } from '@/models/Relay.js'; import { MiSignin } from '@/models/Signin.js'; import { MiSwSubscription } from '@/models/SwSubscription.js'; import { MiUsedUsername } from '@/models/UsedUsername.js'; -import { MiUser } from '@/models/User.js'; +import { MiActiveUser, MiUser } from '@/models/User.js'; import { MiUserIp } from '@/models/UserIp.js'; import { MiUserKeypair } from '@/models/UserKeypair.js'; import { MiUserList } from '@/models/UserList.js'; @@ -172,6 +172,7 @@ export { MiSignin, MiSwSubscription, MiUsedUsername, + MiActiveUser, MiUser, MiUserIp, MiUserKeypair, @@ -244,6 +245,7 @@ export type SigninsRepository = Repository & MiRepository; export type SwSubscriptionsRepository = Repository & MiRepository; export type UsedUsernamesRepository = Repository & MiRepository; export type UsersRepository = Repository & MiRepository; +export type ActiveUsersRepository = Pick & MiRepository, 'findOneBy' | 'findOneByOrFail' | 'findBy' | 'countBy'>; export type UserIpsRepository = Repository & MiRepository; export type UserKeypairsRepository = Repository & MiRepository; export type UserListsRepository = Repository & MiRepository; diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 14a53e0c42..8aee3f058c 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -44,9 +44,7 @@ export class DeleteAccountProcessorService { } @bindThis - public async process(job: Bull.Job): Promise { - this.logger.info(`Deleting account of ${job.data.user.id} ...`); - + private async processImpl(job: Bull.Job): Promise { const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { return; @@ -130,4 +128,23 @@ export class DeleteAccountProcessorService { return 'Account deleted'; } + + @bindThis + public async process(job: Bull.Job): Promise { + this.logger.info(`Deleting account of ${job.data.user.id} ...`); + + // Atomically "soft lock" the entry without actually locking the row + if (! (await this.usersRepository.update({ id: job.data.user.id, isDeleted: false }, { isDeleted: true })).affected) { + this.logger.debug(`Account of ${job.data.user.id} is already being deleted`); + return 'Account deletion already in progress'; + } + + try { + return await this.processImpl(job); + } catch (e) { + await this.usersRepository.update({ id: job.data.user.id }, { isDeleted: false }); + this.logger.error(`Failed to delete account of ${job.data.user.id}: {e.name}: ${e instanceof Error ? e.message : e}`); + return; + } + } } diff --git a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts index 291fa4a6d8..6308e34145 100644 --- a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, DriveFilesRepository, MiDriveFile } from '@/models/_.js'; +import type { DriveFilesRepository, MiDriveFile, ActiveUsersRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { bindThis } from '@/decorators.js'; @@ -20,7 +20,7 @@ export class DeleteDriveFilesProcessorService { constructor( @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + private activeUsersRepository: ActiveUsersRepository, @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, @@ -35,7 +35,7 @@ export class DeleteDriveFilesProcessorService { public async process(job: Bull.Job): Promise { this.logger.info(`Deleting drive files of ${job.data.user.id} ...`); - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + const user = await this.activeUsersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { return; } diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts index e845853017..64a6680794 100644 --- a/packages/backend/src/server/api/endpoints/users.ts +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; +import type { ActiveUsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -47,14 +47,14 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + @Inject(DI.activeUsersRepository) + private activeUsersRepository: ActiveUsersRepository, private userEntityService: UserEntityService, private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.usersRepository.createQueryBuilder('user') + const query = this.activeUsersRepository.createQueryBuilder('user') .where('user.isExplorable = TRUE') .andWhere('user.isSuspended = FALSE'); diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts index 6c686a8eb7..87b464fec4 100644 --- a/packages/backend/src/server/api/endpoints/users/search.ts +++ b/packages/backend/src/server/api/endpoints/users/search.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; +import type { UserProfilesRepository, ActiveUsersRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -45,8 +45,8 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + @Inject(DI.activeUsersRepository) + private activeUsersRepository: ActiveUsersRepository, @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, @@ -61,7 +61,7 @@ export default class extends Endpoint { // eslint- let users: MiUser[] = []; - const nameQuery = this.usersRepository.createQueryBuilder('user') + const nameQuery = this.activeUsersRepository.createQueryBuilder('user') .where(new Brackets(qb => { qb.where('user.name &@~ :query', { query: ps.query }); @@ -101,7 +101,7 @@ export default class extends Endpoint { // eslint- profQuery.andWhere('prof.userHost IS NOT NULL'); } - const query = this.usersRepository.createQueryBuilder('user') + const query = this.activeUsersRepository.createQueryBuilder('user') .where(`user.id IN (${ profQuery.getQuery() })`) .andWhere(new Brackets(qb => { qb diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 062326e28d..29769b6727 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -5,7 +5,7 @@ import { In, IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; +import type { ActiveUsersRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -82,8 +82,8 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + @Inject(DI.activeUsersRepository) + private activeUsersRepository: ActiveUsersRepository, private userEntityService: UserEntityService, private remoteUserResolveService: RemoteUserResolveService, @@ -102,7 +102,7 @@ export default class extends Endpoint { // eslint- return []; } - const users = await this.usersRepository.findBy(isModerator ? { + const users = await this.activeUsersRepository.findBy(isModerator ? { id: In(ps.userIds), } : { id: In(ps.userIds), @@ -132,7 +132,7 @@ export default class extends Endpoint { // eslint- ? { id: ps.userId } : { usernameLower: ps.username!.toLowerCase(), host: IsNull() }; - user = await this.usersRepository.findOneBy(q); + user = await this.activeUsersRepository.findOneBy(q); } if (user == null || (!isModerator && user.isSuspended)) { diff --git a/packages/backend/test-federation/test/user.test.ts b/packages/backend/test-federation/test/user.test.ts index 76605e61d4..754827a7dd 100644 --- a/packages/backend/test-federation/test/user.test.ts +++ b/packages/backend/test-federation/test/user.test.ts @@ -450,6 +450,54 @@ describe('User', () => { strictEqual(followers.length, 0); // Alice's Follow is not processed }); }); + + describe('Deletion atomicity', () => { + const REPS = 5; + const N_USERS = 100; + let users: LoginUser[]; + let observer_a: LoginUser, observer_b: LoginUser; + + beforeAll(async () => { + observer_a = await createAccount('a.test'); + observer_b = await createAccount('b.test'); + for (let i = 0; i < N_USERS; i++) { + users.push(await createAccount('a.test')); + users.push(await createAccount('b.test')); + } + }); + + for (let i = 0; i < REPS; i++) { + test('Follow all users', async () => { + await Promise.all(users.flatMap(async (user, i) => { + await observer_a.client.request('following/create', { userId: user.id }); + await observer_b.client.request('following/create', { userId: user.id }); + })); + }); + + test('Delete all users while updating them', async () => { + await Promise.all(users.flatMap(async (user, i) => { + await user.client.request('i/update', { name: `I'm deleting my account ${i}` }); + await user.client.request('i/delete-account', { password: user.password }); + })); + }); + + test('Check consistency', async () => { + await Promise.all(users.flatMap(async (user, i) => { + await rejects( + async () => await user.client.request('users/show', { userId: user.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_USER'); + return true; + }, + ); + })); + const following_a = await observer_a.client.request('users/following', { userId: observer_a.id }); + strictEqual(following_a.length, 0); + const following_b = await observer_b.client.request('users/following', { userId: observer_b.id }); + strictEqual(following_b.length, 0); + }); + } + }); }); describe('Suspension', () => {