feat(reports): add server-driven report flows (#3330)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:38:00 +02:00
1 parent bc17922b02
commit ece622e800
471 files changed
+56262 -35932

No files matched your search

@@ -5,13 +5,16 @@ import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
import {
clearTestEmails,
createTestAccount,
createUniqueEmail,
findLastTestEmail,
listTestEmails,
setUserACLs,
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 {createTestChannelService} from '@app/api/channel/tests/CrosspostTestUtils';
import {getReportServiceInstance} from '@app/api/middleware/ServiceMiddleware';
import {getGatewayService, getSnowflakeService, setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
import {
createUserCacheService,
@@ -20,23 +23,27 @@ import {
getGuildRepository,
getKVAccountDeletionQueue,
getKVBulkMessageDeletionQueue,
getLimitConfigService,
getUserPermissionUtils,
getUserRepository,
} from '@app/api/middleware/ServiceSingletons';
import {ReportStatus} from '@app/api/report/IReportRepository';
import {ReportRepository} from '@app/api/report/ReportRepository';
import {getReportSearchService} from '@app/api/SearchFactory';
import {drainSearchTasks} from '@app/api/search/SearchTaskTracker';
import {createTestStoreEntitlementService} from '@app/api/store_billing/tests/StoreBillingTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import bulkScheduleUserDeletion from '@app/api/worker/tasks/admin_bulk/BulkScheduleUserDeletion';
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
import type {WorkerTaskHelpers, WorkerTaskResult} from '@pkgs/worker/src/contracts/WorkerTask';
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
interface ReportResponse {
report_id: string;
@@ -70,6 +77,9 @@ function installWorkerDependencies(): void {
guildRepository: getGuildRepository(),
channelRepository: getChannelRepository(),
gatewayService: getGatewayService(),
channelService: createTestChannelService(),
userPermissionUtils: getUserPermissionUtils(),
limitConfigService: getLimitConfigService(),
deletionQueueService: getKVAccountDeletionQueue(),
bulkMessageDeletionQueueService: getKVBulkMessageDeletionQueue(),
stripe: null,
@@ -141,6 +151,58 @@ async function listAuditLogs(action: string): Promise<Array<AdminAuditLog>> {
return logs.filter((log) => log.action === action);
}
async function autoResolveSummary(targetUserId: string): Promise<Record<string, string>> {
const logs = (await listAuditLogs('auto_resolve_reports_on_deletion')).filter(
(log) => log.targetId === BigInt(targetUserId),
);
expect(logs).toHaveLength(1);
return Object.fromEntries(logs[0]!.metadata);
}
async function reportResolvedEmails(harness: ApiTestHarness, reporter: TestAccount) {
return (await listTestEmails(harness, {recipient: reporter.email})).filter(
(email) => email.type === 'report_resolved',
);
}
async function systemDmMessages(harness: ApiTestHarness, account: TestAccount): Promise<Array<string>> {
const channels = await createBuilder<Array<{id: string; recipients?: Array<{id: string}>}>>(harness, account.token)
.get('/users/@me/channels')
.expect(HTTP_STATUS.OK)
.execute();
const systemChannels = channels.filter((channel) => channel.recipients?.some((recipient) => recipient.id === '0'));
const messages = await Promise.all(
systemChannels.map((channel) =>
createBuilder<Array<{content: string | null}>>(harness, account.token)
.get(`/channels/${channel.id}/messages?limit=50`)
.expect(HTTP_STATUS.OK)
.execute(),
),
);
return messages.flat().map((message) => message.content ?? '');
}
const DELETION_WORDING = /delet|\bban|suspend|terminat|remov|enforce/i;
function renderGenericNotice(username: string, reportId: string): {subject: string; body: string} {
const rendered = getEmailTemplate('report_resolved', null, {username, reportId, publicComment: '', hasComment: 'no'});
if (!rendered.ok) {
throw new Error('report_resolved did not render');
}
return rendered.value;
}
async function scheduleDeletionThroughEndpoint(harness: ApiTestHarness, target: TestAccount): Promise<void> {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']);
await createBuilder(harness, admin.token)
.put(`/admin/users/${target.userId}/deletion`)
.header('X-Audit-Log-Reason', AUDIT_LOG_REASON)
.body({reason_code: DeletionReasons.SPAM, days_until_deletion: 60})
.expect(HTTP_STATUS.OK)
.execute();
}
async function isSessionAlive(harness: ApiTestHarness, token: string): Promise<boolean> {
const response = await harness.requestJson({path: '/users/@me', headers: {Authorization: token}});
return response.status === HTTP_STATUS.OK;
@@ -175,8 +237,13 @@ describe('bulkScheduleUserDeletion', () => {
expect(targetLog!.metadata.get('days')).toBe('60');
expect(await isSessionAlive(harness, target.token)).toBe(false);
expect(await new AdminRepository().isEmailBanned(target.email)).toBe(true);
const resolutionLogs = await listAuditLogs('auto_resolve_reports_on_deletion');
expect(resolutionLogs.some((log) => log.targetId === BigInt(target.userId))).toBe(true);
expect(await autoResolveSummary(target.userId)).toEqual({
outcome: 'completed',
found_count: '1',
resolved_count: '1',
failed_count: '0',
notified_count: '1',
});
const summaryLogs = await listAuditLogs('bulk_schedule_deletion');
expect(summaryLogs).toHaveLength(1);
expect(summaryLogs[0]!.targetType).toBe('bulk_job');
@@ -208,6 +275,150 @@ describe('bulkScheduleUserDeletion', () => {
expect(targetLog!.metadata.get('reason_code')).toBe(DeletionReasons.SPAM.toString());
expect(await isSessionAlive(harness, target.token)).toBe(false);
expect(await new AdminRepository().isEmailBanned(target.email)).toBe(true);
expect((await autoResolveSummary(target.userId)).notified_count).toBe('1');
});
test('an auto-resolved report sends the reporter the generic notice through the bulk job', 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);
await clearTestEmails(harness);
await runBulkJob([target.userId], admin.userId);
const sent = await reportResolvedEmails(harness, reporter);
expect(sent.map((email) => email.metadata)).toEqual([{report_id: reportId, public_comment: ''}]);
const notice = renderGenericNotice('reporter', reportId);
expect(notice.body).toContain(reportId);
expect(`${notice.subject}\n${notice.body}`).not.toMatch(DELETION_WORDING);
const me = await createBuilder<{username: string}>(harness, reporter.token)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(await systemDmMessages(harness, reporter)).toEqual([renderGenericNotice(me.username, reportId).body]);
});
test('an auto-resolved report sends the reporter a DM and an email with no deletion wording', async () => {
const reporter = await createTestAccount(harness);
const target = await createTestAccount(harness);
const reportId = await reportUser(harness, reporter, target.userId);
await clearTestEmails(harness);
await scheduleDeletionThroughEndpoint(harness, target);
const sent = await reportResolvedEmails(harness, reporter);
expect(sent.map((email) => email.metadata)).toEqual([{report_id: reportId, public_comment: ''}]);
const me = await createBuilder<{username: string}>(harness, reporter.token)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
const dms = await systemDmMessages(harness, reporter);
expect(dms).toEqual([renderGenericNotice(me.username, reportId).body]);
expect(await listTestEmails(harness, {recipient: reporter.email})).toHaveLength(1);
});
test('an auto-resolved DSA notice sends the notifier the decision email', async () => {
const target = await createTestAccount(harness);
const email = createUniqueEmail('dsa-auto-resolve');
await createBuilderWithoutAuth(harness)
.post('/reports/dsa/email/send')
.body({email})
.expect(HTTP_STATUS.OK)
.execute();
const code = findLastTestEmail(await listTestEmails(harness, {recipient: email}), 'dsa_report_verification')
?.metadata.code;
const {ticket} = await createBuilderWithoutAuth<{ticket: string}>(harness)
.post('/reports/dsa/email/verify')
.body({email, code})
.expect(HTTP_STATUS.OK)
.execute();
const report = await createBuilderWithoutAuth<ReportResponse>(harness)
.post('/reports/dsa')
.body({
ticket,
report_type: 'user',
category: 'harassment',
user_id: target.userId,
reporter_full_legal_name: 'Jane Doe',
reporter_country_of_residence: 'NL',
})
.expect(HTTP_STATUS.OK)
.execute();
await drainSearchTasks();
await clearTestEmails(harness);
await scheduleDeletionThroughEndpoint(harness, target);
expect(await getReportStatus(report.report_id)).toBe(ReportStatus.RESOLVED);
const sent = (await listTestEmails(harness)).filter((entry) => entry.to === email);
expect(sent.map((entry) => [entry.type, entry.metadata])).toEqual([
['dsa_report_resolved', {report_id: report.report_id, public_comment: ''}],
]);
expect((await autoResolveSummary(target.userId)).notified_count).toBe('1');
});
test('a deletion with no pending reports records a completed run with zero counts', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']);
const target = await createTestAccount(harness);
await runBulkJob([target.userId], admin.userId);
expect(await autoResolveSummary(target.userId)).toEqual({
outcome: 'completed',
found_count: '0',
resolved_count: '0',
failed_count: '0',
notified_count: '0',
});
});
test('a report search failure leaves reports pending and is recorded', 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 search = getReportSearchService()!;
const spy = vi.spyOn(search, 'searchReports').mockRejectedValue(new Error('search cluster unreachable'));
try {
const result = await runBulkJob([target.userId], admin.userId);
expect(result.successful_count).toBe(1);
} finally {
spy.mockRestore();
}
expect(await getReportStatus(reportId)).toBe(ReportStatus.PENDING);
expect(await autoResolveSummary(target.userId)).toEqual({
outcome: 'enumeration_failed',
found_count: '0',
resolved_count: '0',
failed_count: '0',
notified_count: '0',
});
});
test('a report that fails to resolve is counted and the others still resolve', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']);
const firstReporter = await createTestAccount(harness);
const secondReporter = await createTestAccount(harness);
const target = await createTestAccount(harness);
const firstReportId = await reportUser(harness, firstReporter, target.userId);
const secondReportId = await reportUser(harness, secondReporter, target.userId);
const reportService = getReportServiceInstance();
const resolveReport = reportService.resolveReport.bind(reportService);
const spy = vi.spyOn(reportService, 'resolveReport').mockImplementation(async (reportId, ...rest) => {
if (reportId.toString() === firstReportId) {
throw new Error('storage write timed out');
}
return resolveReport(reportId, ...rest);
});
await clearTestEmails(harness);
try {
const result = await runBulkJob([target.userId], admin.userId);
expect(result.successful_count).toBe(1);
} finally {
spy.mockRestore();
}
expect(await getReportStatus(firstReportId)).toBe(ReportStatus.PENDING);
expect(await getReportStatus(secondReportId)).toBe(ReportStatus.RESOLVED);
expect(await reportResolvedEmails(harness, firstReporter)).toEqual([]);
expect(await reportResolvedEmails(harness, secondReporter)).toHaveLength(1);
expect(await autoResolveSummary(target.userId)).toEqual({
outcome: 'completed',
found_count: '2',
resolved_count: '1',
failed_count: '1',
notified_count: '1',
});
});
test('keeps deleting the remaining users after one of them fails and reports the failure', async () => {
const admin = await createTestAccount(harness);
@@ -290,33 +501,6 @@ describe('bulkScheduleUserDeletion', () => {
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']);
@@ -397,3 +581,34 @@ describe('bulkScheduleUserDeletion', () => {
expect(after?.deletionScheduledBy?.toString()).toBe(scheduler.userId);
});
});
describe('auto-resolve on scheduled deletion without report search', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness({search: 'disabled'});
});
afterEach(async () => {
await harness.shutdown();
});
test('leaves the report pending and records that search was unavailable', async () => {
const reporter = await createTestAccount(harness);
const target = await createTestAccount(harness);
const report = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/user')
.body({user_id: target.userId, category: 'spam_account'})
.expect(HTTP_STATUS.OK)
.execute();
expect(getReportSearchService()).toBeNull();
await clearTestEmails(harness);
await scheduleDeletionThroughEndpoint(harness, target);
expect(await getReportStatus(report.report_id)).toBe(ReportStatus.PENDING);
expect(await reportResolvedEmails(harness, reporter)).toEqual([]);
expect(await autoResolveSummary(target.userId)).toEqual({
outcome: 'search_unavailable',
found_count: '0',
resolved_count: '0',
failed_count: '0',
notified_count: '0',
});
});
});
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createReportID, type ReportID, type UserID} from '@app/api/BrandedTypes';
import {IAR_SUBMISSION_COLUMNS, type IARSubmissionRow} from '@app/api/database/types/ReportTypes';
import {ReportRepository} from '@app/api/report/ReportRepository';
import {clearAuthenticatedReporterEmails} from '@app/api/worker/tasks/ClearAuthenticatedReporterEmails';
import {beforeEach, describe, expect, it} from 'vitest';
let sequence = 1_490_000_000_000_000_000n;
function nextId(): bigint {
sequence += 1n;
return sequence;
}
describe('clearAuthenticatedReporterEmails', () => {
let repository: ReportRepository;
beforeEach(() => {
repository = new ReportRepository();
});
async function seed(reporterId: bigint | null, reporterEmail: string | null): Promise<ReportID> {
const id = nextId();
await repository.createReport({
...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])),
report_id: id,
reporter_id: reporterId,
reporter_email: reporterEmail,
reporter_full_legal_name: reporterId === null ? 'Notifier Name' : null,
reported_at: new Date(),
status: 0,
report_type: 1,
category: 'harassment',
} as IARSubmissionRow);
return createReportID(id);
}
async function storedEmail(reportId: ReportID): Promise<string | null | undefined> {
return (await repository.getReport(reportId))?.reporterEmail;
}
it('clears the stored address on account reports and keeps it on notices filed without an account', async () => {
const reporterId = nextId();
const accountReports = [];
for (let index = 0; index < 5; index++) {
accountReports.push(await seed(reporterId, `reporter${index}@example.com`));
}
const withoutEmail = await seed(nextId(), null);
const notices = [await seed(null, '[email protected]'), await seed(null, '[email protected]')];
const summary = await clearAuthenticatedReporterEmails(repository, 2);
expect(summary).toEqual({scanned: 8, cleared: 5, skipped: 0, failed: 0});
for (const reportId of accountReports) {
expect(await storedEmail(reportId)).toBeNull();
}
expect(await storedEmail(withoutEmail)).toBeNull();
expect(await storedEmail(notices[0]!)).toBe('[email protected]');
expect(await storedEmail(notices[1]!)).toBe('[email protected]');
const notice = await repository.getReport(notices[0]!);
expect(notice?.reporterFullLegalName).toBe('Notifier Name');
});
it('changes nothing on a second run', async () => {
await seed(nextId(), '[email protected]');
await seed(null, '[email protected]');
await clearAuthenticatedReporterEmails(repository);
const summary = await clearAuthenticatedReporterEmails(repository);
expect(summary).toEqual({scanned: 2, cleared: 0, skipped: 0, failed: 0});
});
it('keeps the other fields of the report', async () => {
const reporterId = nextId();
const reportId = await seed(reporterId, '[email protected]');
const before = await repository.getReport(reportId);
await clearAuthenticatedReporterEmails(repository);
expect(await repository.getReport(reportId)).toEqual({...before, reporterEmail: null});
});
it('counts a report that could not be updated and moves on to the next', async () => {
const failing = await seed(nextId(), '[email protected]');
const other = await seed(nextId(), '[email protected]');
const failingRepository = Object.create(repository) as ReportRepository;
failingRepository.clearReporterEmail = async (reportId: ReportID, reporterId: UserID) => {
if (reportId === failing) throw new Error('write timeout');
return repository.clearReporterEmail(reportId, reporterId);
};
const summary = await clearAuthenticatedReporterEmails(failingRepository);
expect(summary).toEqual({scanned: 2, cleared: 1, skipped: 0, failed: 1});
expect(await storedEmail(failing)).toBe('[email protected]');
expect(await storedEmail(other)).toBeNull();
});
it('counts a report deleted while the backfill ran as skipped', async () => {
const deleted = await seed(nextId(), '[email protected]');
const racingRepository = Object.create(repository) as ReportRepository;
racingRepository.clearReporterEmail = async (reportId: ReportID, reporterId: UserID) => {
await repository.deleteReport(reportId);
return repository.clearReporterEmail(reportId, reporterId);
};
const summary = await clearAuthenticatedReporterEmails(racingRepository);
expect(summary).toEqual({scanned: 1, cleared: 0, skipped: 1, failed: 0});
expect(await repository.getReport(deleted)).toBeNull();
});
});
@@ -0,0 +1,449 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAttachmentID, createChannelID, createReportID, type ReportID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {makeAttachmentCdnKey} from '@app/api/channel/services/message/MessageHelpers';
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
import {
IAR_SUBMISSION_COLUMNS,
type IARMessageContextRow,
type IARSubmissionRow,
} from '@app/api/database/types/ReportTypes';
import type {IARSubmission} from '@app/api/report/IReportRepository';
import {ReportRepository} from '@app/api/report/ReportRepository';
import type {IReportSearchService} from '@app/api/search/IReportSearchService';
import {MockStorageService} from '@app/api/test/mocks/MockStorageService';
import {InMemorySearchProvider} from '@app/api/test/search/InMemorySearchProvider';
import {
processReportRetention,
type ReportRetentionDeps,
resolveReportRetentionDryRun,
} from '@app/api/worker/tasks/ExpireReportSnapshots';
import {serializeReportProfileSnapshot} from '@fluxer/schema/src/domains/report/ReportProfileSnapshotSchemas';
import {ms} from 'itty-time';
import {beforeEach, describe, expect, it} from 'vitest';
const NOW = new Date('2027-06-01T12:00:00.000Z');
const DAY_MS = ms('1 day');
const REPORTS_BUCKET = Config.s3.buckets.reports;
let sequence = 1_480_000_000_000_000_000n;
function nextId(): bigint {
sequence += 1n;
return sequence;
}
function daysAgo(days: number): Date {
return new Date(NOW.getTime() - days * DAY_MS);
}
function attachmentRow(attachmentId: bigint, filename: string): MessageAttachment {
return {
attachment_id: createAttachmentID(attachmentId),
filename,
size: 1024n,
title: null,
description: null,
width: 64,
height: 64,
content_type: 'image/png',
content_hash: null,
placeholder: null,
flags: 0,
duration: null,
nsfw: false,
waveform: null,
};
}
function contextRow(channelId: bigint, attachments: Array<MessageAttachment>): IARMessageContextRow {
return {
message_id: nextId(),
channel_id: channelId,
author_id: nextId(),
webhook_id: null,
author_username: 'context_author',
author_discriminator: 1,
author_avatar_hash: null,
content: 'context',
timestamp: NOW,
edited_timestamp: null,
type: 0,
flags: 0,
mention_everyone: false,
mention_users: null,
mention_roles: null,
mention_channels: null,
attachments,
embeds: null,
sticker_items: null,
};
}
interface SharedAttachment {
channelId: bigint;
attachmentId: bigint;
filename: string;
}
interface SeedOptions {
ageDays: number;
attachment?: SharedAttachment;
profile?: boolean;
legalHoldUntil?: Date | null;
status?: number;
snapshotJson?: string;
}
interface SeededReport {
reportId: ReportID;
attachmentKey: string | null;
profileKey: string | null;
}
function newAttachment(): SharedAttachment {
return {channelId: nextId(), attachmentId: nextId(), filename: 'evidence.png'};
}
function attachmentKey(attachment: SharedAttachment): string {
return makeAttachmentCdnKey(createChannelID(attachment.channelId), attachment.attachmentId, attachment.filename);
}
describe('report retention', () => {
let repository: ReportRepository;
let storage: MockStorageService;
let search: IReportSearchService;
let deps: ReportRetentionDeps;
beforeEach(() => {
repository = new ReportRepository();
storage = new MockStorageService();
search = new InMemorySearchProvider().getReportSearchService();
deps = {reportRepository: repository, storageService: storage, reportSearchService: search};
});
async function seed(options: SeedOptions): Promise<SeededReport> {
const id = nextId();
const reportId = createReportID(id);
let storedAttachmentKey: string | null = null;
let profileKey: string | null = null;
let messageContext: Array<IARMessageContextRow> | null = null;
if (options.attachment) {
const {channelId, attachmentId, filename} = options.attachment;
storedAttachmentKey = attachmentKey(options.attachment);
messageContext = [contextRow(channelId, [attachmentRow(attachmentId, filename)])];
await storage.uploadObject({bucket: REPORTS_BUCKET, key: storedAttachmentKey, body: new Uint8Array([1])});
}
let snapshot: string | null = options.snapshotJson ?? null;
if (options.profile) {
profileKey = `reports/${reportId}/profile/user_avatar/abc${id}`;
await storage.uploadObject({bucket: REPORTS_BUCKET, key: profileKey, body: new Uint8Array([2])});
snapshot ??= serializeReportProfileSnapshot({
captured_at: NOW.toISOString(),
user: {
id: nextId().toString(),
username: 'reported',
discriminator: 1,
global_name: null,
bio: 'bio at report time',
pronouns: null,
avatar: {hash: `abc${id}`, key: profileKey},
banner: null,
},
member: null,
guild: null,
});
}
const row = {
...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])),
report_id: id,
reported_at: daysAgo(options.ageDays),
status: options.status ?? 0,
report_type: 0,
category: 'other',
reported_channel_id: options.attachment?.channelId ?? null,
message_context: messageContext,
reported_profile_snapshot: snapshot,
legal_hold_until: options.legalHoldUntil ?? null,
legal_hold_reason: options.legalHoldUntil ? 'Preservation request' : null,
} as IARSubmissionRow;
const report = await repository.createReport(row);
await search.indexReport(report);
return {reportId, attachmentKey: storedAttachmentKey, profileKey};
}
async function indexedIds(): Promise<Array<string>> {
const result = await search.searchReports('', {}, {limit: 1000});
return result.hits.map((hit) => hit.id).sort();
}
async function expectKept(seeded: SeededReport): Promise<void> {
expect(await repository.getReport(seeded.reportId)).not.toBeNull();
expect(await indexedIds()).toContain(seeded.reportId.toString());
for (const key of [seeded.attachmentKey, seeded.profileKey]) {
if (key) expect(storage.hasObject(REPORTS_BUCKET, key)).toBe(true);
}
}
async function expectGone(seeded: SeededReport): Promise<void> {
expect(await repository.getReport(seeded.reportId)).toBeNull();
expect(await indexedIds()).not.toContain(seeded.reportId.toString());
if (seeded.profileKey) expect(storage.hasObject(REPORTS_BUCKET, seeded.profileKey)).toBe(false);
}
it('deletes a report 365 days after it was filed with its objects, search document and row', async () => {
const expired = await seed({ageDays: 365, attachment: newAttachment(), profile: true});
const kept = await seed({ageDays: 364, attachment: newAttachment(), profile: true});
const summary = await processReportRetention(deps, {now: NOW, dryRun: false});
await expectGone(expired);
expect(storage.hasObject(REPORTS_BUCKET, expired.attachmentKey!)).toBe(false);
await expectKept(kept);
expect(summary).toEqual({
dryRun: false,
scanned: 2,
expired: 1,
held: 0,
deleted: 1,
objectsDeleted: 2,
sharedObjectsKept: 0,
failed: 0,
});
});
it('deletes expired reports whatever their status', async () => {
const pending = await seed({ageDays: 400, status: 0});
const resolved = await seed({ageDays: 400, status: 1});
await processReportRetention(deps, {now: NOW, dryRun: false});
await expectGone(pending);
await expectGone(resolved);
});
it('keeps an object that a retained report still uses', async () => {
const shared = newAttachment();
const expired = await seed({ageDays: 500, attachment: shared});
const retained = await seed({ageDays: 10, attachment: shared});
const summary = await processReportRetention(deps, {now: NOW, dryRun: false});
await expectGone(expired);
await expectKept(retained);
expect(storage.hasObject(REPORTS_BUCKET, attachmentKey(shared))).toBe(true);
expect(summary.sharedObjectsKept).toBe(1);
expect(summary.objectsDeleted).toBe(0);
});
it('deletes an object shared by two expired reports once', async () => {
const shared = newAttachment();
const first = await seed({ageDays: 500, attachment: shared});
const second = await seed({ageDays: 450, attachment: shared});
const summary = await processReportRetention(deps, {now: NOW, dryRun: false});
await expectGone(first);
await expectGone(second);
expect(storage.hasObject(REPORTS_BUCKET, attachmentKey(shared))).toBe(false);
expect(summary.objectsDeleted).toBe(1);
expect(storage.deleteObjectSpy.mock.calls.filter(([, key]) => key === attachmentKey(shared))).toHaveLength(1);
});
it('keeps a report under a legal hold that ends later and deletes one whose hold has ended', async () => {
const shared = newAttachment();
const held = await seed({
ageDays: 500,
attachment: shared,
profile: true,
legalHoldUntil: new Date(NOW.getTime() + DAY_MS),
});
const holdEnded = await seed({ageDays: 500, profile: true, legalHoldUntil: daysAgo(1)});
const sharingWithHeld = await seed({ageDays: 500, attachment: shared});
const summary = await processReportRetention(deps, {now: NOW, dryRun: false});
await expectKept(held);
expect((await repository.getReport(held.reportId))?.legalHoldReason).toBe('Preservation request');
await expectGone(holdEnded);
await expectGone(sharingWithHeld);
expect(storage.hasObject(REPORTS_BUCKET, attachmentKey(shared))).toBe(true);
expect(summary).toMatchObject({scanned: 3, expired: 2, held: 1, deleted: 2, sharedObjectsKept: 1});
});
it('changes nothing in a dry run and reports what it would delete', async () => {
const expired = await seed({ageDays: 366, attachment: newAttachment(), profile: true});
const kept = await seed({ageDays: 1, profile: true});
const summary = await processReportRetention(deps, {now: NOW, dryRun: true});
await expectKept(expired);
await expectKept(kept);
expect(storage.deleteObjectSpy).not.toHaveBeenCalled();
expect(summary).toEqual({
dryRun: true,
scanned: 2,
expired: 1,
held: 0,
deleted: 1,
objectsDeleted: 2,
sharedObjectsKept: 0,
failed: 0,
});
});
it('deletes nothing when the scan cannot finish', async () => {
const expired = await seed({ageDays: 400, attachment: newAttachment(), profile: true});
await seed({ageDays: 400});
await seed({ageDays: 400});
let pages = 0;
const failingRepository = Object.create(repository) as ReportRepository;
failingRepository.listAllReportsPaginated = async (limit: number, lastReportId?: ReportID) => {
pages += 1;
if (pages > 1) throw new Error('read timeout');
return repository.listAllReportsPaginated(limit, lastReportId);
};
await expect(
processReportRetention({...deps, reportRepository: failingRepository}, {now: NOW, dryRun: false, pageSize: 2}),
).rejects.toThrow('read timeout');
await expectKept(expired);
expect(storage.deleteObjectSpy).not.toHaveBeenCalled();
expect(await repository.listAllReportsPaginated(10)).toHaveLength(3);
});
it('pages through every report', async () => {
const expired: Array<SeededReport> = [];
const kept: Array<SeededReport> = [];
for (let index = 0; index < 7; index++) {
expired.push(await seed({ageDays: 400 + index, profile: true}));
kept.push(await seed({ageDays: index, profile: true}));
}
const summary = await processReportRetention(deps, {now: NOW, dryRun: false, pageSize: 3});
expect(summary).toMatchObject({scanned: 14, expired: 7, deleted: 7, objectsDeleted: 7, failed: 0});
for (const report of expired) await expectGone(report);
for (const report of kept) await expectKept(report);
});
it('keeps the row and search document when an object cannot be deleted, then deletes them on the next run', async () => {
const expired = await seed({ageDays: 400, attachment: newAttachment(), profile: true});
storage.configure({shouldFailDelete: true});
const failed = await processReportRetention(deps, {now: NOW, dryRun: false});
expect(failed).toMatchObject({expired: 1, deleted: 0, failed: 1});
expect(await repository.getReport(expired.reportId)).not.toBeNull();
expect(await indexedIds()).toContain(expired.reportId.toString());
storage.configure({shouldFailDelete: false});
const retried = await processReportRetention(deps, {now: NOW, dryRun: false});
expect(retried).toMatchObject({expired: 1, deleted: 1, failed: 0});
await expectGone(expired);
expect(storage.hasObject(REPORTS_BUCKET, expired.attachmentKey!)).toBe(false);
});
it('keeps the row when the search document cannot be deleted', async () => {
const expired = await seed({ageDays: 400});
const failingSearch = Object.create(search) as IReportSearchService;
failingSearch.deleteReport = async () => {
throw new Error('search unavailable');
};
const summary = await processReportRetention(
{...deps, reportSearchService: failingSearch},
{now: NOW, dryRun: false},
);
expect(summary).toMatchObject({deleted: 0, failed: 1});
expect(await repository.getReport(expired.reportId)).not.toBeNull();
});
it('deletes rows when search is not configured', async () => {
const expired = await seed({ageDays: 400, profile: true});
const summary = await processReportRetention({...deps, reportSearchService: null}, {now: NOW, dryRun: false});
expect(summary).toMatchObject({deleted: 1, failed: 0});
expect(await repository.getReport(expired.reportId)).toBeNull();
expect(storage.hasObject(REPORTS_BUCKET, expired.profileKey!)).toBe(false);
});
it('skips a report that was put on hold after the scan', async () => {
const shared = newAttachment();
const seeded = [
await seed({ageDays: 400, attachment: shared, profile: true}),
await seed({ageDays: 400, attachment: shared}),
];
const scanOrder = (await repository.listAllReportsPaginated(10)).map((report) => report.reportId);
const [first, second] = seeded.sort((a, b) => scanOrder.indexOf(a.reportId) - scanOrder.indexOf(b.reportId)) as [
SeededReport,
SeededReport,
];
const holdingRepository = Object.create(repository) as ReportRepository;
holdingRepository.getReport = async (reportId: ReportID): Promise<IARSubmission | null> => {
const report = await repository.getReport(reportId);
if (report && reportId === first.reportId) {
return {...report, legalHoldUntil: new Date(NOW.getTime() + DAY_MS), legalHoldReason: 'Late request'};
}
return report;
};
const summary = await processReportRetention(
{...deps, reportRepository: holdingRepository},
{now: NOW, dryRun: false},
);
await expectKept(first);
await expectGone(second);
expect(storage.hasObject(REPORTS_BUCKET, attachmentKey(shared))).toBe(true);
expect(summary).toMatchObject({expired: 2, deleted: 1, failed: 0});
});
it('keeps an object that a report filed during the run stored again', async () => {
const shared = newAttachment();
const expired = await seed({ageDays: 400, attachment: shared, profile: true});
const rewritingStorage = Object.create(storage) as MockStorageService;
rewritingStorage.getObjectMetadata = async (bucket: string, key: string) => {
const metadata = await storage.getObjectMetadata(bucket, key);
if (metadata && key === attachmentKey(shared)) {
return {...metadata, lastModified: new Date(NOW.getTime() + ms('1 second'))};
}
return metadata;
};
const summary = await processReportRetention(
{...deps, storageService: rewritingStorage},
{now: NOW, dryRun: false},
);
await expectGone(expired);
expect(storage.hasObject(REPORTS_BUCKET, attachmentKey(shared))).toBe(true);
expect(summary).toMatchObject({deleted: 1, objectsDeleted: 1, sharedObjectsKept: 1, failed: 0});
});
it('deletes profile images stored for a report whose snapshot cannot be read', async () => {
const expired = await seed({ageDays: 400, profile: true, snapshotJson: '{"not":"a snapshot"'});
const summary = await processReportRetention(deps, {now: NOW, dryRun: false});
expect(summary).toMatchObject({deleted: 1, objectsDeleted: 1});
await expectGone(expired);
});
it.each([
[undefined, true],
['', true],
['true', true],
['1', true],
['yes', true],
['false', false],
['FALSE', false],
[' 0 ', false],
])('FLUXER_REPORT_RETENTION_DRY_RUN=%j gives a dry run of %s', (raw, expected) => {
expect(resolveReportRetentionDryRun(raw)).toBe(expected);
});
});