import { bareSpaceTypes, expandSpaceScopes, parseScopesForDisplay, parseSpaceScope, ScopePermissions, spaceScopeAllowsManage, spaceScopeMatches, spaceTypeCollections, } from '@pdsjs/core/scope'; import { describe, expect, it } from 'vitest'; const USER = 'did:plc:user'; const AUTHORITY = 'did:plc:authority'; /** * parseSpaceScope returns null for scopes it does not recognise; these cases * all expect a grant, so assert that here rather than at every use. * @param {string} scope * @returns {any} */ function parse(scope) { const grant = parseSpaceScope(scope); if (!grant) throw new Error(`expected a space grant: ${scope}`); return grant; } describe('parseSpaceScope', () => { it('ignores scopes of other kinds', () => { expect(parseSpaceScope('repo:com.example.post')).toBeNull(); expect(parseSpaceScope('atproto')).toBeNull(); }); it('defaults authority to self and skey to *', () => { const g = parse('space:com.example.forum'); expect(g.spaceType).toBe('com.example.forum'); expect(g.authority).toBe('self'); expect(g.skey).toBe('*'); }); it('defaults actions to everything but read_self', () => { const g = parse('space:com.example.forum'); expect(g.actions).toEqual(['read', 'create', 'update', 'delete']); expect(g.actions).not.toContain('read_self'); }); it('leaves collections null when omitted', () => { // Null means "the space type's declared collections" — only the space type // can resolve that, so the grant cannot. expect(parse('space:com.example.forum').collections).toBeNull(); }); it('parses every parameter', () => { const g = parse( `space:com.example.forum?authority=${AUTHORITY}&skey=general` + '&collection=com.example.post&collection=com.example.reply' + '&action=read&action=create&manage=delete', ); expect(g).toEqual({ spaceType: 'com.example.forum', authority: AUTHORITY, skey: 'general', collections: ['com.example.post', 'com.example.reply'], actions: ['read', 'create'], manage: ['delete'], }); }); it('accepts wildcards', () => { const g = parse('space:*?authority=*&skey=*&collection=*'); expect(g.spaceType).toBe('*'); expect(g.authority).toBe('*'); expect(g.collections).toEqual(['*']); }); it('rejects a space type that is not an NSID', () => { expect(parseSpaceScope('space:nodots')).toBeNull(); }); it('rejects a malformed authority', () => { expect(parseSpaceScope('space:com.e.f?authority=alice.test')).toBeNull(); }); it('rejects an over-long skey', () => { const skey = 'x'.repeat(513); expect(parseSpaceScope(`space:com.e.f?skey=${skey}`)).toBeNull(); }); it('rejects a scope whose actions are all unrecognised', () => { expect(parseSpaceScope('space:com.e.f?action=frobnicate')).toBeNull(); }); it('drops unrecognised manage verbs', () => { expect(parse('space:com.e.f?manage=frobnicate').manage).toEqual([]); }); }); describe('spaceScopeMatches', () => { const req = (over = {}) => ({ spaceType: 'com.example.forum', authority: AUTHORITY, skey: 'general', action: 'read', userDid: USER, ...over, }); it('matches on the full tuple', () => { const g = parse( `space:com.example.forum?authority=${AUTHORITY}&skey=general`, ); expect(spaceScopeMatches(g, req())).toBe(true); expect(spaceScopeMatches(g, req({ skey: 'other' }))).toBe(false); expect(spaceScopeMatches(g, req({ spaceType: 'com.other.kind' }))).toBe( false, ); expect(spaceScopeMatches(g, req({ authority: 'did:plc:other' }))).toBe( false, ); }); it('resolves self to the granting user', () => { const g = parse('space:com.example.forum'); expect(spaceScopeMatches(g, req({ authority: USER }))).toBe(true); // The space belongs to someone else, so a `self` grant does not reach it. expect(spaceScopeMatches(g, req({ authority: AUTHORITY }))).toBe(false); }); it('honours wildcards', () => { const g = parse('space:*?authority=*&skey=*'); expect(spaceScopeMatches(g, req({ spaceType: 'anything.at.all' }))).toBe( true, ); }); it('lets read imply read_self but not the reverse', () => { const readOnly = parse( `space:com.example.forum?authority=${AUTHORITY}&action=read`, ); expect(spaceScopeMatches(readOnly, req({ action: 'read_self' }))).toBe( true, ); const selfOnly = parse( `space:com.example.forum?authority=${AUTHORITY}&action=read_self`, ); expect(spaceScopeMatches(selfOnly, req({ action: 'read' }))).toBe(false); expect(spaceScopeMatches(selfOnly, req({ action: 'read_self' }))).toBe( true, ); }); it('constrains writes by collection', () => { const g = parse( `space:com.example.forum?authority=${AUTHORITY}&collection=com.example.post`, ); expect( spaceScopeMatches( g, req({ action: 'create', collection: 'com.example.post' }), ), ).toBe(true); expect( spaceScopeMatches( g, req({ action: 'create', collection: 'com.example.reply' }), ), ).toBe(false); }); it('falls back to the space type declared collections', () => { const g = parse(`space:com.example.forum?authority=${AUTHORITY}`); expect( spaceScopeMatches( g, req({ action: 'create', collection: 'com.example.post', declaredCollections: ['com.example.post'], }), ), ).toBe(true); // No declared collections and none granted means no write targets at all. expect( spaceScopeMatches(g, req({ action: 'create', collection: 'com.e.p' })), ).toBe(false); }); it('does not let a record action imply management', () => { const g = parse(`space:com.example.forum?authority=${AUTHORITY}`); expect( spaceScopeAllowsManage(g, { spaceType: 'com.example.forum', authority: AUTHORITY, skey: 'general', op: 'delete', userDid: USER, }), ).toBe(false); }); it('permits management when the verb is granted', () => { const g = parse( `space:com.example.forum?authority=${AUTHORITY}&manage=create&manage=delete`, ); const base = { spaceType: 'com.example.forum', authority: AUTHORITY, skey: 'general', userDid: USER, }; expect(spaceScopeAllowsManage(g, { ...base, op: 'create' })).toBe(true); expect(spaceScopeAllowsManage(g, { ...base, op: 'update' })).toBe(false); }); }); describe('ScopePermissions', () => { it('collects space grants alongside the others', () => { const p = new ScopePermissions( `repo:com.example.post space:com.example.forum?authority=${AUTHORITY}`, ); expect(p.spacePermissions).toHaveLength(1); expect(p.repoPermissions).toHaveLength(1); }); it('allowsSpace honours the grant', () => { const p = new ScopePermissions( `space:com.example.forum?authority=${AUTHORITY}&action=read`, ); const req = { spaceType: 'com.example.forum', authority: AUTHORITY, skey: 'general', action: 'read', userDid: USER, }; expect(p.allowsSpace(req)).toBe(true); expect(p.allowsSpace({ ...req, action: 'create' })).toBe(false); }); /** @param {string} scope */ const reaches = (scope) => new ScopePermissions(scope).allowsSpace({ spaceType: 'com.example.forum', authority: AUTHORITY, skey: 'k', action: 'delete', collection: 'com.e.p', }); it('the account password session covers spaces', () => { expect(reaches('com.atproto.access')).toBe(true); }); it('an OAuth session reaches no space it was not granted', () => { // `atproto` is in every OAuth authorization request, so a session that // reached spaces through it would give every application every space. // `transition:generic` predates permissioned data entirely. expect(reaches('atproto')).toBe(false); expect(reaches('atproto transition:generic')).toBe(false); expect(reaches('atproto repo:com.example.post blob:image/*')).toBe(false); }); it('the same session reaches a space its scope names', () => { expect( reaches( `atproto space:com.example.forum?authority=${AUTHORITY}&collection=com.e.p`, ), ).toBe(true); }); it('grants nothing without a space scope', () => { const p = new ScopePermissions('repo:com.example.post'); expect( p.allowsSpace({ spaceType: 'com.example.forum', authority: AUTHORITY, skey: 'general', action: 'read', userDid: USER, }), ).toBe(false); }); }); describe('parseScopesForDisplay', () => { it('surfaces space grants for the consent screen', () => { const display = parseScopesForDisplay( `space:com.example.forum?authority=${AUTHORITY}&action=read repo:com.example.post`, ); expect(display.spacePermissions).toHaveLength(1); expect(display.spacePermissions[0].spaceType).toBe('com.example.forum'); expect(display.spacePermissions[0].actions).toEqual(['read']); }); }); describe('space types without collections', () => { const FORUM = { lexicon: 1, id: 'com.example.forum', defs: { main: { type: 'space', collections: ['com.example.post', 'com.example.reply', '*', 'nodots'], }, }, }; it('reads the collections a space type declares', () => { expect(spaceTypeCollections(FORUM)).toEqual([ 'com.example.post', 'com.example.reply', ]); expect(spaceTypeCollections({ defs: { main: { type: 'space' } } })).toEqual( [], ); expect( spaceTypeCollections({ defs: { main: { type: 'record' } } }), ).toBeNull(); expect(spaceTypeCollections(null)).toBeNull(); }); it('names the space types a scope grants without a collection', () => { expect( bareSpaceTypes( 'atproto space:com.example.forum?authority=* space:com.example.forum ' + 'space:com.example.chat?collection=com.example.msg space:* repo:com.example.post', ), ).toEqual(['com.example.forum']); }); it('writes the declared collections into those scopes', () => { const scope = expandSpaceScopes( 'atproto space:com.example.forum?authority=*&manage=create ' + 'space:com.example.forum space:com.example.chat?collection=com.example.msg ' + 'space:com.example.empty', { 'com.example.forum': ['com.example.post', 'com.example.reply'], 'com.example.empty': [], }, ); expect(scope.split(' ')).toEqual([ 'atproto', 'space:com.example.forum?authority=*&manage=create&collection=com.example.post&collection=com.example.reply', 'space:com.example.forum?collection=com.example.post&collection=com.example.reply', 'space:com.example.chat?collection=com.example.msg', 'space:com.example.empty', ]); const grant = parse(scope.split(' ')[1]); expect(grant.collections).toEqual([ 'com.example.post', 'com.example.reply', ]); expect(grant.authority).toBe('*'); expect(grant.manage).toEqual(['create']); }); });