1165 lines
34 KiB
TypeScript
1165 lines
34 KiB
TypeScript
import AtpAgent, { ComAtprotoAdminTakeModerationAction } from '@atproto/api'
|
|
import { AtUri } from '@atproto/uri'
|
|
import { BlobNotFoundError } from '@atproto/repo'
|
|
import {
|
|
adminAuth,
|
|
CloseFn,
|
|
forSnapshot,
|
|
moderatorAuth,
|
|
runTestServer,
|
|
TestServerInfo,
|
|
triageAuth,
|
|
} from './_util'
|
|
import { ImageRef, RecordRef, SeedClient } from './seeds/client'
|
|
import basicSeed from './seeds/basic'
|
|
import {
|
|
ACKNOWLEDGE,
|
|
FLAG,
|
|
TAKEDOWN,
|
|
ESCALATE,
|
|
} from '../src/lexicon/types/com/atproto/admin/defs'
|
|
import {
|
|
REASONOTHER,
|
|
REASONSPAM,
|
|
} from '../src/lexicon/types/com/atproto/moderation/defs'
|
|
|
|
describe('moderation', () => {
|
|
let server: TestServerInfo
|
|
let close: CloseFn
|
|
let agent: AtpAgent
|
|
let sc: SeedClient
|
|
|
|
beforeAll(async () => {
|
|
server = await runTestServer({
|
|
dbPostgresSchema: 'moderation',
|
|
})
|
|
close = server.close
|
|
agent = new AtpAgent({ service: server.url })
|
|
sc = new SeedClient(agent)
|
|
await basicSeed(sc)
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await close()
|
|
})
|
|
|
|
describe('reporting', () => {
|
|
it('creates reports of a repo.', async () => {
|
|
const { data: reportA } =
|
|
await agent.api.com.atproto.moderation.createReport(
|
|
{
|
|
reasonType: REASONSPAM,
|
|
subject: {
|
|
$type: 'com.atproto.admin.defs#repoRef',
|
|
did: sc.dids.bob,
|
|
},
|
|
},
|
|
{
|
|
headers: sc.getHeaders(sc.dids.alice),
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
const { data: reportB } =
|
|
await agent.api.com.atproto.moderation.createReport(
|
|
{
|
|
reasonType: REASONOTHER,
|
|
reason: 'impersonation',
|
|
subject: {
|
|
$type: 'com.atproto.admin.defs#repoRef',
|
|
did: sc.dids.bob,
|
|
},
|
|
},
|
|
{
|
|
headers: sc.getHeaders(sc.dids.carol),
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
expect(forSnapshot([reportA, reportB])).toMatchSnapshot()
|
|
})
|
|
|
|
it("fails reporting a repo that doesn't exist.", async () => {
|
|
const promise = agent.api.com.atproto.moderation.createReport(
|
|
{
|
|
reasonType: REASONSPAM,
|
|
subject: {
|
|
$type: 'com.atproto.admin.defs#repoRef',
|
|
did: 'did:plc:unknown',
|
|
},
|
|
},
|
|
{ headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' },
|
|
)
|
|
await expect(promise).rejects.toThrow('Repo not found')
|
|
})
|
|
|
|
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 agent.api.com.atproto.moderation.createReport(
|
|
{
|
|
reasonType: REASONSPAM,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postA.uriStr,
|
|
cid: postA.cidStr,
|
|
},
|
|
},
|
|
{
|
|
headers: sc.getHeaders(sc.dids.alice),
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
const { data: reportB } =
|
|
await agent.api.com.atproto.moderation.createReport(
|
|
{
|
|
reasonType: REASONOTHER,
|
|
reason: 'defamation',
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postB.uriStr,
|
|
cid: postB.cidStr,
|
|
},
|
|
},
|
|
{
|
|
headers: sc.getHeaders(sc.dids.carol),
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
expect(forSnapshot([reportA, reportB])).toMatchSnapshot()
|
|
})
|
|
|
|
it("fails 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 = agent.api.com.atproto.moderation.createReport(
|
|
{
|
|
reasonType: REASONSPAM,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postUriBad.toString(),
|
|
cid: postA.cidStr,
|
|
},
|
|
},
|
|
{ headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' },
|
|
)
|
|
await expect(promiseA).rejects.toThrow('Record not found')
|
|
|
|
const promiseB = agent.api.com.atproto.moderation.createReport(
|
|
{
|
|
reasonType: REASONOTHER,
|
|
reason: 'defamation',
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postB.uri.toString(),
|
|
cid: postA.cidStr, // bad cid
|
|
},
|
|
},
|
|
{ headers: sc.getHeaders(sc.dids.carol), encoding: 'application/json' },
|
|
)
|
|
await expect(promiseB).rejects.toThrow('Record not found')
|
|
})
|
|
})
|
|
|
|
describe('actioning', () => {
|
|
it('resolves reports on repos and records.', async () => {
|
|
const { data: reportA } =
|
|
await agent.api.com.atproto.moderation.createReport(
|
|
{
|
|
reasonType: REASONSPAM,
|
|
subject: {
|
|
$type: 'com.atproto.admin.defs#repoRef',
|
|
did: sc.dids.bob,
|
|
},
|
|
},
|
|
{
|
|
headers: sc.getHeaders(sc.dids.alice),
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
const post = sc.posts[sc.dids.bob][1].ref
|
|
const { data: reportB } =
|
|
await agent.api.com.atproto.moderation.createReport(
|
|
{
|
|
reasonType: REASONOTHER,
|
|
reason: 'defamation',
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: post.uri.toString(),
|
|
cid: post.cid.toString(),
|
|
},
|
|
},
|
|
{
|
|
headers: sc.getHeaders(sc.dids.carol),
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
const { data: action } =
|
|
await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: TAKEDOWN,
|
|
subject: {
|
|
$type: 'com.atproto.admin.defs#repoRef',
|
|
did: sc.dids.bob,
|
|
},
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
const { data: actionResolvedReports } =
|
|
await agent.api.com.atproto.admin.resolveModerationReports(
|
|
{
|
|
actionId: action.id,
|
|
reportIds: [reportB.id, reportA.id],
|
|
createdBy: 'did:example:admin',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
|
|
expect(forSnapshot(actionResolvedReports)).toMatchSnapshot()
|
|
|
|
// Cleanup
|
|
await agent.api.com.atproto.admin.reverseModerationAction(
|
|
{
|
|
id: action.id,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
})
|
|
|
|
it('resolves reports on missing repos and records.', async () => {
|
|
// Create fresh user
|
|
const deleteme = await sc.createAccount('deleteme', {
|
|
email: 'deleteme.test@bsky.app',
|
|
handle: 'deleteme.test',
|
|
password: 'password',
|
|
})
|
|
const post = await sc.post(deleteme.did, 'delete this post')
|
|
// Report user and post
|
|
const { data: reportA } =
|
|
await agent.api.com.atproto.moderation.createReport(
|
|
{
|
|
reasonType: REASONSPAM,
|
|
subject: {
|
|
$type: 'com.atproto.admin.defs#repoRef',
|
|
did: deleteme.did,
|
|
},
|
|
},
|
|
{
|
|
headers: sc.getHeaders(sc.dids.alice),
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
const { data: reportB } =
|
|
await agent.api.com.atproto.moderation.createReport(
|
|
{
|
|
reasonType: REASONOTHER,
|
|
reason: 'defamation',
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: post.ref.uriStr,
|
|
cid: post.ref.cidStr,
|
|
},
|
|
},
|
|
{
|
|
headers: sc.getHeaders(sc.dids.carol),
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
// Delete full user account
|
|
await agent.api.com.atproto.server.requestAccountDelete(undefined, {
|
|
headers: sc.getHeaders(deleteme.did),
|
|
})
|
|
const { token: deletionToken } = await server.ctx.db.db
|
|
.selectFrom('delete_account_token')
|
|
.where('did', '=', deleteme.did)
|
|
.selectAll()
|
|
.executeTakeFirstOrThrow()
|
|
await agent.api.com.atproto.server.deleteAccount({
|
|
did: deleteme.did,
|
|
password: 'password',
|
|
token: deletionToken,
|
|
})
|
|
await server.processAll()
|
|
// Take action on deleted content
|
|
const { data: action } =
|
|
await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: FLAG,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: post.ref.uriStr,
|
|
cid: post.ref.cidStr,
|
|
},
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
await agent.api.com.atproto.admin.resolveModerationReports(
|
|
{
|
|
actionId: action.id,
|
|
reportIds: [reportB.id, reportA.id],
|
|
createdBy: 'did:example:admin',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
// Check report and action details
|
|
const { data: repoDeletionActionDetail } =
|
|
await agent.api.com.atproto.admin.getModerationAction(
|
|
{ id: action.id - 1 },
|
|
{ headers: { authorization: adminAuth() } },
|
|
)
|
|
const { data: recordActionDetail } =
|
|
await agent.api.com.atproto.admin.getModerationAction(
|
|
{ id: action.id },
|
|
{ headers: { authorization: adminAuth() } },
|
|
)
|
|
const { data: reportADetail } =
|
|
await agent.api.com.atproto.admin.getModerationReport(
|
|
{ id: reportA.id },
|
|
{ headers: { authorization: adminAuth() } },
|
|
)
|
|
const { data: reportBDetail } =
|
|
await agent.api.com.atproto.admin.getModerationReport(
|
|
{ id: reportB.id },
|
|
{ headers: { authorization: adminAuth() } },
|
|
)
|
|
expect(
|
|
forSnapshot({
|
|
repoDeletionActionDetail,
|
|
recordActionDetail,
|
|
reportADetail,
|
|
reportBDetail,
|
|
}),
|
|
).toMatchSnapshot()
|
|
// Cleanup
|
|
await agent.api.com.atproto.admin.reverseModerationAction(
|
|
{
|
|
id: action.id,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
})
|
|
|
|
it('does not resolve report for mismatching repo.', async () => {
|
|
const { data: report } =
|
|
await agent.api.com.atproto.moderation.createReport(
|
|
{
|
|
reasonType: REASONSPAM,
|
|
subject: {
|
|
$type: 'com.atproto.admin.defs#repoRef',
|
|
did: sc.dids.bob,
|
|
},
|
|
},
|
|
{
|
|
headers: sc.getHeaders(sc.dids.alice),
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
const { data: action } =
|
|
await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: TAKEDOWN,
|
|
subject: {
|
|
$type: 'com.atproto.admin.defs#repoRef',
|
|
did: sc.dids.carol,
|
|
},
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
|
|
const promise = agent.api.com.atproto.admin.resolveModerationReports(
|
|
{
|
|
actionId: action.id,
|
|
reportIds: [report.id],
|
|
createdBy: 'did:example:admin',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
|
|
await expect(promise).rejects.toThrow(
|
|
'Report 9 cannot be resolved by action',
|
|
)
|
|
|
|
// Cleanup
|
|
await agent.api.com.atproto.admin.reverseModerationAction(
|
|
{
|
|
id: action.id,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
})
|
|
|
|
it('does not resolve report for mismatching record.', async () => {
|
|
const postRef1 = sc.posts[sc.dids.alice][0].ref
|
|
const postRef2 = sc.posts[sc.dids.bob][0].ref
|
|
const { data: report } =
|
|
await agent.api.com.atproto.moderation.createReport(
|
|
{
|
|
reasonType: REASONSPAM,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postRef1.uriStr,
|
|
cid: postRef1.cidStr,
|
|
},
|
|
},
|
|
{
|
|
headers: sc.getHeaders(sc.dids.alice),
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
const { data: action } =
|
|
await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: TAKEDOWN,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postRef2.uriStr,
|
|
cid: postRef2.cidStr,
|
|
},
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
|
|
const promise = agent.api.com.atproto.admin.resolveModerationReports(
|
|
{
|
|
actionId: action.id,
|
|
reportIds: [report.id],
|
|
createdBy: 'did:example:admin',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
|
|
await expect(promise).rejects.toThrow(
|
|
'Report 10 cannot be resolved by action',
|
|
)
|
|
|
|
// Cleanup
|
|
await agent.api.com.atproto.admin.reverseModerationAction(
|
|
{
|
|
id: action.id,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
})
|
|
|
|
it('supports escalating and acknowledging for triage.', async () => {
|
|
const postRef1 = sc.posts[sc.dids.alice][0].ref
|
|
const postRef2 = sc.posts[sc.dids.bob][0].ref
|
|
const { data: action1 } =
|
|
await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: ESCALATE,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postRef1.uri.toString(),
|
|
cid: postRef1.cid.toString(),
|
|
},
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: triageAuth() },
|
|
},
|
|
)
|
|
expect(action1).toEqual(
|
|
expect.objectContaining({
|
|
action: ESCALATE,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postRef1.uriStr,
|
|
cid: postRef1.cidStr,
|
|
},
|
|
}),
|
|
)
|
|
const { data: action2 } =
|
|
await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: ACKNOWLEDGE,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postRef2.uri.toString(),
|
|
cid: postRef2.cid.toString(),
|
|
},
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: triageAuth() },
|
|
},
|
|
)
|
|
expect(action2).toEqual(
|
|
expect.objectContaining({
|
|
action: ACKNOWLEDGE,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postRef2.uriStr,
|
|
cid: postRef2.cidStr,
|
|
},
|
|
}),
|
|
)
|
|
// Cleanup
|
|
await agent.api.com.atproto.admin.reverseModerationAction(
|
|
{
|
|
id: action1.id,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: triageAuth() },
|
|
},
|
|
)
|
|
await agent.api.com.atproto.admin.reverseModerationAction(
|
|
{
|
|
id: action2.id,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: triageAuth() },
|
|
},
|
|
)
|
|
})
|
|
|
|
it('only allows record to have one current action.', async () => {
|
|
const postRef = sc.posts[sc.dids.alice][0].ref
|
|
const { data: acknowledge } =
|
|
await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: ACKNOWLEDGE,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postRef.uriStr,
|
|
cid: postRef.cidStr,
|
|
},
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
const flagPromise = agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: FLAG,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postRef.uriStr,
|
|
cid: postRef.cidStr,
|
|
},
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
await expect(flagPromise).rejects.toThrow(
|
|
'Subject already has an active action:',
|
|
)
|
|
|
|
// Reverse current then retry
|
|
await agent.api.com.atproto.admin.reverseModerationAction(
|
|
{
|
|
id: acknowledge.id,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
const { data: flag } =
|
|
await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: FLAG,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postRef.uriStr,
|
|
cid: postRef.cidStr,
|
|
},
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
|
|
// Cleanup
|
|
await agent.api.com.atproto.admin.reverseModerationAction(
|
|
{
|
|
id: flag.id,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
})
|
|
|
|
it('only allows repo to have one current action.', async () => {
|
|
const { data: acknowledge } =
|
|
await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: ACKNOWLEDGE,
|
|
subject: {
|
|
$type: 'com.atproto.admin.defs#repoRef',
|
|
did: sc.dids.alice,
|
|
},
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
const flagPromise = agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: FLAG,
|
|
subject: {
|
|
$type: 'com.atproto.admin.defs#repoRef',
|
|
did: sc.dids.alice,
|
|
},
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
await expect(flagPromise).rejects.toThrow(
|
|
'Subject already has an active action:',
|
|
)
|
|
|
|
// Reverse current then retry
|
|
await agent.api.com.atproto.admin.reverseModerationAction(
|
|
{
|
|
id: acknowledge.id,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
const { data: flag } =
|
|
await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: FLAG,
|
|
subject: {
|
|
$type: 'com.atproto.admin.defs#repoRef',
|
|
did: sc.dids.alice,
|
|
},
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
|
|
// Cleanup
|
|
await agent.api.com.atproto.admin.reverseModerationAction(
|
|
{
|
|
id: flag.id,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
})
|
|
|
|
it('only allows blob to have one current action.', async () => {
|
|
const img = sc.posts[sc.dids.carol][0].images[0]
|
|
const postA = await sc.post(sc.dids.carol, 'image A', undefined, [img])
|
|
const postB = await sc.post(sc.dids.carol, 'image B', undefined, [img])
|
|
const { data: acknowledge } =
|
|
await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: ACKNOWLEDGE,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postA.ref.uriStr,
|
|
cid: postA.ref.cidStr,
|
|
},
|
|
subjectBlobCids: [img.image.ref.toString()],
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
const flagPromise = agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: FLAG,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postB.ref.uriStr,
|
|
cid: postB.ref.cidStr,
|
|
},
|
|
subjectBlobCids: [img.image.ref.toString()],
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
await expect(flagPromise).rejects.toThrow(
|
|
'Blob already has an active action:',
|
|
)
|
|
// Reverse current then retry
|
|
await agent.api.com.atproto.admin.reverseModerationAction(
|
|
{
|
|
id: acknowledge.id,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
const { data: flag } =
|
|
await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: FLAG,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: postB.ref.uriStr,
|
|
cid: postB.ref.cidStr,
|
|
},
|
|
subjectBlobCids: [img.image.ref.toString()],
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
|
|
// Cleanup
|
|
await agent.api.com.atproto.admin.reverseModerationAction(
|
|
{
|
|
id: flag.id,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
})
|
|
|
|
it('negates an existing label and reverses.', async () => {
|
|
const { ctx } = server
|
|
const post = sc.posts[sc.dids.bob][0].ref
|
|
const labelingService = ctx.services.appView.label(ctx.db)
|
|
await labelingService.formatAndCreate(
|
|
ctx.cfg.labelerDid,
|
|
post.uriStr,
|
|
post.cidStr,
|
|
{ create: ['kittens'] },
|
|
)
|
|
const action = await actionWithLabels({
|
|
negateLabelVals: ['kittens'],
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: post.uriStr,
|
|
cid: post.cidStr,
|
|
},
|
|
})
|
|
await expect(getRecordLabels(post.uriStr)).resolves.toEqual([])
|
|
await reverse(action.id)
|
|
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 } = server
|
|
const post = sc.posts[sc.dids.bob][0].ref
|
|
const labelingService = ctx.services.appView.label(ctx.db)
|
|
const action = await actionWithLabels({
|
|
negateLabelVals: ['bears'],
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: post.uriStr,
|
|
cid: post.cidStr,
|
|
},
|
|
})
|
|
await expect(getRecordLabels(post.uriStr)).resolves.toEqual([])
|
|
await reverse(action.id)
|
|
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
|
|
const action = await actionWithLabels({
|
|
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 reverse(action.id)
|
|
await expect(getRecordLabels(post.uriStr)).resolves.toEqual([])
|
|
})
|
|
|
|
it('no-ops when creating an existing label and reverses.', async () => {
|
|
const { ctx } = server
|
|
const post = sc.posts[sc.dids.bob][0].ref
|
|
const labelingService = ctx.services.appView.label(ctx.db)
|
|
await labelingService.formatAndCreate(
|
|
ctx.cfg.labelerDid,
|
|
post.uriStr,
|
|
post.cidStr,
|
|
{ create: ['birds'] },
|
|
)
|
|
const action = await actionWithLabels({
|
|
createLabelVals: ['birds'],
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: post.uriStr,
|
|
cid: post.cidStr,
|
|
},
|
|
})
|
|
await expect(getRecordLabels(post.uriStr)).resolves.toEqual(['birds'])
|
|
await reverse(action.id)
|
|
await expect(getRecordLabels(post.uriStr)).resolves.toEqual([])
|
|
})
|
|
|
|
it('creates labels on a repo and reverses.', async () => {
|
|
const action = await actionWithLabels({
|
|
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 reverse(action.id)
|
|
await expect(getRepoLabels(sc.dids.bob)).resolves.toEqual([])
|
|
})
|
|
|
|
it('creates and negates labels on a repo and reverses.', async () => {
|
|
const { ctx } = server
|
|
const labelingService = ctx.services.appView.label(ctx.db)
|
|
await labelingService.formatAndCreate(
|
|
ctx.cfg.labelerDid,
|
|
sc.dids.bob,
|
|
null,
|
|
{ create: ['kittens'] },
|
|
)
|
|
const action = await actionWithLabels({
|
|
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 reverse(action.id)
|
|
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.takeModerationAction(
|
|
{
|
|
action: ACKNOWLEDGE,
|
|
createdBy: 'did:example:moderator',
|
|
reason: 'Y',
|
|
subject: {
|
|
$type: 'com.atproto.admin.defs#repoRef',
|
|
did: sc.dids.bob,
|
|
},
|
|
negateLabelVals: ['a'],
|
|
createLabelVals: ['b', 'c'],
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: triageAuth() },
|
|
},
|
|
)
|
|
await expect(attemptLabel).rejects.toThrow(
|
|
'Must be a full moderator to label content',
|
|
)
|
|
})
|
|
|
|
it('allows full moderators to takedown.', async () => {
|
|
const { data: action } =
|
|
await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: TAKEDOWN,
|
|
createdBy: 'did:example:moderator',
|
|
reason: 'Y',
|
|
subject: {
|
|
$type: 'com.atproto.admin.defs#repoRef',
|
|
did: sc.dids.bob,
|
|
},
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: moderatorAuth() },
|
|
},
|
|
)
|
|
// cleanup
|
|
await reverse(action.id)
|
|
})
|
|
|
|
it('does not allow non-full moderators to takedown.', async () => {
|
|
const attemptTakedownTriage =
|
|
agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: TAKEDOWN,
|
|
createdBy: 'did:example:moderator',
|
|
reason: 'Y',
|
|
subject: {
|
|
$type: 'com.atproto.admin.defs#repoRef',
|
|
did: sc.dids.bob,
|
|
},
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: triageAuth() },
|
|
},
|
|
)
|
|
await expect(attemptTakedownTriage).rejects.toThrow(
|
|
'Must be a full moderator to perform an account takedown',
|
|
)
|
|
})
|
|
|
|
async function actionWithLabels(
|
|
opts: Partial<ComAtprotoAdminTakeModerationAction.InputSchema> & {
|
|
subject: ComAtprotoAdminTakeModerationAction.InputSchema['subject']
|
|
},
|
|
) {
|
|
const result = await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: FLAG,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
...opts,
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
return result.data
|
|
}
|
|
|
|
async function reverse(actionId: number) {
|
|
await agent.api.com.atproto.admin.reverseModerationAction(
|
|
{
|
|
id: actionId,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
}
|
|
|
|
async function getRecordLabels(uri: string) {
|
|
const result = await agent.api.com.atproto.admin.getRecord(
|
|
{ uri },
|
|
{ headers: { authorization: adminAuth() } },
|
|
)
|
|
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: { authorization: adminAuth() } },
|
|
)
|
|
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
|
|
let actionId: number
|
|
beforeAll(async () => {
|
|
post = sc.posts[sc.dids.carol][0]
|
|
blob = post.images[1]
|
|
imageUri = server.ctx.imgUriBuilder
|
|
.getCommonSignedUri('feed_thumbnail', blob.image.ref.toString())
|
|
.replace(server.ctx.cfg.publicUrl, server.url)
|
|
// Warm image server cache
|
|
await fetch(imageUri)
|
|
const cached = await fetch(imageUri)
|
|
expect(cached.headers.get('x-cache')).toEqual('hit')
|
|
const takeAction = await agent.api.com.atproto.admin.takeModerationAction(
|
|
{
|
|
action: TAKEDOWN,
|
|
subject: {
|
|
$type: 'com.atproto.repo.strongRef',
|
|
uri: post.ref.uriStr,
|
|
cid: post.ref.cidStr,
|
|
},
|
|
subjectBlobCids: [blob.image.ref.toString()],
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
actionId = takeAction.data.id
|
|
})
|
|
|
|
it('removes blob from the store', async () => {
|
|
const tryGetBytes = server.ctx.blobstore.getBytes(blob.image.ref)
|
|
await expect(tryGetBytes).rejects.toThrow(BlobNotFoundError)
|
|
})
|
|
|
|
it('prevents blob from being referenced again.', async () => {
|
|
const uploaded = await sc.uploadFile(
|
|
sc.dids.alice,
|
|
'tests/image/fixtures/key-alt.jpg',
|
|
'image/jpeg',
|
|
)
|
|
expect(uploaded.image.ref.equals(blob.image.ref)).toBeTruthy()
|
|
const referenceBlob = sc.post(sc.dids.alice, 'pic', [], [blob])
|
|
await expect(referenceBlob).rejects.toThrow('Could not find blob:')
|
|
})
|
|
|
|
it('prevents image blob from being served, even when cached.', async () => {
|
|
const fetchImage = await fetch(imageUri)
|
|
expect(fetchImage.status).toEqual(404)
|
|
expect(await fetchImage.json()).toEqual({ message: 'Image not found' })
|
|
})
|
|
|
|
it('restores blob when action is reversed.', async () => {
|
|
await agent.api.com.atproto.admin.reverseModerationAction(
|
|
{
|
|
id: actionId,
|
|
createdBy: 'did:example:admin',
|
|
reason: 'Y',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: adminAuth() },
|
|
},
|
|
)
|
|
|
|
// Can post and reference blob
|
|
const post = await sc.post(sc.dids.alice, 'pic', [], [blob])
|
|
expect(post.images[0].image.ref.equals(blob.image.ref)).toBeTruthy()
|
|
|
|
// 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)
|
|
})
|
|
})
|
|
})
|