mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
feat(reports): add server-driven report flows (#3330)
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user