mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(api): limit report auto-resolution on scheduled deletion (#3221)
This commit is contained in:
@@ -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<OAuth2TokenRepository, 'deleteAllAccessTokensForUser' | 'deleteAllRefreshTokensForUser'>;
|
||||
storeEntitlementService: StoreEntitlementService;
|
||||
ncmecRepository: Pick<NcmecRepository, 'getUserWorkflow'>;
|
||||
}
|
||||
|
||||
const minUserRequestedDeletionDays = 14;
|
||||
const minStandardDeletionDays = 60;
|
||||
|
||||
const reportResolvingDeletionReasons: ReadonlySet<number> = 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<string> = 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<string> = 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<void> {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
async function reportUser(
|
||||
harness: ApiTestHarness,
|
||||
reporter: TestAccount,
|
||||
targetUserId: string,
|
||||
category = 'spam_account',
|
||||
): Promise<string> {
|
||||
const report = await createBuilder<ReportResponse>(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<string> {
|
||||
const message = await sendChannelMessage(harness, author.token, channelId, 'Reported content');
|
||||
const report = await createBuilder<ReportResponse>(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']);
|
||||
|
||||
Reference in New Issue
Block a user