216 lines
8.2 KiB
TypeScript
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 wrn(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 createHttpTransport(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: wrn(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: wrn(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: createHttpTransport(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;
|
|
}
|
|
});
|
|
});
|