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); }); });