waybetter/packages/runtime/tests/permissions.test.ts
CRT cb0f7d358d Plugin permissions and app installs: @permissions header, per-plugin API views, validated install/remove
- host plugins.rs: PERMISSIONS, parse_permissions, ScriptPlugin.permissions/problems (None = legacy,
  full API), validate_plugin (file name, 2 MiB, UTF-8 text without binary bytes, header with @name
  and @version, valid @permissions, network:none refused when the code calls network APIs),
  source_findings, PluginStore::install (atomic rename, never enables, keeps the enabled state on
  update) / remove / is_enabled / file_hash. start/stop scripts run the module body, construction,
  start() and stop() inside window.<global>.withPlugin(id, grants, fn) when the runtime has it.
- runtime permissions.ts: parsePermissions, scopePluginApi (undeclared gated members throw a
  PluginPermissionError; scope().ui gated like api.ui), createPluginContext (the view of the plugin
  whose code runs; legacy = full API). A declaration and guard rail, not a sandbox.
- The `commands` permission name comes from the parallel palette-commands work (its API lands there).
2026-09-29 01:08:52 +07:00

92 lines
5.7 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createCorePluginApi, type PluginApiSite } from '../src/pluginApi.ts';
import { CORE_GATES, PluginPermissionError, createPluginContext, parsePermissions, scopePluginApi } from '../src/permissions.ts';
const SITE: PluginApiSite = {
name: 'Demo', product: 'wayBetter for Test', storagePrefix: 'demo-plugin:', styleDataKey: 'demoPlugin',
toastId: 'demo-toast', toastBackground: '#111', itemSelector: 'article.message', chordKeys: ['g'],
};
function fakeApi() {
const noop = () => {};
const doc = {
activeElement: null, documentElement: {}, addEventListener: noop, removeEventListener: noop,
head: { appendChild(node: { isConnected: boolean }) { node.isConnected = true; } },
querySelectorAll: () => [],
createElement: () => ({ dataset: {}, textContent: '', isConnected: false, remove() {} }),
};
const store = new Map<string, string>();
const win = {
document: doc, location: { href: 'https://site.test/' }, addEventListener: noop, removeEventListener: noop,
localStorage: { get length() { return store.size; }, key: (i: number) => [...store.keys()][i] ?? null, getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => store.set(k, v), removeItem: (k: string) => store.delete(k) },
navigator: { clipboard: { writeText: async () => {} } },
requestAnimationFrame: (fn: () => void) => { fn(); return 1; }, cancelAnimationFrame: noop,
MutationObserver: class { observe() {} disconnect() {} },
};
const core = createCorePluginApi(win as unknown as Window & typeof globalThis, SITE);
// A site adds its own gated members, like X's `account()` and `users`.
const users = Object.freeze({ get: () => null, onUpdate: () => () => {} });
return Object.freeze(Object.assign(core, { account: () => ({ handle: 'me' }), users }));
}
const GATES = { ...CORE_GATES, account: 'account', users: 'users' } as const;
test('parsePermissions mirrors the native header rules', () => {
assert.deepEqual(parsePermissions('storage, ui clipboard,UI'), { permissions: ['storage', 'ui', 'clipboard'] });
assert.deepEqual(parsePermissions('network:none'), { permissions: ['network:none'] });
assert.deepEqual(parsePermissions('none'), { permissions: [] });
assert.match((parsePermissions('storage, cookies') as { error: string }).error, /unknown permission "cookies"/);
assert.match((parsePermissions('network, network:none') as { error: string }).error, /contradict/);
});
test('undeclared members throw a clear PluginPermissionError; declared ones work', async () => {
const api = scopePluginApi(fakeApi(), ['storage'], 'notes.plugin.js', GATES);
assert.equal(api.storage('n').set('a', 1), true, 'storage granted');
assert.equal(typeof api.injectCSS, 'function', 'DOM helpers need no permission');
assert.equal(typeof api.theme.current, 'function', 'theme needs no permission');
assert.throws(() => api.copyText('x'), (error: unknown) => error instanceof PluginPermissionError && error.permission === 'clipboard' && /notes\.plugin\.js: api\.copyText needs the "clipboard" permission/.test(error.message));
assert.throws(() => api.toast('hi'), /api\.toast needs the "ui" permission/);
assert.throws(() => api.ui.confirm({ title: 'x' } as never), /api\.ui\.confirm needs the "ui" permission/);
assert.throws(() => (api as unknown as { account(): unknown }).account(), /"account" permission/);
assert.throws(() => (api as unknown as { users: { get(h: string): unknown } }).users.get('x'), /api\.users\.get needs the "users" permission/);
assert.ok(Object.isFrozen(api), 'a plugin cannot patch its view');
});
test('scope().ui is gated like api.ui; the rest of the scope works and disposes', () => {
const api = scopePluginApi(fakeApi(), [], 'plain.plugin.js', GATES);
const scope = api.scope();
let disposed = 0;
scope.defer(() => { disposed += 1; });
scope.css('x', 'a{}');
assert.throws(() => scope.ui.toast('hi'), /scope\(\)\.ui\.toast needs the "ui" permission/);
scope.dispose();
assert.equal(disposed, 1);
const withUi = scopePluginApi(fakeApi(), ['ui'], 'ui.plugin.js', GATES).scope();
assert.equal(typeof withUi.ui.toast, 'function');
});
test('the context hands each plugin its own view while its code runs; legacy plugins get the full API', () => {
const full = fakeApi();
const context = createPluginContext(full, GATES);
assert.equal(context.current(), full, 'outside any plugin: the full API');
const seen = context.run('a.plugin.js', ['clipboard'], () => context.current());
assert.notEqual(seen, full);
assert.throws(() => seen.storage('x'), /a\.plugin\.js: api\.storage needs the "storage" permission/);
// stop() runs later without grants: the registered view is reused.
assert.equal(context.run('a.plugin.js', undefined, () => context.current()), seen);
assert.deepEqual(context.grants('a.plugin.js'), ['clipboard']);
// Nested: a plugin started from inside another's code gets its own view, then the outer one returns.
context.run('outer.plugin.js', ['ui'], () => {
const outer = context.current();
assert.equal(context.run('legacy.plugin.js', null, () => context.current()), full, 'legacy = full API');
assert.equal(context.current(), outer);
});
assert.equal(context.grants('legacy.plugin.js'), null);
assert.equal(context.current(), full);
// An exception inside a plugin never leaves its view installed.
assert.throws(() => context.run('boom.plugin.js', [], () => { throw new Error('boom'); }), /boom/);
assert.equal(context.current(), full);
// Unknown grant names are dropped rather than trusted.
context.run('odd.plugin.js', ['storage', 'root'], () => {});
assert.deepEqual(context.grants('odd.plugin.js'), ['storage']);
});