atproto/packages/ozone/tests/moderation.test.ts
Matthieu Sieben 72eba67af1
Drop axios dependency (#3177)
* Minor adaptation of VerifyCidTransform implementation

* refactor: factorize content-encoding negotiation into new lib

* bsky: Use undici to stream blob

* fixup! bsky: Use undici to stream blob

* disable ssrf bsky protection in dev-env

* remove http requests to self to host "/img/"

* drop axios from tests

* fixes

* fix tests

* reviex changes

* properly handle HEAD requests

* handle client disconnection

* fix tests

* drop unrelated change

* tidy

* tidy

* tidy

* remove axios from dev-env

* remove axios from identity package

* use undici 6

* remove axios dependency from ozone

* tidy

* remove axios from PDS package

* avoid killing bsky-pds connections

* improve debugging data

* Better handle invalid CID

* tidy

* tidy

* refactor "allFulfilled" util in @atproto/common

* tidy

---------

Co-authored-by: devin ivy <devinivy@gmail.com>
2025-01-06 18:34:11 +01:00

887 lines
27 KiB
TypeScript

import {
TestNetwork,
TestOzone,
ImageRef,
RecordRef,
SeedClient,
basicSeed,
ModeratorClient,
} from '@atproto/dev-env'
import { AtpAgent, ToolsOzoneModerationEmitEvent } from '@atproto/api'
import { AtUri } from '@atproto/syntax'
import { forSnapshot } from './_util'
import {
REASONMISLEADING,
REASONOTHER,
REASONSPAM,
} from '../src/lexicon/types/com/atproto/moderation/defs'
import {
ModEventLabel,
REVIEWCLOSED,
REVIEWESCALATED,
} from '../src/lexicon/types/tools/ozone/moderation/defs'
import { EventReverser } from '../src'
import { ImageInvalidator } from '../src/image-invalidator'
import { TAKEDOWN_LABEL } from '../src/mod-service'
import { ids } from '../src/lexicon/lexicons'
describe('moderation', () => {
let network: TestNetwork
let ozone: TestOzone
let mockInvalidator: MockInvalidator
let agent: AtpAgent
let bskyAgent: AtpAgent
let pdsAgent: AtpAgent
let sc: SeedClient
let modClient: ModeratorClient
const repoSubject = (did: string) => ({
$type: 'com.atproto.admin.defs#repoRef',
did,
})
const recordSubject = (ref: RecordRef) => ({
$type: 'com.atproto.repo.strongRef',
uri: ref.uriStr,
cid: ref.cidStr,
})
const getLabel = async (uri: string, val: string, neg = false) => {
return ozone.ctx.db.db
.selectFrom('label')
.selectAll()
.where('uri', '=', uri)
.where('val', '=', val)
.where('neg', '=', neg)
.executeTakeFirst()
}
beforeAll(async () => {
mockInvalidator = new MockInvalidator()
network = await TestNetwork.create({
dbPostgresSchema: 'ozone_moderation',
ozone: {
imgInvalidator: mockInvalidator,
cdnPaths: ['/path1/%s/%s', '/path2/%s/%s'],
},
})
ozone = network.ozone
agent = network.ozone.getClient()
bskyAgent = network.bsky.getClient()
pdsAgent = network.pds.getClient()
sc = network.getSeedClient()
modClient = network.ozone.getModClient()
await basicSeed(sc)
await network.processAll()
})
afterAll(async () => {
await network.close()
})
describe('reporting', () => {
it('creates reports of a repo.', async () => {
const reportA = await sc.createReport({
reasonType: REASONSPAM,
subject: repoSubject(sc.dids.bob),
reportedBy: sc.dids.alice,
})
const reportB = await sc.createReport({
reasonType: REASONOTHER,
reason: 'impersonation',
subject: repoSubject(sc.dids.bob),
reportedBy: sc.dids.carol,
})
expect(forSnapshot([reportA, reportB])).toMatchSnapshot()
})
it("allows reporting a repo that doesn't exist.", async () => {
const promise = sc.createReport({
reasonType: REASONSPAM,
subject: repoSubject('did:plc:unknown'),
reportedBy: sc.dids.alice,
})
await expect(promise).resolves.toBeDefined()
})
it('creates reports of a record.', async () => {
const postA = sc.posts[sc.dids.bob][0].ref
const postB = sc.posts[sc.dids.bob][1].ref
const reportA = await sc.createReport({
reportedBy: sc.dids.alice,
reasonType: REASONSPAM,
subject: recordSubject(postA),
})
const reportB = await sc.createReport({
reasonType: REASONOTHER,
reason: 'defamation',
subject: recordSubject(postB),
reportedBy: sc.dids.carol,
})
expect(forSnapshot([reportA, reportB])).toMatchSnapshot()
})
it("allows reporting a record that doesn't exist.", async () => {
const postA = sc.posts[sc.dids.bob][0].ref
const postB = sc.posts[sc.dids.bob][1].ref
const postUriBad = new AtUri(postA.uriStr)
postUriBad.rkey = 'badrkey'
const promiseA = sc.createReport({
reasonType: REASONSPAM,
subject: {
$type: 'com.atproto.repo.strongRef',
uri: postUriBad.toString(),
cid: postA.cidStr,
},
reportedBy: sc.dids.alice,
})
await expect(promiseA).resolves.toBeDefined()
const promiseB = sc.createReport({
reasonType: REASONOTHER,
reason: 'defamation',
subject: {
$type: 'com.atproto.repo.strongRef',
uri: postB.uri.toString(),
cid: postA.cidStr, // bad cid
},
reportedBy: sc.dids.carol,
})
await expect(promiseB).resolves.toBeDefined()
})
it('creates reports of a DM chat.', async () => {
const messageId1 = 'testmessageid1'
const messageId2 = 'testmessageid2'
const reportA = await sc.createReport({
reportedBy: sc.dids.alice,
reasonType: REASONSPAM,
subject: {
$type: 'chat.bsky.convo.defs#messageRef',
did: sc.dids.carol,
messageId: messageId1,
convoId: 'testconvoid1',
},
})
const reportB = await sc.createReport({
reportedBy: sc.dids.carol,
reasonType: REASONOTHER,
reason: 'defamation',
subject: {
$type: 'chat.bsky.convo.defs#messageRef',
did: sc.dids.carol,
messageId: messageId2,
// @TODO convoId intentionally missing, restore once this behavior is deprecated
},
})
expect(forSnapshot([reportA, reportB])).toMatchSnapshot()
const events = await ozone.ctx.db.db
.selectFrom('moderation_event')
.selectAll()
.where('subjectMessageId', 'in', [messageId1, messageId2])
.where('action', '=', 'tools.ozone.moderation.defs#modEventReport')
.execute()
expect(events.length).toBe(2)
expect(
events.every(
(row) => row.subjectType === 'chat.bsky.convo.defs#messageRef',
),
).toBe(true)
})
})
describe('actioning', () => {
it('resolves reports on repos and records.', async () => {
const post = sc.posts[sc.dids.bob][1].ref
await Promise.all([
sc.createReport({
reasonType: REASONSPAM,
subject: repoSubject(sc.dids.bob),
reportedBy: sc.dids.alice,
}),
sc.createReport({
reasonType: REASONOTHER,
reason: 'defamation',
subject: recordSubject(post),
reportedBy: sc.dids.carol,
}),
])
await modClient.performTakedown({
subject: repoSubject(sc.dids.bob),
})
const moderationStatusOnBobsAccount = await modClient.queryStatuses({
subject: sc.dids.bob,
})
// Validate that subject status is set to review closed and takendown flag is on
expect(moderationStatusOnBobsAccount.subjectStatuses[0]).toMatchObject({
reviewState: REVIEWCLOSED,
takendown: true,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.bob,
},
})
// Cleanup
await modClient.performReverseTakedown({
subject: repoSubject(sc.dids.bob),
})
})
it('supports escalating a subject', async () => {
const alicesPostRef = sc.posts[sc.dids.alice][0].ref
const alicesPostSubject = {
$type: 'com.atproto.repo.strongRef',
uri: alicesPostRef.uri.toString(),
cid: alicesPostRef.cid.toString(),
}
await modClient.emitEvent(
{
event: {
$type: 'tools.ozone.moderation.defs#modEventEscalate',
comment: 'Y',
},
subject: alicesPostSubject,
createdBy: 'did:example:admin',
},
'triage',
)
const alicesPostStatus = await modClient.queryStatuses({
subject: alicesPostRef.uri.toString(),
})
expect(alicesPostStatus.subjectStatuses[0]).toMatchObject({
reviewState: REVIEWESCALATED,
takendown: false,
subject: alicesPostSubject,
})
})
it('adds persistent comment on subject through comment event', async () => {
const alicesPostRef = sc.posts[sc.dids.alice][0].ref
const alicesPostSubject = {
$type: 'com.atproto.repo.strongRef',
uri: alicesPostRef.uri.toString(),
cid: alicesPostRef.cid.toString(),
}
await modClient.emitEvent(
{
event: {
$type: 'tools.ozone.moderation.defs#modEventComment',
sticky: true,
comment: 'This is a persistent note',
},
subject: alicesPostSubject,
createdBy: 'did:example:admin',
},
'triage',
)
const alicesPostStatus = await modClient.queryStatuses({
subject: alicesPostRef.uri.toString(),
})
expect(alicesPostStatus.subjectStatuses[0].comment).toEqual(
'This is a persistent note',
)
})
it('reverses status when revert event is triggered.', async () => {
const alicesPostRef = sc.posts[sc.dids.alice][0].ref
const emitModEvent = async (
event: ToolsOzoneModerationEmitEvent.InputSchema['event'],
overwrites: Partial<ToolsOzoneModerationEmitEvent.InputSchema> = {},
) => {
const baseAction = {
subject: {
$type: 'com.atproto.repo.strongRef',
uri: alicesPostRef.uriStr,
cid: alicesPostRef.cidStr,
},
createdBy: 'did:example:admin',
}
return modClient.emitEvent({
event,
...baseAction,
...overwrites,
})
}
// Validate that subject status is marked as escalated
await emitModEvent({
$type: 'tools.ozone.moderation.defs#modEventReport',
reportType: REASONSPAM,
})
await emitModEvent({
$type: 'tools.ozone.moderation.defs#modEventReport',
reportType: REASONMISLEADING,
})
await emitModEvent({
$type: 'tools.ozone.moderation.defs#modEventEscalate',
})
const alicesPostStatusAfterEscalation = await modClient.queryStatuses({
subject: alicesPostRef.uriStr,
})
expect(
alicesPostStatusAfterEscalation.subjectStatuses[0].reviewState,
).toEqual(REVIEWESCALATED)
// Validate that subject status is marked as takendown
await emitModEvent({
$type: 'tools.ozone.moderation.defs#modEventLabel',
createLabelVals: ['nsfw'],
negateLabelVals: [],
})
await emitModEvent({
$type: 'tools.ozone.moderation.defs#modEventTakedown',
})
const alicesPostStatusAfterTakedown = await modClient.queryStatuses({
subject: alicesPostRef.uriStr,
})
expect(alicesPostStatusAfterTakedown.subjectStatuses[0]).toMatchObject({
reviewState: REVIEWCLOSED,
takendown: true,
})
await emitModEvent({
$type: 'tools.ozone.moderation.defs#modEventReverseTakedown',
})
const alicesPostStatusAfterRevert = await modClient.queryStatuses({
subject: alicesPostRef.uriStr,
})
// Validate that after reverting, the status of the subject is reverted to the last status changing event
expect(alicesPostStatusAfterRevert.subjectStatuses[0]).toMatchObject({
reviewState: REVIEWCLOSED,
takendown: false,
})
// Validate that after reverting, the last review date of the subject
// DOES NOT update to the the last status changing event
expect(
new Date(
alicesPostStatusAfterEscalation.subjectStatuses[0]
.lastReviewedAt as string,
) <
new Date(
alicesPostStatusAfterRevert.subjectStatuses[0]
.lastReviewedAt as string,
),
).toBeTruthy()
})
it('negates an existing label.', async () => {
const { ctx } = ozone
const post = sc.posts[sc.dids.bob][0].ref
const bobsPostSubject = {
$type: 'com.atproto.repo.strongRef',
uri: post.uriStr,
cid: post.cidStr,
}
const modService = ctx.modService(ctx.db)
await modService.formatAndCreateLabels(post.uriStr, post.cidStr, {
create: ['kittens'],
})
await emitLabelEvent({
negateLabelVals: ['kittens'],
createLabelVals: [],
subject: bobsPostSubject,
})
await expect(getRecordLabels(post.uriStr)).resolves.toEqual([])
await emitLabelEvent({
createLabelVals: ['kittens'],
negateLabelVals: [],
subject: bobsPostSubject,
})
await expect(getRecordLabels(post.uriStr)).resolves.toEqual(['kittens'])
// Cleanup
await modService.formatAndCreateLabels(post.uriStr, post.cidStr, {
negate: ['kittens'],
})
})
it('no-ops when negating an already-negated label and reverses.', async () => {
const { ctx } = ozone
const post = sc.posts[sc.dids.bob][0].ref
const modService = ctx.modService(ctx.db)
await emitLabelEvent({
negateLabelVals: ['bears'],
createLabelVals: [],
subject: {
$type: 'com.atproto.repo.strongRef',
uri: post.uriStr,
cid: post.cidStr,
},
})
await expect(getRecordLabels(post.uriStr)).resolves.toEqual([])
await emitLabelEvent({
createLabelVals: ['bears'],
negateLabelVals: [],
subject: {
$type: 'com.atproto.repo.strongRef',
uri: post.uriStr,
cid: post.cidStr,
},
})
await expect(getRecordLabels(post.uriStr)).resolves.toEqual(['bears'])
// Cleanup
await modService.formatAndCreateLabels(post.uriStr, post.cidStr, {
negate: ['bears'],
})
})
it('creates non-existing labels and reverses.', async () => {
const post = sc.posts[sc.dids.bob][0].ref
await emitLabelEvent({
createLabelVals: ['puppies', 'doggies'],
negateLabelVals: [],
subject: {
$type: 'com.atproto.repo.strongRef',
uri: post.uriStr,
cid: post.cidStr,
},
})
await expect(getRecordLabels(post.uriStr)).resolves.toEqual([
'puppies',
'doggies',
])
await emitLabelEvent({
negateLabelVals: ['puppies', 'doggies'],
createLabelVals: [],
subject: {
$type: 'com.atproto.repo.strongRef',
uri: post.uriStr,
cid: post.cidStr,
},
})
await expect(getRecordLabels(post.uriStr)).resolves.toEqual([])
})
it('creates labels on a repo and reverses.', async () => {
await emitLabelEvent({
createLabelVals: ['puppies', 'doggies'],
negateLabelVals: [],
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.bob,
},
})
await expect(getRepoLabels(sc.dids.bob)).resolves.toEqual([
'puppies',
'doggies',
])
await emitLabelEvent({
negateLabelVals: ['puppies', 'doggies'],
createLabelVals: [],
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.bob,
},
})
await expect(getRepoLabels(sc.dids.bob)).resolves.toEqual([])
})
it('creates and negates labels on a repo and reverses.', async () => {
const { ctx } = ozone
const modService = ctx.modService(ctx.db)
await modService.formatAndCreateLabels(sc.dids.bob, null, {
create: ['kittens'],
})
await emitLabelEvent({
createLabelVals: ['puppies'],
negateLabelVals: ['kittens'],
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.bob,
},
})
await expect(getRepoLabels(sc.dids.bob)).resolves.toEqual(['puppies'])
await emitLabelEvent({
negateLabelVals: ['puppies'],
createLabelVals: ['kittens'],
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.bob,
},
})
await expect(getRepoLabels(sc.dids.bob)).resolves.toEqual(['kittens'])
})
it('does not allow triage moderators to label.', async () => {
const attemptLabel = modClient.emitEvent(
{
event: {
$type: 'tools.ozone.moderation.defs#modEventLabel',
negateLabelVals: ['a'],
createLabelVals: ['b', 'c'],
},
createdBy: 'did:example:moderator',
reason: 'Y',
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.bob,
},
},
'triage',
)
await expect(attemptLabel).rejects.toThrow(
'Must be a full moderator to label content',
)
})
it('does not allow take down event on takendown post or reverse takedown on available post.', async () => {
await modClient.performTakedown({
subject: repoSubject(sc.dids.bob),
})
await expect(
modClient.performTakedown({
subject: repoSubject(sc.dids.bob),
}),
).rejects.toThrow('Subject is already taken down')
// Cleanup
await modClient.performReverseTakedown({
subject: repoSubject(sc.dids.bob),
})
await expect(
modClient.performReverseTakedown({
subject: repoSubject(sc.dids.bob),
}),
).rejects.toThrow('Subject is not taken down')
})
it('fans out repo takedowns', async () => {
await modClient.performTakedown({
subject: repoSubject(sc.dids.bob),
})
await ozone.processAll()
const pdsRes1 = await pdsAgent.api.com.atproto.admin.getSubjectStatus(
{
did: sc.dids.bob,
},
{ headers: network.pds.adminAuthHeaders() },
)
expect(pdsRes1.data.takedown?.applied).toBe(true)
const bskyRes1 = await bskyAgent.api.com.atproto.admin.getSubjectStatus(
{
did: sc.dids.bob,
},
{ headers: network.pds.adminAuthHeaders() },
)
expect(bskyRes1.data.takedown?.applied).toBe(true)
const takedownLabel1 = await getLabel(sc.dids.bob, TAKEDOWN_LABEL)
expect(takedownLabel1).toBeDefined()
// cleanup
await modClient.performReverseTakedown({
subject: repoSubject(sc.dids.bob),
})
await ozone.processAll()
const pdsRes2 = await pdsAgent.api.com.atproto.admin.getSubjectStatus(
{
did: sc.dids.bob,
},
{ headers: network.pds.adminAuthHeaders() },
)
expect(pdsRes2.data.takedown?.applied).toBe(false)
const bskyRes2 = await bskyAgent.api.com.atproto.admin.getSubjectStatus(
{
did: sc.dids.bob,
},
{ headers: network.bsky.adminAuthHeaders() },
)
expect(bskyRes2.data.takedown?.applied).toBe(false)
const takedownLabel2 = await getLabel(sc.dids.bob, TAKEDOWN_LABEL)
expect(takedownLabel2).toBeUndefined()
})
it('allows full moderators to takedown.', async () => {
await modClient.emitEvent(
{
event: {
$type: 'tools.ozone.moderation.defs#modEventTakedown',
},
createdBy: 'did:example:moderator',
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.bob,
},
},
'moderator',
)
// cleanup
await reverse({
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.bob,
},
})
})
it('does not allow non-full moderators to takedown.', async () => {
const attemptTakedownTriage = modClient.emitEvent(
{
event: {
$type: 'tools.ozone.moderation.defs#modEventTakedown',
},
createdBy: 'did:example:moderator',
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.bob,
},
},
'triage',
)
await expect(attemptTakedownTriage).rejects.toThrow(
'Must be a full moderator to take this type of action',
)
})
it('automatically reverses actions marked with duration', async () => {
await sc.createReport({
reasonType: REASONSPAM,
subject: repoSubject(sc.dids.bob),
reportedBy: sc.dids.alice,
})
const action = await modClient.performTakedown({
subject: repoSubject(sc.dids.bob),
// Use negative value to set the expiry time in the past so that the action is automatically reversed
// right away without having to wait n number of hours for a successful assertion
durationInHours: -1,
})
await ozone.processAll()
const statusesAfterTakedown = await modClient.queryStatuses(
{ subject: sc.dids.bob },
'moderator',
)
expect(statusesAfterTakedown.subjectStatuses[0]).toMatchObject({
takendown: true,
})
// In the actual app, this will be instantiated and run on server startup
const reverser = new EventReverser(
network.ozone.ctx.db,
// @ts-expect-error Error due to circular dependency with the dev-env package
network.ozone.ctx.modService,
)
await reverser.findAndRevertDueActions()
await ozone.processAll()
const [eventList, statuses] = await Promise.all([
modClient.queryEvents({ subject: sc.dids.bob }, 'moderator'),
modClient.queryStatuses({ subject: sc.dids.bob }, 'moderator'),
])
expect(statuses.subjectStatuses[0]).toMatchObject({
takendown: false,
reviewState: REVIEWCLOSED,
})
// Verify that the automatic reversal is attributed to the original moderator of the temporary action
// and that the reason is set to indicate that the action was automatically reversed.
expect(eventList.events[0]).toMatchObject({
createdBy: action.createdBy,
event: {
$type: 'tools.ozone.moderation.defs#modEventReverseTakedown',
comment:
'[SCHEDULED_REVERSAL] Reverting action as originally scheduled',
},
})
})
async function emitLabelEvent(
opts: Partial<ToolsOzoneModerationEmitEvent.InputSchema> & {
subject: ToolsOzoneModerationEmitEvent.InputSchema['subject']
createLabelVals: ModEventLabel['createLabelVals']
negateLabelVals: ModEventLabel['negateLabelVals']
},
) {
const { createLabelVals, negateLabelVals } = opts
const result = await modClient.emitEvent({
event: {
$type: 'tools.ozone.moderation.defs#modEventLabel',
createLabelVals,
negateLabelVals,
},
createdBy: 'did:example:admin',
reason: 'Y',
...opts,
})
return result.data
}
async function reverse(
opts: Partial<ToolsOzoneModerationEmitEvent.InputSchema> & {
subject: ToolsOzoneModerationEmitEvent.InputSchema['subject']
},
) {
await modClient.emitEvent({
event: {
$type: 'tools.ozone.moderation.defs#modEventReverseTakedown',
},
createdBy: 'did:example:admin',
reason: 'Y',
...opts,
})
}
async function getRecordLabels(uri: string) {
const result = await agent.api.tools.ozone.moderation.getRecord(
{ uri },
{
headers: await network.ozone.modHeaders(
ids.ToolsOzoneModerationGetRecord,
),
},
)
const labels = result.data.labels ?? []
return labels.map((l) => l.val)
}
async function getRepoLabels(did: string) {
const result = await agent.api.tools.ozone.moderation.getRepo(
{ did },
{
headers: await network.ozone.modHeaders(
ids.ToolsOzoneModerationGetRepo,
),
},
)
const labels = result.data.labels ?? []
return labels.map((l) => l.val)
}
})
describe('blob takedown', () => {
let post: { ref: RecordRef; images: ImageRef[] }
let blob: ImageRef
let imageUri: string
beforeAll(async () => {
const { ctx } = network.bsky
post = sc.posts[sc.dids.carol][0]
blob = post.images[1]
imageUri = ctx.views.imgUriBuilder
.getPresetUri(
'feed_thumbnail',
sc.dids.carol,
blob.image.ref.toString(),
)
.replace(ctx.cfg.publicUrl || '', network.bsky.url)
// Warm image server cache
await fetch(imageUri)
const cached = await fetch(imageUri)
expect(cached.headers.get('x-cache')).toEqual('hit')
await modClient.performTakedown({
subject: recordSubject(post.ref),
subjectBlobCids: [blob.image.ref.toString()],
})
await ozone.processAll()
})
it('sets blobCids in moderation status', async () => {
const { subjectStatuses } = await modClient.queryStatuses({
subject: post.ref.uriStr,
})
expect(subjectStatuses[0].subjectBlobCids).toEqual([
blob.image.ref.toString(),
])
})
it('prevents resolution of blob', async () => {
const blobPath = `/blob/${sc.dids.carol}/${blob.image.ref.toString()}`
const resolveBlob = await fetch(`${network.bsky.url}${blobPath}`)
expect(resolveBlob.status).toEqual(404)
expect(await resolveBlob.json()).toEqual({
error: 'NotFoundError',
message: 'Blob not found',
})
})
// @TODO add back in with image invalidation, see bluesky-social/atproto#2087
it.skip('prevents image blob from being served, even when cached.', async () => {
const fetchImage = await fetch(imageUri)
expect(fetchImage.status).toEqual(404)
expect(await fetchImage.json()).toMatchObject({
message: 'Blob not found',
})
})
it('invalidates the image in the cdn', async () => {
const blobCid = blob.image.ref.toString()
expect(mockInvalidator.invalidated.length).toBe(1)
expect(mockInvalidator.invalidated.at(0)?.subject).toBe(blobCid)
expect(mockInvalidator.invalidated.at(0)?.paths.at(0)).toEqual(
`/path1/${sc.dids.carol}/${blobCid}`,
)
expect(mockInvalidator.invalidated.at(0)?.paths.at(1)).toEqual(
`/path2/${sc.dids.carol}/${blobCid}`,
)
})
it('fans takedown out to pds', async () => {
const res = await pdsAgent.api.com.atproto.admin.getSubjectStatus(
{
did: sc.dids.carol,
blob: blob.image.ref.toString(),
},
{ headers: network.pds.adminAuthHeaders() },
)
expect(res.data.takedown?.applied).toBe(true)
})
it('restores blob when action is reversed.', async () => {
await modClient.performReverseTakedown({
subject: recordSubject(post.ref),
subjectBlobCids: [blob.image.ref.toString()],
})
await ozone.processAll()
// Can resolve blob
const blobPath = `/blob/${sc.dids.carol}/${blob.image.ref.toString()}`
const resolveBlob = await fetch(`${network.bsky.url}${blobPath}`)
expect(resolveBlob.status).toEqual(200)
// Can fetch through image server
const fetchImage = await fetch(imageUri)
expect(fetchImage.status).toEqual(200)
const size = Number(fetchImage.headers.get('content-length'))
expect(size).toBeGreaterThan(9000)
})
it('fans reversal out to pds', async () => {
const res = await pdsAgent.api.com.atproto.admin.getSubjectStatus(
{
did: sc.dids.carol,
blob: blob.image.ref.toString(),
},
{ headers: network.pds.adminAuthHeaders() },
)
expect(res.data.takedown?.applied).toBe(false)
})
})
})
class MockInvalidator implements ImageInvalidator {
invalidated: { subject: string; paths: string[] }[] = []
async invalidate(subject: string, paths: string[]) {
this.invalidated.push({ subject, paths })
}
}