Files
TREK/server/tests/unit/plugins/host-rpc.test.ts
T
2026-10-06 21:50:59 +02:00

1041 lines
51 KiB
TypeScript

/**
* The plugin surface that belongs to no domain: the plugin's own sqlite (DbRpc), its
* namespaced entity metadata (MetaRpc) and the host-mediated calls (HostSurfaceRpc —
* user lookup, broadcasts, notifications, LLM, OAuth, scheduler).
*
* These cases used to live in plugin-host-deps.factory.test.ts, which tested the same
* behaviour through a 26-argument wiring sheet. Same requests, same expected codes;
* what changed is that they now run against three decorated classes and a real
* in-memory core DB, and that the host is built by the production factory.
*/
import { db as mockDb } from '../../../src/db/database';
import type { DayAccommodationsRepository } from '../../../src/db/repositories/DayAccommodations.repository';
import type { DaysRepository } from '../../../src/db/repositories/Days.repository';
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository';
import type { PluginEntityMetadataRepository } from '../../../src/db/repositories/PluginEntityMetadata.repository';
import type { PluginScheduledTasksRepository } from '../../../src/db/repositories/PluginScheduledTasks.repository';
import type { ReservationsRepository } from '../../../src/db/repositories/Reservations.repository';
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import { UnreadableLlmResponse } from '../../../src/nest/llm-parse/clients/openai-compatible.client';
import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.resolver';
import type { NotificationsService } from '../../../src/nest/notifications/notifications.service';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { verifyChain } from '../../../src/nest/plugins/host/plugin-audit';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/plugin-host-state';
import { PluginRpcHostFactory, type PluginCallRouter } from '../../../src/nest/plugins/host/plugin-rpc-host.factory';
import { PluginRpcRegistryService } from '../../../src/nest/plugins/host/rpc-kit/registry.service';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
import { HostSurfaceRpc } from '../../../src/nest/plugins/host/rpc/host-surface.rpc';
import { MetaRpc } from '../../../src/nest/plugins/host/rpc/meta.rpc';
import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service';
import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
import type { RpcError, RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
const { broadcast, broadcastToUser } = vi.hoisted(() => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// A real in-memory core db, so the metadata SQL and the entity->trip resolution run
// for real. Trip 1 is owned by user 5; user 6 is a member; user 9 shares nothing.
vi.mock('../../../src/db/database', () => {
const Database = require('better-sqlite3');
const d = new Database(':memory:');
d.exec(`
CREATE TABLE trips (id INTEGER PRIMARY KEY, user_id INTEGER);
CREATE TABLE places (id INTEGER PRIMARY KEY, trip_id INTEGER);
CREATE TABLE days (id INTEGER PRIMARY KEY, trip_id INTEGER);
CREATE TABLE reservations (id INTEGER PRIMARY KEY, trip_id INTEGER);
CREATE TABLE day_accommodations (id INTEGER PRIMARY KEY, trip_id INTEGER);
CREATE TABLE users (id INTEGER PRIMARY KEY, role TEXT, username TEXT, display_name TEXT, avatar TEXT, email TEXT);
CREATE TABLE trip_members (trip_id INTEGER, user_id INTEGER);
CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT, UNIQUE(plugin_id, entity_type, entity_id, key));
CREATE TABLE plugin_capability_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, acting_user_id INTEGER, method TEXT, resource TEXT, code TEXT, ts TEXT, prev_hash TEXT, hash TEXT);
CREATE TABLE plugin_scheduled_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, name TEXT NOT NULL, due_at INTEGER NOT NULL, payload TEXT NOT NULL DEFAULT 'null', every_ms INTEGER, created_at TEXT DEFAULT (datetime('now')), UNIQUE(plugin_id, name));
`);
d.prepare('INSERT INTO trips (id, user_id) VALUES (1, 5)').run();
d.prepare('INSERT INTO places (id, trip_id) VALUES (7, 1)').run();
d.prepare('INSERT INTO days (id, trip_id) VALUES (3, 1)').run();
d.prepare('INSERT INTO reservations (id, trip_id) VALUES (40, 1)').run();
d.prepare('INSERT INTO day_accommodations (id, trip_id) VALUES (11, 1)').run();
d.prepare("INSERT INTO users (id, role, username, display_name) VALUES (5, 'trip_owner', 'owner', 'Owner')").run();
d.prepare("INSERT INTO users (id, role, username, display_name) VALUES (6, 'user', 'ada', 'Ada')").run();
d.prepare("INSERT INTO users (id, role, username, display_name) VALUES (9, 'user', 'eve', 'Eve')").run();
d.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (1, 6)').run();
return {
db: d,
canAccessTrip: (tripId: number, userId: number) =>
tripId === 1 && (userId === 5 || userId === 6) ? { id: 1, user_id: 5 } : undefined,
};
});
vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser }));
const notifySend = vi.fn(async () => undefined);
const { llmExtract } = vi.hoisted(() => ({
llmExtract: vi.fn(async (input: { text?: string }) => [{ text: `answer:${input.text ?? ''}` }]),
}));
vi.mock('../../../src/nest/llm-parse/llm-client.factory', () => ({
createLlmClient: vi.fn(() => ({ extract: llmExtract })),
}));
// Typed from the real method rather than from the always-true body below, so a case that
// swaps in an implementation reading the action key (HOSTRPC-015) still type-checks.
const checkPermission = vi.fn<PermissionsService['checkPermission']>(() => Promise.resolve(true));
const permissions = { checkPermission } as unknown as PermissionsService;
const addons = { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService;
const notifications = { send: notifySend } as unknown as NotificationsService;
// user 7 has no provider configured; everyone else resolves to a stub config.
const llmConfig = {
resolve: vi.fn((uid: number) =>
uid === 7 ? null : { provider: 'openai', model: 'gpt-x', baseUrl: undefined, apiKey: 'sekret' },
),
} as unknown as LlmConfigResolver;
const oauth = {
async getAccessToken(_pid: string, uid: number) {
return uid === 5 ? 'tok-5' : null;
},
} as unknown as PluginOAuthService;
// The per-user settings reads are stubbed rather than seeded: this suite is about the
// RPC surface, and plugin-user-settings.test.ts already covers the encryption round-trip.
const userSettings = {
readOne: vi.fn((_pid: string, uid: number, key: string) => (uid === 5 && key === 'apiKey' ? 'k-5' : undefined)),
} as unknown as PluginUserSettingsService;
// Plan 3j Task 3: `budgetFor`/`appendAudit` now take `PluginCapabilityAuditRepository`,
// not a raw connection. `plugin_capability_audit`'s hand-rolled table above (unlike
// `trips`/`users`) matches the real entity's full column set, so a real MikroORM
// repository would work here too — but every other cross-cutting dependency in this
// file (`canAccessTrip`, `getRole`) is a synchronous stub against this same `mockDb`
// for the same reason (no top-level await in this file), so this stays consistent:
// real raw SQL against `mockDb`, wrapped in the repository's own method shapes.
// HOSTRPC-031 seeds real rows through `mockDb.prepare(...)` and reads them back
// through `budgetSeed`, so this must be backed by the real table, not a canned stub.
const pluginAuditRepo = {
async budgetSeed(pluginId: string, since: string) {
return (mockDb as unknown as { prepare(s: string): { all(...a: unknown[]): unknown[] } })
.prepare(
"SELECT method, COUNT(*) AS n FROM plugin_capability_audit WHERE plugin_id = ? AND code = 'ok' AND ts >= ? AND method IN ('ai.complete','ai.extract','notify.send') GROUP BY method",
)
.all(pluginId, since) as Array<{ method: string; n: number }>;
},
async lastHash(pluginId: string) {
const row = (mockDb as unknown as { prepare(s: string): { get(...a: unknown[]): unknown } })
.prepare('SELECT hash FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT 1')
.get(pluginId) as { hash: string } | undefined;
return row?.hash ?? null;
},
async insertRow(entry: {
plugin_id: string;
acting_user_id: number | null;
method: string;
resource: string | null;
code: string;
ts: string;
prev_hash: string | null;
hash: string;
}) {
(mockDb as unknown as { prepare(s: string): { run(...a: unknown[]): unknown } })
.prepare(
'INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)',
)
.run(
entry.plugin_id,
entry.acting_user_id,
entry.method,
entry.resource,
entry.code,
entry.ts,
entry.prev_hash,
entry.hash,
);
},
async pruneKeepingNewest() {
// Not exercised in this suite (PRUNE_EVERY = 500 appends/plugin) — a no-op stub.
},
} as unknown as PluginCapabilityAuditRepository;
/**
* Plan 3j Task 5: `MetaRpc`/`HostSurfaceRpc`'s own repository conversions
* (MR#/HR#). Same reasoning as `pluginAuditRepo` above — this file's
* hand-trimmed `:memory:` schema (no top-level await here) means a real
* MikroORM repository isn't reachable, so every converted method is
* reproduced as real raw SQL against `mockDb`, wrapped in the repository's
* own method shape, rather than a canned stub — HOSTRPC-014/028/029/030
* read the real table state back out through `mockDb.prepare(...)`
* directly, so this HAS to be the real table.
*/
type RawDb = {
prepare(sql: string): {
get(...a: unknown[]): unknown;
all(...a: unknown[]): unknown[];
run(...a: unknown[]): { changes: number };
};
};
const raw = mockDb as unknown as RawDb;
const tripsRepo = {
// Plan 4 Task 2 — PluginGuards' own canAccessTrip delegate is now this
// repository's findAccessible directly (same access rule the old
// `db/database` mock factory above encoded: trip 1 belongs to user 5,
// user 6 is a member).
async findAccessible(tripId: number | string, userId: number) {
return Number(tripId) === 1 && (userId === 5 || userId === 6) ? { id: 1, user_id: 5, currency: null } : undefined;
},
async sharesTripWith(userIdA: number, userIdB: number) {
return !!raw
.prepare(
`SELECT 1 FROM trips t
LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ?
LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ?
WHERE (t.user_id = ? OR m1.user_id IS NOT NULL)
AND (t.user_id = ? OR m2.user_id IS NOT NULL)
LIMIT 1`,
)
.get(userIdA, userIdB, userIdA, userIdB);
},
async existsById(id: number) {
return !!raw.prepare('SELECT id FROM trips WHERE id = ?').get(id);
},
} as unknown as TripsRepository;
const placesRepo = {
async findTripId(id: number) {
return (raw.prepare('SELECT trip_id FROM places WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
},
} as unknown as PlacesRepository;
const daysRepo = {
async findTripId(id: number) {
return (raw.prepare('SELECT trip_id FROM days WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
},
} as unknown as DaysRepository;
const reservationsRepo = {
async findTripId(id: number) {
return (raw.prepare('SELECT trip_id FROM reservations WHERE id = ?').get(id) as { trip_id: number } | undefined)
?.trip_id;
},
} as unknown as ReservationsRepository;
const dayAccommodationsRepo = {
async getTripId(id: number) {
return (
raw.prepare('SELECT trip_id FROM day_accommodations WHERE id = ?').get(id) as { trip_id: number } | undefined
)?.trip_id;
},
} as unknown as DayAccommodationsRepository;
const metaRepo = {
async findValue(pluginId: string, entityType: string, entityId: number, key: string) {
const row = raw
.prepare('SELECT value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?')
.get(pluginId, entityType, entityId, key) as { value: string } | undefined;
return row?.value ?? null;
},
async countForEntity(pluginId: string, entityType: string, entityId: number) {
return (
raw
.prepare('SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?')
.get(pluginId, entityType, entityId) as { n: number }
).n;
},
async upsertValue(pluginId: string, entityType: string, entityId: number, key: string, value: string) {
raw
.prepare(
`INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(plugin_id, entity_type, entity_id, key)
DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
.run(pluginId, entityType, entityId, key, value);
},
async listForEntity(pluginId: string, entityType: string, entityId: number) {
return raw
.prepare(
'SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key',
)
.all(pluginId, entityType, entityId) as Array<{ key: string; value: string }>;
},
async deleteValue(pluginId: string, entityType: string, entityId: number, key: string) {
return (
raw
.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?')
.run(pluginId, entityType, entityId, key).changes > 0
);
},
// Plan 3j Task 7 fix (must-land 2) — `meta.rpc.ts#set` now calls this ONE method
// instead of `findValue`+`countForEntity`+`upsertValue` separately. One synchronous
// better-sqlite3 statement (no `await` gap between the cap check and the write),
// the same atomicity property the real `PluginEntityMetadataRepository
// .upsertValueCapped` gets from `getEntityManager().transactional`.
async upsertValueCapped(
pluginId: string,
entityType: string,
entityId: number,
key: string,
value: string,
maxKeys: number,
) {
const result = raw
.prepare(
`INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value, updated_at)
SELECT ?, ?, ?, ?, ?, datetime('now')
WHERE EXISTS (SELECT 1 FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?)
OR (SELECT COUNT(*) FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?) < ?
ON CONFLICT(plugin_id, entity_type, entity_id, key)
DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
.run(
pluginId,
entityType,
entityId,
key,
value,
pluginId,
entityType,
entityId,
key,
pluginId,
entityType,
entityId,
maxKeys,
);
return result.changes > 0;
},
} as unknown as PluginEntityMetadataRepository;
const scheduledTasksRepo = {
async existsForPluginAndName(pluginId: string, name: string) {
return !!raw.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').get(pluginId, name);
},
async countForPlugin(pluginId: string) {
return (
raw.prepare('SELECT COUNT(*) AS c FROM plugin_scheduled_tasks WHERE plugin_id = ?').get(pluginId) as { c: number }
).c;
},
async upsertTask(input: {
plugin_id: string;
name: string;
due_at: number;
payload: string;
every_ms: number | null;
}) {
raw
.prepare(
`INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (plugin_id, name) DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload, every_ms = excluded.every_ms`,
)
.run(input.plugin_id, input.name, input.due_at, input.payload, input.every_ms);
},
async deleteByPluginAndName(pluginId: string, name: string) {
return (
raw.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').run(pluginId, name).changes > 0
);
},
// Plan 3j Task 7 fix (must-land 3) — `host-surface.rpc.ts#schedulerSet` now calls
// this ONE method instead of `existsForPluginAndName`+`countForPlugin`+`upsertTask`
// separately. Same one-statement atomicity shape as `metaRepo.upsertValueCapped`
// above, mirroring the real `PluginScheduledTasksRepository.upsertTaskCapped`.
async upsertTaskCapped(
input: { plugin_id: string; name: string; due_at: number; payload: string; every_ms: number | null },
maxTasks: number,
) {
const result = raw
.prepare(
`INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms)
SELECT ?, ?, ?, ?, ?
WHERE EXISTS (SELECT 1 FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?)
OR (SELECT COUNT(*) FROM plugin_scheduled_tasks WHERE plugin_id=?) < ?
ON CONFLICT (plugin_id, name) DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload, every_ms = excluded.every_ms`,
)
.run(
input.plugin_id,
input.name,
input.due_at,
input.payload,
input.every_ms,
input.plugin_id,
input.name,
input.plugin_id,
maxTasks,
);
return result.changes > 0;
},
} as unknown as PluginScheduledTasksRepository;
// Plan 3j Task 1: PluginGuards' own role lookup (PG3/PG4) now goes through
// UsersRepository.getRole, not `dbs.prepare(...)`. Same reasoning as the
// canAccessTrip spy above — a real UsersRepository would need a real
// MikroORM over this file's hand-trimmed `:memory:` schema (async init, no
// top-level await here) for no benefit: checkPermission is a blanket stub
// in this file, so no case depends on which role value flows through it.
// Stubbed against the exact seeded rows (users.ts INSERTs above) so it stays
// accurate if a future case ever does start asserting on the role argument.
const seededRoles: Record<number, string> = { 5: 'trip_owner', 6: 'user', 9: 'user' };
const usersRepo = {
getRole: vi.fn(async (id: number) => seededRoles[id] ?? null),
// HR1 (Plan 3j Task 5) — same "real SQL against mockDb" reasoning as the
// repository stubs above; HOSTRPC-019 asserts the exact returned shape.
async findPublicIdentity(id: number) {
return (
(raw.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(id) as
{ id: number; username: string; display_name: string | null; avatar: string | null } | undefined) ?? null
);
},
} as unknown as UsersRepository;
// Plan 4 Task 2 — PluginGuards'/MetaRpc's/HostSurfaceRpc's own DatabaseService
// params are gone: canAccessTrip now reads through the TripsRepository each
// already injects (`tripsRepo` above, findAccessible-only).
const guards = new PluginGuards(tripsRepo, permissions, addons, usersRepo);
const registry = createTestPluginRegistry([
new DbRpc(userSettings),
new MetaRpc(guards, metaRepo, tripsRepo, placesRepo, daysRepo, reservationsRepo, dayAccommodationsRepo),
new HostSurfaceRpc(
new RealtimeService(),
notifications,
llmConfig,
oauth,
guards,
pluginAuditRepo,
usersRepo,
tripsRepo,
scheduledTasksRepo,
),
]);
const factory = new PluginRpcHostFactory(pluginAuditRepo, registry as unknown as PluginRpcRegistryService);
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: async () => {} };
const makeHost = (id: string, ...perms: string[]) => factory.create(id, new Set(perms), stubRouter);
/**
* A response read loosely, on purpose.
*
* `RpcResponse & Partial<RpcError>` collapsed to `never`: both carry `ok`, one
* as the literal `true` and the other as `false`, so the intersection has no
* inhabitant and every `.ok` / `.result` / `.error` below was an error the
* build never saw, because tests/ sits outside tsconfig's include. The union
* would be the honest runtime type, but `dispatch` here is asserted on from
* both sides in the same expression, so this widens instead of narrowing.
*/
type Res = Pick<RpcResponse, 'k' | 'id'> & {
ok: boolean;
result?: unknown;
error?: RpcError['error'];
};
// The acting user is a REST arg, not a defaulted one: an explicit `undefined` has to
// stay undefined (that is the userless-context case), and a default parameter would
// quietly turn it back into user 5.
const call = (
host: ReturnType<typeof makeHost>,
method: string,
params: Record<string, unknown> = {},
...uid: (number | undefined)[]
): Promise<Res> => host.dispatch({ k: 'req', id: 'x', method, params }, uid.length ? uid[0] : 5) as Promise<Res>;
let tmp: string;
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-hostrpc-'));
process.env.TREK_PLUGINS_DATA_DIR = tmp;
});
afterAll(() => {
closePluginDataDb('wired');
delete process.env.TREK_PLUGINS_DATA_DIR;
fs.rmSync(tmp, { recursive: true, force: true });
});
describe("DbRpc — the plugin's own sqlite", () => {
it('HOSTRPC-001 caches one data db per plugin id', () => {
expect(getPluginDataDb('wired')).toBe(getPluginDataDb('wired'));
});
it('HOSTRPC-002 a granted db:own call runs against the plugin db', async () => {
const host = makeHost('wired', 'db:own');
expect((await call(host, 'db.migrate', { id: '001', sql: 'CREATE TABLE t (v TEXT)' })).ok).toBe(true);
expect((await call(host, 'db.exec', { sql: "INSERT INTO t (v) VALUES ('a')" })).ok).toBe(true);
const rows = await call(host, 'db.query', { sql: 'SELECT v FROM t' });
expect(rows.result).toEqual([{ v: 'a' }]);
});
it('HOSTRPC-003 db.tx dispatches an atomic batch; malformed ops are BAD_PARAMS', async () => {
const host = makeHost('wtx', 'db:own');
await call(host, 'db.migrate', { id: '001', sql: 'CREATE TABLE kv (k TEXT, n INTEGER)' });
const res = await call(host, 'db.tx', {
ops: [
{ sql: 'INSERT INTO kv (k, n) VALUES (?, ?)', args: ['a', 1] },
{ sql: 'SELECT n FROM kv WHERE k = ?', args: ['a'] },
],
});
expect((res.result as { results: unknown[] }).results).toEqual([{ changes: 1 }, { rows: [{ n: 1 }] }]);
expect((await call(host, 'db.tx', { ops: [{ sql: 42 }] })).error?.code).toBe('BAD_PARAMS');
closePluginDataDb('wtx');
});
it('HOSTRPC-004 without db:own not one of the four is reachable', async () => {
const host = makeHost('wnogrant');
for (const method of ['db.query', 'db.exec', 'db.migrate', 'db.tx']) {
expect((await call(host, method, { sql: 'SELECT 1', id: '1', ops: [] })).error?.code).toBe('PERMISSION_DENIED');
}
});
it("HOSTRPC-005 two plugins never see each other's tables", async () => {
// The regression this guards: DbRpc is a SINGLETON that serves every plugin, so a
// handle captured in a field or a closure would hand plugin B the rows of whichever
// plugin happened to construct it first.
const a = makeHost('tenant-a', 'db:own');
const b = makeHost('tenant-b', 'db:own');
await call(a, 'db.migrate', { id: '001', sql: 'CREATE TABLE secrets (v TEXT)' });
await call(a, 'db.exec', { sql: "INSERT INTO secrets (v) VALUES ('a-only')" });
const leaked = await call(b, 'db.query', { sql: 'SELECT v FROM secrets' });
expect(leaked.ok).toBe(false);
expect(leaked.error?.code).toBe('HOST_ERROR');
expect(leaked.error?.message).toMatch(/no such table/i);
// …and plugin A still reads its own row afterwards.
expect((await call(a, 'db.query', { sql: 'SELECT v FROM secrets' })).result).toEqual([{ v: 'a-only' }]);
closePluginDataDb('tenant-a');
closePluginDataDb('tenant-b');
});
it('HOSTRPC-006 a handle closed WITHOUT eviction is recreated, not reused dead', () => {
const a = getPluginDataDb('closedcache');
expect(a.isOpen()).toBe(true);
// The supervisor's dispose() path: close the handle but leave it cached.
a.close();
const b = getPluginDataDb('closedcache');
expect(b).not.toBe(a);
expect(b.isOpen()).toBe(true);
closePluginDataDb('closedcache');
});
it('HOSTRPC-007 closePluginDataDb drops the cached handle', () => {
const a = getPluginDataDb('transient');
closePluginDataDb('transient');
const b = getPluginDataDb('transient');
expect(a).not.toBe(b);
closePluginDataDb('transient');
});
});
describe('DbRpc — the unconditional three', () => {
afterAll(() => closePluginDataDb('open3'));
it("HOSTRPC-008 plugins.call and events.emit route through the host's own router", async () => {
const calls: unknown[] = [];
const emits: unknown[] = [];
const router: PluginCallRouter = {
callPlugin: async (callerId, targetId, fn, args, uid) => {
calls.push({ callerId, targetId, fn, args, uid });
return { echoed: true };
},
emitPluginEvent: async (sourceId, event, payload) => {
emits.push({ sourceId, event, payload });
},
};
// No permission granted at all — these two are registered unconditionally.
const host = factory.create('open3', new Set(), router);
const res = await call(host, 'plugins.call', { targetId: 'other', fn: 'sum', args: [1, 2] }, 5);
expect(res.result).toEqual({ echoed: true });
// The caller id comes from the HOST, never from params, which is what lets the
// router authorise the dependency edge.
expect(calls).toEqual([{ callerId: 'open3', targetId: 'other', fn: 'sum', args: [1, 2], uid: 5 }]);
expect((await call(host, 'events.emit', { event: 'ping', payload: { a: 1 } }, 5)).result).toEqual({ ok: true });
expect(emits).toEqual([{ sourceId: 'open3', event: 'ping', payload: { a: 1 } }]);
});
it("HOSTRPC-009 settings.get returns the acting user's decrypted value; userless yields undefined", async () => {
const host = makeHost('open3');
expect((await call(host, 'settings.get', { key: 'apiKey' }, 5)).result).toEqual({ value: 'k-5' });
expect((await call(host, 'settings.get', { key: 'apiKey' }, undefined)).result).toEqual({ value: undefined });
// Another user's value is simply not there — the read is per acting user.
expect((await call(host, 'settings.get', { key: 'apiKey' }, 6)).result).toEqual({ value: undefined });
});
it('HOSTRPC-010 a missing key is BAD_PARAMS, not a silent undefined', async () => {
expect((await call(makeHost('open3'), 'settings.get', {}, 5)).error?.code).toBe('BAD_PARAMS');
});
});
describe('MetaRpc — namespaced entity metadata', () => {
beforeEach(() => {
checkPermission.mockReset();
checkPermission.mockResolvedValue(true);
});
afterAll(() => closePluginDataDb('meta'));
it('HOSTRPC-011 round-trips and enforces the key/value/access limits', async () => {
const host = makeHost('meta', 'db:meta');
expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: { a: 1 } })).ok).toBe(
true,
);
expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'k' })).result).toEqual({ a: 1 });
expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: 2 })).ok).toBe(true); // upsert
expect((await call(host, 'meta.list', { entityType: 'place', entityId: 7 })).ok).toBe(true); // place -> trip 1
expect((await call(host, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'k' })).result).toEqual({
deleted: true,
});
expect(
(await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'x'.repeat(300), value: 1 })).error?.code,
).toBe('BAD_PARAMS');
expect(
(await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'big', value: 'y'.repeat(70000) })).error
?.code,
).toBe('BAD_PARAMS');
expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 2, key: 'k', value: 1 })).error?.code).toBe(
'RESOURCE_FORBIDDEN',
);
});
it('HOSTRPC-012 an unreadable value comes back as null rather than throwing', async () => {
const host = makeHost('meta', 'db:meta');
await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'corrupt', value: 1 });
(mockDb as unknown as { prepare(sql: string): { run(...a: unknown[]): unknown } })
.prepare("UPDATE plugin_entity_metadata SET value='{not json' WHERE plugin_id='meta' AND key='corrupt'")
.run();
expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'corrupt' })).result).toBeNull();
const listed = (await call(host, 'meta.list', { entityType: 'trip', entityId: 1 })).result as Record<
string,
unknown
>;
expect(listed.corrupt).toBeNull();
await call(host, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'corrupt' });
});
it('HOSTRPC-013a an omitted value is stored as null, not as the string "undefined"', async () => {
const host = makeHost('meta', 'db:meta');
const set = await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'blank' });
expect(set.result).toEqual({ key: 'blank', value: null });
expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'blank' })).result).toBeNull();
await call(host, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'blank' });
});
it('HOSTRPC-013 a key that was never set reads as null, and deleting it reports false', async () => {
const host = makeHost('meta', 'db:meta');
expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'ghost' })).result).toBeNull();
expect((await call(host, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'ghost' })).result).toEqual({
deleted: false,
});
});
it('HOSTRPC-014 the per-entity key quota is enforced on new keys only', async () => {
const host = makeHost('metaquota', 'db:meta');
for (let i = 0; i < 100; i++) {
expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: `k${i}`, value: i })).ok).toBe(true);
}
const overflow = await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k100', value: 1 });
expect(overflow.error?.code).toBe('BAD_PARAMS');
expect(overflow.error?.message).toMatch(/too many metadata keys/);
// An existing key still updates — the quota counts keys, not writes.
expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k0', value: 'again' })).ok).toBe(
true,
);
closePluginDataDb('metaquota');
});
it('HOSTRPC-015 every entity type resolves its own edit action, and a read-only member is refused a write', async () => {
const host = makeHost('meta', 'db:meta');
const seen: string[] = [];
checkPermission.mockImplementation((action) => {
seen.push(action);
return Promise.resolve(true);
});
for (const [entityType, entityId] of [
['trip', 1],
['place', 7],
['day', 3],
['reservation', 40],
['accommodation', 11],
] as const) {
expect((await call(host, 'meta.set', { entityType, entityId, key: 'k', value: 1 })).ok).toBe(true);
}
// Accommodations deliberately ride on day_edit, like the accommodation write path.
expect(seen).toEqual(['trip_edit', 'place_edit', 'day_edit', 'reservation_edit', 'day_edit']);
checkPermission.mockResolvedValue(false);
const write = await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: 2 });
expect(write.error?.code).toBe('RESOURCE_FORBIDDEN');
// …but a READ is only access-gated, so it still works for the same user.
expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'k' })).result).toBe(1);
});
it('HOSTRPC-016 an unknown entityType is BAD_PARAMS and a userless context is refused', async () => {
const host = makeHost('meta', 'db:meta');
expect((await call(host, 'meta.set', { entityType: 'user', entityId: 1, key: 'k', value: 1 })).error?.code).toBe(
'BAD_PARAMS',
);
expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'k' }, undefined)).error?.code).toBe(
'RESOURCE_FORBIDDEN',
);
// An entity id that resolves to nothing is refused rather than reported as empty.
expect((await call(host, 'meta.get', { entityType: 'reservation', entityId: 999, key: 'k' })).error?.code).toBe(
'RESOURCE_FORBIDDEN',
);
});
it("HOSTRPC-017 one plugin never reads another's keys on the same entity", async () => {
const a = makeHost('meta-a', 'db:meta');
const b = makeHost('meta-b', 'db:meta');
await call(a, 'meta.set', { entityType: 'trip', entityId: 1, key: 'shared', value: 'a-value' });
expect((await call(b, 'meta.get', { entityType: 'trip', entityId: 1, key: 'shared' })).result).toBeNull();
expect((await call(b, 'meta.list', { entityType: 'trip', entityId: 1 })).result).toEqual({});
closePluginDataDb('meta-a');
closePluginDataDb('meta-b');
});
it('HOSTRPC-018 without db:meta not one of the four is reachable', async () => {
const host = makeHost('meta', 'db:read:trips');
for (const method of ['meta.get', 'meta.set', 'meta.list', 'meta.delete']) {
expect((await call(host, method, { entityType: 'trip', entityId: 1, key: 'k' })).error?.code).toBe(
'PERMISSION_DENIED',
);
}
});
});
describe('HostSurfaceRpc — users, broadcasts, notify, ai, oauth, scheduler', () => {
beforeEach(() => {
checkPermission.mockReset();
checkPermission.mockResolvedValue(true);
notifySend.mockClear();
llmExtract.mockClear();
broadcast.mockClear();
broadcastToUser.mockClear();
});
afterAll(() => closePluginDataDb('surface'));
it('HOSTRPC-019 users.getById is scoped to people the acting user shares a trip with', async () => {
const host = makeHost('surface', 'db:read:users');
const shared = await call(host, 'users.getById', { id: 6 }, 5);
expect(shared.result).toEqual({ id: 6, username: 'ada', display_name: 'Ada', avatar: null });
// Reading yourself needs no shared trip.
expect((await call(host, 'users.getById', { id: 5 }, 5)).ok).toBe(true);
// A stranger is refused, which is what stops id enumeration.
expect((await call(host, 'users.getById', { id: 9 }, 5)).error?.code).toBe('RESOURCE_FORBIDDEN');
expect((await call(host, 'users.getById', { id: 6 }, undefined)).error?.code).toBe('RESOURCE_FORBIDDEN');
});
it('HOSTRPC-033 a burst of concurrent auditable RPC calls for the SAME plugin never forks the capability-audit hash chain (Plan 3j Task 7 fix, must-land 1)', async () => {
// The REAL production path, not a direct `appendAudit` unit call: `dispatch()`
// audits every `isAuditable` method AFTER computing its answer (`users.getById`
// here, permission `db:read:users` != `db:own`), through the SAME per-plugin
// promise-tail `appendAudit` now serializes on — task-7-review.md's SDK default of
// 16 in-flight RPCs is what made this reachable live.
const host = makeHost('auditburst', 'db:read:users');
const N = 20;
const results = await Promise.all(Array.from({ length: N }, () => call(host, 'users.getById', { id: 5 }, 5)));
expect(results.every((r) => r.ok)).toBe(true);
const rows = (
mockDb as unknown as {
prepare(s: string): {
all(
...a: unknown[]
): Array<{
plugin_id: string;
acting_user_id: number | null;
method: string;
resource: string | null;
code: string;
ts: string;
prev_hash: string | null;
hash: string;
}>;
};
}
)
.prepare(
"SELECT plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash FROM plugin_capability_audit WHERE plugin_id = 'auditburst' ORDER BY id ASC",
)
.all();
expect(rows).toHaveLength(N);
expect(verifyChain(rows)).toBe(true);
const prevHashes = rows.map((r) => r.prev_hash ?? '');
expect(new Set(prevHashes).size).toBe(prevHashes.length); // no two rows read the same "previous" tip
closePluginDataDb('auditburst');
});
it('HOSTRPC-020 trip broadcasts are force-namespaced and membership-gated', async () => {
const host = makeHost('surface', 'ws:broadcast:trip');
expect((await call(host, 'ws.broadcastToTrip', { tripId: 1, event: 'ping', data: { a: 1 } }, 5)).ok).toBe(true);
expect(broadcast).toHaveBeenCalledWith(1, 'plugin:surface:ping', { a: 1 });
// A primitive payload is wrapped rather than dropped.
await call(host, 'ws.broadcastToTrip', { tripId: 1, event: 'ping', data: 'primitive' }, 5);
expect(broadcast).toHaveBeenLastCalledWith(1, 'plugin:surface:ping', { value: 'primitive' });
broadcast.mockClear();
expect((await call(host, 'ws.broadcastToTrip', { tripId: 2, event: 'x', data: {} }, 5)).error?.code).toBe(
'RESOURCE_FORBIDDEN',
);
expect((await call(host, 'ws.broadcastToTrip', { tripId: 1, event: 'x', data: {} }, undefined)).error?.code).toBe(
'RESOURCE_FORBIDDEN',
);
expect(broadcast).not.toHaveBeenCalled();
});
it('HOSTRPC-021 a per-user broadcast may only target the acting user', async () => {
const host = makeHost('surface', 'ws:broadcast:user');
expect((await call(host, 'ws.broadcastToUser', { userId: 5, event: 'hi', data: { x: 2 } }, 5)).ok).toBe(true);
expect(broadcastToUser).toHaveBeenCalledWith(5, { type: 'plugin:surface', event: 'hi', x: 2 });
expect((await call(host, 'ws.broadcastToUser', { userId: 6, event: 'hi', data: {} }, 5)).error?.code).toBe(
'RESOURCE_FORBIDDEN',
);
expect((await call(host, 'ws.broadcastToUser', { userId: 5, event: 'hi', data: {} }, undefined)).error?.code).toBe(
'RESOURCE_FORBIDDEN',
);
});
it('HOSTRPC-022 notify.send forces the recipient and carries the plugin_notification event', async () => {
const host = makeHost('surface', 'notify:send');
expect(
(
await call(
host,
'notify.send',
{ input: { title: 'Delay', body: 'AB123 late', scope: 'user', targetId: 5, link: '/trips/1' } },
5,
)
).ok,
).toBe(true);
expect(notifySend).toHaveBeenCalledWith(
expect.objectContaining({
event: 'plugin_notification',
actorId: null,
scope: 'user',
targetId: 5,
params: expect.objectContaining({ title: 'Delay', body: 'AB123 late', link: '/trips/1' }),
}),
);
expect(
(await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'trip', targetId: 1 } }, 5)).ok,
).toBe(true);
// Another user, a trip the acting user isn't in, and the admin scope are all refused.
expect(
(await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 9 } }, 5)).error
?.code,
).toBe('RESOURCE_FORBIDDEN');
expect(
(await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'trip', targetId: 2 } }, 5)).error
?.code,
).toBe('RESOURCE_FORBIDDEN');
expect(
(await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'admin', targetId: 0 } }, 5)).error
?.code,
).toBe('BAD_PARAMS');
expect(
(await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 5 } }, undefined))
.error?.code,
).toBe('RESOURCE_FORBIDDEN');
});
it('HOSTRPC-023 notify.send rejects an off-site link and an empty or emoji-only title', async () => {
const host = makeHost('surface', 'notify:send');
const base = { body: 'b', scope: 'user', targetId: 5 };
for (const link of ['//evil.com', 'https://evil.com/x', '/\\evil.com', 'javascript:alert(1)']) {
expect((await call(host, 'notify.send', { input: { ...base, title: 't', link } }, 5)).error?.code).toBe(
'BAD_PARAMS',
);
}
expect((await call(host, 'notify.send', { input: { ...base, title: '' } }, 5)).error?.code).toBe('BAD_PARAMS');
// stripEmoji collapses an all-emoji title to '', so it lands on the same refusal.
expect((await call(host, 'notify.send', { input: { ...base, title: '🎉🎉' } }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'notify.send', { input: { ...base, title: 'x'.repeat(201) } }, 5)).error?.code).toBe(
'BAD_PARAMS',
);
expect(
(
await call(
host,
'notify.send',
{ input: { title: 't', body: 'y'.repeat(1001), scope: 'user', targetId: 5 } },
5,
)
).error?.code,
).toBe('BAD_PARAMS');
// A non-string title or body lands on the same refusal as an empty one, rather
// than reaching stripEmoji and throwing there.
expect((await call(host, 'notify.send', { input: { ...base, title: 42 } }, 5)).error?.code).toBe('BAD_PARAMS');
expect(
(await call(host, 'notify.send', { input: { title: 't', body: 42, scope: 'user', targetId: 5 } }, 5)).error?.code,
).toBe('BAD_PARAMS');
expect(notifySend).not.toHaveBeenCalled();
});
it('HOSTRPC-024 ai.complete/extract run under the resolved provider, with caps', async () => {
const host = makeHost('surface', 'ai:invoke');
const completed = await call(host, 'ai.complete', { prompt: 'Summarize' }, 5);
expect(completed.result).toMatchObject({ text: expect.stringContaining('answer:') });
const extracted = await call(host, 'ai.extract', { text: 'AB123 JFK', jsonSchema: { type: 'object' } }, 5);
expect(Array.isArray((extracted.result as { results: unknown[] }).results)).toBe(true);
// user 7 has no provider configured
expect((await call(host, 'ai.complete', { prompt: 'hi' }, 7)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'ai.complete', { prompt: '' }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'ai.complete', { prompt: 'x'.repeat(20001) }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'ai.extract', { text: 'x', jsonSchema: 'not-an-object' }, 5)).error?.code).toBe(
'BAD_PARAMS',
);
expect((await call(host, 'ai.extract', { text: '', jsonSchema: {} }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'ai.extract', { text: 'x'.repeat(20001), jsonSchema: {} }, 5)).error?.code).toBe(
'BAD_PARAMS',
);
expect((await call(host, 'ai.complete', { prompt: 'hi' }, undefined)).error?.code).toBe('RESOURCE_FORBIDDEN');
// A non-string prompt/text is the same refusal as an empty one, not a crash.
expect((await call(host, 'ai.complete', { prompt: 42 }, 5)).error?.code).toBe('BAD_PARAMS');
expect((await call(host, 'ai.extract', { text: 42, jsonSchema: {} }, 5)).error?.code).toBe('BAD_PARAMS');
// …and a null jsonSchema is refused despite typeof null === 'object'.
expect((await call(host, 'ai.extract', { text: 'x', jsonSchema: null }, 5)).error?.code).toBe('BAD_PARAMS');
});
it("HOSTRPC-025 the plugin's system prompt and extraction hint are passed through, with a default", async () => {
const host = makeHost('surface', 'ai:invoke');
await call(host, 'ai.complete', { prompt: 'p', system: 'be terse' }, 5);
expect(llmExtract).toHaveBeenLastCalledWith(expect.objectContaining({ prompt: 'be terse', text: 'p' }));
await call(host, 'ai.complete', { prompt: 'p' }, 5);
expect(llmExtract).toHaveBeenLastCalledWith(
expect.objectContaining({ prompt: expect.stringContaining('helpful assistant') }),
);
await call(host, 'ai.extract', { text: 't', jsonSchema: { type: 'object' }, prompt: '' }, 5);
expect(llmExtract).toHaveBeenLastCalledWith(
expect.objectContaining({ prompt: expect.stringContaining('Extract structured data') }),
);
});
it('HOSTRPC-026 a model that answers with no text yields an empty string, not undefined', async () => {
llmExtract.mockResolvedValueOnce([{ text: 42 } as never]);
expect((await call(makeHost('surface', 'ai:invoke'), 'ai.complete', { prompt: 'p' }, 5)).result).toEqual({
text: '',
});
llmExtract.mockResolvedValueOnce([]);
expect((await call(makeHost('surface', 'ai:invoke'), 'ai.complete', { prompt: 'p' }, 5)).result).toEqual({
text: '',
});
});
/*
* The clients raise on a response nothing could read so the booking import can
* warn per file (#2375). This surface answered an empty value for a model that
* talks prose long before that, and it is versioned on its own, so the plugin
* keeps the empty value — while a request that genuinely failed still has to
* reach it as an error.
*/
it('HOSTRPC-026a an unreadable answer stays empty here, a failed request does not (#2375)', async () => {
const host = makeHost('surface', 'ai:invoke');
llmExtract.mockRejectedValueOnce(
new UnreadableLlmResponse('the model did not answer with JSON: Sure, happy to help.'),
);
expect((await call(host, 'ai.complete', { prompt: 'p' }, 5)).result).toEqual({ text: '' });
llmExtract.mockRejectedValueOnce(new UnreadableLlmResponse('the model returned an empty response'));
expect((await call(host, 'ai.extract', { text: 't', jsonSchema: { type: 'object' } }, 5)).result).toEqual({
results: [],
});
llmExtract.mockRejectedValueOnce(new Error('LLM request failed (503): upstream is down'));
const failed = await call(host, 'ai.complete', { prompt: 'p' }, 5);
expect(failed.error?.code).toBe('HOST_ERROR');
expect(failed.error?.message).toMatch(/503/);
});
it("HOSTRPC-027 oauth.getToken returns only the acting user's access token", async () => {
const host = makeHost('surface', 'oauth:client');
expect((await call(host, 'oauth.getToken', {}, 5)).result).toEqual({ accessToken: 'tok-5' });
expect((await call(host, 'oauth.getToken', {}, 7)).result).toEqual({ accessToken: null }); // not connected
// A userless context gets null, matching the SDK contract, not RESOURCE_FORBIDDEN.
expect((await call(host, 'oauth.getToken', {}, undefined)).result).toEqual({ accessToken: null });
expect((await call(makeHost('surface'), 'oauth.getToken', {}, 5)).error?.code).toBe('PERMISSION_DENIED');
});
it('HOSTRPC-028 scheduler.set upserts by name with caps; cancel removes', async () => {
const host = makeHost('wsched', 'jobs:run');
const count = () =>
(mockDb as unknown as { prepare(s: string): { get(): { n: number } } })
.prepare("SELECT COUNT(*) AS n FROM plugin_scheduled_tasks WHERE plugin_id='wsched'")
.get().n;
const due = Date.now() + 120_000;
// Userless is fine — a scheduled task is like a job.
expect((await call(host, 'scheduler.set', { name: 'poll', dueAt: due, payload: { a: 1 } }, undefined)).ok).toBe(
true,
);
expect(count()).toBe(1);
expect((await call(host, 'scheduler.set', { name: 'poll', dueAt: due + 1000 }, undefined)).ok).toBe(true);
expect(count()).toBe(1); // upsert by (plugin, name)
expect((await call(host, 'scheduler.cancel', { name: 'poll' }, undefined)).result).toEqual({ cancelled: true });
expect(count()).toBe(0);
expect((await call(host, 'scheduler.cancel', { name: 'ghost' }, undefined)).result).toEqual({ cancelled: false });
});
it('HOSTRPC-029 the scheduler caps bound name, horizon, interval and payload', async () => {
const host = makeHost('wsched2', 'jobs:run');
const due = Date.now() + 120_000;
const bad = async (params: Record<string, unknown>) =>
(await call(host, 'scheduler.set', params, undefined)).error?.code;
expect(await bad({ name: '', dueAt: due })).toBe('BAD_PARAMS');
expect(await bad({ name: 'x'.repeat(129), dueAt: due })).toBe('BAD_PARAMS');
expect(await bad({ name: 'far', dueAt: Date.now() + 400 * 24 * 60 * 60 * 1000 })).toBe('BAD_PARAMS');
expect(await bad({ name: 'nan', dueAt: Number.POSITIVE_INFINITY })).toBe('BAD_PARAMS');
expect(await bad({ name: 'fast', dueAt: due, everyMs: 5000 })).toBe('BAD_PARAMS');
expect(await bad({ name: 'fat', dueAt: due, payload: { blob: 'z'.repeat(9000) } })).toBe('BAD_PARAMS');
// A dueAt in the past is clamped to now rather than refused, so a plugin that wakes
// up late still gets its task fired on the next sweep.
expect((await call(host, 'scheduler.set', { name: 'late', dueAt: 1 }, undefined)).ok).toBe(true);
const row = (mockDb as unknown as { prepare(s: string): { get(): { due_at: number } } })
.prepare("SELECT due_at FROM plugin_scheduled_tasks WHERE plugin_id='wsched2' AND name='late'")
.get();
expect(row.due_at).toBeGreaterThan(1);
});
it('HOSTRPC-030 a plugin cannot hoard more than 100 scheduled tasks', async () => {
const host = makeHost('wschedmax', 'jobs:run');
const due = Date.now() + 120_000;
for (let i = 0; i < 100; i++) {
expect((await call(host, 'scheduler.set', { name: `t${i}`, dueAt: due }, undefined)).ok).toBe(true);
}
const over = await call(host, 'scheduler.set', { name: 't100', dueAt: due }, undefined);
expect(over.error?.message).toMatch(/too many scheduled tasks/);
// An existing name still upserts — the cap counts entries, not calls.
expect((await call(host, 'scheduler.set', { name: 't0', dueAt: due + 5 }, undefined)).ok).toBe(true);
});
it("HOSTRPC-031 the daily notify + AI budgets are enforced, seeded from today's audit rows", async () => {
// Seed the audit with a plugin that has already spent its whole day (default caps
// are 100 notify / 200 ai) so the budget is exhausted on first use.
const today = `${new Date().toISOString().slice(0, 10)}T08:00:00.000Z`;
const insert = (mockDb as unknown as { prepare(s: string): { run(...a: unknown[]): unknown } }).prepare(
"INSERT INTO plugin_capability_audit (plugin_id, method, code, ts) VALUES (?, ?, 'ok', ?)",
);
for (let i = 0; i < 100; i++) insert.run('wbudget', 'notify.send', today);
for (let i = 0; i < 200; i++) insert.run('wbudget', 'ai.complete', today);
const host = makeHost('wbudget', 'notify:send', 'ai:invoke');
const notified = await call(
host,
'notify.send',
{ input: { title: 't', body: 'b', scope: 'user', targetId: 5 } },
5,
);
expect(notified.error?.code).toBe('BAD_PARAMS');
expect(notified.error?.message).toMatch(/budget/i);
expect(notifySend).not.toHaveBeenCalled();
expect((await call(host, 'ai.complete', { prompt: 'hi' }, 5)).error?.message).toMatch(/budget/i);
closePluginDataDb('wbudget');
});
it('HOSTRPC-032 every host-surface method is unreachable without its own grant', async () => {
const host = makeHost('surface');
const denied: Record<string, Record<string, unknown>> = {
'users.getById': { id: 6 },
'ws.broadcastToTrip': { tripId: 1, event: 'x' },
'ws.broadcastToUser': { userId: 5, event: 'x' },
'notify.send': { input: { title: 't', body: 'b', scope: 'user', targetId: 5 } },
'ai.complete': { prompt: 'x' },
'ai.extract': { text: 'x', jsonSchema: {} },
'oauth.getToken': {},
'scheduler.set': { name: 'x', dueAt: Date.now() + 1000 },
'scheduler.cancel': { name: 'x' },
};
for (const [method, params] of Object.entries(denied)) {
expect((await call(host, method, params, 5)).error?.code).toBe('PERMISSION_DENIED');
}
});
});