Files
platform/src/servers/plugins/public-paths.test.ts
T
pastilhasandClaude Opus 5 cfbec0b757 plugins: a splash is part of a plugin, like its icon
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>
2026-08-19 06:29:41 +01:00

172 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});