diff --git a/lib/__tests__/plugin-permission-coverage.test.ts b/lib/__tests__/plugin-permission-coverage.test.ts new file mode 100644 index 000000000..aa310798d --- /dev/null +++ b/lib/__tests__/plugin-permission-coverage.test.ts @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { ALL_PERMISSIONS, IMPLICIT_PERMISSIONS } from '../plugin-types'; +import { HOOK_CATALOG } from '../plugin-hook-catalog'; +import { SLOT_PERMISSIONS, SLOT_PROPS } from '../plugin-sandbox/permissions'; + +vi.mock('@/stores/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +import { PERM_PER_METHOD } from '../plugin-sandbox/host-api'; + +const root = path.resolve(__dirname, '../..'); + +/** + * Gates that live outside the three tables, each with the call that + * enforces it. The test reads the file, so a gate that is removed there + * no longer counts here. + */ +const OTHER_GATES: Record = { + 'ui:keyboard': { file: 'lib/plugin-sandbox/loader.ts', call: "pluginHasPermission(plugin, 'ui:keyboard')" }, +}; + +/** + * Permissions in ALL_PERMISSIONS that nothing checks. A plugin could ask + * for one and the user would be asked to allow it, but it would grant + * nothing. None today; one added without a gate shows up here. + */ +const GATES_NOTHING: string[] = []; + +function gatedPermissions(): Map { + const where = new Map(); + const add = (perm: string | null | undefined, by: string) => { + if (!perm) return; + where.set(perm, [...(where.get(perm) ?? []), by]); + }; + for (const [method, perm] of Object.entries(PERM_PER_METHOD)) add(perm, `method ${method}`); + for (const [hook, spec] of Object.entries(HOOK_CATALOG)) add(spec.permission, `hook ${hook}`); + for (const [slot, perms] of Object.entries(SLOT_PERMISSIONS)) for (const perm of perms) add(perm, `slot ${slot}`); + for (const [slot, props] of Object.entries(SLOT_PROPS)) { + if (props === '*') continue; + for (const [prop, perm] of Object.entries(props)) add(perm, `slot prop ${slot}.${prop}`); + } + for (const perm of IMPLICIT_PERMISSIONS) add(perm, 'implicit'); + for (const [perm, gate] of Object.entries(OTHER_GATES)) { + if (readFileSync(path.join(root, gate.file), 'utf8').includes(gate.call)) add(perm, gate.file); + } + return where; +} + +describe('plugin permissions', () => { + it('every permission gates a method, hook or slot (or is a known gap)', () => { + const gated = gatedPermissions(); + const ungated = ALL_PERMISSIONS.filter((perm) => !gated.has(perm)); + expect(ungated).toEqual(GATES_NOTHING); + }); + + it('the tables only use permissions that exist', () => { + const known = new Set(ALL_PERMISSIONS); + expect([...gatedPermissions().keys()].filter((perm) => !known.has(perm))).toEqual([]); + }); + + it('the new host APIs are behind the permissions they were given', () => { + expect(PERM_PER_METHOD).toMatchObject({ + 'calendar.getEvents': 'calendar:read', + 'calendar.createEvent': 'calendar:write', + 'tasks.list': 'tasks:read', + 'tasks.setComplete': 'tasks:write', + 'templates.list': 'templates:read', + 'templates.create': 'templates:write', + 'identities.update': 'identity:write', + 'vacation.get': 'vacation:read', + 'vacation.set': 'vacation:write', + 'email.get': 'email:read', + 'email.list': 'email:read', + 'mailboxes.list': 'email:read', + 'email.getAttachment': 'email:blob-read', + 'email.getRaw': 'email:blob-read', + 'compose.open': 'compose:open', + 'drafts.create': 'email:write', + 'notifications.show': 'ui:notifications', + }); + }); +}); diff --git a/lib/plugin-sandbox/permissions.ts b/lib/plugin-sandbox/permissions.ts index 5bad2b274..6b25573ec 100644 --- a/lib/plugin-sandbox/permissions.ts +++ b/lib/plugin-sandbox/permissions.ts @@ -41,7 +41,7 @@ export function mayRegisterHook(plugin: InstalledPlugin, hookName: string): bool * components receive the data of the surface they sit on (the open message, * the draft), so they are gated like hooks. */ -const SLOT_PERMISSIONS: Record = { +export const SLOT_PERMISSIONS: Readonly> = { 'toolbar-actions': ['ui:toolbar'], 'app-top-banner': ['ui:app-top-banner'], 'email-banner': ['ui:email-banner'], @@ -71,7 +71,7 @@ const SLOT_PERMISSIONS: Record = { * data, and stays behind the matching read permission. Props not listed here * are dropped, so a new prop at a call site is withheld until it is added. */ -const SLOT_PROPS: Record | '*'> = { +export const SLOT_PROPS: Readonly | '*'>> = { 'toolbar-actions': {}, 'app-top-banner': { username: 'account:read', serverUrl: 'account:read' }, 'email-banner': { email: 'email:read' },