atproto/packages/pds/tests/crud.test.ts
rafael 638f5a8312
Fix avatar path resolution in dev-env (#3266)
* Fix avatar path resolution in dev-env

* changeset

* extract dev-env assets to dedicated folder

* add comment

* fix fmt
2024-12-19 11:25:41 -03:00

1587 lines
48 KiB
TypeScript

import fs from 'fs/promises'
import { AtUri } from '@atproto/syntax'
import { AtpAgent } from '@atproto/api'
import { BlobRef } from '@atproto/lexicon'
import { TestNetworkNoAppView } from '@atproto/dev-env'
import { cidForCbor, TID, ui8ToArrayBuffer } from '@atproto/common'
import { BlobNotFoundError } from '@atproto/repo'
import * as Post from '../src/lexicon/types/app/bsky/feed/post'
import { forSnapshot, paginateAll } from './_util'
import AppContext from '../src/context'
import { ids, lexicons } from '../src/lexicon/lexicons'
describe('crud operations', () => {
let network: TestNetworkNoAppView
let ctx: AppContext
let agent: AtpAgent
let aliceAgent: AtpAgent
let bobAgent: AtpAgent
beforeAll(async () => {
network = await TestNetworkNoAppView.create({
dbPostgresSchema: 'crud',
})
// @ts-expect-error Error due to circular dependency with the dev-env package
ctx = network.pds.ctx
agent = network.pds.getClient()
aliceAgent = network.pds.getClient()
bobAgent = network.pds.getClient()
await aliceAgent.createAccount({
email: 'alice@test.com',
handle: 'alice.test',
password: 'alice-pass',
})
await bobAgent.createAccount({
email: 'bob@test.com',
handle: 'bob.test',
password: 'bob-pass',
})
expect(bobAgent.accountDid).not.toBe(aliceAgent.accountDid)
})
afterAll(async () => {
await network.close()
})
it('registers users', async () => {
const res = await agent.api.com.atproto.server.createAccount({
handle: 'user1.test',
email: 'user1@test.com',
password: 'password',
})
expect(res.data.handle).toBe('user1.test')
expect(res.data.accessJwt).toBeDefined()
})
it('describes repo', async () => {
const description = await agent.api.com.atproto.repo.describeRepo({
repo: aliceAgent.accountDid,
})
expect(description.data.handle).toBe('alice.test')
expect(description.data.did).toBe(aliceAgent.accountDid)
const description2 = await agent.api.com.atproto.repo.describeRepo({
repo: bobAgent.accountDid,
})
expect(description2.data.handle).toBe('bob.test')
expect(description2.data.did).toBe(bobAgent.accountDid)
})
it('creates records', async () => {
const res = await aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.post',
record: {
$type: 'app.bsky.feed.post',
text: 'Hello, world!',
createdAt: new Date().toISOString(),
},
})
const uri = new AtUri(res.data.uri)
expect(res.data.uri).toBe(
`at://${aliceAgent.accountDid}/app.bsky.feed.post/${uri.rkey}`,
)
const res1 = await agent.api.com.atproto.repo.listRecords({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.post',
})
expect(res1.data.records.length).toBe(1)
expect(res1.data.records[0].uri).toBe(uri.toString())
expect((res1.data.records[0].value as Post.Record).text).toBe(
'Hello, world!',
)
const res2 = await agent.app.bsky.feed.post.list({
repo: aliceAgent.accountDid,
})
expect(res2.records.length).toBe(1)
expect(res2.records[0].uri).toBe(uri.toString())
expect(res2.records[0].value.text).toBe('Hello, world!')
const res3 = await agent.api.com.atproto.repo.getRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.post',
rkey: uri.rkey,
})
expect(res3.data.uri).toBe(uri.toString())
expect((res3.data.value as Post.Record).text).toBe('Hello, world!')
const res4 = await agent.app.bsky.feed.post.get({
repo: aliceAgent.accountDid,
rkey: uri.rkey,
})
expect(res4.uri).toBe(uri.toString())
expect(res4.value.text).toBe('Hello, world!')
await aliceAgent.api.com.atproto.repo.deleteRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.post',
rkey: uri.rkey,
})
const res5 = await agent.api.com.atproto.repo.listRecords({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.post',
})
expect(res5.data.records.length).toBe(0)
})
it('CRUDs records with the semantic sugars', async () => {
const res1 = await aliceAgent.app.bsky.feed.post.create(
{ repo: aliceAgent.accountDid },
{
$type: 'app.bsky.feed.post',
text: 'Hello, world!',
createdAt: new Date().toISOString(),
},
)
const uri = new AtUri(res1.uri)
const res2 = await agent.app.bsky.feed.post.list({
repo: aliceAgent.accountDid,
})
expect(res2.records.length).toBe(1)
await aliceAgent.app.bsky.feed.post.delete({
repo: aliceAgent.accountDid,
rkey: uri.rkey,
})
const res3 = await agent.app.bsky.feed.post.list({
repo: aliceAgent.accountDid,
})
expect(res3.records.length).toBe(0)
})
it('attaches images to a post', async () => {
const file = await fs.readFile('../dev-env/assets/key-landscape-small.jpg')
const uploadedRes = await aliceAgent.api.com.atproto.repo.uploadBlob(file, {
encoding: 'image/jpeg',
})
const uploaded = uploadedRes.data.blob
// Expect blobstore not to have image yet
await expect(
ctx.blobstore(aliceAgent.accountDid).getBytes(uploaded.ref),
).rejects.toThrow(BlobNotFoundError)
// Associate image with post, image should be placed in blobstore
const res = await aliceAgent.app.bsky.feed.post.create(
{ repo: aliceAgent.accountDid },
{
$type: 'app.bsky.feed.post',
text: "Here's a key!",
createdAt: new Date().toISOString(),
embed: {
$type: 'app.bsky.embed.images',
images: [{ image: uploaded, alt: '' }],
},
},
)
// Ensure image is on post record
const postUri = new AtUri(res.uri)
const post = await aliceAgent.app.bsky.feed.post.get({
rkey: postUri.rkey,
repo: aliceAgent.accountDid,
})
const images = post.value.embed?.images as { image: BlobRef }[]
expect(images.length).toEqual(1)
expect(uploaded.ref.equals(images[0].image.ref)).toBeTruthy()
// Ensure that the uploaded image is now in the blobstore, i.e. doesn't throw BlobNotFoundError
await ctx.blobstore(aliceAgent.accountDid).getBytes(uploaded.ref)
// Cleanup
await aliceAgent.app.bsky.feed.post.delete({
rkey: postUri.rkey,
repo: aliceAgent.accountDid,
})
})
it('creates records with the correct key described by the schema', async () => {
const res1 = await aliceAgent.app.bsky.actor.profile.create(
{ repo: aliceAgent.accountDid },
{
displayName: 'alice',
createdAt: new Date().toISOString(),
},
)
const uri = new AtUri(res1.uri)
expect(uri.rkey).toBe('self')
})
describe('paginates', () => {
let uri1: AtUri
let uri2: AtUri
let uri3: AtUri
let uri4: AtUri
let uri5: AtUri
beforeAll(async () => {
const createPost = async (text: string) => {
const res = await aliceAgent.app.bsky.feed.post.create(
{ repo: aliceAgent.accountDid },
{
$type: 'app.bsky.feed.post',
text,
createdAt: new Date().toISOString(),
},
)
return new AtUri(res.uri)
}
uri1 = await createPost('Post 1')
uri2 = await createPost('Post 2')
uri3 = await createPost('Post 3')
uri4 = await createPost('Post 4')
uri5 = await createPost('Post 5')
})
afterAll(async () => {
for (const uri of [uri1, uri2, uri3, uri4, uri5]) {
await aliceAgent.app.bsky.feed.post.delete({
repo: aliceAgent.accountDid,
rkey: uri.rkey,
})
}
})
it('in forwards order', async () => {
const results = (results) => results.flatMap((res) => res.records)
const paginator = async (cursor?: string) => {
const res = await agent.app.bsky.feed.post.list({
repo: aliceAgent.accountDid,
cursor,
limit: 2,
})
return res
}
const paginatedAll = await paginateAll(paginator)
paginatedAll.forEach((res) =>
expect(res.records.length).toBeLessThanOrEqual(2),
)
const full = await agent.app.bsky.feed.post.list({
repo: aliceAgent.accountDid,
})
expect(full.records.length).toEqual(5)
expect(results(paginatedAll)).toEqual(results([full]))
})
it('in reverse order', async () => {
const results = (results) => results.flatMap((res) => res.records)
const paginator = async (cursor?: string) => {
const res = await agent.app.bsky.feed.post.list({
repo: aliceAgent.accountDid,
reverse: true,
cursor,
limit: 2,
})
return res
}
const paginatedAll = await paginateAll(paginator)
paginatedAll.forEach((res) =>
expect(res.records.length).toBeLessThanOrEqual(2),
)
const full = await agent.app.bsky.feed.post.list({
repo: aliceAgent.accountDid,
reverse: true,
})
expect(full.records.length).toEqual(5)
expect(results(paginatedAll)).toEqual(results([full]))
})
it('reverses', async () => {
const forwards = await agent.app.bsky.feed.post.list({
repo: aliceAgent.accountDid,
})
const reverse = await agent.app.bsky.feed.post.list({
repo: aliceAgent.accountDid,
reverse: true,
})
expect(forwards.cursor).toEqual(uri1.rkey)
expect(reverse.cursor).toEqual(uri5.rkey)
expect(forwards.records.length).toEqual(5)
expect(reverse.records.length).toEqual(5)
expect(forwards.records.reverse()).toEqual(reverse.records)
})
})
describe('deleteRecord', () => {
it('deletes a record if it exists', async () => {
const { repo } = aliceAgent.api.com.atproto
const { data: post } = await repo.createRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyFeedPost,
record: { text: 'post', createdAt: new Date().toISOString() },
})
const uri = new AtUri(post.uri)
await repo.deleteRecord({
repo: uri.host,
collection: uri.collection,
rkey: uri.rkey,
})
const checkPost = repo.getRecord({
repo: uri.host,
collection: uri.collection,
rkey: uri.rkey,
})
await expect(checkPost).rejects.toThrow('Could not locate record')
})
it("no-ops if record doesn't exist", async () => {
const { repo } = aliceAgent.api.com.atproto
const { data: post } = await repo.createRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyFeedPost,
record: { text: 'post', createdAt: new Date().toISOString() },
})
const uri = new AtUri(post.uri)
await repo.deleteRecord({
repo: uri.host,
collection: uri.collection,
rkey: uri.rkey,
})
const checkPost = repo.getRecord({
repo: uri.host,
collection: uri.collection,
rkey: uri.rkey,
})
await expect(checkPost).rejects.toThrow('Could not locate record')
const attemptDelete = repo.deleteRecord({
repo: uri.host,
collection: uri.collection,
rkey: uri.rkey,
})
await expect(attemptDelete).resolves.toBeDefined()
})
it('does not delete the underlying block if it is referenced elsewhere', async () => {
const { repo } = aliceAgent.api.com.atproto
const record = { text: 'post', createdAt: new Date().toISOString() }
const { data: post1 } = await repo.createRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyFeedPost,
record,
})
const { data: post2 } = await repo.createRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyFeedPost,
record,
})
const uri1 = new AtUri(post1.uri)
await repo.deleteRecord({
repo: uri1.host,
collection: uri1.collection,
rkey: uri1.rkey,
})
const uri2 = new AtUri(post2.uri)
const checkPost2 = await repo.getRecord({
repo: uri2.host,
collection: uri2.collection,
rkey: uri2.rkey,
})
expect(checkPost2).toBeDefined()
expect(checkPost2.data.value).toMatchObject(record)
})
})
describe('putRecord', () => {
const profilePath = {
collection: ids.AppBskyActorProfile,
rkey: 'self',
}
it("creates a new record if it doesn't already exist", async () => {
const { repo } = bobAgent.api.com.atproto
const exists = repo.getRecord({
...profilePath,
repo: bobAgent.accountDid,
})
await expect(exists).rejects.toThrow('Could not locate record')
const { data: put } = await repo.putRecord({
...profilePath,
repo: bobAgent.accountDid,
record: {
displayName: 'Robert',
},
})
expect(put.uri).toEqual(
`at://${bobAgent.accountDid}/${ids.AppBskyActorProfile}/self`,
)
const { data: profile } = await repo.getRecord({
...profilePath,
repo: bobAgent.accountDid,
})
expect(profile.value).toEqual({
$type: ids.AppBskyActorProfile,
displayName: 'Robert',
})
})
it('updates a record if it already exists', async () => {
const { repo } = bobAgent.api.com.atproto
const { data: put } = await repo.putRecord({
...profilePath,
repo: bobAgent.accountDid,
record: {
displayName: 'Robert',
description: 'Dog lover',
},
})
expect(put.uri).toEqual(
`at://${bobAgent.accountDid}/${ids.AppBskyActorProfile}/self`,
)
const { data: profile } = await repo.getRecord({
...profilePath,
repo: bobAgent.accountDid,
})
expect(profile.value).toEqual({
$type: ids.AppBskyActorProfile,
displayName: 'Robert',
description: 'Dog lover',
})
})
it('does not produce commit on no-op update', async () => {
const { repo } = bobAgent.api.com.atproto
const rootRes1 = await bobAgent.api.com.atproto.sync.getLatestCommit({
did: bobAgent.accountDid,
})
const { data: put } = await repo.putRecord({
...profilePath,
repo: bobAgent.accountDid,
record: {
displayName: 'Robert',
description: 'Dog lover',
},
})
expect(put.uri).toEqual(
`at://${bobAgent.accountDid}/${ids.AppBskyActorProfile}/self`,
)
const rootRes2 = await bobAgent.api.com.atproto.sync.getLatestCommit({
did: bobAgent.accountDid,
})
expect(rootRes2.data.cid).toEqual(rootRes1.data.cid)
expect(rootRes2.data.rev).toEqual(rootRes1.data.rev)
})
it('fails on user mismatch', async () => {
const { repo } = aliceAgent.api.com.atproto
const put = repo.putRecord({
repo: bobAgent.accountDid,
collection: ids.AppBskyGraphFollow,
rkey: TID.nextStr(),
record: {
subject: aliceAgent.accountDid,
createdAt: new Date().toISOString(),
},
})
await expect(put).rejects.toThrow('Authentication Required')
})
it('fails on invalid record', async () => {
const { repo } = bobAgent.api.com.atproto
const put = repo.putRecord({
...profilePath,
repo: bobAgent.accountDid,
record: {
displayName: 'Robert',
description: 3.141,
},
})
await expect(put).rejects.toThrow(
'Invalid app.bsky.actor.profile record: Record/description must be a string',
)
const { data: profile } = await repo.getRecord({
...profilePath,
repo: bobAgent.accountDid,
})
expect(profile.value).toEqual({
$type: ids.AppBskyActorProfile,
displayName: 'Robert',
description: 'Dog lover',
})
})
// @TODO remove after migrating legacy blobs
it('updates a legacy blob ref when updating profile', async () => {
const { repo } = bobAgent.api.com.atproto
const file = await fs.readFile('../dev-env/assets/key-portrait-small.jpg')
const uploadedRes = await repo.uploadBlob(file, {
encoding: 'image/jpeg',
})
await repo.putRecord({
...profilePath,
repo: bobAgent.accountDid,
record: {
displayName: 'Robert',
avatar: BlobRef.fromJsonRef({
mimeType: uploadedRes.data.blob.mimeType,
cid: uploadedRes.data.blob.ref.toString(),
}),
},
})
const got = await repo.getRecord({
...profilePath,
repo: bobAgent.accountDid,
})
const gotAvatar = got.data.value['avatar'] as BlobRef
expect(gotAvatar.original).toEqual(uploadedRes.data.blob.original)
})
})
// Validation
// --------------
it('defaults an undefined $type on records', async () => {
const res = await aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.post',
record: {
text: 'blah',
createdAt: new Date().toISOString(),
},
})
const uri = new AtUri(res.data.uri)
const got = await agent.api.com.atproto.repo.getRecord({
repo: aliceAgent.accountDid,
collection: uri.collection,
rkey: uri.rkey,
})
expect(got.data.value['$type']).toBe(uri.collection)
})
it('requires the schema to be known if explicitly validating', async () => {
const prom = aliceAgent.api.com.atproto.repo.createRecord({
validate: true,
repo: aliceAgent.accountDid,
collection: 'com.example.foobar',
record: { $type: 'com.example.foobar' },
})
await expect(prom).rejects.toThrow(
'Lexicon not found: lex:com.example.foobar',
)
})
it('does not require the schema to be known if not explicitly validating', async () => {
const prom = await aliceAgent.api.com.atproto.repo.createRecord({
// validate not set
repo: aliceAgent.accountDid,
collection: 'com.example.foobar',
record: { $type: 'com.example.foobar' },
})
expect(prom.data.validationStatus).toBe('unknown')
})
it('requires the $type to match the schema', async () => {
await expect(
aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.post',
record: { $type: 'app.bsky.feed.like' },
}),
).rejects.toThrow(
'Invalid $type: expected app.bsky.feed.post, got app.bsky.feed.like',
)
})
it('requires valid rkey', async () => {
await expect(
aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.generator',
record: {
$type: 'app.bsky.feed.generator',
did: 'did:web:dummy.example.com',
displayName: 'dummy',
createdAt: new Date().toISOString(),
},
rkey: '..',
}),
).rejects.toThrow('record key can not be "." or ".."')
})
it('validates the record on write', async () => {
await expect(
aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.post',
record: { $type: 'app.bsky.feed.post' },
}),
).rejects.toThrow(
'Invalid app.bsky.feed.post record: Record must have the property "text"',
)
})
it('validates datetimes more rigorously than lex sdk', async () => {
const postRecord = {
$type: 'app.bsky.feed.post',
text: 'test',
createdAt: '1985-04-12T23:20:50.123',
}
lexicons.assertValidRecord('app.bsky.feed.post', postRecord)
await expect(
aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.post',
record: postRecord,
}),
).rejects.toThrow(
'Invalid app.bsky.feed.post record: createdAt must be an valid atproto datetime (both RFC-3339 and ISO-8601)',
)
})
describe('unvalidated writes', () => {
it('disallows creation of unknown lexicons when validate is set to true', async () => {
const attempt = aliceAgent.api.com.atproto.repo.createRecord({
validate: true,
repo: aliceAgent.accountDid,
collection: 'com.example.record',
record: {
blah: 'thing',
},
})
await expect(attempt).rejects.toThrow(
'Lexicon not found: lex:com.example.record',
)
})
it('allows creation of unknown lexicons when validate is not set to true', async () => {
// validate: default
const res1 = await aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'com.example.record',
record: {
blah: 'thing1',
},
})
expect(res1.data.validationStatus).toBe('unknown')
const record1 = await ctx.actorStore.read(
aliceAgent.accountDid,
(store) =>
store.record.getRecord(new AtUri(res1.data.uri), res1.data.cid),
)
expect(record1?.value).toEqual({
$type: 'com.example.record',
blah: 'thing1',
})
// validate: false
const res2 = await aliceAgent.api.com.atproto.repo.createRecord({
validate: false,
repo: aliceAgent.accountDid,
collection: 'com.example.record',
record: {
blah: 'thing2',
},
})
expect(res2.data.validationStatus).toBeUndefined()
const record2 = await ctx.actorStore.read(
aliceAgent.accountDid,
(store) =>
store.record.getRecord(new AtUri(res2.data.uri), res2.data.cid),
)
expect(record2?.value).toEqual({
$type: 'com.example.record',
blah: 'thing2',
})
})
it('allows update of unknown lexicons when validate is set to false', async () => {
const createRes = await aliceAgent.api.com.atproto.repo.createRecord({
validate: false,
repo: aliceAgent.accountDid,
collection: 'com.example.record',
record: {
blah: 'thing',
},
})
const uri = new AtUri(createRes.data.uri)
// validate: default
const updateRes1 = await aliceAgent.api.com.atproto.repo.putRecord({
repo: aliceAgent.accountDid,
collection: 'com.example.record',
rkey: uri.rkey,
record: {
blah: 'something else',
},
})
const record1 = await ctx.actorStore.read(
aliceAgent.accountDid,
(store) => store.record.getRecord(uri, updateRes1.data.cid),
)
expect(record1?.value).toEqual({
$type: 'com.example.record',
blah: 'something else',
})
// validate: false
const updateRes2 = await aliceAgent.api.com.atproto.repo.putRecord({
validate: false,
repo: aliceAgent.accountDid,
collection: 'com.example.record',
rkey: uri.rkey,
record: {
blah: 'something else',
},
})
const record2 = await ctx.actorStore.read(
aliceAgent.accountDid,
(store) => store.record.getRecord(uri, updateRes2.data.cid),
)
expect(record2?.value).toEqual({
$type: 'com.example.record',
blah: 'something else',
})
})
it('applyWrites returns results with validation status', async () => {
const existing1 = await aliceAgent.api.com.atproto.repo.createRecord({
validate: false,
repo: aliceAgent.accountDid,
collection: 'com.example.record',
record: {
blah: 'thing1',
},
})
const existing2 = await aliceAgent.api.com.atproto.repo.createRecord({
validate: false,
repo: aliceAgent.accountDid,
collection: 'com.example.record',
record: {
blah: 'thing2',
},
})
const {
data: { results },
} = await aliceAgent.com.atproto.repo.applyWrites({
repo: aliceAgent.accountDid,
writes: [
{
$type: `${ids.ComAtprotoRepoApplyWrites}#create`,
action: 'create',
collection: ids.AppBskyFeedPost,
value: {
$type: ids.AppBskyFeedPost,
text: '👋',
createdAt: new Date().toISOString(),
},
},
{
$type: `${ids.ComAtprotoRepoApplyWrites}#update`,
action: 'update',
collection: 'com.example.record',
rkey: new AtUri(existing1.data.uri).rkey,
value: {},
},
{
$type: `${ids.ComAtprotoRepoApplyWrites}#delete`,
action: 'delete',
collection: 'com.example.record',
rkey: new AtUri(existing2.data.uri).rkey,
},
],
})
expect(forSnapshot(results)).toEqual([
{
$type: `${ids.ComAtprotoRepoApplyWrites}#createResult`,
cid: 'cids(0)',
uri: 'record(0)',
validationStatus: 'valid',
},
{
$type: `${ids.ComAtprotoRepoApplyWrites}#updateResult`,
cid: 'cids(1)',
uri: 'record(1)',
validationStatus: 'unknown',
},
{ $type: `${ids.ComAtprotoRepoApplyWrites}#deleteResult` },
])
})
it('correctly associates images with unknown record types', async () => {
const file = await fs.readFile('../dev-env/assets/key-portrait-small.jpg')
const uploadedRes = await aliceAgent.api.com.atproto.repo.uploadBlob(
file,
{
encoding: 'image/jpeg',
},
)
const res = await aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'com.example.record',
record: {
blah: 'thing',
image: uploadedRes.data.blob,
},
validate: false,
})
const record = await ctx.actorStore.read(aliceAgent.accountDid, (store) =>
store.record.getRecord(new AtUri(res.data.uri), res.data.cid),
)
expect(record?.value).toMatchObject({
$type: 'com.example.record',
blah: 'thing',
})
const recordBlobs = await ctx.actorStore.read(
aliceAgent.accountDid,
(store) =>
store.db.db
.selectFrom('blob')
.innerJoin('record_blob', 'record_blob.blobCid', 'blob.cid')
.where('recordUri', '=', res.data.uri)
.selectAll()
.execute(),
)
expect(recordBlobs.length).toBe(1)
expect(recordBlobs.at(0)?.cid).toBe(uploadedRes.data.blob.ref.toString())
})
it('enforces record type constraint even when unvalidated', async () => {
const attempt = aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'com.example.record',
record: {
$type: 'com.example.other',
blah: 'thing',
},
})
await expect(attempt).rejects.toThrow(
'Invalid $type: expected com.example.record, got com.example.other',
)
})
it('enforces blob ref format even when unvalidated', async () => {
const file = await fs.readFile('../dev-env/assets/key-portrait-small.jpg')
const uploadedRes = await aliceAgent.api.com.atproto.repo.uploadBlob(
file,
{
encoding: 'image/jpeg',
},
)
const attempt = aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'com.example.record',
record: {
blah: 'thing',
image: {
cid: uploadedRes.data.blob.ref.toString(),
mimeType: uploadedRes.data.blob.mimeType,
},
},
validate: false,
})
await expect(attempt).rejects.toThrow(`Legacy blob ref at 'image'`)
})
})
describe('compare-and-swap', () => {
let recordCount = 0 // Ensures unique cids
const postRecord = () => ({
text: `post (${++recordCount})`,
createdAt: new Date().toISOString(),
})
const profileRecord = () => ({
displayName: `ali (${++recordCount})`,
})
it('createRecord succeeds on proper commit cas', async () => {
const { repo, sync } = aliceAgent.api.com.atproto
const { data: commit } = await sync.getLatestCommit({
did: aliceAgent.accountDid,
})
const { data: post } = await repo.createRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyFeedPost,
swapCommit: commit.cid,
record: postRecord(),
})
const uri = new AtUri(post.uri)
const checkPost = repo.getRecord({
repo: uri.host,
collection: uri.collection,
rkey: uri.rkey,
})
await expect(checkPost).resolves.toBeDefined()
})
it('createRecord fails on bad commit cas', async () => {
const { repo, sync } = aliceAgent.api.com.atproto
const { data: staleCommit } = await sync.getLatestCommit({
did: aliceAgent.accountDid,
})
// Update repo, change head
await repo.createRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyFeedPost,
record: postRecord(),
})
const attemptCreate = repo.createRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyFeedPost,
swapCommit: staleCommit.cid,
record: postRecord(),
})
await expect(attemptCreate).rejects.toMatchObject({
error: 'InvalidSwap',
})
})
it('deleteRecord succeeds on proper commit cas', async () => {
const { repo, sync } = aliceAgent.api.com.atproto
const { data: post } = await repo.createRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyFeedPost,
record: postRecord(),
})
const { data: commit } = await sync.getLatestCommit({
did: aliceAgent.accountDid,
})
const uri = new AtUri(post.uri)
await repo.deleteRecord({
repo: uri.host,
collection: uri.collection,
rkey: uri.rkey,
swapCommit: commit.cid,
})
const checkPost = repo.getRecord({
repo: uri.host,
collection: uri.collection,
rkey: uri.rkey,
})
await expect(checkPost).rejects.toThrow('Could not locate record')
})
it('deleteRecord fails on bad commit cas', async () => {
const { repo, sync } = aliceAgent.api.com.atproto
const { data: staleCommit } = await sync.getLatestCommit({
did: aliceAgent.accountDid,
})
const { data: post } = await repo.createRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyFeedPost,
record: postRecord(),
})
const uri = new AtUri(post.uri)
const attemptDelete = repo.deleteRecord({
repo: uri.host,
collection: uri.collection,
rkey: uri.rkey,
swapCommit: staleCommit.cid,
})
await expect(attemptDelete).rejects.toMatchObject({
error: 'InvalidSwap',
})
const checkPost = repo.getRecord({
repo: uri.host,
collection: uri.collection,
rkey: uri.rkey,
})
await expect(checkPost).resolves.toBeDefined()
})
it('deleteRecord succeeds on proper record cas', async () => {
const { repo } = aliceAgent.api.com.atproto
const { data: post } = await repo.createRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyFeedPost,
record: postRecord(),
})
const uri = new AtUri(post.uri)
await repo.deleteRecord({
repo: uri.host,
collection: uri.collection,
rkey: uri.rkey,
swapRecord: post.cid,
})
const checkPost = repo.getRecord({
repo: uri.host,
collection: uri.collection,
rkey: uri.rkey,
})
await expect(checkPost).rejects.toThrow('Could not locate record')
})
it('deleteRecord fails on bad record cas', async () => {
const { repo } = aliceAgent.api.com.atproto
const { data: post } = await repo.createRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyFeedPost,
record: postRecord(),
})
const uri = new AtUri(post.uri)
const attemptDelete = repo.deleteRecord({
repo: uri.host,
collection: uri.collection,
rkey: uri.rkey,
swapRecord: (await cidForCbor({})).toString(),
})
await expect(attemptDelete).rejects.toMatchObject({
error: 'InvalidSwap',
})
const checkPost = repo.getRecord({
repo: uri.host,
collection: uri.collection,
rkey: uri.rkey,
})
await expect(checkPost).resolves.toBeDefined()
})
it('putRecord succeeds on proper commit cas', async () => {
const { repo, sync } = aliceAgent.api.com.atproto
const { data: commit } = await sync.getLatestCommit({
did: aliceAgent.accountDid,
})
const { data: profile } = await repo.putRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyActorProfile,
rkey: 'self',
swapCommit: commit.cid,
record: profileRecord(),
})
const { data: checkProfile } = await repo.getRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyActorProfile,
rkey: 'self',
})
expect(checkProfile.cid).toEqual(profile.cid)
})
it('putRecord fails on bad commit cas', async () => {
const { repo, sync } = aliceAgent.api.com.atproto
const { data: staleCommit } = await sync.getLatestCommit({
did: aliceAgent.accountDid,
})
// Update repo, change head
await repo.createRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyFeedPost,
record: postRecord(),
})
const attemptPut = repo.putRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyActorProfile,
rkey: 'self',
swapCommit: staleCommit.cid,
record: profileRecord(),
})
await expect(attemptPut).rejects.toMatchObject({ error: 'InvalidSwap' })
})
it('putRecord succeeds on proper record cas', async () => {
const { repo } = aliceAgent.api.com.atproto
// Start with missing profile record, to test swapRecord=null
await repo.deleteRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyActorProfile,
rkey: 'self',
})
// Test swapRecord w/ null (ensures create)
const { data: profile1 } = await repo.putRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyActorProfile,
rkey: 'self',
swapRecord: null,
record: profileRecord(),
})
const { data: checkProfile1 } = await repo.getRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyActorProfile,
rkey: 'self',
})
expect(checkProfile1.cid).toEqual(profile1.cid)
// Test swapRecord w/ cid (ensures update)
const { data: profile2 } = await repo.putRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyActorProfile,
rkey: 'self',
swapRecord: profile1.cid,
record: profileRecord(),
})
const { data: checkProfile2 } = await repo.getRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyActorProfile,
rkey: 'self',
})
expect(checkProfile2.cid).toEqual(profile2.cid)
})
it('putRecord fails on bad record cas', async () => {
const { repo } = aliceAgent.api.com.atproto
// Test swapRecord w/ null (ensures create)
const attemptPut1 = repo.putRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyActorProfile,
rkey: 'self',
swapRecord: null,
record: profileRecord(),
})
await expect(attemptPut1).rejects.toMatchObject({ error: 'InvalidSwap' })
// Test swapRecord w/ cid (ensures update)
const attemptPut2 = repo.putRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyActorProfile,
rkey: 'self',
swapRecord: (await cidForCbor({})).toString(),
record: profileRecord(),
})
await expect(attemptPut2).rejects.toMatchObject({ error: 'InvalidSwap' })
})
it('applyWrites succeeds on proper commit cas', async () => {
const { repo, sync } = aliceAgent.api.com.atproto
const { data: commit } = await sync.getLatestCommit({
did: aliceAgent.accountDid,
})
await repo.applyWrites({
repo: aliceAgent.accountDid,
swapCommit: commit.cid,
writes: [
{
$type: `${ids.ComAtprotoRepoApplyWrites}#create`,
action: 'create',
collection: ids.AppBskyFeedPost,
value: { $type: ids.AppBskyFeedPost, ...postRecord() },
},
],
})
})
it('applyWrites fails on bad commit cas', async () => {
const { repo, sync } = aliceAgent.api.com.atproto
const { data: staleCommit } = await sync.getLatestCommit({
did: aliceAgent.accountDid,
})
// Update repo, change head
await repo.createRecord({
repo: aliceAgent.accountDid,
collection: ids.AppBskyFeedPost,
record: postRecord(),
})
const attemptApplyWrite = repo.applyWrites({
repo: aliceAgent.accountDid,
swapCommit: staleCommit.cid,
writes: [
{
$type: `${ids.ComAtprotoRepoApplyWrites}#create`,
action: 'create',
collection: ids.AppBskyFeedPost,
value: { $type: ids.AppBskyFeedPost, ...postRecord() },
},
],
})
await expect(attemptApplyWrite).rejects.toMatchObject({
error: 'InvalidSwap',
})
})
it("writes fail on values that can't reliably transform between cbor to lex", async () => {
const passthroughBody = (data: unknown) =>
ui8ToArrayBuffer(new TextEncoder().encode(JSON.stringify(data)))
const result = aliceAgent.call(
'com.atproto.repo.createRecord',
{},
passthroughBody({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.post',
record: {
text: 'x',
createdAt: new Date().toISOString(),
deepObject: createDeepObject(3000),
},
}),
{
encoding: 'application/json',
},
)
await expect(result).rejects.toMatchObject({
status: 400,
error: 'InvalidRequest',
message: 'Bad record',
})
})
})
it('prevents duplicate likes', async () => {
const now = new Date().toISOString()
const uriA = AtUri.make(
bobAgent.accountDid,
'app.bsky.feed.post',
TID.nextStr(),
)
const cidA = await cidForCbor({ post: 'a' })
const uriB = AtUri.make(
bobAgent.accountDid,
'app.bsky.feed.post',
TID.nextStr(),
)
const cidB = await cidForCbor({ post: 'b' })
const { data: like1 } = await aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.like',
record: {
$type: 'app.bsky.feed.like',
subject: { uri: uriA.toString(), cid: cidA.toString() },
createdAt: now,
},
})
const { data: like2 } = await aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.like',
record: {
$type: 'app.bsky.feed.like',
subject: { uri: uriB.toString(), cid: cidB.toString() },
createdAt: now,
},
})
const { data: like3 } = await aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.like',
record: {
$type: 'app.bsky.feed.like',
subject: { uri: uriA.toString(), cid: cidA.toString() },
createdAt: now,
},
})
const getLike1 = aliceAgent.api.com.atproto.repo.getRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.like',
rkey: new AtUri(like1.uri).rkey,
})
await expect(getLike1).rejects.toThrow('Could not locate record:')
const getLike2 = aliceAgent.api.com.atproto.repo.getRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.like',
rkey: new AtUri(like2.uri).rkey,
})
await expect(getLike2).resolves.toBeDefined()
const getLike3 = aliceAgent.api.com.atproto.repo.getRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.like',
rkey: new AtUri(like3.uri).rkey,
})
await expect(getLike3).resolves.toBeDefined()
})
it('prevents duplicate reposts', async () => {
const now = new Date().toISOString()
const uriA = AtUri.make(
bobAgent.accountDid,
'app.bsky.feed.post',
TID.nextStr(),
)
const cidA = await cidForCbor({ post: 'a' })
const uriB = AtUri.make(
bobAgent.accountDid,
'app.bsky.feed.post',
TID.nextStr(),
)
const cidB = await cidForCbor({ post: 'b' })
const { data: repost1 } =
await aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.repost',
record: {
$type: 'app.bsky.feed.repost',
subject: { uri: uriA.toString(), cid: cidA.toString() },
createdAt: now,
},
})
const { data: repost2 } =
await aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.repost',
record: {
$type: 'app.bsky.feed.repost',
subject: { uri: uriB.toString(), cid: cidB.toString() },
createdAt: now,
},
})
const { data: repost3 } =
await aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.repost',
record: {
$type: 'app.bsky.feed.repost',
subject: { uri: uriA.toString(), cid: cidA.toString() },
createdAt: now,
},
})
const getRepost1 = aliceAgent.api.com.atproto.repo.getRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.repost',
rkey: new AtUri(repost1.uri).rkey,
})
await expect(getRepost1).rejects.toThrow('Could not locate record:')
const getRepost2 = aliceAgent.api.com.atproto.repo.getRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.repost',
rkey: new AtUri(repost2.uri).rkey,
})
await expect(getRepost2).resolves.toBeDefined()
const getRepost3 = aliceAgent.api.com.atproto.repo.getRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.feed.repost',
rkey: new AtUri(repost3.uri).rkey,
})
await expect(getRepost3).resolves.toBeDefined()
})
it('prevents duplicate blocks', async () => {
const now = new Date().toISOString()
const { data: block1 } = await aliceAgent.api.com.atproto.repo.createRecord(
{
repo: aliceAgent.accountDid,
collection: 'app.bsky.graph.block',
record: {
$type: 'app.bsky.graph.block',
subject: bobAgent.accountDid,
createdAt: now,
},
},
)
const { data: block2 } = await bobAgent.api.com.atproto.repo.createRecord({
repo: bobAgent.accountDid,
collection: 'app.bsky.graph.block',
record: {
$type: 'app.bsky.graph.block',
subject: aliceAgent.accountDid,
createdAt: now,
},
})
const { data: block3 } = await aliceAgent.api.com.atproto.repo.createRecord(
{
repo: aliceAgent.accountDid,
collection: 'app.bsky.graph.block',
record: {
$type: 'app.bsky.graph.block',
subject: bobAgent.accountDid,
createdAt: now,
},
},
)
const getBlock1 = aliceAgent.api.com.atproto.repo.getRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.graph.block',
rkey: new AtUri(block1.uri).rkey,
})
await expect(getBlock1).rejects.toThrow('Could not locate record:')
const getBlock2 = aliceAgent.api.com.atproto.repo.getRecord({
repo: bobAgent.accountDid,
collection: 'app.bsky.graph.block',
rkey: new AtUri(block2.uri).rkey,
})
await expect(getBlock2).resolves.toBeDefined()
const getBlock3 = aliceAgent.api.com.atproto.repo.getRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.graph.block',
rkey: new AtUri(block3.uri).rkey,
})
await expect(getBlock3).resolves.toBeDefined()
})
it('prevents duplicate follows', async () => {
const now = new Date().toISOString()
const { data: follow1 } =
await aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.graph.follow',
record: {
$type: 'app.bsky.graph.follow',
subject: bobAgent.accountDid,
createdAt: now,
},
})
const { data: follow2 } = await bobAgent.api.com.atproto.repo.createRecord({
repo: bobAgent.accountDid,
collection: 'app.bsky.graph.follow',
record: {
$type: 'app.bsky.graph.follow',
subject: aliceAgent.accountDid,
createdAt: now,
},
})
const { data: follow3 } =
await aliceAgent.api.com.atproto.repo.createRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.graph.follow',
record: {
$type: 'app.bsky.graph.follow',
subject: bobAgent.accountDid,
createdAt: now,
},
})
const getFollow1 = aliceAgent.api.com.atproto.repo.getRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.graph.follow',
rkey: new AtUri(follow1.uri).rkey,
})
await expect(getFollow1).rejects.toThrow('Could not locate record:')
const getFollow2 = aliceAgent.api.com.atproto.repo.getRecord({
repo: bobAgent.accountDid,
collection: 'app.bsky.graph.follow',
rkey: new AtUri(follow2.uri).rkey,
})
await expect(getFollow2).resolves.toBeDefined()
const getFollow3 = aliceAgent.api.com.atproto.repo.getRecord({
repo: aliceAgent.accountDid,
collection: 'app.bsky.graph.follow',
rkey: new AtUri(follow3.uri).rkey,
})
await expect(getFollow3).resolves.toBeDefined()
})
// Moderation
// --------------
it("doesn't serve taken-down record", async () => {
const created = await aliceAgent.app.bsky.feed.post.create(
{ repo: aliceAgent.accountDid },
{
$type: 'app.bsky.feed.post',
text: 'Hello, world!',
createdAt: new Date().toISOString(),
},
)
const postUri = new AtUri(created.uri)
const post = await agent.app.bsky.feed.post.get({
repo: aliceAgent.accountDid,
rkey: postUri.rkey,
})
const posts = await agent.app.bsky.feed.post.list({
repo: aliceAgent.accountDid,
})
expect(posts.records.map((r) => r.uri)).toContain(post.uri)
const subject = {
$type: 'com.atproto.repo.strongRef',
uri: created.uri,
cid: created.cid,
}
await agent.api.com.atproto.admin.updateSubjectStatus(
{
subject,
takedown: { applied: true },
},
{
encoding: 'application/json',
headers: { authorization: network.pds.adminAuth() },
},
)
const postTakedownPromise = agent.app.bsky.feed.post.get({
repo: aliceAgent.accountDid,
rkey: postUri.rkey,
})
await expect(postTakedownPromise).rejects.toThrow('Could not locate record')
const postsTakedown = await agent.app.bsky.feed.post.list({
repo: aliceAgent.accountDid,
})
expect(postsTakedown.records.map((r) => r.uri)).not.toContain(post.uri)
// Cleanup
await agent.api.com.atproto.admin.updateSubjectStatus(
{
subject,
takedown: { applied: false },
},
{
encoding: 'application/json',
headers: { authorization: network.pds.adminAuth() },
},
)
})
it("doesn't serve taken-down actor", async () => {
const posts = await agent.app.bsky.feed.post.list({
repo: aliceAgent.accountDid,
})
expect(posts.records.length).toBeGreaterThan(0)
const subject = {
$type: 'com.atproto.admin.defs#repoRef',
did: aliceAgent.accountDid,
}
await agent.api.com.atproto.admin.updateSubjectStatus(
{
subject,
takedown: { applied: true },
},
{
encoding: 'application/json',
headers: { authorization: network.pds.adminAuth() },
},
)
const tryListPosts = agent.app.bsky.feed.post.list({
repo: aliceAgent.accountDid,
})
await expect(tryListPosts).rejects.toThrow(/Could not find repo/)
// Cleanup
await agent.api.com.atproto.admin.updateSubjectStatus(
{
subject,
takedown: { applied: false },
},
{
encoding: 'application/json',
headers: { authorization: network.pds.adminAuth() },
},
)
})
})
function createDeepObject(depth: number) {
const obj: any = {}
let iter = obj
for (let i = 0; i < depth; ++i) {
iter.x = {}
iter = iter.x
}
return obj
}