mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
405 lines
15 KiB
TypeScript
405 lines
15 KiB
TypeScript
/**
|
|
* The registry is where a decorator argument is re-checked against
|
|
* protocol/envelope.ts at runtime, and where the granted subset is bound into the
|
|
* per-plugin dispatch map. Types evaporate under an `as` cast, so every compile-time
|
|
* guarantee has a runtime twin here, and each one gets a test.
|
|
*/
|
|
import {
|
|
PluginController,
|
|
PluginHook,
|
|
PluginMethod,
|
|
PluginOpenMethod,
|
|
} from '../../../../src/nest/plugins/host/rpc-kit/decorators';
|
|
import { PluginRpcRegistry } from '../../../../src/nest/plugins/host/rpc-kit/registry';
|
|
import { createTestPluginRegistry } from '../../../../src/nest/plugins/host/rpc-kit/testing';
|
|
import type { PluginRpcContext } from '../../../../src/nest/plugins/host/rpc-kit/types';
|
|
import { Controller } from '@nestjs/common';
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
const ctxFor = (actingUserId: number | undefined): PluginRpcContext => ({
|
|
pluginId: 'p',
|
|
actingUserId,
|
|
data: {} as PluginRpcContext['data'],
|
|
// No case below dispatches to a peer plugin, but the peers are a real collaborator
|
|
// on the context, so they get a stub that fails loudly rather than an empty object
|
|
// whose methods would be undefined the moment a handler reached for one.
|
|
plugins: {
|
|
call: () => Promise.reject(new Error('ctxFor: no peer plugin router in these cases')),
|
|
emit: () => {
|
|
throw new Error('ctxFor: no peer plugin router in these cases');
|
|
},
|
|
},
|
|
});
|
|
|
|
/** A well-formed controller: two tags methods with the permissions the table assigns. */
|
|
@PluginController()
|
|
class TagsRpc {
|
|
@PluginMethod('tags.list', { permission: 'db:read:tags' })
|
|
list(_params: Record<string, unknown>, ctx: PluginRpcContext) {
|
|
return { calledBy: ctx.actingUserId };
|
|
}
|
|
|
|
@PluginMethod('tags.create', { permission: 'db:write:tags' })
|
|
create(params: Record<string, unknown>, ctx: PluginRpcContext) {
|
|
return { name: params.name, by: ctx.actingUserId };
|
|
}
|
|
}
|
|
|
|
describe('PluginRpcRegistry — registration', () => {
|
|
it('RPCKIT-REG-001 an undecorated class is rejected by name', () => {
|
|
class NotAController {}
|
|
expect(() => new PluginRpcRegistry().register(new NotAController())).toThrow(
|
|
/NotAController is not decorated with @PluginController/,
|
|
);
|
|
});
|
|
|
|
it('RPCKIT-REG-002 a Nest @Controller is rejected, because @Injectable rewrites its scope metadata', () => {
|
|
@PluginController()
|
|
@Controller('things')
|
|
class HttpThing {
|
|
@PluginMethod('tags.list', { permission: 'db:read:tags' })
|
|
list() {
|
|
return [];
|
|
}
|
|
}
|
|
expect(() => new PluginRpcRegistry().register(new HttpThing())).toThrow(/is a Nest @Controller/);
|
|
});
|
|
|
|
it('RPCKIT-REG-003 only decorated methods are recorded', () => {
|
|
@PluginController()
|
|
class Mixed {
|
|
@PluginMethod('tags.list', { permission: 'db:read:tags' })
|
|
list() {
|
|
return [];
|
|
}
|
|
helper() {
|
|
return 1;
|
|
}
|
|
}
|
|
const registry = createTestPluginRegistry([new Mixed()]);
|
|
expect(registry.list()).toHaveLength(1);
|
|
expect(registry.methodNames()).toEqual(new Set(['tags.list']));
|
|
});
|
|
|
|
it('RPCKIT-REG-004 an explicit method-name list narrows the scan', () => {
|
|
const registry = new PluginRpcRegistry();
|
|
registry.register(new TagsRpc(), ['list']);
|
|
expect(registry.methodNames()).toEqual(new Set(['tags.list']));
|
|
});
|
|
|
|
it('RPCKIT-REG-005 inherited decorated methods are picked up', () => {
|
|
@PluginController()
|
|
class Base {
|
|
@PluginMethod('tags.list', { permission: 'db:read:tags' })
|
|
list() {
|
|
return [];
|
|
}
|
|
}
|
|
@PluginController()
|
|
class Child extends Base {}
|
|
const registry = createTestPluginRegistry([new Child()]);
|
|
expect(registry.methodNames()).toEqual(new Set(['tags.list']));
|
|
});
|
|
|
|
it('RPCKIT-REG-006 list() reports kind, permission and the owning class', () => {
|
|
const registry = createTestPluginRegistry([new TagsRpc()]);
|
|
expect(registry.list()).toEqual(
|
|
expect.arrayContaining([
|
|
{ kind: 'method', name: 'tags.list', permission: 'db:read:tags', className: 'TagsRpc', methodName: 'list' },
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('PluginRpcRegistry — validate', () => {
|
|
it('RPCKIT-REG-007 a well-formed controller validates', () => {
|
|
expect(() => createTestPluginRegistry([new TagsRpc()])).not.toThrow();
|
|
});
|
|
|
|
it('RPCKIT-REG-008 a permission the table does not assign is named with the expected literal', () => {
|
|
@PluginController()
|
|
class Wrong {
|
|
// The cast is the point: this is exactly what a hand-emitted consumer or an
|
|
// `as` cast would smuggle past tsc.
|
|
@PluginMethod('tags.list', { permission: 'db:write:tags' as 'db:read:tags' })
|
|
list() {
|
|
return [];
|
|
}
|
|
}
|
|
expect(() => createTestPluginRegistry([new Wrong()])).toThrow(
|
|
/method "tags.list" \(Wrong.list\) declares "db:write:tags" but METHOD_PERMISSION says "db:read:tags"/,
|
|
);
|
|
});
|
|
|
|
it('RPCKIT-REG-008b a permission that does not exist at all is reported as unknown, too', () => {
|
|
@PluginController()
|
|
class Typo {
|
|
@PluginMethod('tags.list', { permission: 'db:read:tapps' as 'db:read:tags' })
|
|
list() {
|
|
return [];
|
|
}
|
|
}
|
|
let error: Error | undefined;
|
|
try {
|
|
createTestPluginRegistry([new Typo()]);
|
|
} catch (e) {
|
|
error = e as Error;
|
|
}
|
|
// Both checks fire: it disagrees with the table AND names a permission the host
|
|
// does not know. The second is what catches a permission invented out of thin air.
|
|
expect(error?.message).toMatch(/but METHOD_PERMISSION says "db:read:tags"/);
|
|
expect(error?.message).toMatch(/method "tags.list" \(Typo.list\) declares unknown permission "db:read:tapps"/);
|
|
});
|
|
|
|
it('RPCKIT-REG-009 a method outside KNOWN_METHODS is named', () => {
|
|
@PluginController()
|
|
class Bogus {
|
|
@PluginMethod('tags.lst' as 'tags.list', { permission: 'db:read:tags' })
|
|
list() {
|
|
return [];
|
|
}
|
|
}
|
|
expect(() => createTestPluginRegistry([new Bogus()])).toThrow(
|
|
/method "tags.lst" \(Bogus.list\) is not in KNOWN_METHODS/,
|
|
);
|
|
});
|
|
|
|
it('RPCKIT-REG-010 an open method outside UNCONDITIONAL_METHODS is named', () => {
|
|
@PluginController()
|
|
class Bogus {
|
|
@PluginOpenMethod('tags.list' as 'settings.get')
|
|
get() {
|
|
return {};
|
|
}
|
|
}
|
|
expect(() => createTestPluginRegistry([new Bogus()])).toThrow(
|
|
/open method "tags.list" \(Bogus.get\) is not in UNCONDITIONAL_METHODS/,
|
|
);
|
|
});
|
|
|
|
it('RPCKIT-REG-011 two owners of the same method name both classes', () => {
|
|
@PluginController()
|
|
class Second {
|
|
@PluginMethod('tags.list', { permission: 'db:read:tags' })
|
|
list() {
|
|
return [];
|
|
}
|
|
}
|
|
expect(() => createTestPluginRegistry([new TagsRpc(), new Second()])).toThrow(
|
|
/method "tags.list" is handled by TagsRpc.list and Second.list/,
|
|
);
|
|
});
|
|
|
|
it('RPCKIT-REG-012 a hook permission that disagrees with HOOK_PERMISSION is named', () => {
|
|
@PluginController()
|
|
class Wrong {
|
|
@PluginHook('warningProvider', {
|
|
permission: 'hook:route-provider' as 'hook:trip-warning-provider',
|
|
fn: 'getWarnings',
|
|
})
|
|
declare() {}
|
|
}
|
|
expect(() => createTestPluginRegistry([new Wrong()])).toThrow(
|
|
/hook "warningProvider" \(Wrong.declare\) declares "hook:route-provider" but HOOK_PERMISSION says "hook:trip-warning-provider"/,
|
|
);
|
|
});
|
|
|
|
it('RPCKIT-REG-013 a hook name that is not a HOOK_PERMISSION key is named', () => {
|
|
@PluginController()
|
|
class Wrong {
|
|
@PluginHook('warningProvidr' as 'warningProvider', {
|
|
permission: 'hook:trip-warning-provider',
|
|
fn: 'x',
|
|
})
|
|
declare() {}
|
|
}
|
|
expect(() => createTestPluginRegistry([new Wrong()])).toThrow(
|
|
/hook "warningProvidr" \(Wrong.declare\) is not a HOOK_PERMISSION key/,
|
|
);
|
|
});
|
|
|
|
it('RPCKIT-REG-014 the same (hook, fn) pair declared twice is rejected', () => {
|
|
@PluginController()
|
|
class A {
|
|
@PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' })
|
|
declare() {}
|
|
}
|
|
@PluginController()
|
|
class B {
|
|
@PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' })
|
|
declare() {}
|
|
}
|
|
expect(() => createTestPluginRegistry([new A(), new B()])).toThrow(
|
|
/hook fn "warningProvider#getWarnings" is declared twice \(B.declare\)/,
|
|
);
|
|
});
|
|
|
|
it('RPCKIT-REG-015 two fns of the SAME hook are allowed — the fan-out is not uniform', () => {
|
|
@PluginController()
|
|
class Calendar {
|
|
@PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getName', timeoutMs: 3000 })
|
|
name() {}
|
|
@PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getEvents', timeoutMs: 5000 })
|
|
events() {}
|
|
}
|
|
expect(() => createTestPluginRegistry([new Calendar()])).not.toThrow();
|
|
});
|
|
|
|
it('RPCKIT-REG-016 every problem is reported at once, not just the first', () => {
|
|
@PluginController()
|
|
class Wrong {
|
|
@PluginMethod('tags.list', { permission: 'db:write:tags' as 'db:read:tags' })
|
|
list() {
|
|
return [];
|
|
}
|
|
@PluginOpenMethod('tags.create' as 'settings.get')
|
|
get() {
|
|
return {};
|
|
}
|
|
}
|
|
expect(() => createTestPluginRegistry([new Wrong()])).toThrow(
|
|
/METHOD_PERMISSION says "db:read:tags"; open method "tags.create"/,
|
|
);
|
|
});
|
|
|
|
it('RPCKIT-REG-017 requireTotalCoverage off tolerates an incomplete registry', () => {
|
|
expect(() => createTestPluginRegistry([new TagsRpc()], { requireTotalCoverage: false })).not.toThrow();
|
|
});
|
|
|
|
it('RPCKIT-REG-018 requireTotalCoverage on names an unhandled method and a consumerless hook', () => {
|
|
let error: Error | undefined;
|
|
try {
|
|
createTestPluginRegistry([new TagsRpc()], { requireTotalCoverage: true });
|
|
} catch (e) {
|
|
error = e as Error;
|
|
}
|
|
expect(error?.message).toMatch(/method "db.query" has no handler/);
|
|
expect(error?.message).toMatch(/method "plugins.call" has no handler/);
|
|
expect(error?.message).toMatch(
|
|
/hook "photoProvider" has no host-side consumer \(dead grant on the consent screen\)/,
|
|
);
|
|
// tags.list IS handled, so it must not be listed.
|
|
expect(error?.message).not.toMatch(/method "tags.list" has no handler/);
|
|
});
|
|
});
|
|
|
|
describe('PluginRpcRegistry — bindInto', () => {
|
|
const bind = (registry: PluginRpcRegistry, granted: string[]) => {
|
|
const map = new Map<string, (p: Record<string, unknown>, uid: number | undefined) => unknown>();
|
|
registry.bindInto(map, new Set(granted), ctxFor);
|
|
return map;
|
|
};
|
|
|
|
it('RPCKIT-REG-019 registration is authorization: an ungranted method is ABSENT, not guarded', () => {
|
|
const map = bind(createTestPluginRegistry([new TagsRpc()]), ['db:read:tags']);
|
|
expect([...map.keys()]).toEqual(['tags.list']);
|
|
expect(map.has('tags.create')).toBe(false);
|
|
});
|
|
|
|
it('RPCKIT-REG-020 no grants means an empty map', () => {
|
|
expect(bind(createTestPluginRegistry([new TagsRpc()]), []).size).toBe(0);
|
|
});
|
|
|
|
it('RPCKIT-REG-021 the bound handler receives params and a context', () => {
|
|
const map = bind(createTestPluginRegistry([new TagsRpc()]), ['db:write:tags']);
|
|
expect(map.get('tags.create')!({ name: 'work' }, 42)).toEqual({ name: 'work', by: 42 });
|
|
});
|
|
|
|
it('RPCKIT-REG-022 the handler keeps its `this`', () => {
|
|
@PluginController()
|
|
class Stateful {
|
|
private readonly answer = 7;
|
|
@PluginMethod('tags.list', { permission: 'db:read:tags' })
|
|
list() {
|
|
return this.answer;
|
|
}
|
|
}
|
|
const map = bind(createTestPluginRegistry([new Stateful()]), ['db:read:tags']);
|
|
expect(map.get('tags.list')!({}, 42)).toBe(7);
|
|
});
|
|
|
|
it('RPCKIT-REG-022b a host-scoped http:outbound grant is honoured, not rejected as unknown', () => {
|
|
// The check uses isKnownPermission, not a set lookup on KNOWN_PERMISSIONS. A naive
|
|
// lookup would reject every host-scoped egress grant, because that family is
|
|
// open-ended by design.
|
|
const registry = createTestPluginRegistry([new TagsRpc()]);
|
|
const map = bind(registry, ['db:read:tags', 'http:outbound:api.example.com']);
|
|
expect(map.has('tags.list')).toBe(true);
|
|
});
|
|
|
|
it('RPCKIT-REG-023 an open method binds with no grant at all', () => {
|
|
@PluginController()
|
|
class Open {
|
|
@PluginOpenMethod('settings.get')
|
|
get() {
|
|
return { value: undefined };
|
|
}
|
|
}
|
|
expect([...bind(createTestPluginRegistry([new Open()]), []).keys()]).toEqual(['settings.get']);
|
|
});
|
|
|
|
it('RPCKIT-REG-024 hook declarations are never bound as methods', () => {
|
|
@PluginController()
|
|
class Hooks {
|
|
@PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' })
|
|
declare() {}
|
|
}
|
|
expect(bind(createTestPluginRegistry([new Hooks()]), ['hook:trip-warning-provider']).size).toBe(0);
|
|
});
|
|
|
|
it('RPCKIT-REG-025 a collision with a name the legacy router already owns throws', () => {
|
|
const registry = createTestPluginRegistry([new TagsRpc()]);
|
|
const map = new Map<string, (p: Record<string, unknown>, uid: number | undefined) => unknown>();
|
|
map.set('tags.list', () => 'legacy');
|
|
expect(() => registry.bindInto(map, new Set(['db:read:tags']), ctxFor)).toThrow(
|
|
/plugin RPC method "tags.list" is registered twice \(TagsRpc.list and the legacy router\)/,
|
|
);
|
|
});
|
|
|
|
it('RPCKIT-REG-026 the context is built per dispatch, not once per bind', () => {
|
|
const makeCtx = vi.fn(ctxFor);
|
|
const registry = createTestPluginRegistry([new TagsRpc()]);
|
|
const map = new Map<string, (p: Record<string, unknown>, uid: number | undefined) => unknown>();
|
|
registry.bindInto(map, new Set(['db:read:tags']), makeCtx);
|
|
expect(makeCtx).not.toHaveBeenCalled();
|
|
expect(map.get('tags.list')!({}, 1)).toEqual({ calledBy: 1 });
|
|
expect(map.get('tags.list')!({}, 2)).toEqual({ calledBy: 2 });
|
|
expect(makeCtx).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('PluginRpcRegistry — hook contracts', () => {
|
|
@PluginController()
|
|
class Calendar {
|
|
@PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getName', timeoutMs: 3000 })
|
|
name() {}
|
|
@PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getEvents' })
|
|
events() {}
|
|
}
|
|
|
|
it('RPCKIT-REG-027 every declared pair is listed with its consumer', () => {
|
|
const contracts = createTestPluginRegistry([new Calendar()]).hookContracts();
|
|
expect([...contracts.keys()]).toEqual(['calendarSource#getName', 'calendarSource#getEvents']);
|
|
expect(contracts.get('calendarSource#getName')).toEqual({
|
|
hook: 'calendarSource',
|
|
fn: 'getName',
|
|
permission: 'hook:calendar-source',
|
|
timeoutMs: 3000,
|
|
consumer: 'Calendar.name',
|
|
});
|
|
});
|
|
|
|
it('RPCKIT-REG-028 hookContract returns the budget a call site should use', () => {
|
|
const registry = createTestPluginRegistry([new Calendar()]);
|
|
expect(registry.hookContract('calendarSource', 'getEvents').timeoutMs).toBe(5000);
|
|
});
|
|
|
|
it('RPCKIT-REG-029 asking for an undeclared pair throws and names it', () => {
|
|
const registry = createTestPluginRegistry([new Calendar()]);
|
|
expect(() => registry.hookContract('calendarSource', 'getNothing')).toThrow(
|
|
/no @PluginHook declaration for "calendarSource#getNothing"/,
|
|
);
|
|
});
|
|
});
|