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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
251 lines · 7.3 KB Raw History
import { describe, expect, test, vi } from 'vitest';
import {
  createCachedDidResolver,
  extractPdsEndpoint,
  resolveDid,
} from '../src/did.js';

describe('did', () => {
  describe('extractPdsEndpoint', () => {
    test('extracts PDS endpoint from DID document', () => {
      const didDoc = {
        service: [
          {
            id: '#atproto_pds',
            type: 'AtprotoPersonalDataServer',
            serviceEndpoint: 'https://pds.example.com',
          },
        ],
      };
      expect(extractPdsEndpoint(didDoc)).toBe('https://pds.example.com');
    });

    test('finds PDS by type if id differs', () => {
      const didDoc = {
        service: [
          {
            id: '#pds',
            type: 'AtprotoPersonalDataServer',
            serviceEndpoint: 'https://pds.example.com',
          },
        ],
      };
      expect(extractPdsEndpoint(didDoc)).toBe('https://pds.example.com');
    });

    test('returns null if no PDS service', () => {
      const didDoc = { service: [] };
      expect(extractPdsEndpoint(didDoc)).toBeNull();
    });

    test('returns null if no service array', () => {
      const didDoc = {};
      expect(extractPdsEndpoint(didDoc)).toBeNull();
    });
  });

  describe('resolveDid', () => {
    test('resolves did:plc via PLC directory', async () => {
      const mockFetch = vi.fn().mockResolvedValue({
        ok: true,
        json: () =>
          Promise.resolve({
            service: [
              {
                id: '#atproto_pds',
                type: 'AtprotoPersonalDataServer',
                serviceEndpoint: 'https://bsky.social',
              },
            ],
          }),
      });

      const endpoint = await resolveDid('did:plc:xyz123', { fetch: mockFetch });

      expect(mockFetch).toHaveBeenCalledWith(
        'https://plc.directory/did:plc:xyz123',
      );
      expect(endpoint).toBe('https://bsky.social');
    });

    test('resolves did:web via .well-known', async () => {
      const mockFetch = vi.fn().mockResolvedValue({
        ok: true,
        json: () =>
          Promise.resolve({
            service: [
              {
                id: '#atproto_pds',
                type: 'AtprotoPersonalDataServer',
                serviceEndpoint: 'https://example.com',
              },
            ],
          }),
      });

      const endpoint = await resolveDid('did:web:example.com', {
        fetch: mockFetch,
      });

      expect(mockFetch).toHaveBeenCalledWith(
        'https://example.com/.well-known/did.json',
      );
      expect(endpoint).toBe('https://example.com');
    });

    test('returns null for unknown DID method', async () => {
      const endpoint = await resolveDid('did:unknown:xyz', { fetch: vi.fn() });
      expect(endpoint).toBeNull();
    });

    test('returns null on fetch error', async () => {
      const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'));
      const endpoint = await resolveDid('did:plc:xyz', { fetch: mockFetch });
      expect(endpoint).toBeNull();
    });
  });

  describe('createCachedDidResolver', () => {
    const DID = 'did:plc:cachedaccount0000000000000';

    /**
     * A fetch answering with `doc`, or 404 once `doc` is null.
     * @param {Object|null} doc
     */
    function fetchServing(doc) {
      return vi.fn(async () => ({
        ok: doc !== null,
        json: async () => doc,
      }));
    }

    /**
     * The mocks here answer with the two fields resolveDidDocument reads, not
     * a whole Response.
     * @param {Object} opts
     */
    const cachedResolver = (opts) =>
      createCachedDidResolver(/** @type {any} */ (opts));

    test('reads the directory once for repeated resolutions', async () => {
      const fetch = fetchServing({ id: DID });
      const resolve = cachedResolver({ fetch });

      expect(await resolve(DID)).toEqual({ id: DID });
      expect(await resolve(DID)).toEqual({ id: DID });
      expect(fetch).toHaveBeenCalledTimes(1);
    });

    test('reads once for resolutions that arrive together', async () => {
      const fetch = fetchServing({ id: DID });
      const resolve = cachedResolver({ fetch });

      const answers = await Promise.all([
        resolve(DID),
        resolve(DID),
        resolve(DID),
      ]);
      expect(answers).toEqual([{ id: DID }, { id: DID }, { id: DID }]);
      expect(fetch).toHaveBeenCalledTimes(1);
    });

    test('reads again once the answer has expired', async () => {
      vi.useFakeTimers();
      try {
        const fetch = fetchServing({ id: DID });
        const resolve = cachedResolver({ fetch, ttl: 1000 });

        await resolve(DID);
        vi.advanceTimersByTime(1001);
        await resolve(DID);
        expect(fetch).toHaveBeenCalledTimes(2);
      } finally {
        vi.useRealTimers();
      }
    });

    test('forceRefresh reads past the held answer', async () => {
      const fetch = fetchServing({ id: DID });
      const resolve = cachedResolver({ fetch });

      await resolve(DID);
      await resolve(DID, { forceRefresh: true });
      expect(fetch).toHaveBeenCalledTimes(2);
    });

    test('serves a rotated document after forceRefresh', async () => {
      const fetch = vi
        .fn()
        .mockResolvedValueOnce({ ok: true, json: async () => ({ key: 'old' }) })
        .mockResolvedValue({ ok: true, json: async () => ({ key: 'new' }) });
      const resolve = cachedResolver({ fetch });

      expect(await resolve(DID)).toEqual({ key: 'old' });
      expect(await resolve(DID, { forceRefresh: true })).toEqual({
        key: 'new',
      });
      // The refreshed answer replaces the held one.
      expect(await resolve(DID)).toEqual({ key: 'new' });
    });

    test('holds an unresolvable DID for the shorter negative interval', async () => {
      vi.useFakeTimers();
      try {
        const fetch = fetchServing(null);
        const resolve = cachedResolver({
          fetch,
          ttl: 100000,
          negativeTtl: 1000,
        });

        expect(await resolve(DID)).toBeNull();
        expect(await resolve(DID)).toBeNull();
        expect(fetch).toHaveBeenCalledTimes(1);

        vi.advanceTimersByTime(1001);
        expect(await resolve(DID)).toBeNull();
        expect(fetch).toHaveBeenCalledTimes(2);
      } finally {
        vi.useRealTimers();
      }
    });

    test('serves the held document when the directory stops answering', async () => {
      vi.useFakeTimers();
      try {
        const fetch = vi
          .fn()
          .mockResolvedValueOnce({ ok: true, json: async () => ({ id: DID }) })
          .mockRejectedValue(new Error('directory unreachable'));
        const resolve = cachedResolver({ fetch, ttl: 1000 });

        await resolve(DID);
        vi.advanceTimersByTime(1001);
        expect(await resolve(DID)).toEqual({ id: DID });
      } finally {
        vi.useRealTimers();
      }
    });

    test('stops serving a held document past maxTtl', async () => {
      vi.useFakeTimers();
      try {
        const fetch = vi
          .fn()
          .mockResolvedValueOnce({ ok: true, json: async () => ({ id: DID }) })
          .mockRejectedValue(new Error('directory unreachable'));
        const resolve = cachedResolver({
          fetch,
          ttl: 1000,
          maxTtl: 5000,
        });

        await resolve(DID);
        vi.advanceTimersByTime(5001);
        expect(await resolve(DID)).toBeNull();
      } finally {
        vi.useRealTimers();
      }
    });
  });
});