atproto/packages/pds/tests/recovery.test.ts
Daniel Holmgren bdbd3c3e3f
Sequencer recovery (#2519)
* wip

* flesh out recoverer

* basic tests + handle uncreated repos

* add key recovery when keys are lost

* schemas

* tidy recoverer

* tidy & comment test

* work into script framework

* use account evt instead of tombstone

* add flag for updating keys

* add log

* rotate keys script

* pr feedback

* build branch

* use exact commit data from sequencer to restore

* fixing up key rotation script

* add onIdle

* build branch

* track blobs

* fix types

* fix blob tracking

* patches

* publish identity script

* fix build err

* wip

* build err

* wip

* recovery db + fix some errors

* refactor & add repair script

* dont run script

* fix test

* tidy scripts

* tidy

* change publish identity recoveyr method to rotate keys

* tidy

* pr feedback

* simple readme

* changesets
2025-03-31 17:02:11 -05:00

179 lines
5.9 KiB
TypeScript

import fs from 'node:fs/promises'
import * as ui8 from 'uint8arrays'
import AtpAgent from '@atproto/api'
import { renameIfExists, rmIfExists } from '@atproto/common'
import { SeedClient, TestNetworkNoAppView, basicSeed } from '@atproto/dev-env'
import { verifyRepoCar } from '@atproto/repo'
import { AppContext, scripts } from '../dist'
describe('recovery', () => {
let network: TestNetworkNoAppView
let ctx: AppContext
let sc: SeedClient
let agent: AtpAgent
let alice: string
let bob: string
let elli: string
beforeAll(async () => {
network = await TestNetworkNoAppView.create({
dbPostgresSchema: 'recovery',
})
ctx = network.pds.ctx
sc = network.getSeedClient()
agent = network.pds.getClient()
await basicSeed(sc)
alice = sc.dids.alice
bob = sc.dids.bob
await network.processAll()
})
afterAll(async () => {
await network.close()
})
const getStats = (did: string) => {
return ctx.actorStore.read(did, async (store) => {
const recordCount = await store.record.recordCount()
const root = await store.repo.storage.getRootDetailed()
return {
recordCount,
rev: root.rev,
commit: root.cid,
}
})
}
const getRev = (did: string) => {
return ctx.actorStore.read(did, async (store) => {
const root = await store.repo.storage.getRootDetailed()
return root.rev
})
}
const getCar = async (did: string, since?: string) => {
const res = await agent.api.com.atproto.sync.getRepo({
did,
since,
})
return res.data
}
const backup = async (dids: string[]) => {
for (const did of dids) {
const { dbLocation, keyLocation } = await ctx.actorStore.getLocation(did)
await fs.copyFile(dbLocation, `${dbLocation}-backup`)
await fs.copyFile(keyLocation, `${keyLocation}-backup`)
}
}
const restore = async (dids: string[]) => {
for (const did of dids) {
const { dbLocation, keyLocation } = await ctx.actorStore.getLocation(did)
await rmIfExists(dbLocation)
await rmIfExists(keyLocation)
await renameIfExists(`${dbLocation}-backup`, dbLocation)
await renameIfExists(`${keyLocation}-backup`, keyLocation)
}
}
it('recovers repos based on the sequencer ', async () => {
// backup alice & bob
await backup([alice, bob])
// grab rev times from intermediate repo states
// process a bunch of record creates, updates, and delets for alice
const startRev = await getRev(alice)
let middleRev = ''
for (let i = 0; i < 100; i++) {
if (i === 0) {
middleRev = await getRev(alice)
}
const ref = await sc.post(alice, `post-${i}`)
if (i % 20 === 0) {
await sc.updateProfile(alice, { displayName: `name-${i}` })
}
if (i % 10 === 0) {
await sc.deletePost(alice, ref.ref.uri)
} else {
await sc.like(alice, ref.ref)
}
}
// delete bob's account
const deleteToken = await ctx.accountManager.createEmailToken(
bob,
'delete_account',
)
await agent.com.atproto.server.deleteAccount({
token: deleteToken,
did: bob,
password: sc.accounts[bob].password,
})
// create a new account (elli)
await sc.createAccount('elli', {
handle: 'elli.test',
password: 'elli-pass',
email: 'elli@test.com',
})
elli = sc.dids.elli
for (let i = 0; i < 10; i++) {
await sc.post(elli, `post-${i}`)
}
// get some stats & snapshots from before we do a recovery
const endRev = await getRev(alice)
const startCarBefore = await getCar(alice, startRev)
const middleCarBefore = await getCar(alice, middleRev)
const endCarBefore = await getCar(alice, endRev)
const aliceStatsBefore = await getStats(alice)
const elliCarBefore = await getCar(elli)
const elliStatsBefore = await getStats(elli)
// "restore" all 3 accounts to their backedup state, effectively rolling back the previous mutations
// deleting alice's mutations, restoring bob's account, and deleting elli's account
await restore([alice, bob, elli])
// run recovery operation
await scripts['sequencer-recovery'](network.pds.ctx, ['0', '10', 'true'])
// ensure alice's CAR is exactly the same as before the loss, including intermediate states based on tracked revs
const startCarAfter = await getCar(alice, startRev)
const middleCarAfter = await getCar(alice, middleRev)
const endCarAfter = await getCar(alice, endRev)
const aliceStatsAfter = await getStats(alice)
expect(ui8.equals(startCarAfter, startCarBefore)).toBe(true)
expect(ui8.equals(middleCarAfter, middleCarBefore)).toBe(true)
expect(ui8.equals(endCarAfter, endCarBefore)).toBe(true)
expect(aliceStatsAfter).toMatchObject(aliceStatsBefore)
// ensure bob's account is re-deleted
const attempt = getCar(bob)
await expect(attempt).rejects.toThrow(/Could not find repo for DID/)
const bobExists = await ctx.actorStore.exists(bob)
expect(bobExists).toBe(false)
// ensure elli's CAR is exactly the same as before the loss
// this involves creating a new signing key for her and updating her DID document
const elliCarAfter = await getCar(elli)
const elliStatsAfter = await getStats(elli)
expect(ui8.equals(elliCarAfter, elliCarBefore)).toBe(true)
expect(elliStatsAfter).toMatchObject(elliStatsBefore)
// it creates a new keypair for elli
const elliKey = await ctx.actorStore.keypair(elli)
expect(elliKey.did()).toBeDefined()
})
it('rotates keys for users', async () => {
await scripts['rotate-keys'](network.pds.ctx, [elli])
const elliKey = await ctx.actorStore.keypair(elli)
const plcData = await ctx.plcClient.getDocumentData(elli)
expect(plcData.verificationMethods['atproto']).toEqual(elliKey.did())
// it correctly resigned elli's repo
const elliCar = await getCar(elli)
await verifyRepoCar(elliCar, elli, elliKey.did())
})
})