Files
Maurice f72aba0c91 Finish the plugin RPC decorator rollout
The last 20 wire methods move onto the decorators: db.* and the three
unconditional methods (DbRpc), meta.* (MetaRpc) and the host-mediated surface
of users/broadcasts/notify/ai/oauth/scheduler (HostSurfaceRpc). That closes the
ledger at 113/113, so the legacy has() blocks in rpc-host.ts are gone and the
router is down to what was never domain-specific: build the granted subset of
the dispatch map, dispatch, audit, map errors.

PluginHostDepsFactory was the 26-argument wiring sheet behind those blocks. With
the handlers in the domains it has two dependencies left, so it is now
PluginRpcHostFactory: the plugin's own sqlite handle, the inter-plugin router
bound with this plugin as the caller, and the audit sink.

The 18 host-to-plugin hook calls get the same treatment. They used to spell out
the fn name and the timeout at each controller, which meant nothing checked that
a hook:* grant on the consent screen had a consumer behind it, that the fn
matched what the SDK documents, or that two call sites agreed on the budget.
PluginHooks declares all 18 with @PluginHook and performs them, and the
controllers call it by name.

With both halves complete, requireTotalCoverage goes on: a KNOWN_METHOD without
a handler, or a hook without a consumer, now fails app boot instead of surfacing
as a PERMISSION_DENIED nobody can explain.
2026-08-09 17:38:56 +02:00

110 lines
4.3 KiB
TypeScript

/**
* The DI-discovered registry. It scans providers only, dedupes by instance, and
* validates at boot so a wrong decorator argument fails the app start rather than a
* single plugin activation.
*/
import { describe, it, expect, vi } from 'vitest';
import type { DiscoveryService, MetadataScanner } from '@nestjs/core';
import { PluginRpcRegistryService } from '../../../../src/nest/plugins/host/rpc-kit/registry.service';
import { PluginController, PluginMethod } from '../../../../src/nest/plugins/host/rpc-kit/decorators';
@PluginController()
class TagsRpc {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
@PluginController()
class BadRpc {
@PluginMethod('tags.create', { permission: 'db:read:tags' as 'db:write:tags' })
create() {
return {};
}
}
class PlainProvider {
helper() {
return 1;
}
}
/** Mimics Nest's provider wrappers, including the same instance listed twice. */
function serviceFor(instances: object[]): PluginRpcRegistryService {
const discovery = {
getProviders: () => instances.map((instance) => ({ instance, metatype: instance.constructor })),
} as unknown as DiscoveryService;
const scanner = {
getAllMethodNames: (proto: object) =>
Object.getOwnPropertyNames(proto).filter((n) => n !== 'constructor'),
} as unknown as MetadataScanner;
return new PluginRpcRegistryService(discovery, scanner);
}
describe('PluginRpcRegistryService', () => {
it('RPCKIT-SVC-001 registers the marked providers at boot', () => {
const service = serviceFor([new TagsRpc(), new PlainProvider()]);
service.scanProviders();
expect(service.methodNames()).toEqual(new Set(['tags.list']));
});
it('RPCKIT-SVC-002 the same instance wrapped twice is registered once', () => {
const instance = new TagsRpc();
const service = serviceFor([instance, instance]);
service.scanProviders();
// Without the dedupe this would be two entries and validate() would report
// tags.list as double-owned.
expect(service.list()).toHaveLength(1);
});
it('RPCKIT-SVC-003 unmarked providers are skipped rather than rejected', () => {
const service = serviceFor([new PlainProvider()]);
expect(() => service.scanProviders()).not.toThrow();
expect(service.list()).toEqual([]);
});
it('RPCKIT-SVC-004 a null instance is skipped', () => {
const discovery = {
getProviders: () => [{ instance: null, metatype: TagsRpc }, { instance: undefined, metatype: TagsRpc }],
} as unknown as DiscoveryService;
const scanner = { getAllMethodNames: () => [] } as unknown as MetadataScanner;
const service = new PluginRpcRegistryService(discovery, scanner);
expect(() => service.scanProviders()).not.toThrow();
expect(service.list()).toEqual([]);
});
it('RPCKIT-SVC-005 a wrong decorator argument fails the boot, not the first plugin call', () => {
const service = serviceFor([new BadRpc()]);
expect(() => service.onModuleInit()).toThrow(
/method "tags.create" \(BadRpc.create\) declares "db:read:tags" but METHOD_PERMISSION says "db:write:tags"/,
);
});
it('RPCKIT-SVC-006 boot fails when the surface is incomplete', () => {
// The rollout is finished, so a decorated class that no module registers is no
// longer a silent PERMISSION_DENIED at runtime: the app refuses to start, and the
// message names the method that lost its owner.
const service = serviceFor([new TagsRpc()]);
expect(() => service.onModuleInit()).toThrow(/has no handler/);
});
it('RPCKIT-SVC-008 a hook nobody consumes fails boot too, so no grant is dead', () => {
// A hook:* permission on the consent screen with nothing behind it looks exactly
// like a live one to the user granting it.
const service = serviceFor([new TagsRpc()]);
expect(() => service.onModuleInit()).toThrow(/has no host-side consumer/);
});
it('RPCKIT-SVC-007 only providers are scanned, never controllers', () => {
const getControllers = vi.fn(() => []);
const discovery = {
getProviders: () => [],
getControllers,
} as unknown as DiscoveryService;
const scanner = { getAllMethodNames: () => [] } as unknown as MetadataScanner;
new PluginRpcRegistryService(discovery, scanner).scanProviders();
expect(getControllers).not.toHaveBeenCalled();
});
});