diff --git a/fluxer_api/src/api/admin/services/AdminUserDeletionService.ts b/fluxer_api/src/api/admin/services/AdminUserDeletionService.ts index f3ed0d09d..c80a11027 100644 --- a/fluxer_api/src/api/admin/services/AdminUserDeletionService.ts +++ b/fluxer_api/src/api/admin/services/AdminUserDeletionService.ts @@ -9,6 +9,7 @@ import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserU import * as AuthSession from '@app/api/auth/AuthSession'; import {createReportID, createUserID, type UserID} from '@app/api/BrandedTypes'; import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository'; +import type {NcmecRepository} from '@app/api/csam/NcmecRepository'; import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents'; import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService'; import {Logger} from '@app/api/Logger'; @@ -45,11 +46,28 @@ interface AdminUserDeletionServiceDeps { billingRepository: BillingRepository; oauth2Tokens: Pick; storeEntitlementService: StoreEntitlementService; + ncmecRepository: Pick; } const minUserRequestedDeletionDays = 14; const minStandardDeletionDays = 60; +const reportResolvingDeletionReasons: ReadonlySet = new Set([ + DeletionReasons.SPAM, + DeletionReasons.CHEATING_OR_EXPLOITATION, + DeletionReasons.COORDINATED_RAIDING, + DeletionReasons.AUTOMATION_OR_SELFBOT, + DeletionReasons.SCAM_OR_SOCIAL_ENGINEERING, + DeletionReasons.HARASSMENT_OR_BULLYING, + DeletionReasons.BAN_EVASION, + DeletionReasons.TOKEN_OR_CREDENTIAL_SCAM, + DeletionReasons.HATE_SPEECH_OR_EXTREMIST_CONTENT, + DeletionReasons.MALICIOUS_LINKS_OR_MALWARE, + DeletionReasons.IMPERSONATION_OR_FAKE_IDENTITY, +]); + +const manuallyResolvedReportCategories: ReadonlySet = new Set(['child_safety', 'underage_user', 'self_harm']); + function describePendingDeletion(user: User, prefix: string): Array<[string, string]> { if (!user.pendingDeletionAt) return []; return [ @@ -247,6 +265,8 @@ export class AdminUserDeletionService { let knownIps: ReadonlySet = new Set(); if (data.reason_code !== DeletionReasons.USER_REQUESTED) { knownIps = await this.banIdentifiersForScheduledDeletion({user, adminUserId, auditLogReason}); + } + if (reportResolvingDeletionReasons.has(data.reason_code)) { await this.resolvePendingReportsAgainstUser({user, adminUserId, reasonCode: data.reason_code}); } await emitAdminAction(adminUserId, userId, 'schedule_deletion', {reasonCode: data.reason_code, ips: knownIps}); @@ -389,7 +409,10 @@ export class AdminUserDeletionService { }): Promise { const {user, adminUserId, reasonCode} = params; const outcome = isEnforcementDeletionReason(reasonCode) ? 'actioned' : 'auto_resolved'; - const {reportService, auditService} = this.deps; + const {reportService, auditService, ncmecRepository} = this.deps; + if (await ncmecRepository.getUserWorkflow(user.id)) { + return; + } const reportSearchService = getReportSearchService(); if (!reportSearchService) { Logger.warn( @@ -415,6 +438,7 @@ export class AdminUserDeletionService { ); if (hits.length === 0) break; for (const hit of hits) { + if (manuallyResolvedReportCategories.has(hit.category)) continue; pendingReportIds.add(hit.id); } offset += hits.length; diff --git a/fluxer_api/src/api/admin/services/AdminUserService.ts b/fluxer_api/src/api/admin/services/AdminUserService.ts index 0f0961f6c..e40e27b5b 100644 --- a/fluxer_api/src/api/admin/services/AdminUserService.ts +++ b/fluxer_api/src/api/admin/services/AdminUserService.ts @@ -12,6 +12,7 @@ import {AdminUserSecurityService} from '@app/api/admin/services/AdminUserSecurit import {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator'; import {createChannelID, createUserID, type UserID} from '@app/api/BrandedTypes'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; +import {NcmecRepository} from '@app/api/csam/NcmecRepository'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService'; import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService'; @@ -112,6 +113,7 @@ export class AdminUserService { billingRepository: getBillingRepository(), oauth2Tokens: new OAuth2TokenRepository(), storeEntitlementService: deps.storeEntitlementService, + ncmecRepository: new NcmecRepository(), }); this.contactChangeLogService = contactChangeLog; } diff --git a/fluxer_api/src/api/worker/tests/BulkScheduleUserDeletion.test.ts b/fluxer_api/src/api/worker/tests/BulkScheduleUserDeletion.test.ts index a9752f058..5a7989165 100644 --- a/fluxer_api/src/api/worker/tests/BulkScheduleUserDeletion.test.ts +++ b/fluxer_api/src/api/worker/tests/BulkScheduleUserDeletion.test.ts @@ -10,6 +10,8 @@ import { type TestAccount, } from '@app/api/auth/tests/AuthTestUtils'; import {createReportID, createUserID} from '@app/api/BrandedTypes'; +import {sendChannelMessage, setupTestGuildWithMembers} from '@app/api/channel/tests/ChannelTestUtils'; +import {NcmecRepository} from '@app/api/csam/NcmecRepository'; import {getGatewayService, getSnowflakeService, setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry'; import { createUserCacheService, @@ -97,10 +99,32 @@ async function runBulkJob( return result as unknown as BulkJobResult; } -async function reportUser(harness: ApiTestHarness, reporter: TestAccount, targetUserId: string): Promise { +async function reportUser( + harness: ApiTestHarness, + reporter: TestAccount, + targetUserId: string, + category = 'spam_account', +): Promise { const report = await createBuilder(harness, reporter.token) .post('/reports/user') - .body({user_id: targetUserId, category: 'spam_account'}) + .body({user_id: targetUserId, category}) + .expect(HTTP_STATUS.OK) + .execute(); + await drainSearchTasks(); + return report.report_id; +} + +async function reportMessage( + harness: ApiTestHarness, + reporter: TestAccount, + author: TestAccount, + channelId: string, + category: string, +): Promise { + const message = await sendChannelMessage(harness, author.token, channelId, 'Reported content'); + const report = await createBuilder(harness, reporter.token) + .post('/reports/message') + .body({channel_id: channelId, message_id: message.id, category}) .expect(HTTP_STATUS.OK) .execute(); await drainSearchTasks(); @@ -223,6 +247,76 @@ describe('bulkScheduleUserDeletion', () => { const perUserLogs = await listAuditLogs('schedule_deletion'); expect(perUserLogs.filter((log) => log.targetId === BigInt(target.userId))).toHaveLength(1); }); + test('an abuse deletion resolves a spam report against the user', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']); + const reporter = await createTestAccount(harness); + const target = await createTestAccount(harness); + const reportId = await reportUser(harness, reporter, target.userId); + const result = await runBulkJob([target.userId], admin.userId, DeletionReasons.BAN_EVASION); + expect(result.successful_count).toBe(1); + expect(await getReportStatus(reportId)).toBe(ReportStatus.RESOLVED); + }); + test('an abuse deletion leaves child safety, underage user and self harm reports open', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']); + const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 3); + const [target, selfHarmReporter, userReporter] = members as [TestAccount, TestAccount, TestAccount]; + const childSafetyReportId = await reportMessage(harness, owner, target, systemChannel.id, 'child_safety'); + const selfHarmReportId = await reportMessage(harness, selfHarmReporter, target, systemChannel.id, 'self_harm'); + const underageReportId = await reportUser(harness, userReporter, target.userId, 'underage_user'); + const spamReportId = await reportUser(harness, await createTestAccount(harness), target.userId); + const result = await runBulkJob([target.userId], admin.userId, DeletionReasons.SPAM); + expect(result.successful_count).toBe(1); + expect(await getReportStatus(spamReportId)).toBe(ReportStatus.RESOLVED); + expect(await getReportStatus(childSafetyReportId)).toBe(ReportStatus.PENDING); + expect(await getReportStatus(selfHarmReportId)).toBe(ReportStatus.PENDING); + expect(await getReportStatus(underageReportId)).toBe(ReportStatus.PENDING); + }); + test.each([ + ['OTHER', DeletionReasons.OTHER], + ['INACTIVITY', DeletionReasons.INACTIVITY], + ['CHILD_SEXUAL_CONTENT', DeletionReasons.CHILD_SEXUAL_CONTENT], + ['CHILD_SAFETY_VIOLATION', DeletionReasons.CHILD_SAFETY_VIOLATION], + ])('a deletion for %s leaves reports open', async (_label, reasonCode) => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']); + const reporter = await createTestAccount(harness); + const target = await createTestAccount(harness); + const reportId = await reportUser(harness, reporter, target.userId); + const result = await runBulkJob([target.userId], admin.userId, reasonCode); + expect(result.successful_count).toBe(1); + expect(await getReportStatus(reportId)).toBe(ReportStatus.PENDING); + const resolutionLogs = await listAuditLogs('auto_resolve_reports_on_deletion'); + expect(resolutionLogs.some((log) => log.targetId === BigInt(target.userId))).toBe(false); + }); + test('an abuse deletion leaves reports open for a user with an NCMEC workflow', async () => { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']); + const reporter = await createTestAccount(harness); + const target = await createTestAccount(harness); + const reportId = await reportUser(harness, reporter, target.userId); + const now = new Date(); + await new NcmecRepository().upsertUserWorkflow({ + user_id: BigInt(target.userId), + deleted_at: null, + deleted_by_admin_id: null, + deletion_private_reason: null, + deletion_ncmec_report_id: null, + archive_id: null, + archive_requested_at: null, + archive_requested_by_admin_id: null, + archive_audit_log_reason: null, + archive_completed_at: null, + deletion_job_queued_at: null, + previous_report_ids: null, + created_at: now, + updated_at: now, + }); + const result = await runBulkJob([target.userId], admin.userId, DeletionReasons.SPAM); + expect(result.successful_count).toBe(1); + expect(await getReportStatus(reportId)).toBe(ReportStatus.PENDING); + }); test('a payload with notify_user false emails nobody and records it in the summary', async () => { const admin = await createTestAccount(harness); await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']);