`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.
311 lines
14 KiB
TypeScript
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");
|
|
});
|
|
});
|