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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
438 lines · 14.5 KB Raw History
import {
  matchesMime,
  parseBlobScope,
  parseRepoScope,
  parseRpcScope,
  parseScopesForDisplay,
  ScopePermissions,
} from '@pdsjs/core/scope';
import { describe, expect, test } from 'vitest';

describe('Scope Parsing', () => {
  describe('parseRepoScope', () => {
    test('parses repo scope with query parameter action', () => {
      const result = parseRepoScope('repo:app.bsky.feed.post?action=create');
      expect(result).toEqual({
        collection: 'app.bsky.feed.post',
        actions: ['create'],
      });
    });

    test('parses repo scope with multiple query parameter actions', () => {
      const result = parseRepoScope(
        'repo:app.bsky.feed.post?action=create&action=update',
      );
      expect(result).toEqual({
        collection: 'app.bsky.feed.post',
        actions: ['create', 'update'],
      });
    });

    test('parses repo scope without actions as all actions', () => {
      const result = parseRepoScope('repo:app.bsky.feed.post');
      expect(result).toEqual({
        collection: 'app.bsky.feed.post',
        actions: ['create', 'update', 'delete'],
      });
    });

    test('parses wildcard collection with action', () => {
      const result = parseRepoScope('repo:*?action=create');
      expect(result).toEqual({
        collection: '*',
        actions: ['create'],
      });
    });

    test('parses query-only format', () => {
      const result = parseRepoScope(
        'repo?collection=app.bsky.feed.post&action=create',
      );
      expect(result).toEqual({
        collection: 'app.bsky.feed.post',
        actions: ['create'],
      });
    });

    test('deduplicates repeated actions', () => {
      const result = parseRepoScope(
        'repo:app.bsky.feed.post?action=create&action=create&action=update',
      );
      expect(result).toEqual({
        collection: 'app.bsky.feed.post',
        actions: ['create', 'update'],
      });
    });

    test('returns null for non-repo scope', () => {
      expect(parseRepoScope('atproto')).toBe(null);
      expect(parseRepoScope('blob:image/*')).toBe(null);
      expect(parseRepoScope('transition:generic')).toBe(null);
    });

    test('returns null for invalid repo scope', () => {
      expect(parseRepoScope('repo:')).toBe(null);
      expect(parseRepoScope('repo?')).toBe(null);
    });
  });

  describe('parseBlobScope', () => {
    test('parses wildcard MIME', () => {
      const result = parseBlobScope('blob:*/*');
      expect(result).toEqual({ accept: ['*/*'] });
    });

    test('parses type wildcard', () => {
      const result = parseBlobScope('blob:image/*');
      expect(result).toEqual({ accept: ['image/*'] });
    });

    test('parses specific MIME', () => {
      const result = parseBlobScope('blob:image/png');
      expect(result).toEqual({ accept: ['image/png'] });
    });

    test('parses multiple MIMEs', () => {
      const result = parseBlobScope('blob:image/png,image/jpeg');
      expect(result).toEqual({ accept: ['image/png', 'image/jpeg'] });
    });

    test('returns null for non-blob scope', () => {
      expect(parseBlobScope('atproto')).toBe(null);
      expect(parseBlobScope('repo:*:create')).toBe(null);
    });
  });

  describe('matchesMime', () => {
    test('wildcard matches everything', () => {
      expect(matchesMime('*/*', 'image/png')).toBe(true);
      expect(matchesMime('*/*', 'video/mp4')).toBe(true);
    });

    test('type wildcard matches same type', () => {
      expect(matchesMime('image/*', 'image/png')).toBe(true);
      expect(matchesMime('image/*', 'image/jpeg')).toBe(true);
      expect(matchesMime('image/*', 'video/mp4')).toBe(false);
    });

    test('exact match', () => {
      expect(matchesMime('image/png', 'image/png')).toBe(true);
      expect(matchesMime('image/png', 'image/jpeg')).toBe(false);
    });

    test('case insensitive', () => {
      expect(matchesMime('image/PNG', 'image/png')).toBe(true);
      expect(matchesMime('IMAGE/*', 'image/png')).toBe(true);
    });
  });
});

describe('ScopePermissions', () => {
  describe('static scopes', () => {
    test('atproto grants full access', () => {
      const perms = new ScopePermissions('atproto');
      expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true);
      expect(perms.allowsRepo('any.collection', 'delete')).toBe(true);
      expect(perms.allowsBlob('image/png')).toBe(true);
      expect(perms.allowsBlob('video/mp4')).toBe(true);
    });

    test('transition:generic grants full repo/blob access', () => {
      const perms = new ScopePermissions('transition:generic');
      expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true);
      expect(perms.allowsRepo('any.collection', 'delete')).toBe(true);
      expect(perms.allowsBlob('image/png')).toBe(true);
    });
  });

  // An app-password scope string names no granular permissions. The reference
  // PDS asserts repo, blob and rpc permissions for OAuth credentials only, and
  // bounds an app password by other means.
  describe('app-password sessions', () => {
    for (const scope of [
      'com.atproto.appPass',
      'com.atproto.appPassPrivileged',
    ]) {
      test(`${scope} writes any collection and uploads any blob`, () => {
        const perms = new ScopePermissions(scope);
        expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true);
        expect(perms.allowsRepo('app.bsky.feed.post', 'update')).toBe(true);
        expect(perms.allowsRepo('any.collection', 'delete')).toBe(true);
        expect(perms.allowsBlob('image/png')).toBe(true);
        expect(perms.allowsRpc('app.bsky.feed.getFeed', 'did:web:x#y')).toBe(
          true,
        );
      });

      test(`${scope} is not full access, so it never carries the email`, () => {
        const perms = new ScopePermissions(scope);
        expect(perms.hasFullAccess()).toBe(false);
        expect(perms.hasEmailAccess()).toBe(false);
      });

      test(`${scope} does not reach space scopes`, () => {
        const perms = new ScopePermissions(scope);
        expect(perms.isAppPasswordSession()).toBe(true);
        expect(
          perms.allowsSpace({
            spaceType: 'com.example.space',
            authority: 'did:plc:authority234567890ab',
            skey: 'skey',
            action: 'read',
          }),
        ).toBe(false);
      });
    }
  });

  describe('repo scopes', () => {
    test('wildcard collection allows any collection', () => {
      const perms = new ScopePermissions('repo:*?action=create');
      expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true);
      expect(perms.allowsRepo('app.bsky.feed.like', 'create')).toBe(true);
      expect(perms.allowsRepo('app.bsky.feed.post', 'delete')).toBe(false);
    });

    test('specific collection restricts to that collection', () => {
      const perms = new ScopePermissions(
        'repo:app.bsky.feed.post?action=create',
      );
      expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true);
      expect(perms.allowsRepo('app.bsky.feed.like', 'create')).toBe(false);
    });

    test('multiple actions', () => {
      const perms = new ScopePermissions('repo:*?action=create&action=update');
      expect(perms.allowsRepo('x', 'create')).toBe(true);
      expect(perms.allowsRepo('x', 'update')).toBe(true);
      expect(perms.allowsRepo('x', 'delete')).toBe(false);
    });

    test('multiple scopes combine', () => {
      const perms = new ScopePermissions(
        'repo:app.bsky.feed.post?action=create repo:app.bsky.feed.like?action=delete',
      );
      expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true);
      expect(perms.allowsRepo('app.bsky.feed.like', 'delete')).toBe(true);
      expect(perms.allowsRepo('app.bsky.feed.post', 'delete')).toBe(false);
    });

    test('allowsRepo with query param format scopes', () => {
      const perms = new ScopePermissions(
        'atproto repo:app.bsky.feed.post?action=create',
      );
      expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true);
      expect(perms.allowsRepo('app.bsky.feed.post', 'delete')).toBe(true); // atproto grants full access
    });
  });

  describe('blob scopes', () => {
    test('wildcard allows any MIME', () => {
      const perms = new ScopePermissions('blob:*/*');
      expect(perms.allowsBlob('image/png')).toBe(true);
      expect(perms.allowsBlob('video/mp4')).toBe(true);
    });

    test('type wildcard restricts to type', () => {
      const perms = new ScopePermissions('blob:image/*');
      expect(perms.allowsBlob('image/png')).toBe(true);
      expect(perms.allowsBlob('image/jpeg')).toBe(true);
      expect(perms.allowsBlob('video/mp4')).toBe(false);
    });

    test('specific MIME restricts exactly', () => {
      const perms = new ScopePermissions('blob:image/png');
      expect(perms.allowsBlob('image/png')).toBe(true);
      expect(perms.allowsBlob('image/jpeg')).toBe(false);
    });
  });

  describe('empty/no scope', () => {
    test('no scope denies everything', () => {
      const perms = new ScopePermissions('');
      expect(perms.allowsRepo('x', 'create')).toBe(false);
      expect(perms.allowsBlob('image/png')).toBe(false);
    });

    test('undefined scope denies everything', () => {
      const perms = new ScopePermissions(undefined);
      expect(perms.allowsRepo('x', 'create')).toBe(false);
    });
  });

  describe('assertRepo', () => {
    test('throws ScopeMissingError when denied', () => {
      const perms = new ScopePermissions(
        'repo:app.bsky.feed.post?action=create',
      );
      expect(() => perms.assertRepo('app.bsky.feed.like', 'create')).toThrow(
        /Missing required scope/,
      );
    });

    test('does not throw when allowed', () => {
      const perms = new ScopePermissions(
        'repo:app.bsky.feed.post?action=create',
      );
      expect(() =>
        perms.assertRepo('app.bsky.feed.post', 'create'),
      ).not.toThrow();
    });
  });

  describe('assertBlob', () => {
    test('throws ScopeMissingError when denied', () => {
      const perms = new ScopePermissions('blob:image/*');
      expect(() => perms.assertBlob('video/mp4')).toThrow(
        /Missing required scope/,
      );
    });

    test('does not throw when allowed', () => {
      const perms = new ScopePermissions('blob:image/*');
      expect(() => perms.assertBlob('image/png')).not.toThrow();
    });
  });
});

describe('parseScopesForDisplay', () => {
  test('parses identity-only scope', () => {
    const result = parseScopesForDisplay('atproto');
    expect(result.hasAtproto).toBe(true);
    expect(result.hasTransitionGeneric).toBe(false);
    expect(result.repoPermissions.size).toBe(0);
    expect(result.blobPermissions).toEqual([]);
  });

  test('parses granular repo scopes', () => {
    const result = parseScopesForDisplay(
      'atproto repo:app.bsky.feed.post?action=create&action=update',
    );
    expect(result.repoPermissions.size).toBe(1);
    const postPerms = result.repoPermissions.get('app.bsky.feed.post');
    expect(postPerms).toEqual({
      create: true,
      update: true,
      delete: false,
    });
  });

  test('merges multiple scopes for same collection', () => {
    const result = parseScopesForDisplay(
      'atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete',
    );
    const postPerms = result.repoPermissions.get('app.bsky.feed.post');
    expect(postPerms).toEqual({
      create: true,
      update: false,
      delete: true,
    });
  });

  test('parses blob scopes', () => {
    const result = parseScopesForDisplay('atproto blob:image/*');
    expect(result.blobPermissions).toEqual(['image/*']);
  });

  test('detects transition:generic', () => {
    const result = parseScopesForDisplay('atproto transition:generic');
    expect(result.hasTransitionGeneric).toBe(true);
  });

  test('handles empty scope string', () => {
    const result = parseScopesForDisplay('');
    expect(result.hasAtproto).toBe(false);
    expect(result.hasTransitionGeneric).toBe(false);
    expect(result.repoPermissions.size).toBe(0);
    expect(result.blobPermissions).toEqual([]);
  });
});

describe('parseRpcScope', () => {
  test('parses positional lxm with aud query param', () => {
    expect(
      parseRpcScope(
        'rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app%23bsky_appview',
      ),
    ).toEqual({
      lxms: ['app.bsky.feed.getTimeline'],
      aud: 'did:web:api.bsky.app#bsky_appview',
    });
  });

  test('parses repeated lxm query params', () => {
    expect(
      parseRpcScope('rpc?lxm=com.example.a&lxm=com.example.b&aud=*'),
    ).toEqual({ lxms: ['com.example.a', 'com.example.b'], aud: '*' });
  });

  test('parses full wildcards', () => {
    expect(parseRpcScope('rpc:*?aud=*')).toEqual({ lxms: '*', aud: '*' });
  });

  test('rejects missing aud', () => {
    expect(parseRpcScope('rpc:com.example.method')).toBeNull();
    expect(parseRpcScope('rpc?lxm=com.example.method')).toBeNull();
  });

  test('rejects missing lxm', () => {
    expect(parseRpcScope('rpc?aud=*')).toBeNull();
  });

  test('returns null for non-rpc scopes', () => {
    expect(parseRpcScope('repo:app.bsky.feed.post')).toBeNull();
  });
});

describe('ScopePermissions.allowsRpc', () => {
  test('full access scopes bypass rpc checks', () => {
    const perms = new ScopePermissions('atproto');
    expect(perms.allowsRpc('app.bsky.feed.getTimeline', 'did:web:x#svc')).toBe(
      true,
    );
  });

  test('granular scope without rpc permission denies', () => {
    const perms = new ScopePermissions('repo:app.bsky.feed.post');
    expect(perms.allowsRpc('app.bsky.feed.getTimeline', 'did:web:x#svc')).toBe(
      false,
    );
  });

  test('matching lxm and aud allows', () => {
    const perms = new ScopePermissions(
      'rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app%23bsky_appview',
    );
    expect(
      perms.allowsRpc(
        'app.bsky.feed.getTimeline',
        'did:web:api.bsky.app#bsky_appview',
      ),
    ).toBe(true);
  });

  test('aud mismatch denies', () => {
    const perms = new ScopePermissions(
      'rpc:app.bsky.feed.getTimeline?aud=did:web:other%23svc',
    );
    expect(
      perms.allowsRpc(
        'app.bsky.feed.getTimeline',
        'did:web:api.bsky.app#bsky_appview',
      ),
    ).toBe(false);
  });

  test('lxm matching is case-insensitive', () => {
    const perms = new ScopePermissions('rpc:app.bsky.feed.getTimeline?aud=*');
    expect(perms.allowsRpc('App.Bsky.Feed.GetTimeline', 'did:web:x#svc')).toBe(
      true,
    );
  });

  test('wildcard lxm with specific aud', () => {
    const perms = new ScopePermissions('rpc:*?aud=did:web:x%23svc');
    expect(perms.allowsRpc('com.anything.method', 'did:web:x#svc')).toBe(true);
    expect(perms.allowsRpc('com.anything.method', 'did:web:y#svc')).toBe(false);
  });
});