mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Chord's telemetry schema; spans for calls, methods, relays, holds, activations, natives, loads, reloads, resolves, revocations, routines, jobs and HTTP requests; trace carriers on hold and call (bounded, printable ASCII) continued by relays and workers; events and host-wide metrics; the app kit passes an adapter to its hosts. renderTrace escapes control characters. Example 62 prints a trace across three hosts.
1035 lines
36 KiB
TypeScript
1035 lines
36 KiB
TypeScript
// Tests of the HTTP component (slice 10 §2; decisions "The HTTP core is a fetch handler", "The HTTP server is a native
|
|
// object", "The HTTP component's security baseline"): `fetch` waiting for the publication, the registries resolved
|
|
// once, the listener opening after the publication (none without `listen`, a later change reported), allowed hosts from
|
|
// contributions, authenticators, foreign requests and `Origin`, pages and cross-site routes, the default headers on the
|
|
// component's own answers, throwing routes and wraps, a reload swapping a route while a request runs, `close` (also
|
|
// while authenticating), a mounted handler, and requests' scopes collected.
|
|
|
|
import { InMemoryTelemetryContext, type TelemetryCarrier, type TelemetryContext } from "@earendil-works/pi-telemetry";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { withAbortSignal } from "../../../src/context/index.ts";
|
|
import { arr, local, method, str } from "../../../src/contracts/index.ts";
|
|
import { ChordError } from "../../../src/data/index.ts";
|
|
import { type Ctx, defineFacet } from "../../../src/host/index.ts";
|
|
import { Authenticators, Http, type HttpContribution, HttpFailure } from "../../../src/node/http/index.ts";
|
|
import { expectCollected } from "../../support.ts";
|
|
import { context, contributing, send, served, type TestPrincipal } from "./support.ts";
|
|
|
|
/** A route answering who it runs as. */
|
|
const whoami = contributing({
|
|
routes: {
|
|
"GET /whoami": {
|
|
public: true,
|
|
handler: async (_request, ctx) => ({ kind: "json", value: ctx.principal ?? null }),
|
|
},
|
|
},
|
|
});
|
|
|
|
/** A loopback request to `fetch` directly, as a mounted server would make it. */
|
|
function loopback(path: string, init: RequestInit & { headers?: Record<string, string> } = {}): Request {
|
|
return new Request(`http://127.0.0.1:8788${path}`, {
|
|
...init,
|
|
headers: { host: "127.0.0.1:8788", ...init.headers },
|
|
});
|
|
}
|
|
|
|
/** Resolves with `value` after `ms`. */
|
|
function sleepThen<T>(ms: number, value: T): Promise<T> {
|
|
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
|
|
}
|
|
|
|
/** The connection of `loopback`'s requests. */
|
|
const LOOPBACK = { localAddress: "127.0.0.1", localPort: 8788 };
|
|
|
|
describe("startup", () => {
|
|
it("fetch answers 503 before load at once, then serves; the registries are resolved once", async () => {
|
|
const server = await served({ "@app/who": whoami }, { load: false });
|
|
const registry = vi.spyOn(server.host, "registry");
|
|
expect((await server.http.fetch(loopback("/whoami"), LOOPBACK)).status).toBe(503);
|
|
await server.load();
|
|
expect(await (await server.http.fetch(loopback("/whoami"), LOOPBACK)).text()).toBe("null");
|
|
const calls = registry.mock.calls.length;
|
|
for (let i = 0; i < 3; i++) await server.http.fetch(loopback("/whoami"), LOOPBACK);
|
|
expect(registry.mock.calls.length).toBe(calls);
|
|
await server.close();
|
|
});
|
|
|
|
it("listens once published where its listen setting says; without it no listener", async () => {
|
|
const server = await served({ "@app/who": whoami });
|
|
expect(server.port).toBeGreaterThan(0);
|
|
expect((await send(server.port, "GET", "/whoami")).status).toBe(200);
|
|
await server.close();
|
|
const mounted = await served({ "@app/who": whoami }, { config: { listen: undefined } });
|
|
const listening = mounted.http.listening(context).then(
|
|
() => "listening",
|
|
() => "closed",
|
|
);
|
|
expect((await mounted.http.fetch(loopback("/whoami"), LOOPBACK)).status).toBe(200);
|
|
await mounted.close();
|
|
expect(await listening).toBe("closed");
|
|
});
|
|
});
|
|
|
|
describe("Host and principals", () => {
|
|
it("refuses a Host neither loopback nor contributed (403, with the default headers); admits a contributed one", async () => {
|
|
const server = await served({ "@app/who": whoami, "@app/hosts": contributing({ hosts: ["Notes.Example.com"] }) });
|
|
const refused = await send(server.port, "GET", "/whoami", { headers: { host: "evil.com" } });
|
|
expect([refused.status, refused.headers["x-content-type-options"], refused.headers["cache-control"]]).toEqual([
|
|
403,
|
|
"nosniff",
|
|
"no-store",
|
|
]);
|
|
const through = await send(server.port, "GET", "/whoami", { headers: { host: "notes.example.com" } });
|
|
expect(through.status).toBe(200);
|
|
await server.close();
|
|
});
|
|
|
|
it("admits hosts a writable contribution adds and removes at runtime", async () => {
|
|
const Tunnel = local("app.tunnel", { version: 1, params: {}, members: { name: method(arr(str())) } });
|
|
const tunnel = defineFacet<TestPrincipal>((host) =>
|
|
host.serve(Tunnel, (_, self) => {
|
|
const contribution = self.writable<HttpContribution>({ hosts: [] });
|
|
self.contribute(Http, contribution);
|
|
return {
|
|
name: async (hosts) => {
|
|
contribution.set({ hosts });
|
|
return null;
|
|
},
|
|
};
|
|
}),
|
|
);
|
|
const server = await served({ "@app/tunnel": tunnel, "@app/who": whoami });
|
|
const status = async () =>
|
|
(await send(server.port, "GET", "/whoami", { headers: { host: "t.example.com" } })).status;
|
|
expect(await status()).toBe(403);
|
|
const handle = await server.host.get(Tunnel, {}, context);
|
|
await handle.name(["t.example.com"], context);
|
|
expect(await status()).toBe(200);
|
|
await handle.name([], context);
|
|
expect(await status()).toBe(403);
|
|
await server.close();
|
|
});
|
|
|
|
it("runs a request without a principal anonymous; asks the authenticators in order, skipping a throwing one", async () => {
|
|
const server = await served({
|
|
"@app/who": whoami,
|
|
"@app/a-broken": defineFacet<TestPrincipal>((_host, self) =>
|
|
self.contribute(Authenticators, () => {
|
|
throw new Error("broken");
|
|
}),
|
|
),
|
|
});
|
|
expect((await send(server.port, "GET", "/whoami")).body.toString()).toBe("null");
|
|
const owner = await send(server.port, "GET", "/whoami", { headers: { "x-principal": "owner" } });
|
|
expect(owner.body.toString()).toBe('{"kind":"owner"}');
|
|
expect(server.errors).toMatchObject([
|
|
{ kind: "hook_failed", hook: "authenticate", plugin: "@app/a-broken" },
|
|
{ kind: "hook_failed", hook: "authenticate", plugin: "@app/a-broken" },
|
|
]);
|
|
await server.close();
|
|
});
|
|
|
|
// Opus's security audit H1: a cross-site form posted with the owner's credentials.
|
|
it("never authenticates a foreign request; refuses a foreign Origin and an unsafe browser request without one", async () => {
|
|
let deleted = 0;
|
|
const server = await served({
|
|
"@app/notes": contributing({
|
|
routes: {
|
|
"POST /delete": {
|
|
public: true,
|
|
handler: async (_request, ctx) => {
|
|
if (ctx.principal?.kind === "owner") deleted++;
|
|
return { kind: "json", value: deleted };
|
|
},
|
|
},
|
|
"GET /whoami": {
|
|
public: true,
|
|
handler: async (_request, ctx) => ({ kind: "json", value: ctx.principal ?? null }),
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
const owner = { "x-principal": "owner" };
|
|
const own = `http://127.0.0.1:${server.port}`;
|
|
const crossSite = { ...owner, "sec-fetch-site": "cross-site" };
|
|
expect(
|
|
(await send(server.port, "POST", "/delete", { headers: { ...crossSite, origin: "https://evil.example" } }))
|
|
.status,
|
|
).toBe(403);
|
|
expect(
|
|
(await send(server.port, "POST", "/delete", { headers: { ...owner, "sec-fetch-site": "same-origin" } }))
|
|
.status,
|
|
).toBe(403);
|
|
expect((await send(server.port, "GET", "/whoami", { headers: crossSite })).body.toString()).toBe("null");
|
|
const same = await send(server.port, "POST", "/delete", {
|
|
headers: { ...owner, origin: own, "sec-fetch-site": "same-origin" },
|
|
});
|
|
expect([same.status, same.body.toString()]).toEqual([200, "1"]);
|
|
// Without Fetch Metadata (a script, the TUI): not a browser's request.
|
|
expect((await send(server.port, "POST", "/delete", { headers: owner })).body.toString()).toBe("2");
|
|
await server.close();
|
|
});
|
|
|
|
it("authenticates a top-level navigation to a page from another site; a cross-site route runs anonymous", async () => {
|
|
const seen: unknown[] = [];
|
|
const server = await served({
|
|
"@app/pages": contributing({
|
|
routes: {
|
|
"GET /app": {
|
|
public: true,
|
|
page: true,
|
|
handler: async (_request, ctx) => {
|
|
seen.push(ctx.principal ?? null);
|
|
return { kind: "html", body: "app" };
|
|
},
|
|
},
|
|
"POST /hook": {
|
|
public: true,
|
|
crossSite: true,
|
|
handler: async (_request, ctx) => {
|
|
seen.push(ctx.principal ?? null);
|
|
return { kind: "json", value: 1 };
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
const navigate = {
|
|
"x-principal": "owner",
|
|
"sec-fetch-site": "cross-site",
|
|
"sec-fetch-mode": "navigate",
|
|
"sec-fetch-dest": "document",
|
|
};
|
|
await send(server.port, "GET", "/app", { headers: navigate });
|
|
await send(server.port, "GET", "/app", { headers: { ...navigate, "sec-fetch-dest": "iframe" } });
|
|
const hook = await send(server.port, "POST", "/hook", {
|
|
headers: { "x-principal": "owner", origin: "https://github.com", "sec-fetch-site": "cross-site" },
|
|
});
|
|
expect([hook.status, seen]).toEqual([200, [{ kind: "owner" }, null, null]]);
|
|
await server.close();
|
|
});
|
|
});
|
|
|
|
describe("deny by default (slice 13 §2)", () => {
|
|
it("answers 401 to a request nobody authenticates before a route not marked public runs; serves a public one", async () => {
|
|
let ran = 0;
|
|
const server = await served({
|
|
"@app/notes": contributing({
|
|
routes: {
|
|
"GET /private": async () => {
|
|
ran++;
|
|
return { kind: "json", value: "private" };
|
|
},
|
|
"GET /public": { public: true, handler: async () => ({ kind: "json", value: "public" }) },
|
|
"POST /hook": { public: true, crossSite: true, handler: async () => ({ kind: "json", value: "hook" }) },
|
|
},
|
|
}),
|
|
});
|
|
const nobody = await send(server.port, "GET", "/private");
|
|
expect(nobody.status).toBe(401);
|
|
expect(nobody.headers["cache-control"]).toBe("no-store");
|
|
expect(ran).toBe(0);
|
|
expect((await send(server.port, "GET", "/private", { headers: { "x-principal": "owner" } })).status).toBe(200);
|
|
expect((await send(server.port, "GET", "/public")).body.toString()).toBe('"public"');
|
|
// A cross-site route always runs anonymous: public by being one.
|
|
const hook = await send(server.port, "POST", "/hook", {
|
|
headers: { "sec-fetch-site": "cross-site", origin: "https://ci.example.com" },
|
|
});
|
|
expect(hook.body.toString()).toBe('"hook"');
|
|
await server.close();
|
|
});
|
|
});
|
|
|
|
describe("default headers", () => {
|
|
it("narrows script-src to contributed script paths of the origin (slice 11 fix round 1, Opus L8)", async () => {
|
|
const server = await served({
|
|
"@app/page": contributing({
|
|
routes: { "GET /page": { public: true, handler: async () => ({ kind: "html", body: "<p>x</p>" }) } },
|
|
scripts: ["/_chord/web/"],
|
|
}),
|
|
});
|
|
const page = await send(server.port, "GET", "/page");
|
|
expect(String(page.headers["content-security-policy"])).toMatch(
|
|
new RegExp(`script-src http://127\\.0\\.0\\.1:${server.port}/_chord/web/ 'nonce-[^']+';`),
|
|
);
|
|
await server.close();
|
|
});
|
|
|
|
it("sends the baseline on every answer, a CSP with the response's own nonce and the contributed framers and sources", async () => {
|
|
let nonce = "";
|
|
const server = await served({
|
|
"@app/page": contributing({
|
|
routes: {
|
|
"GET /page": {
|
|
public: true,
|
|
handler: async (request) => {
|
|
nonce = request.nonce;
|
|
return { kind: "html", body: `<script nonce="${request.nonce}"></script>` };
|
|
},
|
|
},
|
|
},
|
|
framers: ["https://shell.example.com"],
|
|
csp: { "img-src": ["https://cdn.example.com"] },
|
|
}),
|
|
});
|
|
const page = await send(server.port, "GET", "/page");
|
|
expect(page.headers).toMatchObject({
|
|
"cache-control": "no-store",
|
|
"x-content-type-options": "nosniff",
|
|
"referrer-policy": "no-referrer",
|
|
});
|
|
expect(page.headers["x-frame-options"]).toBeUndefined();
|
|
expect(page.headers["content-security-policy"]).toBe(
|
|
`default-src 'none'; script-src 'self' 'nonce-${nonce}'; connect-src 'self' ws://127.0.0.1:${server.port}; base-uri 'none'; form-action 'self'; frame-ancestors https://shell.example.com; img-src https://cdn.example.com`,
|
|
);
|
|
// Fable's recheck: a nonce read from one page must not run a script injected into another.
|
|
const first = nonce;
|
|
await send(server.port, "GET", "/page");
|
|
expect(nonce).not.toBe(first);
|
|
const missing = await send(server.port, "GET", "/nope");
|
|
expect([missing.status, missing.headers["x-content-type-options"]]).toEqual([404, "nosniff"]);
|
|
expect(missing.headers["content-security-policy"]).toMatch(/'nonce-[A-Za-z0-9+/]{22}=='/);
|
|
expect(missing.headers["content-security-policy"]).not.toContain(nonce);
|
|
await server.close();
|
|
const plain = await served({});
|
|
expect((await send(plain.port, "GET", "/")).headers["x-frame-options"]).toBe("DENY");
|
|
await plain.close();
|
|
});
|
|
|
|
it("lets a relay's verified request through with its framer", async () => {
|
|
const server = await served({
|
|
"@app/who": whoami,
|
|
"@app/relay": contributing({
|
|
relays: [
|
|
{
|
|
name: "radius",
|
|
verify: (request) =>
|
|
request.headers.get("x-relay") === "secret"
|
|
? { origin: "https://me.relay.example", framer: "https://relay.example" }
|
|
: undefined,
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
const forwarded = { "x-forwarded-for": "203.0.113.9" };
|
|
expect((await send(server.port, "GET", "/whoami", { headers: forwarded })).status).toBe(403);
|
|
const relayed = await send(server.port, "GET", "/whoami", { headers: { ...forwarded, "x-relay": "secret" } });
|
|
expect(relayed.status).toBe(200);
|
|
expect(relayed.headers["content-security-policy"]).toContain("frame-ancestors https://relay.example");
|
|
await server.close();
|
|
});
|
|
});
|
|
|
|
describe("routes and wraps failing", () => {
|
|
it("answers a throwing route 500 without its message, a throwing wrap 500 naming its plugin, an HttpFailure its status", async () => {
|
|
const server = await served({
|
|
"@app/a": contributing({
|
|
routes: {
|
|
"GET /throws": {
|
|
public: true,
|
|
handler: async () => {
|
|
throw new Error("secret");
|
|
},
|
|
},
|
|
"GET /failure": {
|
|
public: true,
|
|
handler: async () => {
|
|
throw new HttpFailure(422, "no");
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
"@app/b": contributing({
|
|
routes: { "GET /wrapped": { public: true, handler: async () => ({ kind: "json", value: 1 }) } },
|
|
wraps: [
|
|
{
|
|
name: "broken",
|
|
phase: "headers",
|
|
wrap: async () => {
|
|
throw new Error("wrap");
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
const thrown = await send(server.port, "GET", "/throws");
|
|
expect([thrown.status, thrown.body.toString()]).toEqual([500, "Internal Server Error"]);
|
|
expect((await send(server.port, "GET", "/failure")).status).toBe(422);
|
|
expect((await send(server.port, "GET", "/wrapped")).status).toBe(500);
|
|
expect(server.errors).toMatchObject([
|
|
{ kind: "route_failed", pattern: "GET /throws", plugin: "@app/a", error: { message: "secret" } },
|
|
{ kind: "wrap_failed", name: "broken", plugin: "@app/b", error: { message: "wrap" } },
|
|
]);
|
|
await server.close();
|
|
});
|
|
|
|
it("runs wraps outermost first around the route, in its request's scope", async () => {
|
|
const order: string[] = [];
|
|
const server = await served({
|
|
"@app/a": contributing({
|
|
routes: {
|
|
"GET /r": {
|
|
public: true,
|
|
handler: async (_request, ctx) => {
|
|
order.push(`route as ${ctx.principal?.kind ?? "anonymous"}`);
|
|
return { kind: "json", value: 1 };
|
|
},
|
|
},
|
|
},
|
|
wraps: [
|
|
{
|
|
name: "inner",
|
|
phase: "transform",
|
|
wrap: async (request, next) => {
|
|
order.push("transform");
|
|
return next(request);
|
|
},
|
|
},
|
|
{
|
|
name: "outer",
|
|
phase: "observe",
|
|
wrap: async (request, next) => {
|
|
order.push("observe");
|
|
const response = await next(request);
|
|
return { ...response, headers: { "X-Observed": "1" } };
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
const answer = await send(server.port, "GET", "/r", { headers: { "x-principal": "owner" } });
|
|
expect([order, answer.headers["x-observed"]]).toEqual([["observe", "transform", "route as owner"], "1"]);
|
|
await server.close();
|
|
});
|
|
});
|
|
|
|
describe("reloads and close", () => {
|
|
it("a reload swapping a route leaves a running request on the old router; the next uses the new one", async () => {
|
|
const gate = Promise.withResolvers<void>();
|
|
const old = contributing({
|
|
routes: {
|
|
"GET /v": {
|
|
public: true,
|
|
handler: async () => {
|
|
await gate.promise;
|
|
return { kind: "html", body: "old" };
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const server = await served({ "@app/v": old });
|
|
const running = send(server.port, "GET", "/v");
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
const next = contributing({
|
|
routes: { "GET /v": { public: true, handler: async () => ({ kind: "html", body: "new" }) } },
|
|
});
|
|
await server.host.reload(server.facets({ "@app/v": next }), context);
|
|
expect((await send(server.port, "GET", "/v")).body.toString()).toBe("new");
|
|
gate.resolve();
|
|
expect((await running).body.toString()).toBe("old");
|
|
await server.close();
|
|
});
|
|
|
|
it("close refuses new requests (503), waits for running ones up to closeTimeout, then seals them (503)", async () => {
|
|
let sealed: AbortSignal | undefined;
|
|
const server = await served(
|
|
{
|
|
"@app/slow": contributing({
|
|
routes: {
|
|
"GET /quick": {
|
|
public: true,
|
|
handler: async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
return { kind: "html", body: "done" };
|
|
},
|
|
},
|
|
"GET /stuck": {
|
|
public: true,
|
|
handler: async (_request, ctx: Ctx) => {
|
|
sealed = ctx.abortSignal;
|
|
return new Promise(() => {});
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{ config: { listen: { host: "127.0.0.1", port: 0 }, closeTimeout: 100 } },
|
|
);
|
|
const quick = send(server.port, "GET", "/quick");
|
|
const stuck = send(server.port, "GET", "/stuck");
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
const closing = server.http.close(context);
|
|
expect((await server.http.fetch(loopback("/quick"), LOOPBACK)).status).toBe(503);
|
|
expect((await quick).body.toString()).toBe("done");
|
|
await closing;
|
|
expect(sealed?.aborted).toBe(true);
|
|
await server.host.close(context);
|
|
expect((await stuck).status).toBe(503);
|
|
await expect(send(server.port, "GET", "/quick")).rejects.toThrow();
|
|
});
|
|
|
|
// Astra's build review: a request authenticating when close began ran its route afterwards.
|
|
it("never runs a route for a request still authenticating when close sealed it", async () => {
|
|
const entered = Promise.withResolvers<void>();
|
|
const gate = Promise.withResolvers<void>();
|
|
let calls = 0;
|
|
const server = await served(
|
|
{
|
|
"@app/r": contributing({
|
|
routes: {
|
|
"GET /": {
|
|
public: true,
|
|
handler: async () => {
|
|
calls++;
|
|
return { kind: "json", value: 1 };
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
config: { listen: { host: "127.0.0.1", port: 0 }, closeTimeout: 20 },
|
|
authenticate: async () => {
|
|
entered.resolve();
|
|
await gate.promise;
|
|
return { kind: "owner" };
|
|
},
|
|
},
|
|
);
|
|
const answer = server.http.fetch(loopback("/"), LOOPBACK);
|
|
await entered.promise;
|
|
await server.http.close(context);
|
|
gate.resolve();
|
|
expect([(await answer).status, calls]).toEqual([503, 0]);
|
|
await server.close();
|
|
});
|
|
});
|
|
|
|
describe("mounting", () => {
|
|
it("without a connection a request is never local", async () => {
|
|
const server = await served({ "@app/who": whoami }, { config: { listen: undefined } });
|
|
expect((await server.http.fetch(loopback("/whoami"))).status).toBe(403);
|
|
await server.close();
|
|
});
|
|
});
|
|
|
|
describe("cancellation", () => {
|
|
// Sol's second recheck C: after cancellation the chain still started the next authenticator.
|
|
it("starts no further authenticator once close sealed the request", async () => {
|
|
const entered = Promise.withResolvers<void>();
|
|
const gate = Promise.withResolvers<void>();
|
|
let later = 0;
|
|
const server = await served(
|
|
{
|
|
"@app/r": contributing({
|
|
routes: { "GET /": { public: true, handler: async () => ({ kind: "html", body: "ok" }) } },
|
|
}),
|
|
"@app/z-later": defineFacet<TestPrincipal>((_host, self) =>
|
|
self.contribute(Authenticators, () => {
|
|
later++;
|
|
return { kind: "owner" };
|
|
}),
|
|
),
|
|
},
|
|
{
|
|
config: { listen: undefined, closeTimeout: 10 },
|
|
authenticate: async () => {
|
|
entered.resolve();
|
|
await gate.promise;
|
|
return undefined;
|
|
},
|
|
},
|
|
);
|
|
const answer = server.http.fetch(loopback("/"), LOOPBACK);
|
|
await entered.promise;
|
|
await server.http.close(context);
|
|
expect((await answer).status).toBe(503);
|
|
gate.resolve();
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
expect(later).toBe(0);
|
|
await server.close();
|
|
});
|
|
|
|
it("asks no authenticator for a request aborted before it arrived", async () => {
|
|
let calls = 0;
|
|
const server = await served(
|
|
{
|
|
"@app/r": contributing({
|
|
routes: { "GET /": { public: true, handler: async () => ({ kind: "html", body: "ok" }) } },
|
|
}),
|
|
},
|
|
{
|
|
config: { listen: undefined },
|
|
authenticate: () => {
|
|
calls++;
|
|
return undefined;
|
|
},
|
|
},
|
|
);
|
|
await server.http.fetch(loopback("/", { signal: AbortSignal.abort() }), LOOPBACK);
|
|
expect(calls).toBe(0);
|
|
await server.close();
|
|
});
|
|
|
|
// Sol's third recheck: a request aborted while its route returned the `bytes` description got a stream born sealed,
|
|
// which never errored; the request never finished.
|
|
it.each(["inside the route", "after the route returned"] as const)(
|
|
"ends a request whose bytes Response was born sealed (aborted %s)",
|
|
async (when) => {
|
|
const abort = new AbortController();
|
|
const entered = Promise.withResolvers<void>();
|
|
let reads = 0;
|
|
let bornSealed: boolean | undefined;
|
|
const server = await served(
|
|
{
|
|
"@app/b": contributing({
|
|
routes: {
|
|
"GET /": {
|
|
public: true,
|
|
handler: async (_request, ctx) => {
|
|
entered.resolve();
|
|
if (when === "inside the route") abort.abort(new Error("client gone"));
|
|
bornSealed = ctx.abortSignal?.aborted;
|
|
return {
|
|
kind: "bytes",
|
|
mime: "application/octet-stream",
|
|
read: async () => {
|
|
reads++;
|
|
return new Uint8Array([1]);
|
|
},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{ config: { listen: undefined, closeTimeout: 1_000 } },
|
|
);
|
|
const answer = server.http.fetch(loopback("/", { signal: abort.signal }), LOOPBACK);
|
|
await entered.promise;
|
|
if (when === "after the route returned") abort.abort(new Error("client gone"));
|
|
await answer;
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
const started = Date.now();
|
|
await server.http.close(context);
|
|
expect([Date.now() - started < 500, reads, bornSealed]).toEqual([true, 0, when === "inside the route"]);
|
|
await server.close();
|
|
},
|
|
);
|
|
|
|
// Sol's second recheck B: an aborted `Response` nobody touched again kept its request: close waited its bound.
|
|
it("ends a request whose bytes Response was aborted and dropped without another read", async () => {
|
|
const server = await served(
|
|
{
|
|
"@app/b": contributing({
|
|
routes: {
|
|
"GET /": {
|
|
public: true,
|
|
handler: async () => ({
|
|
kind: "bytes",
|
|
mime: "application/octet-stream",
|
|
read: async () => new Uint8Array([1]),
|
|
}),
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{ config: { listen: undefined, closeTimeout: 1_000 } },
|
|
);
|
|
const abort = new AbortController();
|
|
await server.http.fetch(loopback("/", { signal: abort.signal }), LOOPBACK);
|
|
abort.abort(new Error("client gone"));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
const started = Date.now();
|
|
await server.http.close(context);
|
|
expect(Date.now() - started).toBeLessThan(500);
|
|
await server.close();
|
|
});
|
|
|
|
// Astra's recheck R4, Sol's N1: a close before the first resolution started a stopped watch; its throw ended the process.
|
|
it("a close before load leaves nothing running: the early request answers 503", async () => {
|
|
const server = await served({}, { load: false, config: { listen: undefined } });
|
|
const waiting = server.http.fetch(loopback("/x"), LOOPBACK);
|
|
await server.http.close(withAbortSignal(AbortSignal.abort(), context));
|
|
await server.load();
|
|
expect((await waiting).status).toBe(503);
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
await server.close();
|
|
});
|
|
|
|
it("a close while the first request resolves the registries starts no follow", async () => {
|
|
const server = await served({ "@app/who": whoami }, { config: { listen: undefined, closeTimeout: 10 } });
|
|
const pending = server.http.fetch(loopback("/whoami"), LOOPBACK);
|
|
await server.http.close(withAbortSignal(AbortSignal.timeout(20), context));
|
|
expect([200, 503]).toContain((await pending).status);
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
await server.close();
|
|
});
|
|
|
|
// Sol's recheck N9.
|
|
it("a close whose context is aborted already does not wait", async () => {
|
|
const entered = Promise.withResolvers<void>();
|
|
const gate = Promise.withResolvers<void>();
|
|
const server = await served(
|
|
{
|
|
"@app/wait": contributing({
|
|
routes: {
|
|
"GET /wait": {
|
|
public: true,
|
|
handler: async () => {
|
|
entered.resolve();
|
|
await gate.promise;
|
|
return { kind: "html", body: "done" };
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{ config: { listen: undefined, closeTimeout: 1000 } },
|
|
);
|
|
const answer = server.http.fetch(loopback("/wait"), LOOPBACK);
|
|
await entered.promise;
|
|
const started = Date.now();
|
|
await server.http.close(withAbortSignal(AbortSignal.abort(), context));
|
|
expect(Date.now() - started).toBeLessThan(500);
|
|
gate.resolve();
|
|
await answer;
|
|
await server.close();
|
|
});
|
|
|
|
// Sol's recheck N5: an authenticator ignoring its signal kept fetch pending after close returned.
|
|
it("close settles a request whose authenticator ignores its signal (503)", async () => {
|
|
const entered = Promise.withResolvers<void>();
|
|
const gate = Promise.withResolvers<void>();
|
|
const server = await served(
|
|
{
|
|
"@app/r": contributing({
|
|
routes: { "GET /": { public: true, handler: async () => ({ kind: "html", body: "ok" }) } },
|
|
}),
|
|
},
|
|
{
|
|
config: { listen: undefined, closeTimeout: 10 },
|
|
authenticate: async () => {
|
|
entered.resolve();
|
|
await gate.promise;
|
|
return { kind: "owner" };
|
|
},
|
|
},
|
|
);
|
|
const answer = server.http.fetch(loopback("/"), LOOPBACK);
|
|
await entered.promise;
|
|
await server.http.close(context);
|
|
const settled = await Promise.race([answer.then((response) => response.status), sleepThen(30, "pending")]);
|
|
expect(settled).toBe(503);
|
|
gate.resolve();
|
|
await server.close();
|
|
});
|
|
|
|
// Astra's recheck R2, Sol's N3: a held `Response` went on calling `read` after its scope was sealed.
|
|
it.each(["the request aborted", "the component closed"] as const)("reads no bytes after %s", async (kind) => {
|
|
let reads = 0;
|
|
let readAfterSeal = false;
|
|
const abort = new AbortController();
|
|
const server = await served(
|
|
{
|
|
"@app/bytes": contributing({
|
|
routes: {
|
|
"GET /stream": {
|
|
public: true,
|
|
handler: async () => ({
|
|
kind: "bytes",
|
|
mime: "application/octet-stream",
|
|
read: async (_offset, _length, ctx) => {
|
|
reads++;
|
|
readAfterSeal ||= ctx.abortSignal?.aborted ?? false;
|
|
return new Uint8Array([reads]);
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{ config: { listen: undefined, closeTimeout: 10 } },
|
|
);
|
|
const response = await server.http.fetch(loopback("/stream", { signal: abort.signal }), LOOPBACK);
|
|
const reader = response.body!.getReader();
|
|
await reader.read();
|
|
if (kind === "the request aborted") abort.abort(new Error("client gone"));
|
|
else await server.http.close(context);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await expect(reader.read()).rejects.toThrow();
|
|
expect({ reads, readAfterSeal }).toEqual({ reads: 1, readAfterSeal: false });
|
|
await server.close();
|
|
});
|
|
|
|
// Sol's recheck N4.
|
|
it("a body that errors after valid JSON fails the request (400); the route's side effect never runs", async () => {
|
|
let writes = 0;
|
|
const server = await served(
|
|
{
|
|
"@app/body": contributing({
|
|
routes: {
|
|
"POST /body": {
|
|
public: true,
|
|
handler: async (request) => {
|
|
const value = await request.json();
|
|
writes++;
|
|
return { kind: "json", value };
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{ config: { listen: undefined } },
|
|
);
|
|
let pulls = 0;
|
|
const body = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
if (pulls++ === 0) controller.enqueue(new TextEncoder().encode("{}"));
|
|
else controller.error(new Error("incoming body checksum failed"));
|
|
},
|
|
});
|
|
const init = { method: "POST", body, duplex: "half" as const };
|
|
const response = await server.http.fetch(loopback("/body", init), LOOPBACK);
|
|
expect([response.status, writes]).toEqual([400, 0]);
|
|
await server.close();
|
|
});
|
|
});
|
|
|
|
describe("answers", () => {
|
|
// Sol's recheck N8: `Response` threw on a body for these statuses.
|
|
it.each([204, 205, 304])("answers %s without a body", async (status) => {
|
|
const server = await served(
|
|
{
|
|
"@app/s": contributing({
|
|
routes: { "GET /": { public: true, handler: async () => ({ kind: "json", value: null, status }) } },
|
|
}),
|
|
},
|
|
{ config: { listen: undefined } },
|
|
);
|
|
const response = await server.http.fetch(loopback("/"), LOOPBACK);
|
|
expect([response.status, response.body, response.headers.has("content-length")]).toEqual([status, null, false]);
|
|
await server.close();
|
|
});
|
|
|
|
// Fable's recheck N3: the kernel's `forbidden` reached the client as a reported 500.
|
|
it("answers a forbidden escaping a route 403, unreported", async () => {
|
|
const server = await served(
|
|
{
|
|
"@app/r": contributing({
|
|
routes: {
|
|
"GET /": {
|
|
public: true,
|
|
handler: async () => {
|
|
throw new ChordError({ code: "forbidden", message: "no" });
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{ config: { listen: undefined } },
|
|
);
|
|
expect((await server.http.fetch(loopback("/"), LOOPBACK)).status).toBe(403);
|
|
expect(server.errors).toEqual([]);
|
|
await server.close();
|
|
});
|
|
|
|
// Opus's recheck R2: one value per header name allowed one cookie per response.
|
|
it("sends several Set-Cookie headers", async () => {
|
|
const server = await served({
|
|
"@app/r": contributing({
|
|
routes: {
|
|
"GET /": {
|
|
public: true,
|
|
handler: async () => ({ kind: "json", value: 1, headers: { "Set-Cookie": ["a=1", "b=2"] } }),
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
expect((await send(server.port, "GET", "/")).headers["set-cookie"]).toEqual(["a=1", "b=2"]);
|
|
const fetched = await server.http.fetch(
|
|
new Request(`http://127.0.0.1:${server.port}/`, { headers: { host: `127.0.0.1:${server.port}` } }),
|
|
{ localAddress: "127.0.0.1", localPort: server.port },
|
|
);
|
|
expect(fetched.headers.getSetCookie()).toEqual(["a=1", "b=2"]);
|
|
await server.close();
|
|
});
|
|
});
|
|
|
|
describe("GC", () => {
|
|
// Sol's second recheck B, her GC probe: 8 of 8 such requests kept their scopes.
|
|
it("collects the scopes of aborted bytes Responses dropped without another read", async () => {
|
|
const refs: WeakRef<object>[] = [];
|
|
const server = await served(
|
|
{
|
|
"@app/b": contributing({
|
|
routes: {
|
|
"GET /": {
|
|
public: true,
|
|
handler: async (_request, ctx) => {
|
|
refs.push(new WeakRef(ctx));
|
|
return {
|
|
kind: "bytes",
|
|
mime: "application/octet-stream",
|
|
read: async () => new Uint8Array([1]),
|
|
};
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{ config: { listen: undefined } },
|
|
);
|
|
for (let i = 0; i < 8; i++) {
|
|
const abort = new AbortController();
|
|
await server.http.fetch(loopback("/", { signal: abort.signal }), LOOPBACK);
|
|
abort.abort(new Error("client gone"));
|
|
}
|
|
await expectCollected(...refs);
|
|
await server.close();
|
|
});
|
|
|
|
it("collects the scopes of bytes Responses born sealed (aborted while the route returned)", async () => {
|
|
const refs: WeakRef<object>[] = [];
|
|
const aborts: AbortController[] = [];
|
|
const server = await served(
|
|
{
|
|
"@app/b": contributing({
|
|
routes: {
|
|
"GET /": {
|
|
public: true,
|
|
handler: async (_request, ctx) => {
|
|
refs.push(new WeakRef(ctx));
|
|
aborts.at(-1)?.abort(new Error("client gone"));
|
|
return {
|
|
kind: "bytes",
|
|
mime: "application/octet-stream",
|
|
read: async () => new Uint8Array([1]),
|
|
};
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{ config: { listen: undefined } },
|
|
);
|
|
// In a function of its own: the test's frame keeps no last request.
|
|
const fetchAborted = async (): Promise<void> => {
|
|
const abort = new AbortController();
|
|
aborts.push(abort);
|
|
await server.http.fetch(loopback("/", { signal: abort.signal }), LOOPBACK);
|
|
};
|
|
for (let i = 0; i < 8; i++) await fetchAborted();
|
|
aborts.length = 0;
|
|
await expectCollected(...refs);
|
|
await server.close();
|
|
});
|
|
|
|
it("collects requests' scopes and requests once answered", async () => {
|
|
const refs: WeakRef<object>[] = [];
|
|
const server = await served({
|
|
"@app/a": contributing({
|
|
routes: {
|
|
"GET /r": {
|
|
public: true,
|
|
handler: async (request, ctx) => {
|
|
refs.push(new WeakRef(ctx), new WeakRef(request));
|
|
return { kind: "json", value: 1 };
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
for (let i = 0; i < 5; i++) await send(server.port, "GET", "/r");
|
|
await expectCollected(...refs);
|
|
await server.close();
|
|
});
|
|
});
|
|
|
|
describe("request spans (slice 9)", () => {
|
|
/** An adapter reading a W3C `traceparent` as the carrier of `parent`, its first span. */
|
|
class W3cTelemetry extends InMemoryTelemetryContext {
|
|
parent: TelemetryCarrier | undefined;
|
|
readonly extracted: (TelemetryCarrier | undefined)[] = [];
|
|
|
|
override extract(carrier: TelemetryCarrier | undefined): TelemetryContext {
|
|
this.extracted.push(carrier);
|
|
if (carrier?.traceparent !== TRACEPARENT || this.parent === undefined) return super.extract(carrier);
|
|
return super.extract(this.parent);
|
|
}
|
|
}
|
|
|
|
const TRACEPARENT = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
|
|
|
|
const items = contributing({
|
|
routes: {
|
|
"GET /items/:id": {
|
|
public: true,
|
|
handler: (_request, ctx) => ctx.trace("app.lookup", {}, async () => ({ kind: "json", value: 1 })),
|
|
},
|
|
"GET /broken": {
|
|
public: true,
|
|
handler: async () => {
|
|
throw new Error("bug");
|
|
},
|
|
},
|
|
},
|
|
relays: [
|
|
{
|
|
name: "radius",
|
|
verify: (request) =>
|
|
request.headers.get("x-relay") === "secret" ? { origin: "https://me.relay.example" } : undefined,
|
|
},
|
|
],
|
|
});
|
|
|
|
it("traces a routed request: its method, route pattern and status, never its path; its handler's ctx under it", async () => {
|
|
const telemetry = new W3cTelemetry();
|
|
const server = await served({ "@app/items": items }, { config: { telemetry } });
|
|
expect((await send(server.port, "GET", "/items/secret-id")).status).toBe(200);
|
|
expect((await send(server.port, "GET", "/broken")).status).toBe(500);
|
|
expect((await send(server.port, "GET", "/nowhere")).status).toBe(404);
|
|
await server.close();
|
|
const requests = telemetry.getSpans().filter(({ name }) => name === "chord.http");
|
|
expect(requests.map(({ attributes, parentId, status }) => [attributes, parentId, status.status])).toEqual([
|
|
[{ method: "GET", route: "GET /items/:id", status: 200 }, null, "ok"],
|
|
[{ method: "GET", route: "GET /broken", status: 500 }, null, "error"],
|
|
]);
|
|
expect(JSON.stringify(telemetry.getSpans())).not.toContain("secret-id");
|
|
const lookup = telemetry.getSpans().find(({ name }) => name === "app.lookup")!;
|
|
expect(lookup.parentId).toBe(requests[0]!.id);
|
|
});
|
|
|
|
it("continues a W3C traceparent only from a request a contributed relay verified", async () => {
|
|
const telemetry = new W3cTelemetry();
|
|
const parent = await telemetry.startSpan({ name: "edge" }, (span) => telemetry.inject(span));
|
|
telemetry.parent = parent;
|
|
const server = await served({ "@app/items": items }, { config: { telemetry } });
|
|
const headers = { traceparent: TRACEPARENT, "x-forwarded-for": "203.0.113.9", "x-relay": "secret" };
|
|
expect((await send(server.port, "GET", "/items/1", { headers })).status).toBe(200);
|
|
expect((await send(server.port, "GET", "/items/2", { headers: { traceparent: TRACEPARENT } })).status).toBe(200);
|
|
await server.close();
|
|
const spans = telemetry.getSpans();
|
|
const edge = spans.find(({ name }) => name === "edge")!;
|
|
expect(spans.filter(({ name }) => name === "chord.http").map(({ parentId }) => parentId)).toEqual([
|
|
edge.id,
|
|
null,
|
|
]);
|
|
});
|
|
|
|
it("takes a relayed traceparent and tracestate only within their bounds and printable (review L2)", async () => {
|
|
const telemetry = new W3cTelemetry();
|
|
const server = await served({ "@app/items": items }, { config: { telemetry } });
|
|
const relay = { "x-forwarded-for": "203.0.113.9", "x-relay": "secret" };
|
|
const get = (headers: Record<string, string>) =>
|
|
send(server.port, "GET", "/items/1", { headers: { ...relay, ...headers } });
|
|
expect((await get({ traceparent: TRACEPARENT, tracestate: "a=1, b=2" })).status).toBe(200);
|
|
expect((await get({ traceparent: `00-${"a".repeat(300)}` })).status).toBe(200);
|
|
expect((await get({ traceparent: TRACEPARENT, tracestate: "s".repeat(513) })).status).toBe(200);
|
|
await server.close();
|
|
expect(telemetry.extracted).toEqual([
|
|
{ traceparent: TRACEPARENT, tracestate: "a=1, b=2" },
|
|
{ traceparent: TRACEPARENT },
|
|
]);
|
|
});
|
|
});
|