Files
WRNexusJS/packages/rpc/test/http.test.ts
T
ClintchizandClaude Opus 5 ce68803471 fix(rpc): close the service-collision fail-open and the fix-wave gaps
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>
2026-08-05 20:10:20 +05:30

124 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 200599.
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();
});
});