Critical: - router: fail loudly (WRN-SERVICE-COLLISION) when two app/services files scan to the same service name, instead of silently letting directory-walk order pick a winner. Important: - server.ts: wrap a throwing input schema so its raw message cannot escape invoke(); returns RPC_INVALID and logs server-side instead. - client.ts: race timeoutMs against transport.call so a stalled transport cannot hang the caller; rejects with a ServiceError(RPC_TRANSPORT). - client.ts: the proxy returns undefined for undeclared properties (incl. then/catch/finally) instead of a function that throws, closing the await-client thenable trap. - gateway.ts / rpc-dispatch.ts: import RPC_PATH_PREFIX / RPC_INTERNAL_HEADER from @wrnexus/rpc instead of hardcoding local copies. - gateway.test.ts: cover the RPC-prefix edge block and internal-header stripping across casing variants. - http.test.ts / client.test.ts: cover anonymous-call header omission, the internal marker, the retryable-status sweep, network/malformed/HTML failures, AbortSignal propagation, the timeout path, and timer cleanup. Minor: - transport.ts: Object.hasOwn for handler lookup; note the entry-only abort check. - client.ts: wrap a missing/invalid WRNEXUS_RPC_SECRET as a ServiceError (RPC_IDENTITY) instead of a bare Error. - rpc/package.json: drop the unused @wrnexus/authz dependency. - server.ts: implement() now throws at construction time if a declared procedure has no own handler. Verified: reverting the service-collision check and the client timeout race each make their new test fail, then restore green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
124 lines
5.3 KiB
TypeScript
124 lines
5.3 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
||
import { RPC_ERROR_CODES, isRetryableStatus } from "../src/errors.ts";
|
||
import { RPC_IDENTITY_HEADER } from "../src/identity.ts";
|
||
import { RPC_INTERNAL_HEADER, httpTransport, rpcPath } from "../src/http.ts";
|
||
|
||
const target = { app: "billing", service: "billing", procedure: "createInvoice" };
|
||
|
||
function transportWith(handler: (request: Request) => Response | Promise<Response>) {
|
||
return httpTransport({
|
||
resolveOrigin: () => "http://billing.test",
|
||
fetch: (async (input: RequestInfo | URL, init?: RequestInit) =>
|
||
handler(new Request(input, init))) as typeof fetch,
|
||
});
|
||
}
|
||
|
||
describe("httpTransport", () => {
|
||
test("posts to the private endpoint with identity", async () => {
|
||
const transport = transportWith(async (request) => {
|
||
expect(request.url).toBe("http://billing.test/__wrnexus/rpc/billing/createInvoice");
|
||
expect(request.headers.get(RPC_IDENTITY_HEADER)).toBe("token");
|
||
expect(request.headers.get("x-wrnexus-internal")).toBe("1");
|
||
expect(await request.json()).toEqual({ amountCents: 5 });
|
||
return Response.json({ ok: true, value: { invoiceId: "inv_1" } });
|
||
});
|
||
expect(await transport.call(target, { amountCents: 5 }, { identity: "token" })).toEqual({
|
||
ok: true,
|
||
value: { invoiceId: "inv_1" },
|
||
});
|
||
});
|
||
|
||
test("classifies unavailable and malformed responses safely", async () => {
|
||
const unavailable = transportWith(() => new Response("busy", { status: 503 }));
|
||
const failed = await unavailable.call(target, {}, {});
|
||
expect(failed).toMatchObject({ code: RPC_ERROR_CODES.transport, retryable: true });
|
||
const malformed = transportWith(() => Response.json({ hello: "world" }));
|
||
expect(await malformed.call(target, {}, {})).toMatchObject({
|
||
code: RPC_ERROR_CODES.malformed,
|
||
retryable: false,
|
||
});
|
||
});
|
||
|
||
test("uses the stable reserved path", () => {
|
||
expect(rpcPath("billing", "createInvoice")).toBe("/__wrnexus/rpc/billing/createInvoice");
|
||
});
|
||
|
||
test("omits the identity header entirely for an anonymous call", async () => {
|
||
const transport = transportWith(async (request) => {
|
||
expect(request.headers.has(RPC_IDENTITY_HEADER)).toBe(false);
|
||
return Response.json({ ok: true, value: {} });
|
||
});
|
||
await transport.call(target, {}, {});
|
||
});
|
||
|
||
test("sets the internal-marker header", async () => {
|
||
const transport = transportWith(async (request) => {
|
||
expect(request.headers.get(RPC_INTERNAL_HEADER)).toBe("1");
|
||
return Response.json({ ok: true, value: {} });
|
||
});
|
||
await transport.call(target, {}, {});
|
||
});
|
||
|
||
test("the full retryable-status sweep matches isRetryableStatus", async () => {
|
||
// 600 is covered directly on isRetryableStatus in errors.test.ts — the
|
||
// Fetch API cannot construct a Response with a status outside 200–599.
|
||
const statuses = [200, 400, 403, 404, 408, 409, 429, 500, 503, 599];
|
||
for (const status of statuses) {
|
||
if (status === 200) continue; // handled by the success-path test above
|
||
const transport = transportWith(() => new Response("x", { status }));
|
||
const result = await transport.call(target, {}, {});
|
||
expect(result).toMatchObject({
|
||
ok: false,
|
||
code: RPC_ERROR_CODES.transport,
|
||
retryable: isRetryableStatus(status),
|
||
});
|
||
}
|
||
});
|
||
|
||
test("a network throw yields a structured failure with no host/address surviving", async () => {
|
||
const transport = httpTransport({
|
||
resolveOrigin: () => "http://internal-billing-host.private:4821",
|
||
fetch: (async () => {
|
||
throw new TypeError("fetch failed: connect ECONNREFUSED 10.0.0.7:4821");
|
||
}) as unknown as typeof fetch,
|
||
});
|
||
const result = await transport.call(target, {}, {});
|
||
expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.transport });
|
||
if (!result.ok) {
|
||
expect(result.message).not.toContain("10.0.0.7");
|
||
expect(result.message).not.toContain("internal-billing-host");
|
||
}
|
||
});
|
||
|
||
test("a malformed JSON body yields a structured failure with no body content surviving", async () => {
|
||
const transport = transportWith(() => new Response("{not json", { status: 200 }));
|
||
const result = await transport.call(target, {}, {});
|
||
expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.malformed });
|
||
if (!result.ok) expect(result.message).not.toContain("{not json");
|
||
});
|
||
|
||
test("an HTML error page yields a structured failure with no page content surviving", async () => {
|
||
const transport = transportWith(
|
||
() =>
|
||
new Response("<html><body>500 Internal Server Error at db-host-42</body></html>", {
|
||
status: 200,
|
||
headers: { "content-type": "text/html" },
|
||
}),
|
||
);
|
||
const result = await transport.call(target, {}, {});
|
||
expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.malformed });
|
||
if (!result.ok) expect(result.message).not.toContain("db-host-42");
|
||
});
|
||
|
||
test("the AbortSignal reaches fetch", async () => {
|
||
const controller = new AbortController();
|
||
let seenSignal: AbortSignal | undefined;
|
||
const transport = transportWith(async (request) => {
|
||
seenSignal = request.signal;
|
||
return Response.json({ ok: true, value: {} });
|
||
});
|
||
await transport.call(target, {}, { signal: controller.signal });
|
||
expect(seenSignal).toBeDefined();
|
||
});
|
||
});
|