Files
TREK/server/tests/unit/plugins/provider-hook-grant.test.ts
T
Maurice 2a8071f642 feat(plugins): search plugins can answer the place search as you type
A searchProvider plugin can now implement an optional suggest() beside
search(). Its places then show up in the place search's typed-ahead list,
after the core suggestions and marked with the plugin's name, on desktop
and phone. An index that cannot take a request per keystroke leaves it
out and keeps answering the full search only.

The child reports which functions each hook object carries at load, so
GET /api/plugin-search/suggest only asks the plugins that implement
suggest: from the second character, three rows at most across all of
them, 800 ms to answer. The feed marks plugins holding
hook:search-provider and the client only asks the route while one is
installed. A plugin row carries its whole place, so picking it skips the
details lookup, which knows no plugin id. The phone's search results now
name their source as the desktop ones do, and the desktop dropdown
scrolls instead of running past the dialog.

Addresses discussion #2221.
2026-09-25 09:19:11 +02:00

164 lines
9.5 KiB
TypeScript

/**
* Audit fix (#1429 provider hooks): a plugin may only be dispatched a provider hook
* if it BOTH implements it (reported by the child at load) AND holds the matching
* hook:* grant the admin consented to. providersOf() is the enforcement point; the
* child reports Object.keys(def.hooks) with no knowledge of grants, so without this
* host-side intersection the hook:* consent would be dead code.
*
* providersOf only reads status/hooks/granted, so we inject bare Supervised entries
* into the private running map rather than spawning real children.
*/
import { describe, it, expect } from 'vitest';
import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor';
import { createPluginRuntime } from '../../helpers/plugin-host';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { db } from '../../../src/db/database';
function makeSupervisor(): PluginSupervisor {
// createRpcHost is never called on the providersOf path (no spawn).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new PluginSupervisor((() => ({})) as any, {}, {});
}
function put(s: PluginSupervisor, id: string, status: string, hooks: string[], granted: string[], hookFns: Record<string, string[]> = {}): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(s as any).running.set(id, { id, status, hooks, hookFns, granted: new Set(granted) });
}
describe('providersOf enforces the hook:* grant', () => {
it('returns a plugin only when it implements the hook AND holds the matching grant', () => {
const s = makeSupervisor();
put(s, 'granted', 'active', ['placeDetailProvider'], ['hook:place-detail-provider']);
put(s, 'ungranted', 'active', ['placeDetailProvider'], ['db:write:places']); // implements it, but hook not granted
put(s, 'notactive', 'starting', ['placeDetailProvider'], ['hook:place-detail-provider']); // granted, but not active
put(s, 'warner', 'active', ['warningProvider'], ['hook:trip-warning-provider']);
expect(s.providersOf('placeDetailProvider')).toEqual(['granted']);
expect(s.providersOf('warningProvider')).toEqual(['warner']);
});
it('a hook name with no permission mapping resolves to nobody', () => {
const s = makeSupervisor();
put(s, 'x', 'active', ['mysteryProvider'], ['hook:mystery', 'db:own']);
expect(s.providersOf('mysteryProvider')).toEqual([]);
});
it('maps the pdf-section / atlas-layer / journal-entry hooks to their grants', () => {
const s = makeSupervisor();
put(s, 'pdf', 'active', ['pdfSectionProvider'], ['hook:pdf-section-provider']);
put(s, 'atlas', 'active', ['atlasLayerProvider'], ['hook:atlas-layer-provider']);
put(s, 'journal', 'active', ['journalEntryProvider'], ['hook:journal-entry-provider']);
put(s, 'crossed', 'active', ['pdfSectionProvider'], ['hook:atlas-layer-provider']); // wrong grant
expect(s.providersOf('pdfSectionProvider')).toEqual(['pdf']);
expect(s.providersOf('atlasLayerProvider')).toEqual(['atlas']);
expect(s.providersOf('journalEntryProvider')).toEqual(['journal']);
});
it('maps mapLayerProvider to hook:map-layer-provider (not the marker grant)', () => {
const s = makeSupervisor();
put(s, 'layers', 'active', ['mapLayerProvider'], ['hook:map-layer-provider']);
// The marker grant must not bleed into the layer hook — they are separate consents.
put(s, 'markersOnly', 'active', ['mapLayerProvider'], ['hook:map-marker-provider']);
expect(s.providersOf('mapLayerProvider')).toEqual(['layers']);
});
it('maps routeProvider to hook:route-provider', () => {
const s = makeSupervisor();
put(s, 'ev', 'active', ['routeProvider'], ['hook:route-provider']);
put(s, 'ungranted', 'active', ['routeProvider'], ['hook:map-layer-provider']); // wrong grant
expect(s.providersOf('routeProvider')).toEqual(['ev']);
});
it('maps poiCategoryProvider to hook:poi-category-provider (not the search grant)', () => {
const s = makeSupervisor();
put(s, 'trails', 'active', ['poiCategoryProvider'], ['hook:poi-category-provider']);
// A search provider only sees words the user typed; the viewport is its own consent.
put(s, 'searchOnly', 'active', ['poiCategoryProvider'], ['hook:search-provider']);
expect(s.providersOf('poiCategoryProvider')).toEqual(['trails']);
});
it('maps dayScheduleProvider to hook:day-schedule-provider', () => {
const s = makeSupervisor();
put(s, 'times', 'active', ['dayScheduleProvider'], ['hook:day-schedule-provider']);
put(s, 'ungranted', 'active', ['dayScheduleProvider'], ['hook:route-provider']); // wrong grant
expect(s.providersOf('dayScheduleProvider')).toEqual(['times']);
});
it('maps dayTintProvider to hook:day-tint-provider', () => {
const s = makeSupervisor();
put(s, 'segments', 'active', ['dayTintProvider'], ['hook:day-tint-provider']);
put(s, 'ungranted', 'active', ['dayTintProvider'], ['hook:day-schedule-provider']); // wrong grant
expect(s.providersOf('dayTintProvider')).toEqual(['segments']);
});
it('returns tint providers in insertion order — the controller lets the first win a contested day', () => {
const s = makeSupervisor();
put(s, 'first', 'active', ['dayTintProvider'], ['hook:day-tint-provider']);
put(s, 'second', 'active', ['dayTintProvider'], ['hook:day-tint-provider']);
expect(s.providersOf('dayTintProvider')).toEqual(['first', 'second']);
});
it('maps notificationChannel to hook:notification-channel', () => {
const s = makeSupervisor();
put(s, 'gotify', 'active', ['notificationChannel'], ['hook:notification-channel']);
// Implements the hook but was never granted it — must not become a channel.
put(s, 'sneaky', 'active', ['notificationChannel'], ['notify:send', 'http:outbound']);
// Granted, but disabled — a channel must not survive being turned off.
put(s, 'off', 'stopped', ['notificationChannel'], ['hook:notification-channel']);
expect(s.providersOf('notificationChannel')).toEqual(['gotify']);
});
});
describe('providersOf narrowed to one optional function (#2221)', () => {
it('keeps only the providers whose hook reported that function at load, with every other gate still on', () => {
const s = makeSupervisor();
const both = { searchProvider: ['search', 'suggest'] };
put(s, 'typeahead', 'active', ['searchProvider'], ['hook:search-provider'], both);
put(s, 'search-only', 'active', ['searchProvider'], ['hook:search-provider'], { searchProvider: ['search'] });
put(s, 'ungranted', 'active', ['searchProvider'], ['hook:poi-category-provider'], both);
put(s, 'restarting', 'starting', ['searchProvider'], ['hook:search-provider'], both);
// A function of another hook of the same plugin does not count for this one.
put(s, 'crossed', 'active', ['searchProvider', 'photoProvider'], ['hook:search-provider'], { searchProvider: ['search'], photoProvider: ['suggest'] });
expect(s.providersOf('searchProvider', 'suggest')).toEqual(['typeahead']);
expect(s.providersOf('searchProvider')).toEqual(['typeahead', 'search-only', 'crossed']);
});
it('reads the functions off the loaded report the way it reads anything from a child: warily', async () => {
const s = makeSupervisor();
const starting = () => ({
id: 'p', status: 'starting', granted: new Set(['hook:search-provider']), hooks: [], hookFns: {}, jobs: [],
events: [], exports: [], mcpTools: [], subscriptions: [], pending: new Map(), invocations: new Map(), crashes: [],
});
const sup = starting();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(s as any).running.set('p', sup);
// Parsed from JSON, as it crosses the IPC channel, so `__proto__` is an own key here.
const hookFns = JSON.parse('{"searchProvider":["search","suggest",7],"__proto__":["suggest"],"mysteryProvider":["suggest"],"photoProvider":"suggest"}');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (s as any).onMessage(sup, { k: 'evt', topic: 'loaded', data: { hooks: ['searchProvider'], hookFns } });
expect(sup.status).toBe('active');
expect(sup.hookFns).toEqual({ searchProvider: ['search', 'suggest'] });
expect(Object.getPrototypeOf(sup.hookFns)).toBe(Object.prototype);
expect(s.providersOf('searchProvider', 'suggest')).toEqual(['p']);
// A report with no usable functions at all still activates; it just offers none.
const bare = starting();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(s as any).running.set('p', bare);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (s as any).onMessage(bare, { k: 'evt', topic: 'loaded', data: { hooks: ['searchProvider'], hookFns: ['suggest'] } });
expect(bare.status).toBe('active');
expect(bare.hookFns).toEqual({});
expect(s.providersOf('searchProvider', 'suggest')).toEqual([]);
expect(s.providersOf('searchProvider')).toEqual(['p']);
});
});
describe('runtime.invokeHook defense-in-depth', () => {
it('refuses a plugin id that is not a granted provider of the hook, even if passed directly', async () => {
const rt = createPluginRuntime(new DatabaseService(db));
// one legitimate granted provider exists, so providersOf('placeDetailProvider') = ['ok']
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(rt as any).supervisor.running.set('ok', { id: 'ok', status: 'active', hooks: ['placeDetailProvider'], events: [], granted: new Set(['hook:place-detail-provider']) });
await expect(rt.invokeHook('other', 'placeDetailProvider', 'getDetails', [1])).rejects.toThrow(/not a granted provider/);
});
});