assets/splash.png is discovered by presence, published by the same copy that already took the whole assets directory, and carried on DiscoveredPlugin beside icon. The platform draws no splash — a plugin has no launch. It is carried because the plugin repository is where a product's artwork lives now, and the phone app that fronts the same product needs somewhere to read it from that is not a designer's folder. The pair is the point: the icon is a reduction of the splash's world, and keeping them together is what stops them drifting. Publishing no longer gates on the icon alone, which left a plugin shipping only a splash serving nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
172 lines
7.0 KiB
TypeScript
172 lines
7.0 KiB
TypeScript
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<string>();
|
||
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();
|
||
});
|
||
});
|