Files
threadcount-community/scripts/mock-jackson.cjs
T
ThreadCount 344b1701dd ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:54:35 +10:00

89 lines
5.0 KiB
JavaScript

/* A stand-in for the BoxyHQ Jackson broker, for scripts/e2e-sso.sh.
*
* node scripts/mock-jackson.cjs [port] (default 3199)
*
* Speaks just enough of Jackson's two surfaces for the product to complete a login:
* management POST/GET/DELETE /api/v1/sso (Api-Key checked; connections kept in memory)
* front door GET /api/oauth/authorize → 302 straight back to redirect_uri with ?code&state
* POST /api/oauth/token → { access_token }
* GET /api/oauth/userinfo → the profile for that token
* Who "signs in" is whatever the test last told it: POST /__mock/identity {email,name}. The code
* and token are single-use and bound to that identity, so a stale code fails as it would for real.
* Nothing here is a broker; it exists so the product's own routes can be exercised end to end. */
const http = require("http");
const crypto = require("crypto");
const PORT = Number(process.argv[2] || 3199);
const API_KEY = process.env.MOCK_JACKSON_KEY || "e2e-jackson-key";
const conns = new Map(); // tenant → { tenant, product, name, idpMetadata }
const codes = new Map(); // code → identity
const tokens = new Map(); // token → identity
let identity = { email: "", name: "" };
function send(res, status, body, headers = {}) {
res.writeHead(status, { "content-type": "application/json", ...headers });
res.end(body === undefined ? "" : JSON.stringify(body));
}
function readBody(req) {
return new Promise((resolve) => { let d = ""; req.on("data", (c) => (d += c)); req.on("end", () => resolve(d)); });
}
http.createServer(async (req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
const p = url.pathname;
if (p === "/__mock/identity" && req.method === "POST") {
identity = JSON.parse((await readBody(req)) || "{}");
return send(res, 200, { ok: true, identity });
}
if (p === "/__mock/connections") return send(res, 200, [...conns.values()]);
if (p === "/api/v1/sso") {
if (req.headers.authorization !== `Api-Key ${API_KEY}`) return send(res, 401, { error: { message: "Unauthorized" } });
if (req.method === "POST") {
const form = new URLSearchParams(await readBody(req));
const tenant = form.get("tenant"), product = form.get("product");
if (!tenant || !product) return send(res, 400, { error: { message: "tenant and product required" } });
if (!form.get("metadataUrl") && !form.get("encodedRawMetadata")) return send(res, 400, { error: { message: "Please provide metadata" } });
if (form.get("encodedRawMetadata") && !Buffer.from(form.get("encodedRawMetadata"), "base64").toString().includes("EntityDescriptor")) return send(res, 400, { error: { message: "Invalid IdP metadata" } });
const c = { tenant, product, name: form.get("name"), idpMetadata: { entityID: "https://idp.example/saml", provider: "idp.example" }, redirectUrl: JSON.parse(form.get("redirectUrl") || "[]") };
conns.set(tenant, c);
return send(res, 200, c);
}
const tenant = url.searchParams.get("tenant");
if (req.method === "GET") return send(res, 200, conns.has(tenant) ? [conns.get(tenant)] : []);
if (req.method === "DELETE") { const had = conns.delete(tenant); return send(res, had ? 200 : 404, had ? { ok: true } : { error: { message: "not found" } }); }
}
if (p === "/api/oauth/authorize") {
const tenant = url.searchParams.get("tenant");
const redirect = url.searchParams.get("redirect_uri"), state = url.searchParams.get("state");
const c = conns.get(tenant);
if (!c) return send(res, 404, { error: "no connection for tenant" });
if (!c.redirectUrl.includes(redirect)) return send(res, 400, { error: "redirect_uri not registered" });
const code = crypto.randomBytes(16).toString("hex");
codes.set(code, { ...identity });
// The product registers its PUBLIC callback (NEXT_PUBLIC_SITE_URL); the browser in these tests
// is talking to the local dev server, so the mock sends it back there on the registered path.
const back = (process.env.MOCK_CALLBACK_BASE || "http://127.0.0.1:3111") + new URL(redirect).pathname;
res.writeHead(302, { location: `${back}?code=${code}&state=${encodeURIComponent(state || "")}` });
return res.end();
}
if (p === "/api/oauth/token" && req.method === "POST") {
const form = new URLSearchParams(await readBody(req));
const id = codes.get(form.get("code"));
codes.delete(form.get("code"));
if (!id) return send(res, 400, { error: "invalid_grant" });
const t = crypto.randomBytes(16).toString("hex");
tokens.set(t, id);
return send(res, 200, { access_token: t, token_type: "bearer" });
}
if (p === "/api/oauth/userinfo") {
const t = (req.headers.authorization || "").replace(/^Bearer /, "");
const id = tokens.get(t);
if (!id) return send(res, 401, { error: "invalid_token" });
return send(res, 200, { email: id.email, name: id.name, id: id.email });
}
send(res, 404, { error: "not found" });
}).listen(PORT, "127.0.0.1", () => console.log("mock jackson on", PORT));