Files
continuum-backend/tests/authz.test.ts
T
octoturge 51e1827f5a Stop storing printers' access codes
`printers.access_code` held a printer's LAN password - the same value Bambu
uses for MQTT and as the FTPS password. It was writable through
`POST /printers`, excluded from `printerProjection` so it could not leave the
server, and **never read by any backend code path**. A secret at rest in the
cloud, serving no purpose.

The credential belongs on the proxy, which is the only thing that uses it.
Under the three-tier architecture it goes FE -> API -> proxy, with the API
relaying it rather than persisting it.

Considered and rejected, deliberately: keeping the column so the console
could show an operator their printer's access code. Useful, but not worth the
risk. Removing the column is what makes that rejection real - a stored secret
is one a future route can leak, and the projection was the only thing
standing between this column and a response.

`PRINTER_WRITABLE` and the two schemas derived from it move to
`db/validators.ts`, which already declares itself the single place these
shapes are defined. The reason is testability: a route imports `db/index.ts`
and so needs a live `DATABASE_URL`, and "what may a client set" is exactly
the kind of thing worth asserting with no database in sight.

Four tests pin the decision so it is enforced rather than remembered, because
a column is easy to re-add and the symptom would be silent. One of them
records a hole rather than a fix: `metadata` is a free-form jsonb column that
is both writable and in the public projection, so a well-meaning client can
still stash a credential there and have it stored and served back. Removing
the dedicated column narrowed the door; it did not close it.

The e2e script still submits `accessCode: "SECRET123"` on purpose. The field
is now unknown to the create schema, the schema is open, and Elysia cleans it
away rather than rejecting the request - so the canary proves against a real
server that a client which tries to hand the cloud a credential has it
dropped and never echoed. That is runtime behaviour the unit test can only
establish at the schema level.

Migration 0003 is a single `DROP COLUMN IF EXISTS`, applied locally. There is
no production, staging or dev environment and nothing was writing the column,
so there is no data to preserve and no consumer to break.
2026-10-20 00:44:35 +00:00

311 lines
14 KiB
TypeScript

import { describe, expect, test, beforeAll, afterAll } from "bun:test";
import { Elysia } from "elysia";
import { toErrorResponse } from "@continuum/ts-core";
import { roleSatisfies, ORG_ROLES } from "../src/auth/roles";
import { startDevAuthIssuer } from "../scripts/dev-auth";
import { Value } from "@sinclair/typebox/value";
import { printers } from "../src/db/schema";
import { farmProjection, printerProjection } from "../src/db/projections";
import { PrinterCreateSchema } from "../src/db/validators";
const USER = "11111111-1111-1111-1111-111111111111";
describe("the role ladder", () => {
test("is ordered viewer < operator < owner", () => {
expect(ORG_ROLES).toEqual(["viewer", "operator", "owner"]);
});
// Written out in full rather than derived from the ladder: a table that
// computes the expected answer the same way the implementation does agrees
// with a wrong implementation.
const cases: Array<[string, string, boolean]> = [
["viewer", "viewer", true],
["viewer", "operator", false],
["viewer", "owner", false],
["operator", "viewer", true],
["operator", "operator", true],
["operator", "owner", false],
["owner", "viewer", true],
["owner", "operator", true],
["owner", "owner", true],
];
for (const [actual, minimum, expected] of cases) {
test(`'${actual}' ${expected ? "satisfies" : "does not satisfy"} '${minimum}'`, () => {
expect(roleSatisfies(actual as never, minimum as never)).toBe(expected);
});
}
test("a viewer cannot send printer commands", () => {
// The boundary that moves physical machines, called out on its own
// because it is the one that is expensive to get wrong.
expect(roleSatisfies("viewer", "operator")).toBe(false);
});
});
/**
* Exercises the real verification path - `createRemoteJWKSet` against a real
* JWKS endpoint over HTTP - rather than a stubbed verifier. The things that
* actually go wrong here (an unsigned token accepted, a token signed by
* another key accepted, an expired token accepted) are all invisible to a
* test that mocks the verifier out.
*/
describe("requireUser", () => {
let issuer: Awaited<ReturnType<typeof startDevAuthIssuer>>;
let app: Elysia;
beforeAll(async () => {
issuer = await startDevAuthIssuer();
process.env.SUPABASE_URL = issuer.url;
// Imported after SUPABASE_URL is set: the verifier is built at module
// load so a misconfigured deployment fails at boot, not on first request.
const { requireUser } = await import("../src/auth/plugin");
app = new Elysia().onError(toErrorResponse).use(requireUser).get("/whoami", ({ user }) => user);
});
afterAll(() => issuer.stop());
const get = (headers: Record<string, string> = {}) =>
app.handle(new Request("http://localhost/whoami", { headers }));
test("accepts a properly signed token and exposes the subject", async () => {
const token = await issuer.mint({ sub: USER, email: "farm@example.test" });
const response = await get({ authorization: `Bearer ${token}` });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ id: USER, email: "farm@example.test" });
});
test("rejects a request with no Authorization header", async () => {
const response = await get();
expect(response.status).toBe(401);
expect((await response.json()).error.code).toBe("UNAUTHORIZED");
});
test("rejects a non-Bearer Authorization header", async () => {
const response = await get({ authorization: "Basic dXNlcjpwYXNz" });
expect(response.status).toBe(401);
});
test("rejects a token signed by a key that is not in the JWKS", async () => {
const token = await issuer.mintWithForeignKey({ sub: USER });
const response = await get({ authorization: `Bearer ${token}` });
expect(response.status).toBe(401);
});
test("rejects an expired token", async () => {
const token = await issuer.mint({ sub: USER, expiresIn: -60 });
const response = await get({ authorization: `Bearer ${token}` });
expect(response.status).toBe(401);
});
test("rejects a token issued for another audience", async () => {
// Supabase issues `aud: "authenticated"` for user sessions. Its anon and
// service-role keys are also valid project JWTs with other audiences, so
// without this check the public anon key would authenticate as a user.
const token = await issuer.mint({ sub: USER, audience: "anon" });
const response = await get({ authorization: `Bearer ${token}` });
expect(response.status).toBe(401);
});
test("does not say why a token was rejected", async () => {
// Distinguishing "expired" from "bad signature" tells whoever is probing
// which part of a forged token to fix next.
const expired = await get({
authorization: `Bearer ${await issuer.mint({ sub: USER, expiresIn: -60 })}`,
});
const foreign = await get({
authorization: `Bearer ${await issuer.mintWithForeignKey({ sub: USER })}`,
});
expect((await expired.json()).error.message).toBe((await foreign.json()).error.message);
});
});
describe("configuration", () => {
test("the process refuses to start without SUPABASE_URL", async () => {
// Fail at boot, not on the first authenticated request: a backend that
// starts and then 500s on every login looks like an outage somewhere
// else. Run in a subprocess because the module caches its verifier.
const proc = Bun.spawn(["bun", "-e", "await import('./src/auth/verifier.ts')"], {
cwd: import.meta.dir + "/..",
env: { ...process.env, SUPABASE_URL: "" },
stderr: "pipe",
stdout: "pipe",
});
expect(await proc.exited).not.toBe(0);
expect(await new Response(proc.stderr).text()).toContain("SUPABASE_URL");
});
// An empty value, not an unset one, because that is what the committed
// `.env.example` ships: the old `process.env.EDGE_JWT_SECRET ?? "dev-..."`
// only caught null and undefined, so copying the template and filling in
// everything else left the edge JWTs signed with "". Anything that knew
// that - or the committed fallback literal - could mint a token for any
// farm_id and open /ws/edge/v1 as that farm's gateway.
test("the process refuses to start with an empty EDGE_JWT_SECRET", async () => {
const proc = Bun.spawn(["bun", "-e", "await import('./src/auth/edge_secret.ts')"], {
cwd: import.meta.dir + "/..",
env: { ...process.env, EDGE_JWT_SECRET: "" },
stderr: "pipe",
stdout: "pipe",
});
expect(await proc.exited).not.toBe(0);
expect(await new Response(proc.stderr).text()).toContain("EDGE_JWT_SECRET");
});
test("no route holds a hardcoded signing secret", async () => {
// The old fallback was duplicated across two routes, and a third copy
// would be just as exploitable as the first two, so this greps the tree
// rather than the files that happened to have it. Two shapes, because
// either one reintroduces the hole: a literal passed to `jwt()`, and a
// defaulted read of the variable (`?? "..."` / `|| "..."`), which is
// what let an empty `EDGE_JWT_SECRET` through.
const grep = async (pattern: string) => {
const proc = Bun.spawn(["grep", "-rnE", pattern, "src"], {
cwd: import.meta.dir + "/..",
stdout: "pipe",
});
return new Response(proc.stdout).text();
};
expect(await grep('secret:\\s*["\'`]')).toBe("");
expect(await grep('EDGE_JWT_SECRET\\s*(\\?\\?|\\|\\|)')).toBe("");
// And the variable is read in exactly one place, so there is only ever
// one line to get right.
expect(await grep("process\\.env\\.EDGE_JWT_SECRET")).toContain("src/auth/edge_secret.ts");
expect((await grep("process\\.env\\.EDGE_JWT_SECRET")).trim().split("\n")).toHaveLength(1);
});
});
/**
* Object storage is a credential like any other, and used to be the one that
* failed quietly.
*
* `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY` defaulted to `""`, so a
* deployment that copied `.env.example` and filled in everything else
* started happily. `POST /jobs` then handed a client a presigned URL signed
* with an empty key, and the first symptom was a 403 from R2 - in the
* client's uploader, or inside a gateway that had downloaded R2's XML error
* document instead of a print. Nothing in that chain said "R2 is not
* configured".
*/
describe("object storage configuration", () => {
const importS3 = (env: Record<string, string>) =>
Bun.spawn(["bun", "-e", "await import('./src/s3/index.ts')"], {
cwd: import.meta.dir + "/..",
env: { ...process.env, ...env },
stderr: "pipe",
stdout: "pipe",
});
// Every other variable is filled in, so the one under test is the only
// thing missing and the error has to name it. Without this the first
// missing variable in read order wins and the assertion passes for the
// wrong reason.
const complete = {
R2_ACCESS_KEY_ID: "key",
R2_SECRET_ACCESS_KEY: "secret",
R2_ACCOUNT_ID: "acct",
};
test("fails at boot when a credential is empty, naming the variable", async () => {
for (const name of ["R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY"]) {
const proc = importS3({ ...complete, [name]: "" });
expect(await proc.exited).not.toBe(0);
expect(await new Response(proc.stderr).text()).toContain(name);
}
});
/**
* An empty string, not just an absent variable - which is the case that
* actually happens, because the committed template ships these keys with
* no value. `??` would have missed every one of them.
*/
test("treats an empty credential as missing, not as a value", async () => {
const proc = importS3({ ...complete, R2_ACCESS_KEY_ID: "" });
expect(await proc.exited).not.toBe(0);
expect(await new Response(proc.stderr).text()).toContain("R2_ACCESS_KEY_ID");
});
test("refuses to start with no endpoint and no account id to derive one from", async () => {
const proc = importS3({ ...complete, R2_ENDPOINT: "", R2_ACCOUNT_ID: "" });
expect(await proc.exited).not.toBe(0);
expect(await new Response(proc.stderr).text()).toContain("R2_ENDPOINT");
});
test("starts with a complete configuration", async () => {
// R2_ENDPOINT empty on purpose: it must be derived from the account id,
// which is the `||`-not-`??` case that caught this module out.
const proc = importS3({ ...complete, R2_ENDPOINT: "" });
expect(await proc.exited).toBe(0);
});
test("reads each credential in exactly one place", async () => {
// So there is only ever one line to get right, the same rule
// `EDGE_JWT_SECRET` is held to above.
for (const name of ["R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY"]) {
const proc = Bun.spawn(["grep", "-rn", `process.env.${name}`, "src"], {
cwd: import.meta.dir + "/..",
stdout: "pipe",
});
const hits = (await new Response(proc.stdout).text()).trim();
// Read through the `required()` helper, so the literal name appears
// only where it is passed in - not as a `process.env.X` at all.
expect(hits).toBe("");
}
});
});
/**
* The credentials this backend is not allowed to hold or hand out.
*
* `printers.accessCode` used to exist: writable through `POST /printers`,
* excluded from the projection, and never read by any backend code path - a
* printer's LAN password at rest in the cloud for no purpose. It was removed
* (2026-10-19) because the credential belongs on the proxy, the only thing
* that uses it, and the alternative of surfacing it in the UI was considered
* and rejected: useful, but not worth the risk.
*
* These tests exist so that decision is enforced rather than remembered. A
* column is easy to re-add, and the symptom would be silent.
*/
describe("credentials the backend must not hold", () => {
test("a printer has no access-code column to store one in", () => {
// Against the Drizzle table rather than live SQL, so it fails in CI with
// no database - the point is the declaration, not this machine's schema.
expect(Object.keys(printers)).not.toContain("accessCode");
});
test("a client that sends one anyway has it dropped, not stored", () => {
// The create schema is open, so a stray field validates. Elysia cleans
// the body against the schema, which is what actually discards it - so
// the honest description is "ignored", not "rejected". Worth pinning:
// if the field ever became retained, nothing else here would notice.
const body = {
farmId: "3f2b1c5e-0000-4000-8000-000000000000",
name: "P1P",
vendor: "bambu" as const,
accessCode: "SECRET123",
};
const cleaned = Value.Clean(PrinterCreateSchema, structuredClone(body)) as Record<string, unknown>;
expect(cleaned).not.toHaveProperty("accessCode");
expect(cleaned.name).toBe("P1P");
});
test("the farm's edge proxy token is still kept out of responses", () => {
// The credential that does remain. `POST /auth/edge-token` trades it for
// an edge JWT, so with it and a farm id anyone can connect as that
// farm's gateway and drive its printers.
expect(Object.keys(farmProjection.columns)).not.toContain("edgeProxyToken");
});
test("metadata is the remaining way a secret could reach the cloud", () => {
// Not a bug to fix here, but a hole to know about: `metadata` is a
// free-form jsonb column that is both writable and in the public
// projection, so a well-meaning client could stash an access code in it
// and the API would store it and serve it back. Removing the dedicated
// column narrowed the door; it did not close it. If this assertion ever
// fails because `metadata` left the projection, that is an improvement -
// delete the test.
expect(Object.keys(printerProjection.columns)).toContain("metadata");
});
});