phase-5/security-hardening #14

Merged
Zayin_Admin merged 21 commits from phase-5/security-hardening into main 2026-07-30 03:04:37 +00:00
Showing only changes of commit cda841f8c7 - Show all commits
+128 -33
View File
@@ -236,6 +236,97 @@ function createEventRecorder(): { events: string[]; expression: string } {
* module also registers a restricted `GET /events/:eventId` route and a restricted, state-changing * module also registers a restricted `GET /events/:eventId` route and a restricted, state-changing
* `POST /events` route against that same permission, so the full HTTP mounting/enforcement chain * `POST /events` route against that same permission, so the full HTTP mounting/enforcement chain
* can be exercised live. */ * can be exercised live. */
/** A handler gate shared with a dynamically imported fixture module through `globalThis`, the same
* technique `createEventRecorder` uses. The handler resolves `entered` when it is genuinely inside
* user code and then awaits `released`, so a test can position shutdown precisely instead of
* inferring handler entry from a connection count. `release` is idempotent and safe in `finally`. */
function createHandlerGate(): {
readonly entered: Promise<void>;
readonly expression: string;
release: () => void;
} {
const key = `ekklesia.api.integration.gate.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
const symbol = Symbol.for(key);
let signalEntered: () => void = () => undefined;
let signalReleased: () => void = () => undefined;
const entered = new Promise<void>((resolve) => {
signalEntered = resolve;
});
const released = new Promise<void>((resolve) => {
signalReleased = resolve;
});
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = {
enter: () => signalEntered(),
released,
};
eventSymbols.push(symbol);
return {
entered,
expression: `globalThis[Symbol.for(${JSON.stringify(key)})]`,
release: () => signalReleased(),
};
}
/** A fixture module with one public route whose handler blocks on the supplied gate, plus a stop
* hook that records when it ran — everything the graceful-drain ordering assertion needs. */
async function createGatedRouteModule(
modulesDirectory: string,
id: string,
recorder: { expression: string },
gate: { expression: string },
): Promise<void> {
const permissionId = `${id}.gated.read`;
const moduleDirectory = path.join(modulesDirectory, id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version: "0.1.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" },
id,
manifestVersion: 3,
name: `${id} module`,
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
version: "0.1.0",
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default {
id: ${JSON.stringify(id)},
version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/gated",
permission: ${JSON.stringify(permissionId)},
maxBodyBytes: 0,
async handler() {
${recorder.expression}.push(${JSON.stringify(`${id}:handler-entered`)});
${gate.expression}.enter();
await ${gate.expression}.released;
${recorder.expression}.push(${JSON.stringify(`${id}:handler-finished`)});
return Response.json({ gated: true });
},
});
},
start() {},
stop() { ${recorder.expression}.push(${JSON.stringify(`${id}:stop`)}); },
};`,
),
]);
}
async function createFixtureModule( async function createFixtureModule(
modulesDirectory: string, modulesDirectory: string,
id: string, id: string,
@@ -2779,18 +2870,28 @@ describe.sequential("live API — bounded HTTP drain and shutdown", () => {
} }
}); });
it("lets an already-entered request finish and close cleanly rather than force-closing it", async () => { it("lets a handler that has already entered finish, and closes its transport before module stop", async () => {
// Deterministic rather than racing an unresolved `fetch`: a raw socket sends a complete request // A real handler gate, not an inference from the connection count: the fixture module's route
// with `Connection: close`, and shutdown only begins once the server provably holds that // handler announces that it has *entered*, then blocks until this test releases it. Shutdown
// connection — observed by polling the real server's own connection count, never by sleeping. // therefore begins at a point where the handler is provably inside user code, and the ordering
// claim below is an assertion about recorded events rather than about timing.
const modulesDirectory = await createFixtureModulesDirectory();
const recorder = createEventRecorder();
const gate = createHandlerGate();
await createGatedRouteModule(modulesDirectory, "drainorder", recorder, gate);
const database = await createDisposableDatabase("drain_order"); const database = await createDisposableDatabase("drain_order");
await applyCoreMigrations(database.url); await applyCoreMigrations(database.url);
const running = await startConfiguredApi(database.url, { drainTimeoutMsOverride: 20_000 }); const running = await startConfiguredApi(database.url, {
drainTimeoutMsOverride: 20_000,
modulesDirectoryOverride: modulesDirectory,
});
const address = running.server.address(); const address = running.server.address();
if (!address || typeof address !== "object") { if (!address || typeof address !== "object") {
throw harnessError("The API integration server has no bound address."); throw harnessError("The API integration server has no bound address.");
} }
const socket = net.connect({ host: "127.0.0.1", port: address.port }); const socket = net.connect({ host: "127.0.0.1", port: address.port });
let shutdown: Promise<void> | undefined;
try { try {
const responseBytes: Buffer[] = []; const responseBytes: Buffer[] = [];
const responseClosed = new Promise<void>((resolve) => { const responseClosed = new Promise<void>((resolve) => {
@@ -2802,44 +2903,38 @@ describe.sequential("live API — bounded HTTP drain and shutdown", () => {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
socket.once("error", reject); socket.once("error", reject);
socket.write( socket.write(
"GET /api/v1/principal HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", `GET /api/v1/modules/drainorder/gated HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`,
() => resolve(), () => resolve(),
); );
}); });
// Bounded, condition-based: wait until the server actually holds the connection, so the // Bounded wait on the handler's own signal — no sleep, no proxy for "entered".
// request has demonstrably entered before shutdown starts. await withDeadline(gate.entered, 20_000);
const connectionCounter = running.server as unknown as { expect(recorder.events).toContain("drainorder:handler-entered");
getConnections?: (callback: (error: Error | null, count: number) => void) => void;
};
const deadline = Date.now() + 15_000;
for (;;) {
const connections = await new Promise<number>((resolve) => {
if (typeof connectionCounter.getConnections !== "function") {
resolve(1);
return;
}
connectionCounter.getConnections((_error, count) => resolve(count ?? 0));
});
if (connections > 0) {
break;
}
if (Date.now() > deadline) {
throw harnessError("The request never reached the server.");
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
// The graceful path: nothing is stalled, so shutdown completes without reaching the drain // Shutdown starts while the handler is still inside user code.
// deadline and without force-closing anything. shutdown = running.shutdown();
await expect(withDeadline(running.shutdown(), 30_000)).resolves.toBeUndefined(); // Release it, then require the graceful path to complete.
gate.release();
await expect(withDeadline(shutdown, 30_000)).resolves.toBeUndefined();
// The already-entered request completed normally — full response, clean transport close. // The response really was served, in full, rather than force-closed mid-flight.
await withDeadline(responseClosed, 10_000); await withDeadline(responseClosed, 10_000);
const raw = Buffer.concat(responseBytes).toString("utf8"); const raw = Buffer.concat(responseBytes).toString("utf8");
expect(raw).toContain("HTTP/1.1 200"); expect(raw).toContain("HTTP/1.1 200");
expect(raw).toContain('{"kind":"anonymous"}'); expect(raw).toContain('"gated":true');
// Explicit ordering: the handler finished and the module stopped, in that order.
const finished = recorder.events.indexOf("drainorder:handler-finished");
const stopped = recorder.events.indexOf("drainorder:stop");
expect(finished).toBeGreaterThanOrEqual(0);
expect(stopped).toBeGreaterThanOrEqual(0);
expect(finished).toBeLessThan(stopped);
} finally { } finally {
gate.release();
if (shutdown) {
await shutdown.catch(() => undefined);
}
socket.destroy(); socket.destroy();
await running.shutdown().catch(() => undefined); await running.shutdown().catch(() => undefined);
await dropDatabase(database.name); await dropDatabase(database.name);