UNPKG

@atproto/ozone

Version:

Backend service for moderating the Bluesky network.

489 lines (419 loc) 17 kB
import { AtpAgent, ToolsOzoneModerationListScheduledActions, } from '@atproto/api' import { HOUR, MINUTE } from '@atproto/common' import { SeedClient, TestNetwork, basicSeed } from '@atproto/dev-env' import { ModEventTakedown } from '../dist/lexicon/types/tools/ozone/moderation/defs' import { ids } from '../src/lexicon/lexicons' import { ProtectedTagSettingKey } from '../src/setting/constants' describe('scheduled action processor', () => { let network: TestNetwork let adminAgent: AtpAgent let sc: SeedClient const getAdminHeaders = async (route: string) => { return { headers: await network.ozone.modHeaders(route, 'admin'), } } const scheduleTestAction = async ( subject: string, scheduling: any, emailData?: { emailSubject?: string; emailContent?: string }, ) => { return await adminAgent.tools.ozone.moderation.scheduleAction( { action: { $type: 'tools.ozone.moderation.scheduleAction#takedown', comment: 'Test scheduled takedown', policies: ['spam'], severityLevel: 'sev-1', strikeCount: 1, ...emailData, }, subjects: [subject], createdBy: 'did:plc:moderator', scheduling, }, await getAdminHeaders(ids.ToolsOzoneModerationScheduleAction), ) } const getScheduledActions = async ( statuses: ToolsOzoneModerationListScheduledActions.InputSchema['statuses'], subjects?: string[], ) => { const { data } = await adminAgent.tools.ozone.moderation.listScheduledActions( { subjects, statuses }, await getAdminHeaders(ids.ToolsOzoneModerationListScheduledActions), ) return data.actions } const getModerationEvents = async (subject: string, types?: string[]) => { const { data } = await adminAgent.tools.ozone.moderation.queryEvents( { subject, types }, await getAdminHeaders(ids.ToolsOzoneModerationQueryEvents), ) return data.events } beforeAll(async () => { network = await TestNetwork.create({ dbPostgresSchema: 'ozone_scheduled_action_processor_test', }) adminAgent = network.ozone.getClient() sc = network.getSeedClient() await basicSeed(sc) await network.processAll() }) afterAll(async () => { await network.close() }) describe('findAndExecuteScheduledActions', () => { it('processes actions scheduled for immediate execution', async () => { const testSubject = sc.dids.alice const pastTime = new Date(Date.now() - 1000).toISOString() await scheduleTestAction( testSubject, { executeAt: pastTime }, { emailSubject: 'Test Email Subject', emailContent: 'Test Email Content', }, ) const pendingActions = await getScheduledActions( ['pending'], [testSubject], ) expect(pendingActions.length).toBe(1) await network.ozone.daemon.ctx.scheduledActionProcessor.findAndExecuteScheduledActions() const executedActions = await getScheduledActions( ['executed'], [testSubject], ) expect(executedActions.length).toBe(1) expect(executedActions[0].status).toBe('executed') expect(executedActions[0].executionEventId).toBeDefined() const modEvents = await getModerationEvents(testSubject, [ 'tools.ozone.moderation.defs#modEventTakedown', 'tools.ozone.moderation.defs#modEventEmail', ]) expect(modEvents.length).toBe(2) const takedownEvent = modEvents.find( (e) => e.event.$type === 'tools.ozone.moderation.defs#modEventTakedown', ) const emailEvent = modEvents.find( (e) => e.event.$type === 'tools.ozone.moderation.defs#modEventEmail', ) expect(takedownEvent?.event['comment']).toBeDefined() expect(emailEvent?.event['subjectLine']).toBe('Test Email Subject') expect(emailEvent?.event['content']).toBe('Test Email Content') }) it('skips actions scheduled for future execution', async () => { const testSubject = sc.dids.bob // Schedule an action for future execution (1 hour from now) const futureTime = new Date(Date.now() + HOUR).toISOString() await scheduleTestAction(testSubject, { executeAt: futureTime }) // Process scheduled actions await network.ozone.daemon.ctx.scheduledActionProcessor.findAndExecuteScheduledActions() // Verify action is still pending const pendingActions = await getScheduledActions( ['pending'], [testSubject], ) expect(pendingActions.length).toBe(1) const executedActions = await getScheduledActions( ['executed'], [testSubject], ) expect(executedActions.length).toBe(0) }) it('skips randomized actions before executeAfter time', async () => { const testSubject = 'did:plc:future_randomized' // Schedule an action with future executeAfter const futureAfter = new Date(Date.now() + 30 * MINUTE).toISOString() const futureUntil = new Date(Date.now() + HOUR).toISOString() await scheduleTestAction(testSubject, { executeAfter: futureAfter, executeUntil: futureUntil, }) // Process scheduled actions await network.ozone.daemon.ctx.scheduledActionProcessor.findAndExecuteScheduledActions() // Verify action is still pending const pendingActions = await getScheduledActions( ['pending'], [testSubject], ) expect(pendingActions.length).toBe(1) }) it('always executes randomized actions past executeUntil deadline', async () => { const testSubject = 'did:plc:overdue_randomized' // Schedule an action that's past its deadline const pastAfter = new Date(Date.now() - HOUR).toISOString() const pastUntil = new Date(Date.now() - 30 * MINUTE).toISOString() await scheduleTestAction(testSubject, { executeAfter: pastAfter, executeUntil: pastUntil, }) // Process scheduled actions await network.ozone.daemon.ctx.scheduledActionProcessor.findAndExecuteScheduledActions() // Verify action is executed (should always execute past deadline) const executedActions = await getScheduledActions( ['executed'], [testSubject], ) expect(executedActions.length).toBe(1) expect(executedActions[0].status).toBe('executed') }) }) describe('executeScheduledAction', () => { it('handles takedown actions with all properties', async () => { const testSubject = 'did:plc:detailed_takedown' // Schedule a detailed takedown action await adminAgent.tools.ozone.moderation.scheduleAction( { action: { $type: 'tools.ozone.moderation.scheduleAction#takedown', comment: 'Detailed takedown test', durationInHours: 24, acknowledgeAccountSubjects: true, policies: ['spam', 'harassment'], }, subjects: [testSubject], createdBy: 'did:plc:moderator', scheduling: { executeAt: new Date(Date.now() - 1000).toISOString(), }, modTool: { name: 'test-tool' }, }, await getAdminHeaders(ids.ToolsOzoneModerationScheduleAction), ) // Process the action await network.ozone.daemon.ctx.scheduledActionProcessor.findAndExecuteScheduledActions() // Verify the moderation event has all properties const modEvents = await getModerationEvents(testSubject, [ 'tools.ozone.moderation.defs#modEventTakedown', 'tools.ozone.moderation.defs#modEventEmail', ]) // No email was sent expect(modEvents.length).toBe(1) const takedownEvent = modEvents[0].event as ModEventTakedown expect(takedownEvent.comment).toContain('[SCHEDULED_ACTION]') expect(takedownEvent.comment).toContain('Detailed takedown test') expect(takedownEvent.durationInHours).toBe(24) expect(takedownEvent.acknowledgeAccountSubjects).toBe(true) expect(takedownEvent.policies).toEqual(['spam', 'harassment']) }) it('marks action as failed when moderation event creation fails', async () => { const testSubject = 'did:plc:invalid_subject' await scheduleTestAction(testSubject, { executeAt: new Date(Date.now() - 1000).toISOString(), }) const pendingActions = await getScheduledActions( ['pending'], [testSubject], ) expect(pendingActions.length).toBe(1) const actionId = pendingActions[0].id // Manually update the action type to trigger error in processing await network.ozone.ctx.db.db .updateTable('scheduled_action') .set({ action: 'unknown' }) .where('id', '=', actionId) .execute() await network.ozone.daemon.ctx.scheduledActionProcessor.executeScheduledAction( actionId, ) const failedActions = await getScheduledActions(['failed'], [testSubject]) expect(failedActions.length).toBe(1) expect(failedActions[0].status).toBe('failed') expect(failedActions[0].lastFailureReason).toBeDefined() }) it('skips actions that are no longer pending', async () => { const testSubject = 'did:plc:already_processed' // Schedule and then cancel an action await scheduleTestAction(testSubject, { executeAt: new Date(Date.now() - 1000).toISOString(), }) await adminAgent.tools.ozone.moderation.cancelScheduledActions( { subjects: [testSubject] }, await getAdminHeaders(ids.ToolsOzoneModerationCancelScheduledActions), ) const cancelledActions = await getScheduledActions( ['cancelled'], [testSubject], ) expect(cancelledActions.length).toBe(1) const actionId = cancelledActions[0].id await network.ozone.daemon.ctx.scheduledActionProcessor.executeScheduledAction( actionId, ) const modEvents = await getModerationEvents(testSubject) const takedownEvents = modEvents.filter( (e) => e.event.$type === 'tools.ozone.moderation.defs#modEventTakedown', ) expect(takedownEvents.length).toBe(0) }) it('processes multiple actions in batch', async () => { const subjects = ['did:plc:batch1', 'did:plc:batch2', 'did:plc:batch3'] const pastTime = new Date(Date.now() - 1000).toISOString() for (const subject of subjects) { await scheduleTestAction(subject, { executeAt: pastTime }) } await network.ozone.daemon.ctx.scheduledActionProcessor.findAndExecuteScheduledActions() const executedActions = await getScheduledActions(['executed'], subjects) expect(executedActions.length).toBe(3) for (const subject of subjects) { const modEvents = await getModerationEvents(subject, [ 'tools.ozone.moderation.defs#modEventTakedown', ]) expect(modEvents.length).toBe(1) } }) }) describe('takedown validation checks', () => { it('fails when trying to takedown an already taken down account', async () => { const testSubject = 'did:plc:already_takendown' // takedown the account manually await adminAgent.tools.ozone.moderation.emitEvent( { subject: { $type: 'com.atproto.admin.defs#repoRef', did: testSubject, }, event: { $type: 'tools.ozone.moderation.defs#modEventTakedown', comment: 'Manual takedown first', }, createdBy: adminAgent.session?.did || 'did:plc:admin', }, await getAdminHeaders(ids.ToolsOzoneModerationEmitEvent), ) // Schedule a takedown for the already taken down account await scheduleTestAction(testSubject, { executeAt: new Date(Date.now() - 1000).toISOString(), }) // Process the scheduled action await network.ozone.daemon.ctx.scheduledActionProcessor.findAndExecuteScheduledActions() // Verify the scheduled action failed const failedActions = await getScheduledActions(['failed'], [testSubject]) expect(failedActions.length).toBe(1) expect(failedActions[0].status).toBe('failed') expect(failedActions[0].lastFailureReason).toContain( 'Account is already taken down', ) }) it('enforces protected tag restrictions when account has protected tags', async () => { const testSubject = 'did:plc:protected_tag_test' // add the protected tag to the account await adminAgent.tools.ozone.moderation.emitEvent( { subject: { $type: 'com.atproto.admin.defs#repoRef', did: testSubject, }, event: { $type: 'tools.ozone.moderation.defs#modEventTag', add: ['vip'], remove: [], }, createdBy: adminAgent.session?.did || 'did:plc:admin', }, await getAdminHeaders(ids.ToolsOzoneModerationEmitEvent), ) // add protected tag setting for the instance and make that tag actionable by a mod only await adminAgent.tools.ozone.setting.upsertOption( { key: ProtectedTagSettingKey, scope: 'instance', managerRole: 'tools.ozone.team.defs#roleAdmin', value: { vip: { moderators: [sc.dids.alice] } }, }, await getAdminHeaders(ids.ToolsOzoneSettingUpsertOption), ) // Schedule a takedown action created by a non-admin moderator await adminAgent.tools.ozone.moderation.scheduleAction( { action: { $type: 'tools.ozone.moderation.scheduleAction#takedown', comment: 'Test protected tag enforcement', }, subjects: [testSubject], createdBy: 'did:plc:non_admin_moderator', // Non-admin creator scheduling: { executeAt: new Date(Date.now() - 1000).toISOString(), }, }, await getAdminHeaders(ids.ToolsOzoneModerationScheduleAction), ) // Process the scheduled action await network.ozone.daemon.ctx.scheduledActionProcessor.findAndExecuteScheduledActions() // Verify the scheduled action failed due to protected tag restrictions const failedActions = await getScheduledActions(['failed'], [testSubject]) expect(failedActions.length).toBe(1) expect(failedActions[0].status).toBe('failed') expect(failedActions[0].lastFailureReason).toContain('tag') // Clean up protected tags setting await adminAgent.tools.ozone.setting.removeOptions( { keys: [ProtectedTagSettingKey], scope: 'instance', }, await getAdminHeaders(ids.ToolsOzoneSettingRemoveOptions), ) }) it('allows takedown of accounts with protected tags when created by authorized user', async () => { const testSubject = 'did:plc:authorized_protected_tag_test' // Set up protected tags configuration allowing admins await adminAgent.tools.ozone.setting.upsertOption( { key: ProtectedTagSettingKey, scope: 'instance', managerRole: 'tools.ozone.team.defs#roleAdmin', value: { vip: { roles: ['tools.ozone.team.defs#roleAdmin'] } }, }, await getAdminHeaders(ids.ToolsOzoneSettingUpsertOption), ) // Add a protected tag to the account await adminAgent.tools.ozone.moderation.emitEvent( { subject: { $type: 'com.atproto.admin.defs#repoRef', did: testSubject, }, event: { $type: 'tools.ozone.moderation.defs#modEventTag', add: ['vip'], remove: [], }, createdBy: adminAgent.session?.did || 'did:plc:admin', }, await getAdminHeaders(ids.ToolsOzoneModerationEmitEvent), ) await adminAgent.tools.ozone.moderation.scheduleAction( { action: { $type: 'tools.ozone.moderation.scheduleAction#takedown', comment: 'Admin takedown of protected account', }, subjects: [testSubject], createdBy: network.ozone.ctx.cfg.service.did, scheduling: { executeAt: new Date(Date.now() - 1000).toISOString(), }, }, await getAdminHeaders(ids.ToolsOzoneModerationScheduleAction), ) await network.ozone.daemon.ctx.scheduledActionProcessor.findAndExecuteScheduledActions() const executedActions = await getScheduledActions( ['executed'], [testSubject], ) expect(executedActions.length).toBe(1) expect(executedActions[0].status).toBe('executed') const modEvents = await getModerationEvents(testSubject, [ 'tools.ozone.moderation.defs#modEventTakedown', ]) expect(modEvents.length).toBe(1) }) }) })