Files
WRNexusJS/packages/rpc/test/integration.test.ts
Clintchiz 98205daef6 fix(rpc): isolate integration test from cross-suite fetch pollution
packages/csr's actions.test.ts and reactive.test.ts both leave
globalThis.fetch mutated across bun test files (reactive.test.ts's
'cache invalidation refetches...' test replaces it and never restores
it). Since bun test runs files sequentially rather than importing all
of them up front, a module-level capture of fetch in this file would
already observe csr's leftover mock (csr sorts before rpc).

Route the real-socket assertion through a small node:http-backed fetch
implementation instead of relying on globalThis.fetch at all, keeping
the test's actual target - httpTransport()'s default resolveOrigin -
unaffected by any other suite's global mutation.
2026-08-05 20:57:08 +05:30

216 lines
8.2 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test";
import * as http from "node:http";
import type { Context } from "@wrnexus/core";
import { v } from "@wrnexus/validation";
import { serviceClient } from "../src/client.ts";
import { defineService, procedure } from "../src/contract.ts";
import { RPC_ERROR_CODES } from "../src/errors.ts";
import { httpTransport } from "../src/http.ts";
import { implement } from "../src/server.ts";
import { inProcessTransport } from "../src/transport.ts";
import { handleRpcRequest } from "../../dev-server/src/rpc-dispatch.ts";
const originalRpcSecret = process.env.WRNEXUS_RPC_SECRET;
const originalAppName = process.env.WRNEXUS_APP_NAME;
afterEach(() => {
if (originalRpcSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET;
else process.env.WRNEXUS_RPC_SECRET = originalRpcSecret;
if (originalAppName === undefined) delete process.env.WRNEXUS_APP_NAME;
else process.env.WRNEXUS_APP_NAME = originalAppName;
});
const billing = defineService({
name: "billing",
procedures: {
createInvoice: procedure
.input(v.object({ amountCents: v.number() }))
.output<{ invoiceId: string; forSubject: string }>()
.permission("invoice:create")
.build(),
},
});
function wire(allowed: boolean) {
const service = implement(
billing,
{
createInvoice: async ({ amountCents }, ctx) => ({
invoiceId: `inv_${amountCents}`,
forSubject: ctx.subject?.subjectId ?? "anon",
}),
},
{ selfApp: "billing", checkPermission: async () => allowed },
);
return inProcessTransport({
"billing/createInvoice": (payload, identity) =>
service.invoke("createInvoice", payload, identity),
});
}
function httpWire(allowed: boolean) {
const service = implement(
billing,
{
createInvoice: async ({ amountCents }, ctx) => ({
invoiceId: `inv_${amountCents}`,
forSubject: ctx.subject?.subjectId ?? "anon",
}),
},
{ selfApp: "billing", checkPermission: async () => allowed },
);
return httpTransport({
resolveOrigin: () => "http://billing.internal",
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
return (await handleRpcRequest(
request,
new URL(request.url),
new Map([["billing", service]]),
))!;
}) as typeof fetch,
});
}
// packages/csr's actions.test.ts deletes globalThis.fetch (and restores its
// own captured copy) around each of its tests, and packages/csr's
// reactive.test.ts's "cache invalidation refetches matching client Async
// boundaries" test replaces globalThis.fetch with a mock and never restores
// it at all. bun test runs test files sequentially — importing a file and
// running its tests before moving to the next — rather than importing every
// file up front, so a module-level `const realFetch = fetch` captured here
// would already observe whatever packages/csr left behind by the time this
// file (which sorts after csr) is loaded. The variable under test below is
// `resolveOrigin`, not `fetch`, so instead of depending on the shared
// `globalThis.fetch` at all, this makes a real request over a real socket
// using node:http directly. That keeps the assertion about
// httpTransport()'s default origin resolution intact regardless of what any
// other suite does to the global `fetch` binding.
function realFetch(url: string, init: RequestInit): Promise<Response> {
return new Promise((resolve, reject) => {
const target = new URL(url);
const req = http.request(
{
hostname: target.hostname,
port: target.port,
path: `${target.pathname}${target.search}`,
method: init.method ?? "GET",
headers: init.headers as Record<string, string>,
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("error", reject);
res.on("end", () => {
resolve(
new Response(Buffer.concat(chunks), {
status: res.statusCode ?? 500,
}),
);
});
},
);
req.on("error", reject);
if (typeof init.body === "string") req.write(init.body);
req.end();
});
}
describe("RPC integration", () => {
test("propagates identity and validates the callee permission", async () => {
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
process.env.WRNEXUS_APP_NAME = "web";
const client = serviceClient(billing, {
app: "billing",
as: { user: { id: "u1" }, tenant: { id: "acme" }, locals: {} } as unknown as Context,
transport: wire(true),
});
expect(await client.createInvoice({ amountCents: 250 })).toEqual({
invoiceId: "inv_250",
forSubject: "u1",
});
});
test("denies when the callee permission check refuses", async () => {
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
process.env.WRNEXUS_APP_NAME = "web";
const client = serviceClient(billing, {
app: "billing",
as: { user: { id: "u1" }, locals: {} } as unknown as Context,
transport: wire(false),
});
await expect(client.createInvoice({ amountCents: 1 })).rejects.toMatchObject({
code: RPC_ERROR_CODES.denied,
});
});
test("uses the real HTTP transport and private dispatcher end to end", async () => {
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
process.env.WRNEXUS_APP_NAME = "web";
const client = serviceClient(billing, {
app: "billing",
as: { user: { id: "u1" }, locals: {} } as unknown as Context,
transport: httpWire(true),
});
expect(await client.createInvoice({ amountCents: 7 })).toEqual({
invoiceId: "inv_7",
forSubject: "u1",
});
await expect(client.createInvoice({ amountCents: "bad" } as never)).rejects.toMatchObject({
code: RPC_ERROR_CODES.invalid,
});
});
test("the default httpTransport reaches the callee over a real socket via the internal origin", async () => {
const service = implement(
billing,
{
createInvoice: async ({ amountCents }, ctx) => ({
invoiceId: `inv_${amountCents}`,
forSubject: ctx.subject?.subjectId ?? "anon",
}),
},
{ selfApp: "billing", checkPermission: async () => true },
);
const server = Bun.serve({
port: 0,
hostname: "127.0.0.1",
async fetch(req) {
const url = new URL(req.url);
const res = await handleRpcRequest(req, url, new Map([["billing", service]]));
return res ?? new Response("Not found", { status: 404 });
},
});
const originalWorkspace = process.env.WRNEXUS_WORKSPACE_ORIGINS;
const originalInternal = process.env.WRNEXUS_INTERNAL_ORIGINS;
try {
// The workspace (public) origin deliberately points somewhere that
// cannot serve the RPC — the gateway 404s the RPC prefix on any
// request that arrives at a public origin. Only the internal-origin
// map points at the real server. If httpTransport() ever falls back
// to the public origin by default again, this call fails.
process.env.WRNEXUS_WORKSPACE_ORIGINS = JSON.stringify({
billing: "http://127.0.0.1:1", // unroutable — nothing listens here
});
process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({
billing: `http://127.0.0.1:${server.port}`,
});
const client = serviceClient(billing, {
app: "billing",
// no resolveOrigin override — uses the real default; fetch is pinned
// to the node:http-backed implementation above (see comment there).
transport: httpTransport({ fetch: realFetch as unknown as typeof fetch }),
});
expect(await client.createInvoice({ amountCents: 42 })).toEqual({
invoiceId: "inv_42",
forSubject: "anon",
});
} finally {
server.stop(true);
if (originalWorkspace === undefined) delete process.env.WRNEXUS_WORKSPACE_ORIGINS;
else process.env.WRNEXUS_WORKSPACE_ORIGINS = originalWorkspace;
if (originalInternal === undefined) delete process.env.WRNEXUS_INTERNAL_ORIGINS;
else process.env.WRNEXUS_INTERNAL_ORIGINS = originalInternal;
}
});
});