Files
continuum-backend/tests/edge_upgrade.test.ts
T
octoturge 57af710b1a Verify the edge token at the upgrade, not in the first frame
`/ws/edge/v1` took its credential from a `token` field on the `hello`
message. It now takes it from `Authorization: Bearer` on the upgrade
request, verified before the socket exists, so an unauthenticated peer
gets an HTTP 401 and no WebSocket: no entry in `connections`, no frame
ever parsed, no state of any kind allocated for it.

The old arrangement worked, and every message type refused to act
without a verified farm. But "it cannot do anything" is a weaker
guarantee than "it does not exist" - an anonymous peer still got a live
socket and had its JSON run through `EdgeClientMessageSchema` and the
message switch before proving anything, and it took a timeout to stop
anyone holding sockets open for free. A `?token=` query parameter was
never on the table: a bearer credential in a URL lands in access logs,
proxy logs and error breadcrumbs, which is the thing the proxy's
hand-written `Debug` for `PrinterCommand` already exists to prevent.

The decision lives in `src/auth/edge_upgrade.ts`, a leaf module with no
imports but the error types - the same reason as `dispatch/job_status.ts`
and `stream.ts`. This is the authentication boundary for the entire
gateway protocol, so it should be the last thing only reachable through
a running database. It checks `scope` and not just the signature (the
same secret could be made to sign something else one day), rejects a
token whose farm claim is absent or not a string (that would otherwise
produce a connection whose `farmId` is `undefined`, which reads as
"every farm" to a lookup), and answers every failure with one message -
which part of a forged token to fix next is not something to tell its
holder.

`conn.farmId` is therefore set from the verified token in `open`, and
the `hello` handler no longer verifies anything. It still checks
`message.farm_id` against the token's farm, so a gateway naming a farm
its token was not minted for is refused rather than silently working
against the wrong one. The handshake timer stays but changed meaning: it
is no longer an auth timeout, just a bound on an authenticated socket
that never identifies its gateway.

Verified, not assumed. That a throwing `resolve` fails the upgrade is a
framework behaviour the whole design rests on, so `tests/
edge_upgrade.test.ts` stands up a real Elysia app with a real JWT plugin
and asserts a socket never opens without a valid edge token, and that a
resolved value reaches `ws.data`. The real `continuum-proxy` was also run
against this composition: valid token gives HTTP 101 with the farm taken
from the token, forged token gives 401 at the handshake.

The committed captures predate this and still contain `token:
"dev-token"`. That validates - the schema is a liberal reader - and is
harmless, because the field is now ignored and such a gateway is refused
at the upgrade for having no header. A test says so.

95 tests (was 85), `bunx tsc --noEmit` clean.
2026-10-17 13:59:56 +00:00

137 lines
5.5 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import { Elysia } from "elysia";
import jwt from "@elysiajs/jwt";
import { UnauthorizedError } from "@continuum/ts-core";
import { farmFromEdgeUpgrade } from "../src/auth/edge_upgrade";
/**
* The gateway protocol's authentication boundary.
*
* The edge JWT travels in `Authorization: Bearer` on the upgrade request and
* is verified before any socket exists. It used to be a `token` field on the
* `hello` message, which meant an unauthenticated peer got a live socket and
* a parsed frame before proving anything. These tests are what stops that
* coming back.
*/
describe("farmFromEdgeUpgrade", () => {
const verifyOk = async (token: string) =>
token === "good" ? { scope: "edge", farmId: "farm-1" } : false;
it("returns the farm from a valid edge token", async () => {
expect(await farmFromEdgeUpgrade("Bearer good", verifyOk)).toBe("farm-1");
});
it("refuses a missing or malformed Authorization header", async () => {
for (const header of [undefined, null, "", "good", "Basic good", "Bearer"]) {
await expect(farmFromEdgeUpgrade(header, verifyOk)).rejects.toThrow(UnauthorizedError);
}
});
it("refuses a token the verifier rejects", async () => {
await expect(farmFromEdgeUpgrade("Bearer forged", verifyOk)).rejects.toThrow(UnauthorizedError);
});
/**
* A signature alone is not authorisation. The same secret could be made to
* sign something else one day, and such a token must not open a farm's
* gateway socket.
*/
it("refuses a correctly signed token minted for a different scope", async () => {
const verify = async () => ({ scope: "user", farmId: "farm-1" });
await expect(farmFromEdgeUpgrade("Bearer good", verify)).rejects.toThrow(UnauthorizedError);
});
/**
* Without this, the connection's `farmId` would be `undefined`, which to
* anything doing a lookup reads as "every farm" rather than as an error.
*/
it("refuses a token with no usable farm claim", async () => {
for (const farmId of [undefined, null, "", 42, {}]) {
const verify = async () => ({ scope: "edge", farmId }) as Record<string, unknown>;
await expect(farmFromEdgeUpgrade("Bearer good", verify)).rejects.toThrow(UnauthorizedError);
}
});
/**
* The messages must not say which part failed - that tells the holder of a
* forged token what to fix next. Asserted rather than assumed, because the
* natural way to write this code is one specific message per branch.
*/
it("says the same thing however the token fails", async () => {
const messages = new Set<string>();
for (const verify of [
async () => false as const,
async () => ({ scope: "user", farmId: "farm-1" }),
async () => ({ scope: "edge" }) as Record<string, unknown>,
]) {
await farmFromEdgeUpgrade("Bearer x", verify).catch((err: Error) =>
messages.add(err.message),
);
}
expect(messages.size).toBe(1);
});
});
/**
* That the check above actually gates the upgrade, which is the property the
* whole design rests on and is a framework behaviour rather than ours: a
* `resolve` that throws must fail the HTTP upgrade so `open` never runs.
*
* This builds its own Elysia app rather than importing `edgeWsRoute`, which
* pulls in `../../db` and so a live Postgres. What it proves is that this
* composition refuses a socket and that a resolved value reaches `ws.data`;
* what it cannot prove is that `edge.ts` is wired this way. `scripts/
* e2e-edge.sh` runs the real route against a real database and a real
* gateway, and is the test for that.
*/
describe("a resolve that throws gates the websocket upgrade", () => {
const SECRET = "edge-upgrade-test-secret";
const app = new Elysia()
.use(jwt({ name: "edgeJwt", secret: SECRET }))
.get("/mint", ({ edgeJwt, query }) =>
edgeJwt.sign({ scope: query.scope ?? "edge", farmId: "farm-1" }),
)
.resolve({ as: "scoped" }, async ({ headers, edgeJwt }): Promise<{ edgeFarmId: string }> => ({
edgeFarmId: await farmFromEdgeUpgrade(headers.authorization, (t) => edgeJwt.verify(t)),
}))
.ws("/ws/edge/v1", {
open(ws) {
ws.send(JSON.stringify({ farm: (ws.data as { edgeFarmId: string }).edgeFarmId }));
},
})
.listen(0);
const base = `localhost:${app.server!.port}`;
function attempt(headers: Record<string, string>): Promise<string> {
return new Promise((resolve) => {
const ws = new WebSocket(`ws://${base}/ws/edge/v1`, { headers } as never);
const done = (r: string) => {
try {
ws.close();
} catch {
// Already closed by the server; nothing to do.
}
resolve(r);
};
ws.onmessage = (event) => done(`opened:${event.data}`);
ws.onerror = () => done("refused");
ws.onclose = () => done("refused");
setTimeout(() => done("timed out"), 5_000);
});
}
it("opens for a valid edge token and exposes its farm", async () => {
const token = await (await fetch(`http://${base}/mint`)).text();
expect(await attempt({ Authorization: `Bearer ${token}` })).toBe('opened:{"farm":"farm-1"}');
});
it("never opens a socket without a valid edge token", async () => {
const wrongScope = await (await fetch(`http://${base}/mint?scope=user`)).text();
expect(await attempt({})).toBe("refused");
expect(await attempt({ Authorization: "Bearer not-a-jwt" })).toBe("refused");
expect(await attempt({ Authorization: `Bearer ${wrongScope}` })).toBe("refused");
});
});