import { describe, expect, test, afterEach } from 'bun:test'; import { isPluginPublicPath, pluginForProtocolHeaders, pluginForRootPath, resolvePluginPublicPaths, resolvePluginRootPaths, setPluginProtocolHeaders, setPluginPublicPaths, setPluginRootClaims, } from './public-paths'; import type { DiscoveredPlugin } from './manifest'; // This is an auth boundary: everything matched here skips `userMiddleware` AND the permission gate. The // property worth pinning is not "the happy path works" but "a plugin cannot name a path outside its own // mount", because that is the only failure that would matter. const plugin = (appName: string, unauthenticatedPaths?: string[], publisher = 'officerdev'): DiscoveredPlugin => ({ appName, dir: `/tmp/${appName}`, manifest: { publisher, version: '1.0.0', platform: '>=1.0.0', label: appName, summary: '', color: '#000', permissions: [], ...(unauthenticatedPaths ? { unauthenticatedPaths } : {}), }, icon: null, splash: null, api: null, schema: null, sidecar: null, web: null, }) as DiscoveredPlugin; afterEach(() => { setPluginPublicPaths([]); setPluginRootClaims([]); setPluginProtocolHeaders([]); }); describe('resolving a plugin’s declared paths', () => { test('resolves against the plugin’s own mount', () => { expect(resolvePluginPublicPaths(plugin('vault', ['/bw/*'])).paths).toEqual(['/api/vault/bw/*']); }); test('a third-party plugin resolves under its publisher, never at the root', () => { const { paths } = resolvePluginPublicPaths(plugin('vault', ['/bw/*'], 'alice')); expect(paths).toEqual(['/api/p/alice/vault/bw/*']); }); test('declaring nothing is the ordinary case and yields nothing', () => { expect(resolvePluginPublicPaths(plugin('kiosk')).paths).toEqual([]); }); // The rules `publicPathProblem` enforces, checked through this function because that is how they are // actually reached. Each is refused rather than corrected. test.each([ ['..', ['/../../auth']], ['a double slash', ['//auth']], ['a mid-path wildcard', ['/a/*/b']], ['a bare wildcard suffix', ['/a*']], ['the whole plugin', ['/*']], ['the plugin root', ['/']], ['a relative path', ['bw/*']], ['a query string', ['/bw?x=1']], ])('refuses %s', (_label, declared) => { const { paths, refused } = resolvePluginPublicPaths(plugin('vault', declared)); expect(paths).toEqual([]); expect(refused).toHaveLength(1); }); test('one bad entry does not take the good ones with it', () => { const { paths, refused } = resolvePluginPublicPaths(plugin('vault', ['/bw/*', '/../nope'])); expect(paths).toEqual(['/api/vault/bw/*']); expect(refused).toHaveLength(1); }); }); describe('matching a request path', () => { test('a trailing /* matches the prefix and everything under it', () => { setPluginPublicPaths([{ pattern: '/api/vault/bw/*', appName: 'vault' }]); expect(isPluginPublicPath('/api/vault/bw')).toBe(true); expect(isPluginPublicPath('/api/vault/bw/identity/connect/token')).toBe(true); }); test('it does not leak to the plugin’s own gated routes', () => { setPluginPublicPaths([{ pattern: '/api/vault/bw/*', appName: 'vault' }]); // The whole point: /config stays behind the session, one segment away from a public prefix. expect(isPluginPublicPath('/api/vault/config')).toBe(false); expect(isPluginPublicPath('/api/vault')).toBe(false); }); test('a prefix match is on segments, not characters', () => { setPluginPublicPaths([{ pattern: '/api/vault/bw/*', appName: 'vault' }]); // `/bwx` shares the string prefix and must not match — the same class of bug `permissionForApiPath` // guards against with `/user` vs `/username`. expect(isPluginPublicPath('/api/vault/bwx')).toBe(false); expect(isPluginPublicPath('/api/vault/bwevil/x')).toBe(false); }); test('an exact pattern matches only itself', () => { setPluginPublicPaths([{ pattern: '/api/kiosk/manifest', appName: 'kiosk' }]); expect(isPluginPublicPath('/api/kiosk/manifest')).toBe(true); expect(isPluginPublicPath('/api/kiosk/manifest/extra')).toBe(false); }); test('nothing is public when nothing is declared — the default for every plugin today', () => { expect(isPluginPublicPath('/api/vault/bw/identity')).toBe(false); expect(isPluginPublicPath('/api/users')).toBe(false); }); }); describe('top-level claims', () => { const withRoots = (appName: string, roots: string[]) => { const p = plugin(appName); (p.manifest as { unauthenticatedRootPaths?: string[] }).unauthenticatedRootPaths = roots; return p; }; test('a single lowercase segment is claimed', () => { const { segments, refused } = resolvePluginRootPaths(withRoots('vault', ['/identity', '/icons']), new Set()); expect(segments).toEqual(['identity', 'icons']); expect(refused).toEqual([]); }); // The whole point of the reserved list: a plugin claiming these would shadow the platform for every // request, and `/api` in particular is where all of Officer lives. test.each([['/api'], ['/auth'], ['/plugins'], ['/dav']])('refuses the reserved segment %s', (root) => { const { segments, refused } = resolvePluginRootPaths(withRoots('evil', [root]), new Set()); expect(segments).toEqual([]); expect(refused).toHaveLength(1); }); test.each([['/a/b'], ['identity'], ['/Identity'], ['/']])('refuses the malformed segment %s', (root) => { expect(resolvePluginRootPaths(withRoots('evil', [root]), new Set()).segments).toEqual([]); }); test('two plugins cannot both claim one segment — first come, loser named', () => { const taken = new Set(); expect(resolvePluginRootPaths(withRoots('vault', ['/identity']), taken).segments).toEqual(['identity']); const second = resolvePluginRootPaths(withRoots('other', ['/identity']), taken); expect(second.segments).toEqual([]); expect(second.refused[0]).toContain('already claimed'); }); test('matching a claimed root is by segment, not string prefix', () => { setPluginRootClaims([{ segment: 'identity', appName: 'vault' }]); expect(pluginForRootPath('/identity')).toBe('vault'); expect(pluginForRootPath('/identity/connect/token')).toBe('vault'); expect(pluginForRootPath('/identityx')).toBeNull(); expect(pluginForRootPath('/other')).toBeNull(); }); test('a protocol header hands /api over, and its absence does not', () => { setPluginProtocolHeaders([{ header: 'bitwarden-client-name', appName: 'vault' }]); expect(pluginForProtocolHeaders(new Headers({ 'bitwarden-client-name': 'browser' }))).toBe('vault'); expect(pluginForProtocolHeaders(new Headers({ authorization: 'Bearer x' }))).toBeNull(); }); test('nothing is claimed by default', () => { setPluginRootClaims([]); setPluginProtocolHeaders([]); expect(pluginForRootPath('/identity')).toBeNull(); expect(pluginForProtocolHeaders(new Headers({ 'bitwarden-client-name': 'browser' }))).toBeNull(); }); });