From c8312f5979fe5a0d3372b4e0e63aa0dd7703f02f Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 21 Dec 2023 12:35:09 -0600 Subject: [PATCH] bsky moderation test --- .../api/com/atproto/admin/getSubjectStatus.ts | 73 ++ packages/bsky/src/api/index.ts | 2 + .../20231220T225126090Z-blob-takedowns.ts | 20 + .../bsky/src/services/moderation/index.ts | 38 + .../__snapshots__/moderation.test.ts.snap | 55 - packages/bsky/tests/admin/moderation.test.ts | 1011 +++-------------- 6 files changed, 291 insertions(+), 908 deletions(-) create mode 100644 packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts delete mode 100644 packages/bsky/tests/admin/__snapshots__/moderation.test.ts.snap diff --git a/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts b/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts new file mode 100644 index 00000000000..652f8a26bf2 --- /dev/null +++ b/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts @@ -0,0 +1,73 @@ +import { InvalidRequestError } from '@atproto/xrpc-server' +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { OutputSchema } from '../../../../lexicon/types/com/atproto/admin/getSubjectStatus' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.admin.getSubjectStatus({ + auth: ctx.roleVerifier, + handler: async ({ params }) => { + const { did, uri, blob } = params + const modService = ctx.services.moderation(ctx.db.getPrimary()) + let body: OutputSchema | null = null + if (blob) { + if (!did) { + throw new InvalidRequestError( + 'Must provide a did to request blob state', + ) + } + const takedown = await modService.getBlobTakedownRef(did, blob) + if (takedown) { + body = { + subject: { + $type: 'com.atproto.admin.defs#repoBlobRef', + did: did, + cid: blob, + }, + takedown, + } + } + } else if (uri) { + const [takedown, cidRes] = await Promise.all([ + modService.getRecordTakedownRef(uri), + ctx.db + .getPrimary() + .db.selectFrom('record') + .where('uri', '=', uri) + .select('cid') + .executeTakeFirst(), + ]) + if (cidRes && takedown) { + body = { + subject: { + $type: 'com.atproto.repo.strongRef', + uri, + cid: cidRes.cid, + }, + takedown, + } + } + } else if (did) { + const takedown = await modService.getRepoTakedownRef(did) + if (takedown) { + body = { + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: did, + }, + takedown, + } + } + } else { + throw new InvalidRequestError('No provided subject') + } + if (body === null) { + throw new InvalidRequestError('Subject not found', 'NotFound') + } + return { + encoding: 'application/json', + body, + } + }, + }) +} diff --git a/packages/bsky/src/api/index.ts b/packages/bsky/src/api/index.ts index 7f070b787dc..ff61ed5dbd5 100644 --- a/packages/bsky/src/api/index.ts +++ b/packages/bsky/src/api/index.ts @@ -41,6 +41,7 @@ import registerPush from './app/bsky/notification/registerPush' import getPopularFeedGenerators from './app/bsky/unspecced/getPopularFeedGenerators' import getTimelineSkeleton from './app/bsky/unspecced/getTimelineSkeleton' import createReport from './com/atproto/moderation/createReport' +import getSubjectStatus from './com/atproto/admin/getSubjectStatus' import updateSubjectStatus from './com/atproto/admin/updateSubjectStatus' import emitModerationEvent from './com/atproto/admin/emitModerationEvent' import searchRepos from './com/atproto/admin/searchRepos' @@ -104,6 +105,7 @@ export default function (server: Server, ctx: AppContext) { getTimelineSkeleton(server, ctx) // com.atproto createReport(server, ctx) + getSubjectStatus(server, ctx) updateSubjectStatus(server, ctx) emitModerationEvent(server, ctx) searchRepos(server, ctx) diff --git a/packages/bsky/src/db/migrations/20231220T225126090Z-blob-takedowns.ts b/packages/bsky/src/db/migrations/20231220T225126090Z-blob-takedowns.ts index 6b29041b7dd..2bb8f611b5e 100644 --- a/packages/bsky/src/db/migrations/20231220T225126090Z-blob-takedowns.ts +++ b/packages/bsky/src/db/migrations/20231220T225126090Z-blob-takedowns.ts @@ -16,6 +16,16 @@ export async function up(db: Kysely): Promise { .alterTable('record') .dropConstraint('record_takedown_id_fkey') .execute() + await db.schema + .alterTable('actor') + .alterColumn('takedownId') + .setDataType('varchar') + .execute() + await db.schema + .alterTable('record') + .alterColumn('takedownId') + .setDataType('varchar') + .execute() } export async function down(db: Kysely): Promise { @@ -38,4 +48,14 @@ export async function down(db: Kysely): Promise { ['id'], ) .execute() + await db.schema + .alterTable('actor') + .alterColumn('takedownId') + .setDataType('integer') + .execute() + await db.schema + .alterTable('record') + .alterColumn('takedownId') + .setDataType('integer') + .execute() } diff --git a/packages/bsky/src/services/moderation/index.ts b/packages/bsky/src/services/moderation/index.ts index 40a2498cf43..0f54c520f59 100644 --- a/packages/bsky/src/services/moderation/index.ts +++ b/packages/bsky/src/services/moderation/index.ts @@ -15,6 +15,7 @@ import { isModEventEmail, RepoRef, RepoBlobRef, + StatusAttr, } from '../../lexicon/types/com/atproto/admin/defs' import { addHoursToDate } from '../../util/date' import { @@ -666,6 +667,39 @@ export class ModerationService { return { statuses: results, cursor: keyset.packFromResult(results) } } + async getRepoTakedownRef(did: string): Promise { + const res = await this.db.db + .selectFrom('actor') + .where('did', '=', did) + .selectAll() + .executeTakeFirst() + return res ? formatStatus(res.takedownId) : null + } + + async getRecordTakedownRef(uri: string): Promise { + const res = await this.db.db + .selectFrom('record') + .where('uri', '=', uri) + .selectAll() + .executeTakeFirst() + return res ? formatStatus(res.takedownId) : null + } + + async getBlobTakedownRef( + did: string, + cid: string, + ): Promise { + const res = await this.db.db + .selectFrom('blob_takedown') + .where('did', '=', did) + .where('cid', '=', cid) + .selectAll() + .executeTakeFirst() + // this table only tracks takedowns not all blobs + // so if no result is returned then the blob is not taken down (rather than not found) + return formatStatus(res?.takedownId ?? null) + } + async isSubjectTakendown( subject: { did: string } | { uri: AtUri }, ): Promise { @@ -683,6 +717,10 @@ export class ModerationService { } } +const formatStatus = (ref: string | null): StatusAttr => { + return ref ? { applied: true, ref } : { applied: false } +} + export type TakedownSubjects = { did: string subjects: (RepoRef | RepoBlobRef | StrongRef)[] diff --git a/packages/bsky/tests/admin/__snapshots__/moderation.test.ts.snap b/packages/bsky/tests/admin/__snapshots__/moderation.test.ts.snap deleted file mode 100644 index 33a973e714f..00000000000 --- a/packages/bsky/tests/admin/__snapshots__/moderation.test.ts.snap +++ /dev/null @@ -1,55 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`moderation reporting creates reports of a record. 1`] = ` -Array [ - Object { - "createdAt": "1970-01-01T00:00:00.000Z", - "id": 4, - "reasonType": "com.atproto.moderation.defs#reasonSpam", - "reportedBy": "user(0)", - "subject": Object { - "$type": "com.atproto.repo.strongRef", - "cid": "cids(0)", - "uri": "record(0)", - }, - }, - Object { - "createdAt": "1970-01-01T00:00:00.000Z", - "id": 5, - "reason": "defamation", - "reasonType": "com.atproto.moderation.defs#reasonOther", - "reportedBy": "user(1)", - "subject": Object { - "$type": "com.atproto.repo.strongRef", - "cid": "cids(1)", - "uri": "record(1)", - }, - }, -] -`; - -exports[`moderation reporting creates reports of a repo. 1`] = ` -Array [ - Object { - "createdAt": "1970-01-01T00:00:00.000Z", - "id": 1, - "reasonType": "com.atproto.moderation.defs#reasonSpam", - "reportedBy": "user(0)", - "subject": Object { - "$type": "com.atproto.admin.defs#repoRef", - "did": "user(1)", - }, - }, - Object { - "createdAt": "1970-01-01T00:00:00.000Z", - "id": 2, - "reason": "impersonation", - "reasonType": "com.atproto.moderation.defs#reasonOther", - "reportedBy": "user(2)", - "subject": Object { - "$type": "com.atproto.admin.defs#repoRef", - "did": "user(1)", - }, - }, -] -`; diff --git a/packages/bsky/tests/admin/moderation.test.ts b/packages/bsky/tests/admin/moderation.test.ts index 93e0998f2d3..9424065de04 100644 --- a/packages/bsky/tests/admin/moderation.test.ts +++ b/packages/bsky/tests/admin/moderation.test.ts @@ -1,885 +1,210 @@ -import { TestNetwork, ImageRef, RecordRef, SeedClient } from '@atproto/dev-env' -import AtpAgent, { - ComAtprotoAdminEmitModerationEvent, - ComAtprotoAdminQueryModerationStatuses, - ComAtprotoModerationCreateReport, -} from '@atproto/api' -import { AtUri } from '@atproto/syntax' -import { forSnapshot } from '../_util' +import { ImageRef, SeedClient, TestNetwork } from '@atproto/dev-env' +import AtpAgent from '@atproto/api' import basicSeed from '../seeds/basic' import { - REASONMISLEADING, - REASONOTHER, - REASONSPAM, -} from '../../src/lexicon/types/com/atproto/moderation/defs' -import { - ModEventLabel, - ModEventTakedown, - REVIEWCLOSED, - REVIEWESCALATED, + RepoBlobRef, + RepoRef, } from '../../src/lexicon/types/com/atproto/admin/defs' -import { PeriodicModerationEventReversal } from '../../src' - -type BaseCreateReportParams = - | { account: string } - | { content: { uri: string; cid: string } } -type CreateReportParams = BaseCreateReportParams & { - author: string -} & Omit - -type TakedownParams = BaseCreateReportParams & - Omit +import { Main as StrongRef } from '../../src/lexicon/types/com/atproto/repo/strongRef' describe('moderation', () => { let network: TestNetwork let agent: AtpAgent - let pdsAgent: AtpAgent let sc: SeedClient - const createReport = async (params: CreateReportParams) => { - const { author, ...rest } = params - return agent.api.com.atproto.moderation.createReport( + let repoSubject: RepoRef + let recordSubject: StrongRef + let blobSubject: RepoBlobRef + let blobRef: ImageRef + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'bsky_moderation', + }) + + agent = network.bsky.getClient() + sc = network.getSeedClient() + await basicSeed(sc) + await network.processAll() + repoSubject = { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.bob, + } + const post = sc.posts[sc.dids.carol][0] + recordSubject = { + $type: 'com.atproto.repo.strongRef', + uri: post.ref.uriStr, + cid: post.ref.cidStr, + } + blobRef = post.images[1] + blobSubject = { + $type: 'com.atproto.admin.defs#repoBlobRef', + did: sc.dids.carol, + cid: blobRef.image.ref.toString(), + } + }) + + afterAll(async () => { + await network.close() + }) + + it('takes down accounts', async () => { + await agent.api.com.atproto.admin.updateSubjectStatus( { - // Set default type to spam - reasonType: REASONSPAM, - ...rest, - subject: - 'account' in params - ? { - $type: 'com.atproto.admin.defs#repoRef', - did: params.account, - } - : { - $type: 'com.atproto.repo.strongRef', - uri: params.content.uri, - cid: params.content.cid, - }, + subject: repoSubject, + takedown: { applied: true, ref: 'test-repo' }, }, { - headers: await network.serviceHeaders(author), encoding: 'application/json', + headers: network.bsky.adminAuthHeaders('moderator'), }, ) - } + const res = await agent.api.com.atproto.admin.getSubjectStatus( + { + did: repoSubject.did, + }, + { headers: network.bsky.adminAuthHeaders('moderator') }, + ) + expect(res.data.subject.did).toEqual(sc.dids.bob) + expect(res.data.takedown?.applied).toBe(true) + expect(res.data.takedown?.ref).toBe('test-repo') + }) - const performTakedown = async ({ - durationInHours, - ...rest - }: TakedownParams & Pick) => - agent.api.com.atproto.admin.emitModerationEvent( + it('restores takendown accounts', async () => { + await agent.api.com.atproto.admin.updateSubjectStatus( { - event: { - $type: 'com.atproto.admin.defs#modEventTakedown', - durationInHours, - }, - subject: - 'account' in rest - ? { - $type: 'com.atproto.admin.defs#repoRef', - did: rest.account, - } - : { - $type: 'com.atproto.repo.strongRef', - uri: rest.content.uri, - cid: rest.content.cid, - }, - createdBy: 'did:example:admin', - ...rest, + subject: repoSubject, + takedown: { applied: false }, }, { encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), + headers: network.bsky.adminAuthHeaders('moderator'), }, ) + const res = await agent.api.com.atproto.admin.getSubjectStatus( + { + did: repoSubject.did, + }, + { headers: network.bsky.adminAuthHeaders('moderator') }, + ) + expect(res.data.subject.did).toEqual(sc.dids.bob) + expect(res.data.takedown?.applied).toBe(false) + expect(res.data.takedown?.ref).toBeUndefined() + }) - const performReverseTakedown = async (params: TakedownParams) => - agent.api.com.atproto.admin.emitModerationEvent( + it('takes down records', async () => { + await agent.api.com.atproto.admin.updateSubjectStatus( { - event: { - $type: 'com.atproto.admin.defs#modEventReverseTakedown', - }, - subject: - 'account' in params - ? { - $type: 'com.atproto.admin.defs#repoRef', - did: params.account, - } - : { - $type: 'com.atproto.repo.strongRef', - uri: params.content.uri, - cid: params.content.cid, - }, - createdBy: 'did:example:admin', - ...params, + subject: recordSubject, + takedown: { applied: true, ref: 'test-record' }, }, { encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), + headers: network.bsky.adminAuthHeaders('moderator'), }, ) - - const getStatuses = async ( - params: ComAtprotoAdminQueryModerationStatuses.QueryParams, - ) => { - const { data } = await agent.api.com.atproto.admin.queryModerationStatuses( - params, - { headers: network.bsky.adminAuthHeaders() }, + const res = await agent.api.com.atproto.admin.getSubjectStatus( + { + uri: recordSubject.uri, + }, + { headers: network.bsky.adminAuthHeaders('moderator') }, ) - - return data - } - - beforeAll(async () => { - network = await TestNetwork.create({ - dbPostgresSchema: 'bsky_moderation', - }) - agent = network.bsky.getClient() - pdsAgent = network.pds.getClient() - sc = network.getSeedClient() - await basicSeed(sc) - await network.processAll() - }) - - afterAll(async () => { - await network.close() + expect(res.data.subject.uri).toEqual(recordSubject.uri) + expect(res.data.takedown?.applied).toBe(true) + expect(res.data.takedown?.ref).toBe('test-record') }) - describe('reporting', () => { - it('creates reports of a repo.', async () => { - const { data: reportA } = await createReport({ - reasonType: REASONSPAM, - account: sc.dids.bob, - author: sc.dids.alice, - }) - const { data: reportB } = await createReport({ - reasonType: REASONOTHER, - reason: 'impersonation', - account: sc.dids.bob, - author: sc.dids.carol, - }) - expect(forSnapshot([reportA, reportB])).toMatchSnapshot() - }) - - it("allows reporting a repo that doesn't exist.", async () => { - const promise = createReport({ - reasonType: REASONSPAM, - account: 'did:plc:unknown', - author: 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 { data: reportA } = await createReport({ - author: sc.dids.alice, - reasonType: REASONSPAM, - content: { - $type: 'com.atproto.repo.strongRef', - uri: postA.uriStr, - cid: postA.cidStr, - }, - }) - const { data: reportB } = await createReport({ - reasonType: REASONOTHER, - reason: 'defamation', - content: { - $type: 'com.atproto.repo.strongRef', - uri: postB.uriStr, - cid: postB.cidStr, - }, - author: 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 = createReport({ - reasonType: REASONSPAM, - content: { - $type: 'com.atproto.repo.strongRef', - uri: postUriBad.toString(), - cid: postA.cidStr, - }, - author: sc.dids.alice, - }) - await expect(promiseA).resolves.toBeDefined() - - const promiseB = createReport({ - reasonType: REASONOTHER, - reason: 'defamation', - content: { - $type: 'com.atproto.repo.strongRef', - uri: postB.uri.toString(), - cid: postA.cidStr, // bad cid - }, - author: sc.dids.carol, - }) - await expect(promiseB).resolves.toBeDefined() - }) + it('restores takendown records', async () => { + await agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: recordSubject, + takedown: { applied: false }, + }, + { + encoding: 'application/json', + headers: network.bsky.adminAuthHeaders('moderator'), + }, + ) + const res = await agent.api.com.atproto.admin.getSubjectStatus( + { + uri: recordSubject.uri, + }, + { headers: network.bsky.adminAuthHeaders('moderator') }, + ) + expect(res.data.subject.uri).toEqual(recordSubject.uri) + expect(res.data.takedown?.applied).toBe(false) + expect(res.data.takedown?.ref).toBeUndefined() }) - describe('actioning', () => { - it('resolves reports on repos and records.', async () => { - const post = sc.posts[sc.dids.bob][1].ref - - await Promise.all([ - createReport({ - reasonType: REASONSPAM, - account: sc.dids.bob, - author: sc.dids.alice, - }), - createReport({ - reasonType: REASONOTHER, - reason: 'defamation', - content: { - uri: post.uri.toString(), - cid: post.cid.toString(), - }, - author: sc.dids.carol, - }), - ]) - - await performTakedown({ - account: sc.dids.bob, - }) - - const moderationStatusOnBobsAccount = await getStatuses({ - 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 performReverseTakedown({ - account: 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 agent.api.com.atproto.admin.emitModerationEvent( - { - event: { - $type: 'com.atproto.admin.defs#modEventEscalate', - comment: 'Y', - }, - subject: alicesPostSubject, - createdBy: 'did:example:admin', - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders('triage'), - }, - ) - - const alicesPostStatus = await getStatuses({ - 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 agent.api.com.atproto.admin.emitModerationEvent( - { - event: { - $type: 'com.atproto.admin.defs#modEventComment', - sticky: true, - comment: 'This is a persistent note', - }, - subject: alicesPostSubject, - createdBy: 'did:example:admin', - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders('triage'), - }, - ) - - const alicesPostStatus = await getStatuses({ - 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: ComAtprotoAdminEmitModerationEvent.InputSchema['event'], - overwrites: Partial = {}, - ) => { - const baseAction = { - subject: { - $type: 'com.atproto.repo.strongRef', - uri: alicesPostRef.uriStr, - cid: alicesPostRef.cidStr, - }, - createdBy: 'did:example:admin', - } - return agent.api.com.atproto.admin.emitModerationEvent( - { - event, - ...baseAction, - ...overwrites, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), - }, - ) - } - // Validate that subject status is marked as escalated - await emitModEvent({ - $type: 'com.atproto.admin.defs#modEventReport', - reportType: REASONSPAM, - }) - await emitModEvent({ - $type: 'com.atproto.admin.defs#modEventReport', - reportType: REASONMISLEADING, - }) - await emitModEvent({ - $type: 'com.atproto.admin.defs#modEventEscalate', - }) - const alicesPostStatusAfterEscalation = await getStatuses({ - subject: alicesPostRef.uriStr, - }) - expect( - alicesPostStatusAfterEscalation.subjectStatuses[0].reviewState, - ).toEqual(REVIEWESCALATED) - - // Validate that subject status is marked as takendown - - await emitModEvent({ - $type: 'com.atproto.admin.defs#modEventLabel', - createLabelVals: ['nsfw'], - negateLabelVals: [], - }) - await emitModEvent({ - $type: 'com.atproto.admin.defs#modEventTakedown', - }) - - const alicesPostStatusAfterTakedown = await getStatuses({ - subject: alicesPostRef.uriStr, - }) - expect(alicesPostStatusAfterTakedown.subjectStatuses[0]).toMatchObject({ - reviewState: REVIEWCLOSED, - takendown: true, - }) - - await emitModEvent({ - $type: 'com.atproto.admin.defs#modEventReverseTakedown', - }) - const alicesPostStatusAfterRevert = await getStatuses({ - 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 } = network.bsky - const post = sc.posts[sc.dids.bob][0].ref - const bobsPostSubject = { - $type: 'com.atproto.repo.strongRef', - uri: post.uriStr, - cid: post.cidStr, - } - const labelingService = ctx.services.label(ctx.db.getPrimary()) - await labelingService.formatAndCreate( - ctx.cfg.labelerDid, - 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 labelingService.formatAndCreate( - ctx.cfg.labelerDid, - post.uriStr, - post.cidStr, - { negate: ['kittens'] }, - ) - }) - - it('no-ops when negating an already-negated label and reverses.', async () => { - const { ctx } = network.bsky - const post = sc.posts[sc.dids.bob][0].ref - const labelingService = ctx.services.label(ctx.db.getPrimary()) - 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 labelingService.formatAndCreate( - ctx.cfg.labelerDid, - 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 } = network.bsky - const labelingService = ctx.services.label(ctx.db.getPrimary()) - await labelingService.formatAndCreate( - ctx.cfg.labelerDid, - 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 = agent.api.com.atproto.admin.emitModerationEvent( + it('does not allow non-full moderators to update subject state', async () => { + const subject = { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.bob, + } + const attemptTakedownTriage = + agent.api.com.atproto.admin.updateSubjectStatus( { - event: { - $type: 'com.atproto.admin.defs#modEventLabel', - negateLabelVals: ['a'], - createLabelVals: ['b', 'c'], - }, - createdBy: 'did:example:moderator', - reason: 'Y', - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.bob, - }, + subject, + takedown: { applied: true }, }, { encoding: 'application/json', headers: network.bsky.adminAuthHeaders('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 performTakedown({ - account: sc.dids.bob, - }) - await expect( - performTakedown({ - account: sc.dids.bob, - }), - ).rejects.toThrow('Subject is already taken down') - - // Cleanup - await performReverseTakedown({ - account: sc.dids.bob, - }) - await expect( - performReverseTakedown({ - account: sc.dids.bob, - }), - ).rejects.toThrow('Subject is not taken down') - }) - it('fans out repo takedowns to pds', async () => { - await performTakedown({ - account: sc.dids.bob, - }) - - const res1 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( - { - did: sc.dids.bob, - }, - { headers: network.pds.adminAuthHeaders() }, - ) - expect(res1.data.takedown?.applied).toBe(true) - - // cleanup - await performReverseTakedown({ account: sc.dids.bob }) - - const res2 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( - { - did: sc.dids.bob, - }, - { headers: network.pds.adminAuthHeaders() }, - ) - expect(res2.data.takedown?.applied).toBe(false) - }) - - it('fans out record takedowns to pds', async () => { - const post = sc.posts[sc.dids.bob][0] - const uri = post.ref.uriStr - const cid = post.ref.cidStr - await performTakedown({ - content: { uri, cid }, - }) - const res1 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( - { uri }, - { headers: network.pds.adminAuthHeaders() }, - ) - expect(res1.data.takedown?.applied).toBe(true) - - // cleanup - await performReverseTakedown({ content: { uri, cid } }) - - const res2 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( - { uri }, - { headers: network.pds.adminAuthHeaders() }, - ) - expect(res2.data.takedown?.applied).toBe(false) - }) - - it('allows full moderators to takedown.', async () => { - await agent.api.com.atproto.admin.emitModerationEvent( - { - event: { - $type: 'com.atproto.admin.defs#modEventTakedown', - }, - createdBy: 'did:example:moderator', - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.bob, - }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders('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 = - agent.api.com.atproto.admin.emitModerationEvent( - { - event: { - $type: 'com.atproto.admin.defs#modEventTakedown', - }, - createdBy: 'did:example:moderator', - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.bob, - }, - }, - { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders('triage'), - }, - ) - await expect(attemptTakedownTriage).rejects.toThrow( - 'Must be a full moderator to perform an account takedown', - ) - }) - it('automatically reverses actions marked with duration', async () => { - await createReport({ - reasonType: REASONSPAM, - account: sc.dids.bob, - author: sc.dids.alice, - }) - const { data: action } = await performTakedown({ - account: 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, - }) - - const { data: statusesAfterTakedown } = - await agent.api.com.atproto.admin.queryModerationStatuses( - { subject: sc.dids.bob }, - { headers: network.bsky.adminAuthHeaders('moderator') }, - ) - - expect(statusesAfterTakedown.subjectStatuses[0]).toMatchObject({ - takendown: true, - }) - - // In the actual app, this will be instantiated and run on server startup - const periodicReversal = new PeriodicModerationEventReversal( - network.bsky.ctx, - ) - await periodicReversal.findAndRevertDueActions() + await expect(attemptTakedownTriage).rejects.toThrow( + 'Must be a full moderator to update subject state', + ) + const res = await agent.api.com.atproto.admin.getSubjectStatus( + { + did: subject.did, + }, + { headers: network.bsky.adminAuthHeaders('moderator') }, + ) + expect(res.data.takedown?.applied).toBe(false) + }) - const [{ data: eventList }, { data: statuses }] = await Promise.all([ - agent.api.com.atproto.admin.queryModerationEvents( - { subject: sc.dids.bob }, - { headers: network.bsky.adminAuthHeaders('moderator') }, - ), - agent.api.com.atproto.admin.queryModerationStatuses( - { subject: sc.dids.bob }, - { headers: network.bsky.adminAuthHeaders('moderator') }, - ), - ]) + describe('blob takedown', () => { + let blobUri: string + let imageUri: string - 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: 'com.atproto.admin.defs#modEventReverseTakedown', - comment: - '[SCHEDULED_REVERSAL] Reverting action as originally scheduled', - }, - }) + beforeAll(async () => { + blobUri = `${network.bsky.url}/blob/${blobSubject.did}/${blobSubject.cid}` + imageUri = network.bsky.ctx.imgUriBuilder + .getPresetUri('feed_thumbnail', blobSubject.did, blobSubject.cid) + .replace(network.bsky.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') }) - async function emitLabelEvent( - opts: Partial & { - subject: ComAtprotoAdminEmitModerationEvent.InputSchema['subject'] - createLabelVals: ModEventLabel['createLabelVals'] - negateLabelVals: ModEventLabel['negateLabelVals'] - }, - ) { - const { createLabelVals, negateLabelVals } = opts - const result = await agent.api.com.atproto.admin.emitModerationEvent( + it('takes down blobs', async () => { + await agent.api.com.atproto.admin.updateSubjectStatus( { - event: { - $type: 'com.atproto.admin.defs#modEventLabel', - createLabelVals, - negateLabelVals, - }, - createdBy: 'did:example:admin', - reason: 'Y', - ...opts, + subject: blobSubject, + takedown: { applied: true, ref: 'test-blob' }, }, { encoding: 'application/json', headers: network.bsky.adminAuthHeaders(), }, ) - return result.data - } - - async function reverse( - opts: Partial & { - subject: ComAtprotoAdminEmitModerationEvent.InputSchema['subject'] - }, - ) { - await agent.api.com.atproto.admin.emitModerationEvent( - { - event: { - $type: 'com.atproto.admin.defs#modEventReverseTakedown', - }, - createdBy: 'did:example:admin', - reason: 'Y', - ...opts, - }, + const res = await agent.api.com.atproto.admin.getSubjectStatus( { - encoding: 'application/json', - headers: network.bsky.adminAuthHeaders(), + did: blobSubject.did, + blob: blobSubject.cid, }, + { headers: network.bsky.adminAuthHeaders('moderator') }, ) - } - - async function getRecordLabels(uri: string) { - const result = await agent.api.com.atproto.admin.getRecord( - { uri }, - { headers: network.bsky.adminAuthHeaders() }, - ) - const labels = result.data.labels ?? [] - return labels.map((l) => l.val) - } - - async function getRepoLabels(did: string) { - const result = await agent.api.com.atproto.admin.getRepo( - { did }, - { headers: network.bsky.adminAuthHeaders() }, - ) - 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.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 performTakedown({ - content: { - uri: post.ref.uriStr, - cid: post.ref.cidStr, - }, - subjectBlobCids: [blob.image.ref.toString()], - }) - }) - - it('sets blobCids in moderation status', async () => { - const { subjectStatuses } = await getStatuses({ - subject: post.ref.uriStr, - }) - - expect(subjectStatuses[0].subjectBlobCids).toEqual([ - blob.image.ref.toString(), - ]) + expect(res.data.subject.did).toEqual(blobSubject.did) + expect(res.data.subject.cid).toEqual(blobSubject.cid) + expect(res.data.takedown?.applied).toBe(true) + expect(res.data.takedown?.ref).toBe('test-blob') }) it('prevents resolution of blob', async () => { - const blobPath = `/blob/${sc.dids.carol}/${blob.image.ref.toString()}` - const resolveBlob = await fetch(`${network.bsky.url}${blobPath}`) + const resolveBlob = await fetch(blobUri) expect(resolveBlob.status).toEqual(404) expect(await resolveBlob.json()).toEqual({ error: 'NotFoundError', @@ -893,29 +218,20 @@ describe('moderation', () => { expect(await fetchImage.json()).toEqual({ message: 'Image not found' }) }) - it('fans takedown out to pds', async () => { - const res = await pdsAgent.api.com.atproto.admin.getSubjectStatus( + it('restores blob when takedown is removed', async () => { + await agent.api.com.atproto.admin.updateSubjectStatus( { - did: sc.dids.carol, - blob: blob.image.ref.toString(), + subject: blobSubject, + takedown: { applied: false }, }, - { headers: network.pds.adminAuthHeaders() }, - ) - expect(res.data.takedown?.applied).toBe(true) - }) - - it('restores blob when action is reversed.', async () => { - await performReverseTakedown({ - content: { - uri: post.ref.uriStr, - cid: post.ref.cidStr, + { + encoding: 'application/json', + headers: network.bsky.adminAuthHeaders(), }, - subjectBlobCids: [blob.image.ref.toString()], - }) + ) // Can resolve blob - const blobPath = `/blob/${sc.dids.carol}/${blob.image.ref.toString()}` - const resolveBlob = await fetch(`${network.bsky.url}${blobPath}`) + const resolveBlob = await fetch(blobUri) expect(resolveBlob.status).toEqual(200) // Can fetch through image server @@ -924,16 +240,5 @@ describe('moderation', () => { 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) - }) }) })