~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
288 lines · 9.9 KB Raw History
// The storage views an extension receives: record calls check the
// collection they name against what the extension declared; everything
// else reaches the server's own port unchanged.
import { describe, expect, it, vi } from 'vitest';
import {
  anonymousRequest,
  collectionOfUri,
  createCollectionScope,
  scopeActorStorage,
  scopeCommits,
  scopeSpaceBrowser,
  scopeSpaceStorage,
} from '../packages/core/src/extension-scope.js';

const DID = 'did:plc:scopetest';
const SPACE = `at://${DID}/space/com.example.team/main`;

/** @param {string[]|undefined} declared */
const scopeOf = (declared) =>
  createCollectionScope(
    () => 'demo',
    () => declared,
  );

describe('collectionOfUri', () => {
  it('reads the segment before the record key, public or in a space', () => {
    expect(collectionOfUri(`at://${DID}/app.example.post/3k`)).toBe(
      'app.example.post',
    );
    expect(collectionOfUri(`${SPACE}/${DID}/app.example.post/3k`)).toBe(
      'app.example.post',
    );
    expect(collectionOfUri('at://nothing')).toBe('');
  });
});

describe('createCollectionScope', () => {
  it('allows the declared collections and nothing else', () => {
    const scope = scopeOf(['app.example.post']);
    expect(scope.allows('app.example.post')).toBe(true);
    expect(scope.allows('app.example.like')).toBe(false);
    expect(() => scope.assert('app.example.like')).toThrow(
      'Extension demo may not touch app.example.like: it declares app.example.post',
    );
    expect(() => scope.assertAll()).toThrow(/every collection/);
  });

  it('allows everything for a wildcard and nothing when undeclared', () => {
    expect(scopeOf(['*']).allows('anything.at.all')).toBe(true);
    expect(() => scopeOf(['*']).assertAll()).not.toThrow();
    expect(() => scopeOf(undefined).assert('app.example.post')).toThrow(
      /declares no collections/,
    );
  });

  it('reads the declaration on every check', () => {
    /** @type {string[]|undefined} */
    let declared;
    const scope = createCollectionScope(
      () => 'late',
      () => declared,
    );
    expect(scope.allows('a.b')).toBe(false);
    declared = ['a.b'];
    expect(scope.allows('a.b')).toBe(true);
  });
});

describe('scopeActorStorage', () => {
  function fakeStorage() {
    return {
      getRecord: vi.fn(async () => null),
      listRecords: vi.fn(async () => ({ records: [], cursor: null })),
      listAllRecords: vi.fn(async () => []),
      putRecord: vi.fn(async () => {}),
      deleteRecord: vi.fn(async () => {}),
      linkBlobToRecord: vi.fn(async () => {}),
      unlinkBlobsFromRecord: vi.fn(async () => {}),
      putBlock: vi.fn(async () => {}),
      // Bound to the storage, so a port written as a class still works.
      getHandle: vi.fn(
        /** @this {unknown} */ async function () {
          return this === storage ? 'bound.handle' : 'unbound';
        },
      ),
      someField: 42,
    };
  }
  const storage = fakeStorage();
  const scoped = scopeActorStorage(
    /** @type {any} */ (storage),
    scopeOf(['app.example.post']),
  );

  it('passes record calls in the declared collection through', async () => {
    await scoped.getRecord(`at://${DID}/app.example.post/1`);
    await scoped.listRecords('app.example.post', null, 10);
    await scoped.putRecord(
      `at://${DID}/app.example.post/1`,
      'bafy',
      'app.example.post',
      '1',
      new Uint8Array(),
    );
    await scoped.deleteRecord(`at://${DID}/app.example.post/1`);
    await scoped.linkBlobToRecord('bafyblob', `at://${DID}/app.example.post/1`);
    await scoped.unlinkBlobsFromRecord(`at://${DID}/app.example.post/1`);
    expect(storage.getRecord).toHaveBeenCalledTimes(1);
    expect(storage.listRecords).toHaveBeenCalledTimes(1);
    expect(storage.putRecord).toHaveBeenCalledTimes(1);
    expect(storage.deleteRecord).toHaveBeenCalledTimes(1);
    expect(storage.linkBlobToRecord).toHaveBeenCalledTimes(1);
    expect(storage.unlinkBlobsFromRecord).toHaveBeenCalledTimes(1);
  });

  it('refuses record calls outside it before reaching the port', async () => {
    const other = `at://${DID}/app.example.like/1`;
    await expect(scoped.getRecord(other)).rejects.toThrow(/may not touch/);
    await expect(
      scoped.listRecords('app.example.like', null, 10),
    ).rejects.toThrow(/may not touch/);
    await expect(
      scoped.putRecord(
        other,
        'bafy',
        'app.example.like',
        '1',
        new Uint8Array(),
      ),
    ).rejects.toThrow(/may not touch/);
    await expect(scoped.deleteRecord(other)).rejects.toThrow(/may not touch/);
    await expect(scoped.linkBlobToRecord('bafyblob', other)).rejects.toThrow(
      /may not touch/,
    );
    await expect(scoped.unlinkBlobsFromRecord(other)).rejects.toThrow(
      /may not touch/,
    );
    await expect(scoped.listAllRecords()).rejects.toThrow(/every collection/);
    expect(storage.getRecord).toHaveBeenCalledTimes(1);
    expect(storage.listAllRecords).not.toHaveBeenCalled();
  });

  it('passes everything that names no collection through, bound', async () => {
    await scoped.putBlock('bafy', new Uint8Array());
    expect(storage.putBlock).toHaveBeenCalledTimes(1);
    expect(await scoped.getHandle()).toBe('bound.handle');
    expect(/** @type {any} */ (scoped).someField).toBe(42);
  });
});

describe('scopeSpaceStorage', () => {
  const storage = {
    getSpaceRecord: vi.fn(async () => null),
    getSpaceRecordCid: vi.fn(async () => null),
    listSpaceRecords: vi.fn(async () => ({ records: [], cursor: null })),
    listAllSpaceRecords: vi.fn(async () => []),
    commitSpaceWrite: vi.fn(async () => {}),
    getSpace: vi.fn(async () => null),
  };
  const scoped = scopeSpaceStorage(
    /** @type {any} */ (storage),
    scopeOf(['app.example.post']),
  );

  it('checks the collection a record call or commit names', async () => {
    await scoped.getSpaceRecord(SPACE, 'app.example.post', '1');
    await scoped.listSpaceRecords(SPACE, {
      collection: 'app.example.post',
      limit: 5,
    });
    await scoped.commitSpaceWrite(SPACE, {
      writes: [
        /** @type {any} */ ({
          action: 'create',
          collection: 'app.example.post',
          rkey: '1',
        }),
      ],
      setHash: new Uint8Array(),
      rev: '3l',
      indexedAt: 'now',
    });
    await scoped.getSpace(SPACE);
    expect(storage.getSpaceRecord).toHaveBeenCalledTimes(1);
    expect(storage.commitSpaceWrite).toHaveBeenCalledTimes(1);
    expect(storage.getSpace).toHaveBeenCalledTimes(1);

    await expect(
      scoped.getSpaceRecordCid(SPACE, 'app.example.like', '1'),
    ).rejects.toThrow(/may not touch/);
    await expect(scoped.listSpaceRecords(SPACE, { limit: 5 })).rejects.toThrow(
      /every collection/,
    );
    await expect(scoped.listAllSpaceRecords(SPACE)).rejects.toThrow(
      /every collection/,
    );
    await expect(
      scoped.commitSpaceWrite(SPACE, {
        writes: [
          /** @type {any} */ ({
            action: 'create',
            collection: 'app.example.post',
            rkey: '1',
          }),
          /** @type {any} */ ({
            action: 'create',
            collection: 'app.example.like',
            rkey: '2',
          }),
        ],
        setHash: new Uint8Array(),
        rev: '3l',
        indexedAt: 'now',
      }),
    ).rejects.toThrow(/may not touch app.example.like/);
    expect(storage.commitSpaceWrite).toHaveBeenCalledTimes(1);
  });
});

describe('scopeCommits', () => {
  it('refuses a commit with any op outside the scope, before it starts', async () => {
    const createCommit = vi.fn(async () => ({ cid: 'bafy', rev: '3l' }));
    const commit = scopeCommits(createCommit, scopeOf(['app.example.post']));
    await expect(
      commit(DID, [
        { action: 'create', uri: `at://${DID}/app.example.post/1` },
      ]),
    ).resolves.toEqual({ cid: 'bafy', rev: '3l' });
    await expect(
      commit(DID, [
        { action: 'create', uri: `at://${DID}/app.example.post/2` },
        { action: 'delete', uri: `at://${DID}/app.example.like/1` },
      ]),
    ).rejects.toThrow(/may not touch app.example.like/);
    expect(createCommit).toHaveBeenCalledTimes(1);
  });
});

describe('anonymousRequest', () => {
  it('removes the credential headers and keeps the rest', async () => {
    const request = new Request('https://pds.internal/xrpc/x', {
      method: 'POST',
      headers: {
        authorization: 'Bearer secret',
        cookie: 'pdsjs_account=secret',
        dpop: 'proof',
        'content-type': 'application/json',
      },
      body: '{"a":1}',
    });
    const anonymous = anonymousRequest(request);
    expect(anonymous.headers.has('authorization')).toBe(false);
    expect(anonymous.headers.has('cookie')).toBe(false);
    expect(anonymous.headers.has('dpop')).toBe(false);
    expect(anonymous.headers.get('content-type')).toBe('application/json');
    expect(anonymous.method).toBe('POST');
    expect(await anonymous.text()).toBe('{"a":1}');
  });

  it('returns a request that carries none unchanged', () => {
    const request = new Request('https://pds.internal/xrpc/x');
    expect(anonymousRequest(request)).toBe(request);
  });
});

describe('scopeSpaceBrowser', () => {
  const browser = {
    listSpaces: vi.fn(async () => []),
    countRecords: vi.fn(async () => []),
    countMembers: vi.fn(async () => 0),
    listMembers: vi.fn(async () => []),
    listRecords: vi.fn(async () => ({ records: [], truncated: false })),
    listAllRecords: vi.fn(async () => []),
  };
  const scoped = scopeSpaceBrowser(browser, scopeOf(['app.example.post']));

  it('checks a collection listing and needs every collection for the whole view', async () => {
    await scoped.listSpaces();
    await scoped.listMembers(SPACE);
    await scoped.listRecords(SPACE, 'app.example.post', 10);
    expect(browser.listRecords).toHaveBeenCalledTimes(1);
    await expect(
      scoped.listRecords(SPACE, 'app.example.like', 10),
    ).rejects.toThrow(/may not touch/);
    await expect(scoped.listAllRecords()).rejects.toThrow(/every collection/);
    expect(browser.listAllRecords).not.toHaveBeenCalled();
  });
});