144 lines
10 KiB
TypeScript
144 lines
10 KiB
TypeScript
// Distribution features: plugin permissions on X's API, accurate headers on every bundled plugin,
|
|
// the install screen's helpers as the app binds them (framework storeModel), the store catalog on
|
|
// disk (X's part of the shared catalog), and the signing script round trip (framework CLI).
|
|
import assert from 'node:assert/strict';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import test from 'node:test';
|
|
import { createPluginContext, parsePermissions, PluginPermissionError } from '../../../waybetter/packages/runtime/src/permissions.ts';
|
|
import { createPluginApi, X_PERMISSION_GATES } from '../src/inject/pluginApi.ts';
|
|
import { supersededFiles } from '../../../waybetter/packages/runtime/src/featureTable.ts';
|
|
import { X_TABLE } from '../src/areas/table.ts';
|
|
import { filterItems, freeThemeId, installedThemeId, itemState, kindForFile, newerVersion, permissionBadges, previewLines, type StoreItem } from '../src/shell/installModel.ts';
|
|
|
|
const ROOT = new URL('../../../', import.meta.url);
|
|
const PLUGINS = new URL('../src-tauri/bundled-plugins/', import.meta.url);
|
|
const fakeWindow = { document: { documentElement: {} }, location: { href: 'https://x.com/home' } } as unknown as Window & typeof globalThis;
|
|
|
|
function header(source: string): string[] | null {
|
|
const body = /^\s*\/\*\*([\s\S]*?)\*\//.exec(source)?.[1] ?? '';
|
|
const line = body.split(/\r?\n/).map(l => l.trim().replace(/^\*+/, '').trim()).find(l => l.startsWith('@permissions'));
|
|
if (!line) return null;
|
|
const parsed = parsePermissions(line.slice('@permissions'.length));
|
|
assert.ok('permissions' in parsed, `invalid @permissions: ${line}`);
|
|
return (parsed as { permissions: string[] }).permissions;
|
|
}
|
|
|
|
/** Gated API members a plugin's source uses (the same members X_PERMISSION_GATES lists). */
|
|
function used(source: string): string[] {
|
|
const out = new Set<string>();
|
|
if (/\.storage\(/.test(source)) out.add('storage');
|
|
if (/\.ui\b|\.toast\(/.test(source)) out.add('ui');
|
|
if (/\.copyText\(/.test(source)) out.add('clipboard');
|
|
if (/\.account\(/.test(source)) out.add('account');
|
|
if (/\.users\./.test(source)) out.add('users');
|
|
if (/\.commands\./.test(source)) out.add('commands');
|
|
return [...out].sort();
|
|
}
|
|
|
|
test("X's plugin API gates account() and users on top of the core's storage/ui/clipboard", () => {
|
|
assert.deepEqual({ ...X_PERMISSION_GATES }, { storage: 'storage', ui: 'ui', toast: 'ui', copyText: 'clipboard', commands: 'commands', account: 'account', users: 'users' });
|
|
const full = createPluginApi(fakeWindow);
|
|
const context = createPluginContext(full, X_PERMISSION_GATES);
|
|
const view = context.run('notes.plugin.js', ['storage'], () => context.current());
|
|
assert.throws(() => view.account(), (error: unknown) => error instanceof PluginPermissionError && /notes\.plugin\.js: api\.account needs the "account" permission/.test(error.message));
|
|
assert.throws(() => view.users.get('someone'), /api\.users\.get needs the "users" permission/);
|
|
assert.throws(() => view.copyText('x'), /"clipboard"/);
|
|
assert.equal(typeof view.tweet, 'function', 'reading a rendered post needs no permission');
|
|
// A legacy plugin (no header line) keeps the full API.
|
|
const legacy = context.run('old.plugin.js', null, () => context.current());
|
|
assert.equal(legacy, full);
|
|
});
|
|
|
|
test('every bundled plugin declares exactly the gated API it uses, plus network:none', () => {
|
|
const files = readdirSync(PLUGINS).filter(file => file.endsWith('.plugin.js'));
|
|
assert.ok(files.length >= 14);
|
|
for (const file of files) {
|
|
const source = readFileSync(new URL(file, PLUGINS), 'utf8');
|
|
const declared = header(source);
|
|
// The Emoji Picker is being redesigned in parallel; its header is added with that work.
|
|
if (declared === null && file === 'emoji-picker.plugin.js') continue;
|
|
assert.ok(declared, `${file} has @permissions`);
|
|
assert.deepEqual(declared!.filter(p => !p.startsWith('network')).sort(), used(source), `${file}: declared vs used`);
|
|
assert.ok(declared!.includes('network:none'), `${file} promises no network`);
|
|
assert.doesNotMatch(source, /\bfetch\(|XMLHttpRequest|WebSocket|sendBeacon|EventSource/, `${file} really makes no requests`);
|
|
}
|
|
});
|
|
|
|
const item = (patch: Partial<StoreItem>): StoreItem => ({
|
|
apps: ['x'], kind: 'plugin', id: 'tool', name: 'Tool', author: 'me', version: '1.1.0', description: 'Does things', category: 'tools', tags: ['x'],
|
|
url: 'https://waybetter.fyi/store/plugins/tool.plugin.js', file: 'tool.plugin.js', sha256: 'a'.repeat(64), size: 10, permissions: ['ui'], ...patch,
|
|
});
|
|
|
|
test('store item state: install, installed, update, changed locally, built in', () => {
|
|
const base = { plugins: {}, records: {}, builtinThemes: ['nord'], userThemes: [] as string[] };
|
|
assert.equal(itemState(item({}), base).state, 'install');
|
|
assert.equal(itemState(item({}), { ...base, plugins: { 'tool.plugin.js': { version: '1.1.0', sha256: 'a'.repeat(64), enabled: true } } }).state, 'installed');
|
|
assert.equal(itemState(item({}), { ...base, plugins: { 'tool.plugin.js': { version: '1.0.0', sha256: 'b'.repeat(64), enabled: false } } }).state, 'update');
|
|
assert.equal(itemState(item({}), { ...base, plugins: { 'tool.plugin.js': { version: '1.1.0', sha256: 'b'.repeat(64), enabled: false } } }).state, 'modified');
|
|
const theme = item({ kind: 'theme', id: 'nord', file: undefined, permissions: undefined });
|
|
assert.equal(itemState(theme, base).state, 'builtin');
|
|
const own = item({ kind: 'theme', id: 'sunset', file: undefined, version: '1.2.0' });
|
|
const records = { 'theme:sunset-2': { kind: 'theme' as const, source: 'store' as const, storeId: 'sunset', version: '1.0.0', sha256: 'c'.repeat(64), installedAt: '' } };
|
|
assert.equal(itemState(own, { ...base, records, userThemes: ['sunset-2'] }).state, 'update');
|
|
assert.equal(itemState(own, { ...base, records, userThemes: [] }).state, 'install', 'a theme the owner deleted is not installed');
|
|
assert.equal(installedThemeId(own, records, ['sunset-2']), 'sunset-2');
|
|
});
|
|
|
|
test('install helpers: file kinds, theme ids, versions, badges, search, previews', () => {
|
|
assert.equal(kindForFile('Focus.plugin.js'), 'plugin');
|
|
assert.equal(kindForFile('nord.bttheme.JSON'), 'theme');
|
|
assert.equal(kindForFile('photo.png'), null);
|
|
assert.equal(freeThemeId('nord', ['x', 'paper']), 'nord');
|
|
assert.equal(freeThemeId('nord', ['nord', 'nord-2']), 'nord-3');
|
|
assert.ok(newerVersion('1.10.0', '1.9.0') && !newerVersion('1.0.0', '1.0.0') && !newerVersion('0.9', '1.0'));
|
|
assert.equal(permissionBadges(null)[0]!.label, 'Legacy · full page access');
|
|
assert.equal(permissionBadges([])[0]!.id, 'none');
|
|
assert.deepEqual(permissionBadges(['ui', 'network:none']).map(b => b.tone), ['plain', 'ok']);
|
|
const items = [item({}), item({ id: 'nord', kind: 'theme', name: 'Nord', category: 'dark', description: 'Arctic', permissions: undefined })];
|
|
assert.deepEqual(filterItems(items, 'arctic', 'all', 'all').map(i => i.id), ['nord']);
|
|
assert.deepEqual(filterItems(items, '', 'plugin', 'all').map(i => i.id), ['tool']);
|
|
assert.deepEqual(filterItems(items, 'ui', 'all', 'tools').map(i => i.id), ['tool'], 'permissions are searchable');
|
|
assert.deepEqual(previewLines('a\r\nb\nc', 2), { lines: ['a', 'b'], hidden: 1 });
|
|
});
|
|
|
|
test('the committed store catalog lists every bundled plugin with its header version and permissions', () => {
|
|
const catalog = JSON.parse(readFileSync(new URL('apps/web/public/store/catalog.json', ROOT), 'utf8')) as { format: number; items: StoreItem[] };
|
|
assert.equal(catalog.format, 2);
|
|
const mine = catalog.items.filter(entry => entry.apps?.includes('x'));
|
|
assert.ok(mine.length > 0, "X's items are in the shared catalog");
|
|
const plugins = mine.filter(entry => entry.kind === 'plugin');
|
|
// A bundled file a built-in feature replaced stays in the app for the one-time move of an install,
|
|
// and is not a Store item any more (its area has a page on the site instead).
|
|
const superseded = new Set(supersededFiles(X_TABLE).map(entry => entry.file));
|
|
const bundled = readdirSync(PLUGINS).filter(file => file.endsWith('.plugin.js') && !superseded.has(`bt-${file}`));
|
|
assert.ok(bundled.length > 0 && superseded.size === 13, 'the Emoji Picker stays a plugin; the other thirteen are features');
|
|
assert.deepEqual(plugins.map(entry => entry.file).sort(), bundled.map(file => `bt-${file}`).sort(), 'every bundled plugin that is not a feature is in the store');
|
|
for (const entry of plugins) {
|
|
assert.match(entry.file!, /^bt-[a-z-]+\.plugin\.js$/, 'installed under the bundled file name');
|
|
assert.ok(entry.url?.startsWith('https://waybetter.fyi/store/'));
|
|
const copy = readFileSync(new URL(`apps/web/public/store/plugins/${entry.file}`, ROOT));
|
|
assert.equal(copy.length, entry.size);
|
|
}
|
|
assert.ok(mine.some(entry => entry.kind === 'theme' && entry.id === 'nord'));
|
|
assert.ok(!mine.some(entry => entry.kind === 'theme' && entry.id === 'x'), "X's own look is not in the store");
|
|
});
|
|
|
|
test('sign-store.mjs: a fresh key signs, the signature verifies, a changed byte does not', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'wb-store-'));
|
|
const key = join(dir, 'test-store.key');
|
|
const pub = join(dir, 'store.pub');
|
|
const catalog = join(dir, 'catalog.json');
|
|
writeFileSync(catalog, JSON.stringify({ format: 2, name: 'Test', generatedAt: '2026-09-28T00:00:00Z', items: [] }));
|
|
const script = new URL('scripts/sign-store.mjs', ROOT).pathname.replace(/^\/([A-Za-z]:)/, '$1');
|
|
const run = (...args: string[]) => execFileSync(process.execPath, [script, ...args], { encoding: 'utf8' });
|
|
assert.match(run('--init', '--key', key, '--pub', pub), /Created the store key/);
|
|
assert.doesNotMatch(run('--init', '--key', key, '--pub', pub), /PRIVATE/, 'never prints key material');
|
|
assert.match(run(catalog, '--key', key, '--pub', pub), /Signed and verified/);
|
|
assert.equal(Buffer.from(readFileSync(`${catalog}.sig`, 'utf8').trim(), 'base64').length, 64);
|
|
assert.equal(Buffer.from(readFileSync(pub, 'utf8').trim(), 'base64').length, 32);
|
|
writeFileSync(catalog, readFileSync(catalog, 'utf8').replace('Test', 'Tesu'));
|
|
assert.throws(() => execFileSync(process.execPath, [script, '--verify', catalog, '--pub', pub], { stdio: 'pipe' }), /does NOT verify|Command failed/);
|
|
});
|