atproto/packages/pds/tests/account-deletion.test.ts
Daniel Holmgren e32dc025e8
Mute lists ()
* possible schemas

* more lexicons

* codegen

* tables

* hook up processor

* getlist route

* add getListBlocks route

* migration

* didnt save something

* add reason + facets

* description facets

* get facets into db

* add to accnt deletion tests before i forget

* tests & bugfixes

* quickfix

* move defs to .graph

* add purpose

* remove unused actor defs

* add creator info to list view

* refactor to graph service

* lexicons

* impl

* writer methods

* bug in accnt service

* handle blobs

* tests

* fix typos

* tidy

* fix snap

* pr feedback

* missed drop index in migration

* remove listitem reason

* allow puts for lists

* make mute lists idempotent

* test mute list updates

* fix up

* fix err test

* update snap
2023-05-11 14:29:08 -05:00

415 lines
12 KiB
TypeScript

import { once, EventEmitter } from 'events'
import { Selectable } from 'kysely'
import Mail from 'nodemailer/lib/mailer'
import AtpAgent from '@atproto/api'
import { SeedClient } from './seeds/client'
import basicSeed from './seeds/basic'
import { Database } from '../src'
import * as util from './_util'
import { ServerMailer } from '../src/mailer'
import { BlobNotFoundError, BlobStore } from '@atproto/repo'
import { RepoRoot } from '../src/db/tables/repo-root'
import { UserAccount } from '../src/db/tables/user-account'
import { IpldBlock } from '../src/db/tables/ipld-block'
import { Post } from '../src/app-view/db/tables/post'
import { Like } from '../src/app-view/db/tables/like'
import { Repost } from '../src/app-view/db/tables/repost'
import { Follow } from '../src/app-view/db/tables/follow'
import { RepoBlob } from '../src/db/tables/repo-blob'
import { Blob } from '../src/db/tables/blob'
import {
PostEmbedImage,
PostEmbedExternal,
PostEmbedRecord,
} from '../src/app-view/db/tables/post-embed'
import { RepoCommitHistory } from '../src/db/tables/repo-commit-history'
import { RepoCommitBlock } from '../src/db/tables/repo-commit-block'
import { Record } from '../src/db/tables/record'
import { RepoSeq } from '../src/db/tables/repo-seq'
import { ACKNOWLEDGE } from '../src/lexicon/types/com/atproto/admin/defs'
import { UserState } from '../src/db/tables/user-state'
import { ActorBlock } from '../src/app-view/db/tables/actor-block'
import { List } from '../src/app-view/db/tables/list'
import { ListItem } from '../src/app-view/db/tables/list-item'
describe('account deletion', () => {
let server: util.TestServerInfo
let agent: AtpAgent
let close: util.CloseFn
let sc: SeedClient
let mailer: ServerMailer
let db: Database
let initialDbContents: DbContents
let updatedDbContents: DbContents
let blobstore: BlobStore
const mailCatcher = new EventEmitter()
let _origSendMail
// chose carol because she has blobs
let carol
beforeAll(async () => {
server = await util.runTestServer({
dbPostgresSchema: 'account_deletion',
})
close = server.close
mailer = server.ctx.mailer
db = server.ctx.db
blobstore = server.ctx.blobstore
agent = new AtpAgent({ service: server.url })
sc = new SeedClient(agent)
await basicSeed(sc)
carol = sc.accounts[sc.dids.carol]
// Catch emails for use in tests
_origSendMail = mailer.transporter.sendMail
mailer.transporter.sendMail = async (opts) => {
const result = await _origSendMail.call(mailer.transporter, opts)
mailCatcher.emit('mail', opts)
return result
}
initialDbContents = await getDbContents(db)
})
afterAll(async () => {
mailer.transporter.sendMail = _origSendMail
if (close) {
await close()
}
})
const getMailFrom = async (promise): Promise<Mail.Options> => {
const result = await Promise.all([once(mailCatcher, 'mail'), promise])
return result[0][0]
}
const getTokenFromMail = (mail: Mail.Options) =>
mail.html?.toString().match(/>([a-z0-9]{5}-[a-z0-9]{5})</i)?.[1]
let token
it('requests account deletion', async () => {
const mail = await getMailFrom(
agent.api.com.atproto.server.requestAccountDelete(undefined, {
headers: sc.getHeaders(carol.did),
}),
)
expect(mail.to).toEqual(carol.email)
expect(mail.html).toContain('Delete your Bluesky account')
token = getTokenFromMail(mail)
if (!token) {
return expect(token).toBeDefined()
}
})
it('fails account deletion with a bad token', async () => {
const attempt = agent.api.com.atproto.server.deleteAccount({
token: '123456',
did: carol.did,
password: carol.password,
})
await expect(attempt).rejects.toThrow('Token is invalid')
})
it('fails account deletion with a bad password', async () => {
const attempt = agent.api.com.atproto.server.deleteAccount({
token,
did: carol.did,
password: 'bad-pass',
})
await expect(attempt).rejects.toThrow('Invalid did or password')
})
it('deletes account with a valid token & password', async () => {
// Perform account deletion, including when there's an existing mod action on the account
await agent.api.com.atproto.admin.takeModerationAction(
{
action: ACKNOWLEDGE,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: carol.did,
},
createdBy: 'did:example:admin',
reason: 'X',
},
{
encoding: 'application/json',
headers: { authorization: util.adminAuth() },
},
)
await agent.api.com.atproto.server.deleteAccount({
token,
did: carol.did,
password: carol.password,
})
await server.ctx.backgroundQueue.processAll() // Finish background hard-deletions
})
it('no longer lets the user log in', async () => {
const attempt = agent.api.com.atproto.server.createSession({
identifier: carol.handle,
password: carol.password,
})
await expect(attempt).rejects.toThrow('Invalid identifier or password')
})
it('no longer store the user account or repo', async () => {
updatedDbContents = await getDbContents(db)
expect(updatedDbContents.roots).toEqual(
initialDbContents.roots.filter((row) => row.did !== carol.did),
)
expect(updatedDbContents.users).toEqual(
initialDbContents.users.filter((row) => row.did !== carol.did),
)
expect(updatedDbContents.userState).toEqual(
initialDbContents.userState.filter((row) => row.did !== carol.did),
)
expect(updatedDbContents.blocks).toEqual(
initialDbContents.blocks.filter((row) => row.creator !== carol.did),
)
expect(updatedDbContents.seqs).toEqual(
initialDbContents.seqs.filter((row) => row.did !== carol.did),
)
expect(updatedDbContents.commitBlocks).toEqual(
initialDbContents.commitBlocks.filter((row) => row.creator !== carol.did),
)
expect(updatedDbContents.commitHistories).toEqual(
initialDbContents.commitHistories.filter(
(row) => row.creator !== carol.did,
),
)
})
it('no longer stores indexed records from the user', async () => {
expect(updatedDbContents.records).toEqual(
initialDbContents.records.filter((row) => row.did !== carol.did),
)
expect(updatedDbContents.posts).toEqual(
initialDbContents.posts.filter((row) => row.creator !== carol.did),
)
expect(updatedDbContents.likes).toEqual(
initialDbContents.likes.filter((row) => row.creator !== carol.did),
)
expect(updatedDbContents.actorBlocks).toEqual(
initialDbContents.actorBlocks.filter((row) => row.creator !== carol.did),
)
expect(updatedDbContents.lists).toEqual(
initialDbContents.lists.filter((row) => row.creator !== carol.did),
)
expect(updatedDbContents.listItems).toEqual(
initialDbContents.listItems.filter((row) => row.creator !== carol.did),
)
expect(updatedDbContents.reposts).toEqual(
initialDbContents.reposts.filter((row) => row.creator !== carol.did),
)
expect(updatedDbContents.follows).toEqual(
initialDbContents.follows.filter((row) => row.creator !== carol.did),
)
expect(updatedDbContents.postImages).toEqual(
initialDbContents.postImages.filter(
(row) => !row.postUri.includes(carol.did),
),
)
expect(updatedDbContents.postExternals).toEqual(
initialDbContents.postExternals.filter(
(row) => !row.postUri.includes(carol.did),
),
)
})
it('deletes relevant blobs', async () => {
const imgs = sc.posts[carol.did][0].images
// carols first blob is used by other accounts
const first = imgs[0].image.ref
// carols second blob is used by only her
const second = imgs[1].image.ref
const got = await blobstore.getBytes(first)
expect(got).toBeDefined()
const attempt = blobstore.getBytes(second)
await expect(attempt).rejects.toThrow(BlobNotFoundError)
expect(updatedDbContents.repoBlobs).toEqual(
initialDbContents.repoBlobs.filter((row) => row.did !== carol.did),
)
expect(updatedDbContents.blobs).toEqual(
initialDbContents.blobs.filter((row) => row.creator !== carol.did),
)
})
it('no longer displays the users posts in feeds', async () => {
const feed = await agent.api.app.bsky.feed.getTimeline(undefined, {
headers: sc.getHeaders(sc.dids.alice),
})
const found = feed.data.feed.filter(
(item) => item.post.author.did === carol.did,
)
expect(found.length).toBe(0)
})
it('removes notifications from the user', async () => {
const notifs = await agent.api.app.bsky.notification.listNotifications(
undefined,
{
headers: sc.getHeaders(sc.dids.alice),
},
)
const found = notifs.data.notifications.filter(
(item) => item.author.did === sc.dids.carol,
)
expect(found.length).toBe(0)
})
it('can delete an empty user', async () => {
const eve = await sc.createAccount('eve', {
handle: 'eve.test',
email: 'eve@test.com',
password: 'eve-test',
})
const mail = await getMailFrom(
agent.api.com.atproto.server.requestAccountDelete(undefined, {
headers: sc.getHeaders(eve.did),
}),
)
const token = getTokenFromMail(mail)
if (!token) {
return expect(token).toBeDefined()
}
await agent.api.com.atproto.server.deleteAccount({
token,
did: eve.did,
password: eve.password,
})
})
})
type DbContents = {
roots: RepoRoot[]
users: UserAccount[]
userState: UserState[]
blocks: IpldBlock[]
seqs: Selectable<RepoSeq>[]
commitHistories: RepoCommitHistory[]
commitBlocks: RepoCommitBlock[]
records: Record[]
posts: Post[]
postImages: PostEmbedImage[]
postExternals: PostEmbedExternal[]
postRecords: PostEmbedRecord[]
likes: Like[]
reposts: Repost[]
follows: Follow[]
actorBlocks: ActorBlock[]
lists: List[]
listItems: ListItem[]
repoBlobs: RepoBlob[]
blobs: Blob[]
}
const getDbContents = async (db: Database): Promise<DbContents> => {
const [
roots,
users,
userState,
blocks,
seqs,
commitHistories,
commitBlocks,
records,
posts,
postImages,
postExternals,
postRecords,
likes,
reposts,
follows,
actorBlocks,
lists,
listItems,
repoBlobs,
blobs,
] = await Promise.all([
db.db.selectFrom('repo_root').orderBy('did').selectAll().execute(),
db.db.selectFrom('user_account').orderBy('did').selectAll().execute(),
db.db.selectFrom('user_state').orderBy('did').selectAll().execute(),
db.db
.selectFrom('ipld_block')
.orderBy('creator')
.orderBy('cid')
.selectAll()
.execute(),
db.db.selectFrom('repo_seq').orderBy('seq').selectAll().execute(),
db.db
.selectFrom('repo_commit_history')
.orderBy('creator')
.orderBy('commit')
.selectAll()
.execute(),
db.db
.selectFrom('repo_commit_block')
.orderBy('creator')
.orderBy('commit')
.orderBy('block')
.selectAll()
.execute(),
db.db.selectFrom('record').orderBy('uri').selectAll().execute(),
db.db.selectFrom('post').orderBy('uri').selectAll().execute(),
db.db
.selectFrom('post_embed_image')
.orderBy('postUri')
.selectAll()
.execute(),
db.db
.selectFrom('post_embed_external')
.orderBy('postUri')
.selectAll()
.execute(),
db.db
.selectFrom('post_embed_record')
.orderBy('postUri')
.selectAll()
.execute(),
db.db.selectFrom('like').orderBy('uri').selectAll().execute(),
db.db.selectFrom('repost').orderBy('uri').selectAll().execute(),
db.db.selectFrom('follow').orderBy('uri').selectAll().execute(),
db.db.selectFrom('actor_block').orderBy('uri').selectAll().execute(),
db.db.selectFrom('list').orderBy('uri').selectAll().execute(),
db.db.selectFrom('list_item').orderBy('uri').selectAll().execute(),
db.db
.selectFrom('repo_blob')
.orderBy('did')
.orderBy('cid')
.selectAll()
.execute(),
db.db.selectFrom('blob').orderBy('cid').selectAll().execute(),
])
return {
roots,
users,
userState,
blocks,
seqs,
commitHistories,
commitBlocks,
records,
posts,
postImages,
postExternals,
postRecords,
likes,
reposts,
follows,
actorBlocks,
lists,
listItems,
repoBlobs,
blobs,
}
}