Files
TREK/server/tests/unit/plugins/rpc-kit/metadata.test.ts
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

95 lines
3.5 KiB
TypeScript

/**
* The metadata store under the plugin RPC decorators. It is a WeakMap/WeakSet keyed
* by constructor rather than reflect-metadata, so these tests run without a Nest app
* and without any polyfill import order to get right.
*/
import { describe, it, expect } from 'vitest';
import {
addEntry,
getEntry,
isPluginController,
markController,
type ClassRef,
} from '../../../../src/nest/plugins/host/rpc-kit/metadata';
import type { PluginRpcEntry } from '../../../../src/nest/plugins/host/rpc-kit/types';
const methodEntry = (methodName: string, method: 'tags.list' | 'tags.create' = 'tags.list'): PluginRpcEntry => ({
kind: 'method',
methodName,
method,
permission: method === 'tags.list' ? 'db:read:tags' : 'db:write:tags',
});
describe('rpc-kit metadata store', () => {
it('RPCKIT-META-001 an unmarked class is not a plugin controller', () => {
class Plain {}
expect(isPluginController(Plain)).toBe(false);
});
it('RPCKIT-META-002 markController makes the class recognisable', () => {
class Marked {}
markController(Marked as unknown as ClassRef);
expect(isPluginController(Marked)).toBe(true);
});
it('RPCKIT-META-003 a non-function is never a controller', () => {
expect(isPluginController({})).toBe(false);
expect(isPluginController(undefined)).toBe(false);
expect(isPluginController('Marked')).toBe(false);
});
it('RPCKIT-META-004 an entry is retrievable by its method name', () => {
class Holder {}
const entry = methodEntry('list');
addEntry(Holder as unknown as ClassRef, entry);
expect(getEntry(Holder as unknown as ClassRef, 'list')).toBe(entry);
});
it('RPCKIT-META-005 an unknown method name resolves to undefined', () => {
class Holder {}
addEntry(Holder as unknown as ClassRef, methodEntry('list'));
expect(getEntry(Holder as unknown as ClassRef, 'missing')).toBeUndefined();
});
it('RPCKIT-META-006 a second entry for the same name replaces the first', () => {
class Holder {}
addEntry(Holder as unknown as ClassRef, methodEntry('list'));
const second = methodEntry('list', 'tags.create');
addEntry(Holder as unknown as ClassRef, second);
expect(getEntry(Holder as unknown as ClassRef, 'list')).toBe(second);
});
it('RPCKIT-META-007 entries are found on a subclass through the prototype chain', () => {
class Base {}
class Child extends Base {}
const entry = methodEntry('list');
addEntry(Base as unknown as ClassRef, entry);
expect(getEntry(Child as unknown as ClassRef, 'list')).toBe(entry);
});
it('RPCKIT-META-008 a subclass entry wins over the inherited one', () => {
class Base {}
class Child extends Base {}
addEntry(Base as unknown as ClassRef, methodEntry('list'));
const own = methodEntry('list', 'tags.create');
addEntry(Child as unknown as ClassRef, own);
expect(getEntry(Child as unknown as ClassRef, 'list')).toBe(own);
});
it('RPCKIT-META-009 walking the chain stops cleanly at the root', () => {
class Lonely {}
expect(getEntry(Lonely as unknown as ClassRef, 'anything')).toBeUndefined();
});
it('RPCKIT-META-010 two classes keep separate entry maps', () => {
class A {}
class B {}
const a = methodEntry('list');
const b = methodEntry('list', 'tags.create');
addEntry(A as unknown as ClassRef, a);
addEntry(B as unknown as ClassRef, b);
expect(getEntry(A as unknown as ClassRef, 'list')).toBe(a);
expect(getEntry(B as unknown as ClassRef, 'list')).toBe(b);
});
});