chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Repo handler tests - record writes, scope checks, validation, applyWrites
import { describe, expect, it } from 'vitest';
import { createRepoHandlers } from '../packages/core/src/handlers/xrpc-repo.js';
import { cborDecode } from '../packages/core/src/repo.js';
const DID = 'did:plc:repotestaccount23456';
const COLLECTION = 'app.bsky.feed.post';
const FULL = { did: DID, scope: 'com.atproto.access' };
/**
* @param {{
* did?: string|null, readOnly?: boolean, inactive?: Response|null,
* resolver?: {assertValid: (nsid: string, record: Object) => Promise<string>}|null,
* proxied?: Response|null,
* }} [options]
*/
function createRepo({
did = DID,
readOnly = false,
inactive = null,
resolver = null,
proxied = null,
} = {}) {
/** @type {Map<string, {cid: string, value: Uint8Array}>} */
const records = new Map();
/** @type {Map<string, Uint8Array>} */
const blocks = new Map();
/** @type {Array<{blobCid: string, uri: string}>} */
const blobLinks = [];
/** @type {string[]} */
const unlinked = [];
/** @type {Array<Array<{action: string, uri: string, cid?: string}>>} */
const commits = [];
/** @type {Array<{did: string, olderThan: number, only?: string[]}>} */
const reaped = [];
let seq = 0;
const actorStorage = {
records,
blocks,
blobLinks,
unlinked,
async getHandle() {
return 'alice.example.com';
},
async listAllRecords() {
return [...records.keys()].map((uri) => ({
key: uri.split(`${DID}/`)[1],
uri,
}));
},
async putBlock(/** @type {string} */ cid, /** @type {Uint8Array} */ data) {
blocks.set(cid, data);
},
async putRecord(
/** @type {string} */ uri,
/** @type {string} */ cid,
/** @type {string} */ _collection,
/** @type {string} */ _rkey,
/** @type {Uint8Array} */ value,
) {
records.set(uri, { cid, value });
},
async getRecord(/** @type {string} */ uri) {
return records.get(uri) || null;
},
async deleteRecord(/** @type {string} */ uri) {
records.delete(uri);
},
async listRecords(
/** @type {string} */ collection,
/** @type {string|null} */ _cursor,
/** @type {number} */ limit,
/** @type {boolean|undefined} */ reverse,
) {
const rows = [...records.entries()]
.filter(([uri]) => uri.includes(`/${collection}/`))
.sort(([a], [b]) => (a < b ? -1 : 1));
if (reverse) rows.reverse();
return {
records: rows
.slice(0, limit)
.map(([uri, r]) => ({ uri, cid: r.cid, value: r.value })),
cursor: null,
};
},
async linkBlobToRecord(
/** @type {string} */ blobCid,
/** @type {string} */ uri,
) {
blobLinks.push({ blobCid, uri });
},
async unlinkBlobsFromRecord(/** @type {string} */ uri) {
unlinked.push(uri);
},
async getLatestCommit() {
return seq === 0
? null
: { seq, cid: `bafycommit${seq}`, rev: `rev${seq}` };
},
};
const repo = createRepoHandlers({
actorStorage: /** @type {any} */ (actorStorage),
lexiconResolver: /** @type {any} */ (resolver),
readOnly,
getDid: async () => did,
readOnlyError: () =>
Response.json(
{ error: 'AuthenticationRequired', message: 'This PDS is read-only' },
{ status: 401 },
),
withRepoLock: (fn) => fn(),
createCommit: async (_did, ops) => {
commits.push(ops);
seq += 1;
return {};
},
checkAccountActive: async () => inactive,
readJsonBounded: async (request) => request.json(),
handleProxyHeader: async () => proxied,
reapOrphanedBlobs: async (d, olderThan, only) => {
reaped.push({ did: d, olderThan, only });
},
});
return { repo, actorStorage, records, commits, blobLinks, unlinked, reaped };
}
/**
* @param {ReturnType<typeof createRepoHandlers>} repo
* @param {string} path
* @param {{body?: Object, auth?: {did: string, scope: string}|null}} [options]
*/
async function call(repo, path, { body, auth = null } = {}) {
const route = repo.routes[path.split('?')[0]];
const url = new URL(`https://pds.example.com${path}`);
/** @type {RequestInit} */
const init = { method: body ? 'POST' : 'GET' };
if (body) init.body = JSON.stringify(body);
const request = new Request(url, init);
const handler =
/** @type {(request: Request, url: URL, auth: unknown) => Promise<Response>} */ (
route.handler
);
return handler(request, url, auth);
}
const DESCRIBE = '/xrpc/com.atproto.repo.describeRepo';
const CREATE = '/xrpc/com.atproto.repo.createRecord';
const GET = '/xrpc/com.atproto.repo.getRecord';
const LIST = '/xrpc/com.atproto.repo.listRecords';
const PUT = '/xrpc/com.atproto.repo.putRecord';
const APPLY = '/xrpc/com.atproto.repo.applyWrites';
const DELETE = '/xrpc/com.atproto.repo.deleteRecord';
describe('describeRepo', () => {
it('lists the collections the repo holds', async () => {
const { repo } = createRepo();
await call(repo, CREATE, {
auth: FULL,
body: { repo: DID, collection: COLLECTION, record: { text: 'a' } },
});
const body = await (await call(repo, DESCRIBE)).json();
expect(body.did).toBe(DID);
expect(body.handle).toBe('alice.example.com');
expect(body.handleIsCorrect).toBe(true);
expect(body.collections).toEqual([COLLECTION]);
});
it('is a RepoNotFound for another repo', async () => {
const { repo } = createRepo();
const response = await call(repo, `${DESCRIBE}?repo=did:plc:someoneelse`);
expect(response.status).toBe(404);
expect((await response.json()).error).toBe('RepoNotFound');
});
});
describe('createRecord', () => {
it('stores the record, links its blobs, and commits once', async () => {
const { repo, records, commits, blobLinks } = createRepo();
const response = await call(repo, CREATE, {
auth: FULL,
body: {
repo: DID,
collection: COLLECTION,
record: {
text: 'hello',
image: { $type: 'blob', ref: { $link: 'bafkblob1' } },
},
},
});
const body = await response.json();
expect(response.status).toBe(200);
expect(body.uri).toMatch(new RegExp(`^at://${DID}/${COLLECTION}/`));
expect(records.get(body.uri)?.cid).toBe(body.cid);
expect(commits).toHaveLength(1);
expect(commits[0][0].action).toBe('create');
expect(blobLinks).toEqual([{ blobCid: 'bafkblob1', uri: body.uri }]);
expect(body.commit.rev).toBe('rev1');
});
it('sets $type from the collection', async () => {
const { repo, records } = createRepo();
const body = await (
await call(repo, CREATE, {
auth: FULL,
body: { repo: DID, collection: COLLECTION, record: { text: 'a' } },
})
).json();
const stored = cborDecode(
/** @type {Uint8Array} */ (records.get(body.uri)?.value),
);
expect(stored.$type).toBe(COLLECTION);
});
it('takes a caller-supplied rkey', async () => {
const { repo } = createRepo();
const body = await (
await call(repo, CREATE, {
auth: FULL,
body: {
repo: DID,
collection: COLLECTION,
rkey: 'chosen',
record: { text: 'a' },
},
})
).json();
expect(body.uri).toBe(`at://${DID}/${COLLECTION}/chosen`);
});
it('refuses a key the repo already holds, and keeps the record', async () => {
const { repo, records, commits } = createRepo();
const body = { repo: DID, collection: COLLECTION, rkey: 'taken' };
const first = await (
await call(repo, CREATE, {
auth: FULL,
body: { ...body, record: { text: 'first' } },
})
).json();
const response = await call(repo, CREATE, {
auth: FULL,
body: { ...body, record: { text: 'second' } },
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
error: 'InvalidRequest',
message: `Record already exists: ${first.uri}`,
});
expect(records.get(first.uri)?.cid).toBe(first.cid);
expect(commits).toHaveLength(1);
});
it('needs a session', async () => {
const { repo } = createRepo();
const response = await call(repo, CREATE, {
body: { repo: DID, collection: COLLECTION, record: {} },
});
expect(response.status).toBe(401);
});
it('refuses a read-only server', async () => {
const { repo } = createRepo({ readOnly: true });
const response = await call(repo, CREATE, {
auth: FULL,
body: { repo: DID, collection: COLLECTION, record: {} },
});
expect(response.status).toBe(401);
expect((await response.json()).message).toMatch(/read-only/);
});
it('passes on the reason an account is inactive', async () => {
const { repo } = createRepo({
inactive: Response.json({ error: 'AccountDeactivated' }, { status: 400 }),
});
const response = await call(repo, CREATE, {
auth: FULL,
body: { repo: DID, collection: COLLECTION, record: {} },
});
expect((await response.json()).error).toBe('AccountDeactivated');
});
it('refuses a repo that is not this one', async () => {
const { repo } = createRepo();
const response = await call(repo, CREATE, {
auth: FULL,
body: {
repo: 'did:plc:someoneelse',
collection: COLLECTION,
record: {},
},
});
expect(response.status).toBe(403);
expect((await response.json()).message).toMatch(/Not authorized/);
});
it('refuses a collection the scope does not cover', async () => {
const { repo, records } = createRepo();
const response = await call(repo, CREATE, {
auth: { did: DID, scope: 'repo:app.bsky.feed.like' },
body: { repo: DID, collection: COLLECTION, record: {} },
});
expect(response.status).toBe(403);
expect((await response.json()).message).toContain(COLLECTION);
expect(records.size).toBe(0);
});
it('reports a schema failure as InvalidRecord', async () => {
const { repo, records } = createRepo({
resolver: {
async assertValid() {
throw new Error('text must be a string');
},
},
});
const response = await call(repo, CREATE, {
auth: FULL,
body: { repo: DID, collection: COLLECTION, record: { text: 1 } },
});
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toBe('InvalidRecord');
expect(body.message).toMatch(/text must be a string/);
expect(records.size).toBe(0);
});
it('skips validation when the caller asks it to', async () => {
let called = false;
const { repo } = createRepo({
resolver: {
async assertValid() {
called = true;
return 'valid';
},
},
});
const response = await call(repo, CREATE, {
auth: FULL,
body: {
repo: DID,
collection: COLLECTION,
record: { text: 1 },
validate: false,
},
});
expect(response.status).toBe(200);
expect(called).toBe(false);
});
});
describe('getRecord', () => {
it('answers with the stored value', async () => {
const { repo } = createRepo();
const created = await (
await call(repo, CREATE, {
auth: FULL,
body: {
repo: DID,
collection: COLLECTION,
rkey: 'one',
record: { text: 'hi' },
},
})
).json();
const body = await (
await call(repo, `${GET}?repo=${DID}&collection=${COLLECTION}&rkey=one`)
).json();
expect(body.uri).toBe(created.uri);
expect(body.cid).toBe(created.cid);
expect(body.value.text).toBe('hi');
});
it('is a RecordNotFound for an rkey that is not there', async () => {
const { repo } = createRepo();
const response = await call(
repo,
`${GET}?repo=${DID}&collection=${COLLECTION}&rkey=absent`,
);
expect(response.status).toBe(404);
expect((await response.json()).error).toBe('RecordNotFound');
});
});
describe('listRecords', () => {
it('lists a collection', async () => {
const { repo } = createRepo();
for (const rkey of ['a', 'b']) {
await call(repo, CREATE, {
auth: FULL,
body: {
repo: DID,
collection: COLLECTION,
rkey,
record: { text: rkey },
},
});
}
const body = await (
await call(repo, `${LIST}?repo=${DID}&collection=${COLLECTION}`)
).json();
expect(body.records).toHaveLength(2);
expect(body.records[0].value.text).toBeTruthy();
});
it('lists newest rkey first, oldest first with reverse', async () => {
const { repo } = createRepo();
for (const rkey of ['a', 'b']) {
await call(repo, CREATE, {
auth: FULL,
body: {
repo: DID,
collection: COLLECTION,
rkey,
record: { text: rkey },
},
});
}
const texts = async (/** @type {string} */ params) => {
const body = await (
await call(
repo,
`${LIST}?repo=${DID}&collection=${COLLECTION}${params}`,
)
).json();
return body.records.map(
(/** @type {{value: {text: string}}} */ r) => r.value.text,
);
};
expect(await texts('')).toEqual(['b', 'a']);
expect(await texts('&reverse=true')).toEqual(['a', 'b']);
});
});
describe('putRecord', () => {
it('replaces the value and commits an update', async () => {
const { repo, commits } = createRepo();
await call(repo, CREATE, {
auth: FULL,
body: {
repo: DID,
collection: COLLECTION,
rkey: 'one',
record: { text: 'first' },
},
});
const response = await call(repo, PUT, {
auth: FULL,
body: {
repo: DID,
collection: COLLECTION,
rkey: 'one',
record: { text: 'second' },
},
});
expect(response.status).toBe(200);
const body = await (
await call(repo, `${GET}?repo=${DID}&collection=${COLLECTION}&rkey=one`)
).json();
expect(body.value.text).toBe('second');
expect(commits[1][0].action).toBe('update');
});
});
describe('deleteRecord', () => {
it('removes the record and unlinks its blobs', async () => {
const { repo, records, unlinked, commits } = createRepo();
const created = await (
await call(repo, CREATE, {
auth: FULL,
body: {
repo: DID,
collection: COLLECTION,
rkey: 'one',
record: { text: 'a' },
},
})
).json();
const response = await call(repo, DELETE, {
auth: FULL,
body: { repo: DID, collection: COLLECTION, rkey: 'one' },
});
expect(response.status).toBe(200);
expect(records.has(created.uri)).toBe(false);
expect(unlinked).toContain(created.uri);
expect(commits[1][0].action).toBe('delete');
});
it('reaps only the blobs the deleted record held', async () => {
// Another orphan can be a blob a client uploaded for a record it is
// about to write. Reaping it would leave that record without its blob.
const { repo, reaped } = createRepo();
const blob = 'bafkreiheldbytherecordaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
await call(repo, CREATE, {
auth: FULL,
body: {
repo: DID,
collection: COLLECTION,
rkey: 'one',
record: {
text: 'a',
log: { $type: 'blob', ref: { $link: blob }, mimeType: 'text/plain' },
},
},
});
await call(repo, CREATE, {
auth: FULL,
body: {
repo: DID,
collection: COLLECTION,
rkey: 'two',
record: { text: 'b' },
},
});
const before = Date.now();
for (const rkey of ['one', 'two']) {
await call(repo, DELETE, {
auth: FULL,
body: { repo: DID, collection: COLLECTION, rkey },
});
}
expect(reaped).toHaveLength(1);
expect(reaped[0].only).toEqual([blob]);
expect(reaped[0].olderThan).toBeGreaterThan(before);
expect(reaped[0].olderThan).toBeLessThanOrEqual(Date.now() + 1);
});
it('deleting a record that is not there commits nothing', async () => {
// The tree does not change, so there is nothing to announce. Committing
// anyway puts an op on the firehose that the MST diff does not contain —
// a delete for a record the repo never had — and clients retry, so one
// stray delete became a run of impossible commits.
const { repo, commits } = createRepo();
const response = await call(repo, DELETE, {
auth: FULL,
body: { repo: DID, collection: COLLECTION, rkey: 'never-existed' },
});
expect(response.status).toBe(200);
expect(commits).toHaveLength(0);
});
it('deleting the same record twice commits once', async () => {
const { repo, commits } = createRepo();
await call(repo, CREATE, {
auth: FULL,
body: {
repo: DID,
collection: COLLECTION,
rkey: 'one',
record: { text: 'a' },
},
});
const body = { repo: DID, collection: COLLECTION, rkey: 'one' };
await call(repo, DELETE, { auth: FULL, body });
const second = await call(repo, DELETE, { auth: FULL, body });
expect(second.status).toBe(200);
// One for the create, one for the delete that did something.
expect(commits).toHaveLength(2);
});
});
describe('applyWrites', () => {
it('a delete of a record that is not there contributes no op', async () => {
const { repo, commits } = createRepo();
const response = await call(repo, APPLY, {
auth: FULL,
body: {
repo: DID,
writes: [
{
$type: 'com.atproto.repo.applyWrites#delete',
collection: COLLECTION,
rkey: 'never-existed',
},
],
},
});
expect(response.status).toBe(200);
// The result still stands, one per write, but nothing was committed.
const body = await response.json();
expect(body.results).toHaveLength(1);
expect(body.results[0].$type).toBe(
'com.atproto.repo.applyWrites#deleteResult',
);
expect(commits).toHaveLength(0);
});
it('lands every operation against one commit', async () => {
const { repo, records, commits } = createRepo();
const response = await call(repo, APPLY, {
auth: FULL,
body: {
repo: DID,
writes: [
{
$type: 'com.atproto.repo.applyWrites#create',
collection: COLLECTION,
rkey: 'a',
value: { text: 'a' },
},
{
$type: 'com.atproto.repo.applyWrites#create',
collection: COLLECTION,
rkey: 'b',
value: { text: 'b' },
},
],
},
});
expect(response.status).toBe(200);
expect(records.size).toBe(2);
// One commit carrying both operations, not one commit each.
expect(commits).toHaveLength(1);
expect(commits[0]).toHaveLength(2);
});
it('refuses a create at a key the repo already holds, writing nothing', async () => {
const { repo, records, commits } = createRepo();
const first = await (
await call(repo, CREATE, {
auth: FULL,
body: {
repo: DID,
collection: COLLECTION,
rkey: 'taken',
record: { text: 'first' },
},
})
).json();
const response = await call(repo, APPLY, {
auth: FULL,
body: {
repo: DID,
writes: [
{
$type: 'com.atproto.repo.applyWrites#create',
collection: COLLECTION,
rkey: 'other',
value: { text: 'other' },
},
{
$type: 'com.atproto.repo.applyWrites#create',
collection: COLLECTION,
rkey: 'taken',
value: { text: 'second' },
},
],
},
});
expect(response.status).toBe(400);
expect((await response.json()).message).toBe(
`Record already exists: ${first.uri}`,
);
expect(records.size).toBe(1);
expect(records.get(first.uri)?.cid).toBe(first.cid);
expect(commits).toHaveLength(1);
});
it('refuses two creates of one key in a batch', async () => {
const { repo, records, commits } = createRepo();
const create = {
$type: 'com.atproto.repo.applyWrites#create',
collection: COLLECTION,
rkey: 'twice',
value: { text: 'a' },
};
const response = await call(repo, APPLY, {
auth: FULL,
body: {
repo: DID,
writes: [create, { ...create, value: { text: 'b' } }],
},
});
expect(response.status).toBe(400);
expect(records.size).toBe(0);
expect(commits).toHaveLength(0);
});
it('refuses a read-only server', async () => {
const { repo } = createRepo({ readOnly: true });
const response = await call(repo, APPLY, {
auth: FULL,
body: { repo: DID, writes: [] },
});
expect(response.status).toBe(401);
});
it('needs a session', async () => {
const { repo } = createRepo();
const response = await call(repo, APPLY, {
body: { repo: DID, writes: [] },
});
expect(response.status).toBe(401);
});
});
describe('applyWrites operations', () => {
it('updates and deletes in the same commit as a create', async () => {
const { repo, records, commits, unlinked } = createRepo();
await call(repo, CREATE, {
auth: FULL,
body: {
repo: DID,
collection: COLLECTION,
rkey: 'old',
record: { text: 'first' },
},
});
const response = await call(repo, APPLY, {
auth: FULL,
body: {
repo: DID,
writes: [
{
$type: 'com.atproto.repo.applyWrites#update',
collection: COLLECTION,
rkey: 'old',
value: { text: 'second' },
},
{
$type: 'com.atproto.repo.applyWrites#create',
collection: COLLECTION,
rkey: 'fresh',
value: { text: 'new' },
},
{
$type: 'com.atproto.repo.applyWrites#delete',
collection: COLLECTION,
rkey: 'fresh',
},
],
},
});
const body = await response.json();
expect(response.status).toBe(200);
expect(
body.results.map((/** @type {{$type: string}} */ r) => r.$type),
).toEqual([
'com.atproto.repo.applyWrites#updateResult',
'com.atproto.repo.applyWrites#createResult',
'com.atproto.repo.applyWrites#deleteResult',
]);
// The batch is one commit carrying all three actions.
expect(commits[1].map((o) => o.action)).toEqual([
'update',
'create',
'delete',
]);
expect(records.has(`at://${DID}/${COLLECTION}/fresh`)).toBe(false);
expect(unlinked).toContain(`at://${DID}/${COLLECTION}/fresh`);
});
it('needs an rkey to update', async () => {
const { repo } = createRepo();
const response = await call(repo, APPLY, {
auth: FULL,
body: {
repo: DID,
writes: [
{
$type: 'com.atproto.repo.applyWrites#update',
collection: COLLECTION,
value: {},
},
],
},
});
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/rkey required for update/);
});
it('needs an rkey to delete', async () => {
const { repo } = createRepo();
const response = await call(repo, APPLY, {
auth: FULL,
body: {
repo: DID,
writes: [
{
$type: 'com.atproto.repo.applyWrites#delete',
collection: COLLECTION,
},
],
},
});
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/rkey required for delete/);
});
it('refuses a write type it does not know', async () => {
const { repo } = createRepo();
const response = await call(repo, APPLY, {
auth: FULL,
body: {
repo: DID,
writes: [{ $type: 'com.atproto.repo.applyWrites#frobnicate' }],
},
});
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/Unknown write operation/);
});
it('refuses a batch operation the scope does not cover', async () => {
const { repo, records, commits } = createRepo();
const response = await call(repo, APPLY, {
auth: { did: DID, scope: 'repo:app.bsky.feed.like' },
body: {
repo: DID,
writes: [
{
$type: 'com.atproto.repo.applyWrites#create',
collection: COLLECTION,
rkey: 'a',
value: {},
},
],
},
});
expect(response.status).toBe(403);
expect((await response.json()).message).toContain(COLLECTION);
expect(records.size).toBe(0);
expect(commits).toHaveLength(0);
});
// The whole batch is checked before anything is written, so a partly covered
// batch changes nothing rather than landing its allowed half.
it('writes nothing when one operation in a batch is not covered', async () => {
const { repo, records, commits } = createRepo();
const response = await call(repo, APPLY, {
auth: { did: DID, scope: `repo:${COLLECTION}?action=create` },
body: {
repo: DID,
writes: [
{
$type: 'com.atproto.repo.applyWrites#create',
collection: COLLECTION,
rkey: 'allowed',
value: {},
},
{
$type: 'com.atproto.repo.applyWrites#delete',
collection: COLLECTION,
rkey: 'allowed',
},
],
},
});
expect(response.status).toBe(403);
expect((await response.json()).message).toMatch(/delete/);
expect(records.size).toBe(0);
expect(commits).toHaveLength(0);
});
it('needs writes to be an array', async () => {
const { repo } = createRepo();
const response = await call(repo, APPLY, {
auth: FULL,
body: { repo: DID, writes: 'nope' },
});
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/must be an array/);
});
it('refuses a repo that is not this one', async () => {
const { repo } = createRepo();
const response = await call(repo, APPLY, {
auth: FULL,
body: { repo: 'did:plc:someoneelse', writes: [] },
});
expect(response.status).toBe(403);
});
it('needs an account', async () => {
const { repo } = createRepo({ did: null });
const response = await call(repo, APPLY, {
auth: FULL,
body: { repo: DID, writes: [] },
});
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/not initialized/);
});
it('passes on the reason an account is inactive', async () => {
const { repo } = createRepo({
inactive: Response.json({ error: 'AccountDeactivated' }, { status: 400 }),
});
const response = await call(repo, APPLY, {
auth: FULL,
body: { repo: DID, writes: [] },
});
expect((await response.json()).error).toBe('AccountDeactivated');
});
it('reports a schema failure and writes nothing', async () => {
const { repo, records } = createRepo({
resolver: {
async assertValid() {
throw new Error('text must be a string');
},
},
});
const response = await call(repo, APPLY, {
auth: FULL,
body: {
repo: DID,
writes: [
{
$type: 'com.atproto.repo.applyWrites#create',
collection: COLLECTION,
value: { text: 1 },
},
],
},
});
expect(response.status).toBe(400);
expect((await response.json()).error).toBe('InvalidRecord');
expect(records.size).toBe(0);
});
});
describe('putRecord and deleteRecord guards', () => {
it('needs a session to put', async () => {
const { repo } = createRepo();
const response = await call(repo, PUT, {
body: { repo: DID, collection: COLLECTION, rkey: 'a', record: {} },
});
expect(response.status).toBe(401);
});
it('refuses a read-only server on put', async () => {
const { repo } = createRepo({ readOnly: true });
const response = await call(repo, PUT, {
auth: FULL,
body: { repo: DID, collection: COLLECTION, rkey: 'a', record: {} },
});
expect(response.status).toBe(401);
});
// putRecord is an upsert, so a scope allowing only one of create or update is
// not enough to overwrite a record.
it('refuses a collection the scope does not cover on put', async () => {
const { repo, records } = createRepo();
const response = await call(repo, PUT, {
auth: { did: DID, scope: 'repo:app.bsky.feed.like' },
body: { repo: DID, collection: COLLECTION, rkey: 'a', record: {} },
});
expect(response.status).toBe(403);
expect((await response.json()).message).toContain(COLLECTION);
expect(records.size).toBe(0);
});
it('refuses a put scope that allows create but not update', async () => {
const { repo, records } = createRepo();
const response = await call(repo, PUT, {
auth: { did: DID, scope: `repo:${COLLECTION}?action=create` },
body: { repo: DID, collection: COLLECTION, rkey: 'a', record: {} },
});
expect(response.status).toBe(403);
expect((await response.json()).message).toMatch(/update/);
expect(records.size).toBe(0);
});
it('needs a session to delete', async () => {
const { repo } = createRepo();
const response = await call(repo, DELETE, {
body: { repo: DID, collection: COLLECTION, rkey: 'a' },
});
expect(response.status).toBe(401);
});
it('refuses a read-only server on delete', async () => {
const { repo } = createRepo({ readOnly: true });
const response = await call(repo, DELETE, {
auth: FULL,
body: { repo: DID, collection: COLLECTION, rkey: 'a' },
});
expect(response.status).toBe(401);
});
it('refuses a collection the scope does not cover on delete', async () => {
const { repo } = createRepo();
const response = await call(repo, DELETE, {
auth: { did: DID, scope: 'repo:app.bsky.feed.like' },
body: { repo: DID, collection: COLLECTION, rkey: 'a' },
});
expect(response.status).toBe(403);
});
});
// An app-password session names no granular scopes, and must still be able to
// write. It is bounded elsewhere: it cannot mint app passwords or read the email.
describe('app-password sessions', () => {
const APP_PASS = { did: DID, scope: 'com.atproto.appPass' };
it('creates, overwrites and deletes a record', async () => {
const { repo, records } = createRepo();
const created = await (
await call(repo, CREATE, {
auth: APP_PASS,
body: {
repo: DID,
collection: COLLECTION,
rkey: 'one',
record: { text: 'a' },
},
})
).json();
expect(records.has(created.uri)).toBe(true);
const put = await call(repo, PUT, {
auth: APP_PASS,
body: {
repo: DID,
collection: COLLECTION,
rkey: 'one',
record: { text: 'b' },
},
});
expect(put.status).toBe(200);
const removed = await call(repo, DELETE, {
auth: APP_PASS,
body: { repo: DID, collection: COLLECTION, rkey: 'one' },
});
expect(removed.status).toBe(200);
expect(records.has(created.uri)).toBe(false);
});
it('applies a batch', async () => {
const { repo, records } = createRepo();
const response = await call(repo, APPLY, {
auth: APP_PASS,
body: {
repo: DID,
writes: [
{
$type: 'com.atproto.repo.applyWrites#create',
collection: COLLECTION,
rkey: 'a',
value: { text: 'a' },
},
],
},
});
expect(response.status).toBe(200);
expect(records.size).toBe(1);
});
});
describe('getRecord and listRecords guards', () => {
it('needs repo, collection and rkey to get', async () => {
const { repo } = createRepo();
const response = await call(repo, `${GET}?repo=${DID}`);
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/Missing required/);
});
it('needs repo and collection to list', async () => {
const { repo } = createRepo();
const response = await call(repo, `${LIST}?repo=${DID}`);
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/Missing required/);
});
});