Files
WRNexusJS/packages/dev-server/test/gateway.test.ts
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

179 lines
6.6 KiB
TypeScript

import { expect, test } from "bun:test";
import { RPC_INTERNAL_HEADER, RPC_PATH_PREFIX } from "@wrnexus/rpc";
import {
defaultGatewayHostname,
forwardAuthFailure,
forwardAuthHeaders,
gatewayProxyHeaders,
stripUntrustedInternalHeaders,
gatewayRestartDelay,
internalError,
isRpcGatewayPath,
stripInternalError,
} from "../src/gateway.ts";
import { resolveProductionHostname } from "../src/prod.ts";
test("gateway-managed production apps bind to loopback", () => {
expect(resolveProductionHostname(undefined, "127.0.0.1")).toBe("127.0.0.1");
expect(resolveProductionHostname("0.0.0.0", "127.0.0.1")).toBe("127.0.0.1");
expect(resolveProductionHostname("10.0.0.5", "")).toBe("10.0.0.5");
expect(resolveProductionHostname(undefined, "")).toBe("0.0.0.0");
});
test("gateway uses platform-safe hostname defaults", () => {
expect(defaultGatewayHostname("development")).toBe("127.0.0.1");
expect(defaultGatewayHostname("production")).toBe("0.0.0.0");
});
test("gateway disables compression for its internal proxy hop", () => {
const request = new Request("http://localhost:3000/path", {
headers: { host: "web.localhost:3000", "accept-encoding": "gzip, deflate" },
});
const headers = gatewayProxyHeaders(request, new URL(request.url), "127.0.0.1", true);
expect(headers.get("accept-encoding")).toBe("identity");
expect(headers.get("x-forwarded-host")).toBe("web.localhost:3000");
expect(headers.get("x-forwarded-proto")).toBe("http");
expect(headers.get("x-forwarded-for")).toBe("127.0.0.1");
});
test("gateway proxy headers do not preserve the RPC internal marker", () => {
const request = new Request("http://localhost:3000/path", {
headers: { "x-wrnexus-internal": "1" },
});
const headers = stripUntrustedInternalHeaders(
gatewayProxyHeaders(request, new URL(request.url), "127.0.0.1", true),
);
expect(headers.has("x-wrnexus-internal")).toBe(false);
});
test("forward auth preserves intentional verifier redirects", () => {
const redirected = forwardAuthFailure(
new Response(null, { status: 302, headers: { location: "/login?returnTo=%2Fadmin" } }),
"http://sso.localhost:3000/api/verify",
);
const denied = forwardAuthFailure(new Response(null, { status: 401 }), "http://sso.localhost");
expect(redirected.status).toBe(302);
expect(redirected.headers.get("location")).toBe(
"http://sso.localhost:3000/login?returnTo=%2Fadmin",
);
expect(denied.status).toBe(401);
expect(denied.headers.has("location")).toBe(false);
});
test("forward auth exposes the verifier public origin instead of its internal app port", () => {
const redirected = forwardAuthFailure(
new Response(null, {
status: 302,
headers: { location: "/sign-in?returnTo=%2Fadmin" },
}),
"http://127.0.0.1:85/api/verify",
"http://sso.localhost",
);
expect(redirected.headers.get("location")).toBe("http://sso.localhost/sign-in?returnTo=%2Fadmin");
});
test("gateway reads and strips internal app diagnostics", async () => {
const response = new Response("safe public error", {
status: 500,
headers: {
"x-wrnexus-internal-error": encodeURIComponent('Error: Unknown workspace app "admin"'),
},
});
expect(internalError(response)).toBe('Error: Unknown workspace app "admin"');
const stripped = stripInternalError(response);
expect(stripped.status).toBe(500);
expect(stripped.headers.has("x-wrnexus-internal-error")).toBe(false);
expect(await stripped.text()).toBe("safe public error");
});
test("forward auth describes the original gateway request", () => {
const headers = forwardAuthHeaders(
new Request("https://admin.example.test/settings?tab=security", {
headers: {
host: "admin.example.test",
cookie: "session=abc",
authorization: "Bearer token",
},
}),
);
expect(headers.get("x-forwarded-host")).toBe("admin.example.test");
expect(headers.get("x-forwarded-proto")).toBe("https");
expect(headers.get("x-original-host")).toBe("admin.example.test");
expect(headers.get("x-original-proto")).toBe("https");
expect(headers.get("x-original-method")).toBe("GET");
expect(headers.get("x-original-uri")).toBe("/settings?tab=security");
expect(headers.get("cookie")).toBe("session=abc");
expect(headers.get("authorization")).toBe("Bearer token");
});
test("forward auth uses the protected app public protocol", () => {
const headers = forwardAuthHeaders(
new Request("http://127.0.0.1:10050/settings", {
headers: { host: "admin.wrnx.in" },
}),
"https://admin.wrnx.in",
);
expect(headers.get("x-original-host")).toBe("admin.wrnx.in");
expect(headers.get("x-original-proto")).toBe("https");
});
test("nested SSO proxy keeps the protected app's original request headers", () => {
const authHeaders = forwardAuthHeaders(
new Request("http://admin.localhost:3000/settings", {
headers: { host: "admin.localhost:3000" },
}),
);
authHeaders.set("host", "sso.localhost:3000");
const verifierRequest = new Request("http://sso.localhost:3000/api/verify", {
headers: authHeaders,
});
const proxied = gatewayProxyHeaders(
verifierRequest,
new URL(verifierRequest.url),
"127.0.0.1",
true,
);
expect(proxied.get("x-forwarded-host")).toBe("sso.localhost:3000");
expect(proxied.get("x-original-host")).toBe("admin.localhost:3000");
expect(proxied.get("x-original-uri")).toBe("/settings");
});
test("the reserved RPC prefix is refused at the gateway before any proxying", () => {
expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(true);
expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true);
expect(isRpcGatewayPath("/api/billing")).toBe(false);
expect(isRpcGatewayPath("/__wrnexus/rpcfoo")).toBe(false);
});
test("an inbound internal-marker header from outside is stripped regardless of casing", () => {
for (const name of [
RPC_INTERNAL_HEADER,
RPC_INTERNAL_HEADER.toUpperCase(),
"X-WrNexus-Internal",
]) {
const request = new Request("http://localhost:3000/path", {
headers: { [name]: "1" },
});
const headers = stripUntrustedInternalHeaders(
gatewayProxyHeaders(request, new URL(request.url), "127.0.0.1", true),
);
expect(headers.has(RPC_INTERNAL_HEADER)).toBe(false);
}
});
test("gateway respawns development apps after an HMR restart exit", () => {
expect(gatewayRestartDelay("development", 97, null)).toBe(0);
expect(gatewayRestartDelay("development", 1, null)).toBe(1200);
expect(gatewayRestartDelay("development", 0, null)).toBeNull();
expect(gatewayRestartDelay("development", 97, "SIGTERM")).toBeNull();
expect(gatewayRestartDelay("production", 97, null)).toBeNull();
});