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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
299 lines · 8.9 KB Raw History
// The /account/api/spaces endpoints, driven through pds.fetch with a cookie
// session against fake storage, with the space routes composed over a fake
// browser and admin port.
import { describe, expect, it } from 'vitest';
import {
  apiGet,
  apiPost,
  createPds as createBasePds,
  DID,
  sessionCookie,
} from '../../../test/helpers/account-api-harness.js';
import { createSpaceAccountRoutes } from '../src/account.js';

/**
 * @param {Object} [options]
 * @param {Object} [options.spaceBrowser]
 * @param {Object} [options.spaceAdmin]
 */
function createPds({ spaceBrowser, spaceAdmin } = {}) {
  return createBasePds({
    extensions: [
      (host) => ({
        name: 'spaces',
        routes: createSpaceAccountRoutes({
          host,
          spaceBrowser: /** @type {any} */ (spaceBrowser ?? null),
          spaceAdmin: /** @type {any} */ (spaceAdmin ?? null),
        }),
        features: { spaceAdmin: Boolean(spaceAdmin) },
      }),
    ],
  });
}

describe('/account/api/spaces', () => {
  it('reports the feature off without a browser', async () => {
    const pds = createPds();
    const body = await (
      await apiGet(pds, '/account/api/spaces', await sessionCookie())
    ).json();
    expect(body).toEqual({ enabled: false, spaces: [] });
  });

  it('lists spaces with their member and record counts', async () => {
    const pds = createPds({
      spaceBrowser: {
        async listSpaces() {
          return [
            {
              uri: 'at://space.example/1',
              spaceType: 'chat.example.space',
              spaceDid: 'did:web:space.example',
              isOwner: true,
              policy: {
                $type: 'com.atproto.memberspace.policy',
                publicWrite: true,
              },
              createdAt: '2026-01-01T00:00:00.000Z',
              deletedAt: null,
            },
          ];
        },
        async countMembers() {
          return 3;
        },
        async countRecords() {
          return 12;
        },
      },
    });
    const body = await (
      await apiGet(pds, '/account/api/spaces', await sessionCookie())
    ).json();
    expect(body.enabled).toBe(true);
    expect(body.spaces).toEqual([
      {
        uri: 'at://space.example/1',
        spaceType: 'chat.example.space',
        authority: 'did:web:space.example',
        isOwner: true,
        policy: {
          $type: 'com.atproto.memberspace.policy',
          publicWrite: true,
        },
        createdAt: '2026-01-01T00:00:00.000Z',
        deleted: false,
        members: 3,
        collections: 12,
      },
    ]);
  });
});

describe('space admin endpoints', () => {
  const SPACE = `at://${DID}/space/com.example.team/main`;

  /** A spaceAdmin recording its calls. */
  function recordingAdmin() {
    /** @type {string[]} */
    const calls = [];
    return {
      calls,
      async createSpace(/** @type {{type: string, skey?: string}} */ opts) {
        calls.push(`create ${opts.type} ${opts.skey ?? ''}`);
        return { uri: SPACE };
      },
      async putMember(
        /** @type {string} */ space,
        /** @type {string} */ did,
        /** @type {{read: boolean, write: boolean}} */ access,
      ) {
        calls.push(`put ${space} ${did} ${access.read} ${access.write}`);
      },
      async removeMember(
        /** @type {string} */ space,
        /** @type {string} */ did,
      ) {
        calls.push(`remove ${space} ${did}`);
      },
    };
  }

  it('answers with an error when spaces are not enabled', async () => {
    const pds = createPds();
    const cookie = await sessionCookie();
    for (const [path, body] of [
      ['/account/api/spaces/create', { type: 'com.example.team' }],
      ['/account/api/spaces/members/add', { space: SPACE, actor: DID }],
      ['/account/api/spaces/members/remove', { space: SPACE, did: DID }],
    ]) {
      const response = await apiPost(pds, String(path), body, cookie);
      expect(response.status).toBe(400);
      expect((await response.json()).error).toMatch(/not enabled/);
    }
  });

  it('creates a space and requires a type', async () => {
    const spaceAdmin = recordingAdmin();
    const pds = createPds({ spaceAdmin });
    const cookie = await sessionCookie();

    const created = await apiPost(
      pds,
      '/account/api/spaces/create',
      { type: 'com.example.team', skey: 'main' },
      cookie,
    );
    expect(created.status).toBe(200);
    expect((await created.json()).uri).toBe(SPACE);
    expect(spaceAdmin.calls).toContain('create com.example.team main');

    const missing = await apiPost(
      pds,
      '/account/api/spaces/create',
      {},
      cookie,
    );
    expect(missing.status).toBe(400);
    expect((await missing.json()).error).toMatch(/type is required/);
  });

  it('lists members through the browser', async () => {
    const pds = createPds({
      spaceBrowser: {
        async listMembers() {
          return [DID, 'did:plc:friend'];
        },
      },
    });
    const response = await apiGet(
      pds,
      `/account/api/spaces/members?space=${encodeURIComponent(SPACE)}`,
      await sessionCookie(),
    );
    expect(await response.json()).toEqual({
      enabled: true,
      members: [DID, 'did:plc:friend'],
    });
  });

  it('adds a member by DID without resolving anything', async () => {
    const spaceAdmin = recordingAdmin();
    const pds = createPds({ spaceAdmin });
    const response = await apiPost(
      pds,
      '/account/api/spaces/members/add',
      { space: SPACE, actor: 'did:plc:friend' },
      await sessionCookie(),
    );
    expect(response.status).toBe(200);
    expect((await response.json()).did).toBe('did:plc:friend');
    expect(spaceAdmin.calls).toContain(`put ${SPACE} did:plc:friend true true`);
  });

  it('resolves a handle through the well-known document', async () => {
    const spaceAdmin = recordingAdmin();
    const pds = createPds({ spaceAdmin });
    const realFetch = globalThis.fetch;
    globalThis.fetch = /** @type {any} */ (
      async (/** @type {any} */ input) => {
        const url = String(input);
        if (url === 'https://friend.example/.well-known/atproto-did') {
          return new Response('did:plc:friend\n');
        }
        return realFetch(input);
      }
    );
    try {
      const response = await apiPost(
        pds,
        '/account/api/spaces/members/add',
        { space: SPACE, actor: 'friend.example' },
        await sessionCookie(),
      );
      expect(response.status).toBe(200);
      expect((await response.json()).did).toBe('did:plc:friend');
    } finally {
      globalThis.fetch = realFetch;
    }
  });

  it('falls back to DNS and fails closed when both miss', async () => {
    const spaceAdmin = recordingAdmin();
    const pds = createPds({ spaceAdmin });
    const realFetch = globalThis.fetch;
    globalThis.fetch = /** @type {any} */ (
      async (/** @type {any} */ input) => {
        const url = String(input);
        if (url.includes('.well-known')) {
          return new Response('nope', { status: 404 });
        }
        if (url.startsWith('https://cloudflare-dns.com/')) {
          if (url.includes('_atproto.friend.example')) {
            return Response.json({
              Answer: [{ data: '"did=did:plc:dnsfriend"' }],
            });
          }
          return Response.json({ Answer: [] });
        }
        return realFetch(input);
      }
    );
    try {
      const resolved = await apiPost(
        pds,
        '/account/api/spaces/members/add',
        { space: SPACE, actor: 'friend.example' },
        await sessionCookie(),
      );
      expect((await resolved.json()).did).toBe('did:plc:dnsfriend');

      const unresolved = await apiPost(
        pds,
        '/account/api/spaces/members/add',
        { space: SPACE, actor: 'nobody.example' },
        await sessionCookie(),
      );
      expect(unresolved.status).toBe(400);
      expect((await unresolved.json()).error).toMatch(/could not resolve/);
    } finally {
      globalThis.fetch = realFetch;
    }
  });

  it('refuses an actor that is not a plausible handle', async () => {
    const pds = createPds({ spaceAdmin: recordingAdmin() });
    const response = await apiPost(
      pds,
      '/account/api/spaces/members/add',
      { space: SPACE, actor: 'not a handle' },
      await sessionCookie(),
    );
    expect(response.status).toBe(400);
    expect((await response.json()).error).toMatch(/not a handle/);
  });

  it('removes a member and requires both fields', async () => {
    const spaceAdmin = recordingAdmin();
    const pds = createPds({ spaceAdmin });
    const cookie = await sessionCookie();

    const removed = await apiPost(
      pds,
      '/account/api/spaces/members/remove',
      { space: SPACE, did: 'did:plc:friend' },
      cookie,
    );
    expect(removed.status).toBe(200);
    expect(spaceAdmin.calls).toContain(`remove ${SPACE} did:plc:friend`);

    const missing = await apiPost(
      pds,
      '/account/api/spaces/members/remove',
      { space: SPACE },
      cookie,
    );
    expect(missing.status).toBe(400);
  });
});