@atproto/ozone
Version:
Backend service for moderating the Bluesky network.
300 lines (254 loc) • 9.67 kB
text/typescript
import AtpAgent from '@atproto/api'
import { SECOND } from '@atproto/common'
import {
ModeratorClient,
SeedClient,
TestNetwork,
basicSeed,
} from '@atproto/dev-env'
import { ids } from '../src/lexicon/lexicons'
import { SeverityLevelSettingKey } from '../src/setting/constants'
const strikeConfig = {
'sev-1': {
strikeCount: 1,
expiresInDays: 0, // Set to 0 so we can use future timestamps
},
'sev-2': {
strikeCount: 2,
expiresInDays: 0,
},
}
describe('strike expiry processor', () => {
let network: TestNetwork
let agent: AtpAgent
let sc: SeedClient
let modClient: ModeratorClient
const repoSubject = (did: string) => ({
$type: 'com.atproto.admin.defs#repoRef',
did,
})
const configureSeverityLevels = async () => {
await agent.tools.ozone.setting.upsertOption(
{
scope: 'instance',
key: SeverityLevelSettingKey,
value: strikeConfig,
description: 'Severity level configuration for strike system',
managerRole: 'tools.ozone.team.defs#roleAdmin',
},
{
encoding: 'application/json',
headers: await network.ozone.modHeaders(
ids.ToolsOzoneSettingUpsertOption,
'admin',
),
},
)
}
const getAccountStatus = async (did: string) => {
const { subjectStatuses } = await modClient.queryStatuses({ subject: did })
return subjectStatuses[0]
}
beforeAll(async () => {
network = await TestNetwork.create({
dbPostgresSchema: 'ozone_strike_expiry_processor',
})
agent = network.ozone.getClient()
sc = network.getSeedClient()
modClient = network.ozone.getModClient()
await basicSeed(sc)
await network.processAll()
await configureSeverityLevels()
})
afterAll(async () => {
await network.close()
})
it('processes expired strikes and updates active strike count', async () => {
const bobDid = sc.dids.bob
const bobPost1 = {
$type: 'com.atproto.repo.strongRef',
uri: sc.posts[bobDid][0].ref.uriStr,
cid: sc.posts[bobDid][0].ref.cidStr,
}
const bobPost2 = {
$type: 'com.atproto.repo.strongRef',
uri: sc.posts[bobDid][1].ref.uriStr,
cid: sc.posts[bobDid][1].ref.cidStr,
}
// first strike on a post that expires in 2 seconds
const expiresAt1 = new Date(Date.now() + 2 * SECOND).toISOString()
await modClient.emitEvent({
event: {
$type: 'tools.ozone.moderation.defs#modEventTakedown',
severityLevel: 'sev-2',
strikeCount: 2,
strikeExpiresAt: expiresAt1,
comment: 'First violation - expires soon',
},
subject: bobPost1,
})
// second strike on another post that expires in 3 seconds
const expiresAt2 = new Date(Date.now() + 3 * SECOND).toISOString()
await modClient.emitEvent({
event: {
$type: 'tools.ozone.moderation.defs#modEventTakedown',
strikeCount: 1,
severityLevel: 'sev-1',
strikeExpiresAt: expiresAt2,
comment: 'Second violation - expires later',
},
subject: bobPost2,
})
// account-level event to ensure account status is created
await modClient.emitEvent({
event: {
$type: 'tools.ozone.moderation.defs#modEventComment',
comment: 'Account under review',
},
subject: repoSubject(bobDid),
})
// Verify initial state - both strikes are active
let status = await getAccountStatus(bobDid)
expect(status.accountStrike).toBeDefined()
expect(status.accountStrike!.activeStrikeCount).toBe(3) // 2 + 1
expect(status.accountStrike!.totalStrikeCount).toBe(3)
// Wait for first strike to expire
await new Promise((resolve) => setTimeout(resolve, 2.1 * SECOND))
// Run the processor
await network.ozone.daemon.ctx.strikeExpiryProcessor.processExpiredStrikes()
// Verify first strike expired - only second strike remains active
status = await getAccountStatus(bobDid)
expect(status.accountStrike).toBeDefined()
expect(status.accountStrike!.activeStrikeCount).toBe(1) // Only second strike
expect(status.accountStrike!.totalStrikeCount).toBe(3) // Total unchanged
// Wait for second strike to expire
await new Promise((resolve) => setTimeout(resolve, 1 * SECOND))
// Run the processor again
await network.ozone.daemon.ctx.strikeExpiryProcessor.processExpiredStrikes()
// Verify all strikes expired
status = await getAccountStatus(bobDid)
expect(status.accountStrike).toBeDefined()
expect(status.accountStrike!.activeStrikeCount).toBe(0)
expect(status.accountStrike!.totalStrikeCount).toBe(3) // Total unchanged
})
it('handles accounts with no expired strikes', async () => {
const aliceDid = sc.dids.alice
// strike that expires far in the future
const expiresAt = new Date(Date.now() + 1000 * SECOND).toISOString()
await modClient.emitEvent({
event: {
$type: 'tools.ozone.moderation.defs#modEventTakedown',
severityLevel: 'sev-2',
strikeCount: 2,
strikeExpiresAt: expiresAt,
comment: 'Future expiry',
},
subject: repoSubject(aliceDid),
})
// Get initial state
let status = await getAccountStatus(aliceDid)
expect(status.accountStrike).toBeDefined()
const initialActiveCount = status.accountStrike!.activeStrikeCount!
expect(initialActiveCount).toBe(2)
await network.ozone.daemon.ctx.strikeExpiryProcessor.processExpiredStrikes()
// Verify nothing changed
status = await getAccountStatus(aliceDid)
expect(status.accountStrike).toBeDefined()
expect(status.accountStrike!.activeStrikeCount).toBe(initialActiveCount)
})
it('handles strikes with no expiry date (permanent strikes)', async () => {
const carolDid = sc.dids.carol
// permanent strike (no expiry)
await modClient.emitEvent({
event: {
$type: 'tools.ozone.moderation.defs#modEventTakedown',
severityLevel: 'sev-2',
strikeCount: 2,
comment: 'Permanent strike - no expiry',
},
subject: repoSubject(carolDid),
})
// Get initial state
let status = await getAccountStatus(carolDid)
expect(status.accountStrike).toBeDefined()
expect(status.accountStrike!.activeStrikeCount).toBe(2)
await network.ozone.daemon.ctx.strikeExpiryProcessor.processExpiredStrikes()
// Verify permanent strikes remain active
status = await getAccountStatus(carolDid)
expect(status.accountStrike).toBeDefined()
expect(status.accountStrike!.activeStrikeCount).toBe(2)
expect(status.accountStrike!.totalStrikeCount).toBe(2)
})
it('processes multiple accounts with expired strikes in batch', async () => {
const danDid = 'did:plc:dan'
const eveDid = 'did:plc:eve'
const expiresAt = new Date(Date.now() + 1 * SECOND).toISOString()
// strikes to multiple accounts
await modClient.emitEvent({
event: {
$type: 'tools.ozone.moderation.defs#modEventTakedown',
severityLevel: 'sev-1',
strikeCount: 1,
strikeExpiresAt: expiresAt,
comment: 'Dan violation',
},
subject: repoSubject(danDid),
})
await modClient.emitEvent({
event: {
$type: 'tools.ozone.moderation.defs#modEventTakedown',
severityLevel: 'sev-2',
strikeCount: 2,
strikeExpiresAt: expiresAt,
comment: 'Eve violation',
},
subject: repoSubject(eveDid),
})
// Verify initial states
let danStatus = await getAccountStatus(danDid)
let eveStatus = await getAccountStatus(eveDid)
expect(danStatus.accountStrike?.activeStrikeCount).toBe(1)
expect(eveStatus.accountStrike?.activeStrikeCount).toBe(2)
// Wait for strikes to expire
await new Promise((resolve) => setTimeout(resolve, 1.1 * SECOND))
await network.ozone.daemon.ctx.strikeExpiryProcessor.processExpiredStrikes()
// Verify both accounts' strikes expired
danStatus = await getAccountStatus(danDid)
eveStatus = await getAccountStatus(eveDid)
expect(danStatus.accountStrike?.activeStrikeCount).toBe(0)
expect(eveStatus.accountStrike?.activeStrikeCount).toBe(0)
})
it('updates cursor to track last processed timestamp', async () => {
const frankDid = 'did:plc:frank'
const expiresAt = new Date(Date.now() + 1 * SECOND).toISOString()
await modClient.emitEvent({
event: {
$type: 'tools.ozone.moderation.defs#modEventTakedown',
severityLevel: 'sev-1',
strikeCount: 1,
strikeExpiresAt: expiresAt,
comment: 'Frank violation',
},
subject: repoSubject(frankDid),
})
// Wait for strike to expire
await new Promise((resolve) => setTimeout(resolve, 1.1 * SECOND))
// Get cursor before processing
const cursorBefore =
await network.ozone.daemon.ctx.strikeExpiryProcessor.getCursor()
await network.ozone.daemon.ctx.strikeExpiryProcessor.processExpiredStrikes()
// Get cursor after processing
const cursorAfter =
await network.ozone.daemon.ctx.strikeExpiryProcessor.getCursor()
expect(cursorAfter).not.toBe(cursorBefore)
expect(cursorAfter).toBeTruthy()
// Verify strike was processed
const status = await getAccountStatus(frankDid)
expect(status.accountStrike?.activeStrikeCount).toBe(0)
// running processor again should not reprocess the same strike
await network.ozone.daemon.ctx.strikeExpiryProcessor.processExpiredStrikes()
const cursorAfterSecond =
await network.ozone.daemon.ctx.strikeExpiryProcessor.getCursor()
expect(cursorAfterSecond).not.toBe(cursorAfter)
})
})