Files
TREK/server/tests/unit/plugins/rpc-kit/registry.service.test.ts
T
Maurice 7b11680cc3 Add a decorator kit for the plugin RPC surface
The method-to-permission association exists twice today: declaratively in
METHOD_PERMISSION, and imperatively as 44 if (has(...)) blocks in rpc-host.ts,
with nothing asserting the two agree. protocol-paths.test.ts checks that each
method has some permission, never that it is the same one the router gates on,
and plugin-audit.ts inherits any drift silently.

@PluginMethod binds the two together. Its permission argument is typed as
MethodPermission<M>, so it resolves to the single literal the table assigns to
that method and a mismatch does not compile. validate() re-checks the same
argument against the same table at boot, because types evaporate under an as
cast. @PluginHook does the same against HOOK_PERMISSION.

UNCONDITIONAL_METHODS was missing from envelope.ts, so the three methods the
router registers outside every grant check had no vocabulary of their own.
Adding it here, with a compile-time proof that it stays disjoint from
KNOWN_METHODS: giving those three a METHOD_PERMISSION row would change which
methods isAuditable considers, and settings.get is deliberately the one
unconditional method that is not audited.

The kit is written to stay extractable into its own package, so an ESLint rule
pins what it may import. Note the rule is a deny list on purpose: these patterns
are gitignore-style, and a leading ** would exclude the parent directory of
every allowed path, which makes the ! re-includes silently ineffective.
2026-08-09 11:58:56 +02:00

100 lines
3.7 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.onModuleInit();
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.onModuleInit();
// 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.onModuleInit()).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.onModuleInit()).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 total coverage is NOT required while the legacy router still owns methods', () => {
const service = serviceFor([new TagsRpc()]);
expect(() => service.onModuleInit()).not.toThrow();
});
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).onModuleInit();
expect(getControllers).not.toHaveBeenCalled();
});
});