Files
NetMesh/electron/plugins/rpcRouter.test.cjs

428 lines
15 KiB
JavaScript
Raw Normal View History

"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { PluginRpcError, PluginRpcRouter, RPC_ERRORS } = require("./rpcRouter.cjs");
const { PLUGIN_RPC_MAX_JSON_BYTES } = require("./constants.cjs");
function createRouter(options = {}) {
const sent = [];
const transfers = [];
const protocolErrors = [];
const router = new PluginRpcRouter({
pluginId: "com.example.test",
send(message, transfer = []) {
options.send?.(message, transfer);
sent.push(message);
transfers.push(transfer);
},
handlers: options.handlers,
requestHandlers: options.requestHandlers,
notificationHandlers: options.notificationHandlers,
maxPending: options.maxPending,
defaultTimeoutMs: options.defaultTimeoutMs ?? 100,
onBeforeMessage: options.onBeforeMessage,
onIncomingStream: options.onIncomingStream,
onProtocolError(error) { protocolErrors.push(error); },
});
return { router, sent, transfers, protocolErrors };
}
test("raw message guards run before schema work and can contain every transport message class", async () => {
const messages = [
{ malformed: true },
{ jsonrpc: "2.0", id: 1, method: "storage.get", params: { key: "x" } },
{ jsonrpc: "2.0", method: "log.write", params: {} },
{ jsonrpc: "2.0", id: 2, result: null },
{ jsonrpc: "2.0", method: "$/progress", params: { token: "x", value: null } },
{ jsonrpc: "2.0", method: "$/cancelRequest", params: { cancellationId: "x" } },
{ frame: { streamId: "x", sequence: 0, kind: "open", windowBytes: 1 } },
];
for (const guarded of messages) {
let seen;
const fixture = createRouter({
onBeforeMessage(message) {
seen = message;
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "runtime message quota exceeded");
},
});
await fixture.router.accept(guarded);
assert.equal(seen, guarded);
assert.equal(fixture.router.closed, true);
assert.equal(fixture.protocolErrors[0].code, RPC_ERRORS.resourceExhausted);
}
});
test("raw message guards must remain synchronous", async () => {
const fixture = createRouter({ onBeforeMessage: async () => true });
await fixture.router.accept({ jsonrpc: "2.0", method: "log.write", params: {} });
assert.equal(fixture.router.closed, true);
assert.match(fixture.protocolErrors[0].message, /must be synchronous/);
});
test("RPC correlation validates initialize results against the reserved contract", async () => {
const fixture = createRouter();
const resultPromise = fixture.router.request("plugin.initialize", {
netcattyVersion: "1.0.0",
apiVersion: "0.1.0-internal",
supportedFeatures: [],
});
const request = fixture.sent[0];
await fixture.router.accept({
jsonrpc: "2.0",
id: request.id,
result: {
pluginId: "com.example.test",
pluginVersion: "1.0.0",
apiVersion: "0.1.0-internal",
enabledFeatures: [],
},
});
assert.equal((await resultPromise).pluginId, "com.example.test");
const invalidPromise = fixture.router.request("plugin.initialize", {
netcattyVersion: "1.0.0",
apiVersion: "0.1.0-internal",
supportedFeatures: [],
});
await fixture.router.accept({ jsonrpc: "2.0", id: fixture.sent.at(-1).id, result: {} });
await assert.rejects(invalidPromise, /Initialize result violates/);
});
test("RPC deadline sends cancellation and rejects without leaking pending state", async () => {
const fixture = createRouter({ defaultTimeoutMs: 10 });
const request = fixture.router.request("plugin.activate", {});
await assert.rejects(request, (error) => error instanceof PluginRpcError && error.code === RPC_ERRORS.deadlineExceeded);
assert.equal(fixture.sent.at(-1).method, "$/cancelRequest");
assert.equal(fixture.router.pending.size, 0);
});
test("one late response after cancellation is ignored without hiding duplicates", async () => {
const fixture = createRouter({ defaultTimeoutMs: 10 });
const request = fixture.router.request("provider.invoke", {});
const requestId = fixture.sent[0].id;
await assert.rejects(request, (error) => error?.code === RPC_ERRORS.deadlineExceeded);
await fixture.router.accept({ jsonrpc: "2.0", id: requestId, result: { late: true } });
assert.equal(fixture.router.closed, false);
await fixture.router.accept({ jsonrpc: "2.0", id: requestId, result: { duplicate: true } });
assert.equal(fixture.router.closed, true);
assert.equal(fixture.protocolErrors.length, 1);
});
test("retired response IDs are not reused for new requests", async () => {
const fixture = createRouter({ defaultTimeoutMs: 10 });
fixture.router.nextId = Number.MAX_SAFE_INTEGER;
const expired = fixture.router.request("provider.expired", {});
assert.equal(fixture.sent[0].id, Number.MAX_SAFE_INTEGER);
await assert.rejects(expired);
fixture.router.nextId = Number.MAX_SAFE_INTEGER;
const next = fixture.router.request("provider.next", {});
assert.equal(fixture.sent.at(-1).id, 0);
fixture.router.close();
await assert.rejects(next);
});
test("incoming request deadlines do not block later requests", async () => {
const fixture = createRouter({
defaultTimeoutMs: 20,
handlers: {
"storage.get": async () => new Promise(() => {}),
"storage.keys": async () => ({ keys: ["ready"] }),
},
});
await fixture.router.accept({
jsonrpc: "2.0",
id: "slow",
method: "storage.get",
params: { key: "blocked" },
deadlineMs: 10,
});
assert.equal(fixture.sent[0].error.code, RPC_ERRORS.deadlineExceeded);
await fixture.router.accept({
jsonrpc: "2.0",
id: "next",
method: "storage.keys",
params: {},
});
assert.deepEqual(fixture.sent[1].result, { keys: ["ready"] });
});
test("a stalled stream consumer does not block frames for another stream", async () => {
let releaseSlowChunk;
const slowChunk = new Promise((resolve) => { releaseSlowChunk = resolve; });
const fixture = createRouter({
onIncomingStream({ streamId, bind }) {
bind({
async onChunk(_chunk, release) {
if (streamId === "slow") await slowChunk;
release();
},
});
return true;
},
});
const contract = await import("@netcatty/plugin-contract");
await fixture.router.accept({
frame: { streamId: "slow", sequence: 0, kind: "open", windowBytes: 1024 },
});
const pendingSlowChunk = fixture.router.accept({
frame: {
streamId: "slow",
sequence: 1,
kind: "chunk",
data: contract.createJsonStreamChunk({ value: "slow" }),
},
});
await new Promise((resolve) => setImmediate(resolve));
await fixture.router.accept({
frame: { streamId: "independent", sequence: 0, kind: "open", windowBytes: 1024 },
});
assert.equal(fixture.router.streams.incoming.has("independent"), true);
releaseSlowChunk();
await pendingSlowChunk;
});
test("incoming handlers can await a nested outgoing RPC response", async () => {
let fixture;
fixture = createRouter({
handlers: {
"storage.get": async () => fixture.router.request("plugin.nested", {}),
},
});
const incoming = fixture.router.accept({
jsonrpc: "2.0",
id: "incoming",
method: "storage.get",
params: { key: "value" },
});
await new Promise((resolve) => setImmediate(resolve));
const nested = fixture.sent[0];
assert.equal(nested.method, "plugin.nested");
await fixture.router.accept({ jsonrpc: "2.0", id: nested.id, result: { value: 42 } });
await incoming;
assert.deepEqual(fixture.sent[1], {
jsonrpc: "2.0",
id: "incoming",
result: { value: 42 },
});
});
test("an already-aborted outgoing request is never sent", async () => {
const fixture = createRouter();
const controller = new AbortController();
controller.abort();
await assert.rejects(
fixture.router.request("plugin.activate", {}, { signal: controller.signal }),
(error) => error?.code === RPC_ERRORS.cancelled,
);
assert.deepEqual(fixture.sent, []);
});
test("synchronous transport failure releases RPC correlation and cancellation ownership", async () => {
const fixture = createRouter({ send() { throw new Error("port is closed"); } });
await assert.rejects(fixture.router.request("plugin.activate", {}), /port is closed/);
assert.equal(fixture.router.pending.size, 0);
assert.equal(fixture.router.pendingCancellationIds.size, 0);
});
test("outgoing cancellation IDs cannot alias concurrent requests", async () => {
const fixture = createRouter();
const first = fixture.router.request("plugin.activate", {}, { cancellationId: "same" });
await assert.rejects(
fixture.router.request("plugin.deactivate", {}, { cancellationId: "same" }),
(error) => error?.code === RPC_ERRORS.invalidParams,
);
fixture.router.close();
await assert.rejects(first, (error) => error?.code === RPC_ERRORS.unavailable);
});
test("duplicate in-flight request and cancellation IDs fail without replacing ownership", async () => {
let release;
const blocked = new Promise((resolve) => { release = resolve; });
const fixture = createRouter({
handlers: { "storage.keys": async () => blocked },
});
const first = fixture.router.accept({
jsonrpc: "2.0",
id: "same-id",
method: "storage.keys",
params: {},
cancellationId: "same-cancel",
});
await new Promise((resolve) => setImmediate(resolve));
await fixture.router.accept({
jsonrpc: "2.0",
id: "same-id",
method: "storage.keys",
params: {},
cancellationId: "different-cancel",
});
await fixture.router.accept({
jsonrpc: "2.0",
id: "different-id",
method: "storage.keys",
params: {},
cancellationId: "same-cancel",
});
assert.deepEqual(fixture.sent.map((message) => message.error?.code), [
RPC_ERRORS.invalidParams,
RPC_ERRORS.invalidParams,
]);
release({ keys: [] });
await first;
assert.deepEqual(fixture.sent.at(-1), {
jsonrpc: "2.0",
id: "same-id",
result: { keys: [] },
});
assert.equal(fixture.router.closed, false);
});
test("invalid host handler results become bounded internal errors", async () => {
const fixture = createRouter({
handlers: { "storage.keys": async () => ({ unsafe: 1n }) },
});
await fixture.router.accept({
jsonrpc: "2.0",
id: "invalid-result",
method: "storage.keys",
params: {},
});
assert.equal(fixture.sent[0].error.code, RPC_ERRORS.internal);
assert.equal(fixture.router.closed, false);
});
test("incoming requests retain host-assigned plugin identity and unsupported methods fail immediately", async () => {
const identities = [];
const fixture = createRouter({
handlers: {
"storage.keys": async (_params, context) => {
identities.push(context.pluginId);
return { keys: [] };
},
},
});
await fixture.router.accept({ jsonrpc: "2.0", id: "one", method: "storage.keys", params: {} });
assert.deepEqual(identities, ["com.example.test"]);
assert.deepEqual(fixture.sent[0], { jsonrpc: "2.0", id: "one", result: { keys: [] } });
await fixture.router.accept({ jsonrpc: "2.0", id: "two", method: "host.unsupported", params: {} });
assert.equal(fixture.sent[1].error.code, RPC_ERRORS.methodNotFound);
});
test("request and notification handler ownership cannot be confused", async () => {
const calls = [];
const fixture = createRouter({
requestHandlers: { "storage.set": async () => { calls.push("request"); return null; } },
notificationHandlers: { "log.write": async () => { calls.push("notification"); } },
});
await fixture.router.accept({ jsonrpc: "2.0", method: "storage.set", params: { key: "x", value: 1 } });
await fixture.router.accept({ jsonrpc: "2.0", id: "log", method: "log.write", params: {} });
assert.deepEqual(calls, []);
assert.equal(fixture.sent[0].error.code, RPC_ERRORS.methodNotFound);
await fixture.router.accept({ jsonrpc: "2.0", id: "write", method: "storage.set", params: {} });
await fixture.router.accept({ jsonrpc: "2.0", method: "log.write", params: {} });
assert.deepEqual(calls, ["request", "notification"]);
});
test("closing the router aborts in-flight notification handlers", async () => {
let observedSignal;
const fixture = createRouter({
notificationHandlers: {
"events.consume": async (_params, context) => {
observedSignal = context.signal;
await new Promise((resolve, reject) => {
context.signal.addEventListener("abort", () => reject(context.signal.reason), { once: true });
});
},
},
});
const accepting = fixture.router.accept({
jsonrpc: "2.0",
method: "events.consume",
params: {},
});
await new Promise((resolve) => setImmediate(resolve));
fixture.router.close();
await accepting;
assert.equal(observedSignal.aborted, true);
assert.equal(fixture.router.inflightNotifications.size, 0);
});
test("outgoing requests validate method-specific plugin results before resolving", async () => {
const fixture = createRouter();
const response = fixture.router.request("provider.invoke", {}, {
validateResult(result) {
if (result?.status !== "ok") throw new TypeError("Invalid provider result");
return Object.freeze({ ...result });
},
});
await fixture.router.accept({
jsonrpc: "2.0",
id: fixture.sent[0].id,
result: { status: "failed" },
});
await assert.rejects(response, /Invalid provider result/);
await assert.rejects(
fixture.router.request("provider.invoke", {}, { validateResult: true }),
/validator must be a function/,
);
});
test("outgoing requests keep transferable setup under router correlation and validation", async () => {
const fixture = createRouter();
const port = { postMessage() {} };
const response = fixture.router.request("plugin.terminal.interceptor.attach", {
descriptor: {
providerId: "com.example.input",
direction: "input",
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
},
}, {
transfer: [port],
validateResult(result) {
if (result?.accepted !== true) throw new TypeError("Invalid attachment result");
return undefined;
},
});
assert.deepEqual(fixture.transfers[0], [port]);
await fixture.router.accept({
jsonrpc: "2.0",
id: fixture.sent[0].id,
result: { accepted: true },
});
assert.equal(await response, undefined);
await assert.rejects(
fixture.router.request("plugin.terminal.interceptor.attach", {}, { transfer: port }),
/transfer list must be an array/,
);
});
test("malformed RPC closes the peer instead of accepting a near-match", async () => {
const fixture = createRouter();
await fixture.router.accept({ jsonrpc: "2.0", id: -1, method: "plugin.activate" });
assert.equal(fixture.protocolErrors.length, 1);
assert.equal(fixture.router.closed, true);
});
test("oversized control messages close the peer before dispatch", async () => {
let calls = 0;
const fixture = createRouter({
handlers: { "storage.set": () => { calls += 1; } },
});
await fixture.router.accept({
jsonrpc: "2.0",
id: "oversized",
method: "storage.set",
params: { key: "value", value: "x".repeat(PLUGIN_RPC_MAX_JSON_BYTES) },
});
assert.equal(calls, 0);
assert.equal(fixture.router.closed, true);
assert.match(fixture.protocolErrors[0].message, /exceeds/);
});