NODEDC_DESIGN_GUIDELINE/server/nodedc-auth.test.mjs

253 lines
10 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import { createFoundryAuth } from "./nodedc-auth.mjs";
const HANDOFF_TOKEN = "handoff-token-must-never-appear-in-diagnostics";
const LAUNCHER_SESSION_ID = "launcher-session-must-never-appear-in-diagnostics";
const INTERNAL_ACCESS_TOKEN = "internal-access-token-must-never-appear-in-diagnostics";
const USER = Object.freeze({
id: "user_root",
email: "root@example.test",
name: "DC SUDO",
groups: ["nodedc:superadmin"],
});
test("Foundry session validation is burst-safe, failure-tolerant and revocation-bounded", async (t) => {
const restoreEnvironment = setEnvironment({
NODE_ENV: "production",
NODEDC_FOUNDRY_AUTH_REQUIRED: "true",
NODEDC_FOUNDRY_COOKIE_SECURE: "false",
NODEDC_FOUNDRY_SESSION_TTL_MS: String(60 * 60 * 1000),
NODEDC_LAUNCHER_BASE_URL: "https://launcher.example.test",
NODEDC_LAUNCHER_INTERNAL_URL: "http://launcher.internal.test",
NODEDC_INTERNAL_ACCESS_TOKEN: INTERNAL_ACCESS_TOKEN,
});
t.after(restoreEnvironment);
let clock = 1_000_000;
let validationMode = "active";
let validationCalls = 0;
const diagnostics = [];
const fetchStub = async (url, init) => {
assert.equal(init.headers.Authorization, `Bearer ${INTERNAL_ACCESS_TOKEN}`);
const pathname = new URL(url).pathname;
const payload = JSON.parse(init.body);
if (pathname === "/api/internal/handoff/consume") {
assert.equal(payload.token, HANDOFF_TOKEN);
return jsonResponse(200, {
ok: true,
launcherSessionId: LAUNCHER_SESSION_ID,
user: USER,
});
}
assert.equal(pathname, "/api/internal/session/validate");
assert.equal(payload.launcherSessionId, LAUNCHER_SESSION_ID);
validationCalls += 1;
// Let every request in a simulated Cesium tile burst reach the shared
// validation promise before it resolves.
await new Promise((resolve) => setImmediate(resolve));
if (validationMode === "transient") {
return jsonResponse(503, { ok: false, active: false, error: "launcher_temporarily_unavailable" });
}
if (validationMode === "network") {
throw new TypeError(`network failed ${INTERNAL_ACCESS_TOKEN}`);
}
if (validationMode === "timeout") {
const error = new Error(`timeout ${LAUNCHER_SESSION_ID}`);
error.name = "TimeoutError";
throw error;
}
if (validationMode === "inactive") {
return jsonResponse(200, { ok: true, active: false, error: "service_access_denied" });
}
return jsonResponse(200, { ok: true, active: true, user: USER });
};
const auth = createFoundryAuth({
now: () => clock,
fetch: fetchStub,
validationTtlMs: 20,
validationGraceMs: 30,
validationRetryMs: 5,
onDiagnostic: (record) => diagnostics.push(record),
});
const cookie = await createSessionCookie(auth);
await t.test("concurrent tile burst performs one Launcher validation", async () => {
clock += 21;
const results = await Promise.all(
Array.from({ length: 64 }, (_, index) => authorize(auth, cookie, `/api/map-gateway/api/map/cache?tile=${index}`)),
);
assert.equal(validationCalls, 1);
assert.ok(results.every(({ handled }) => handled === false));
assert.ok(results.every(({ response }) => response.getHeader("set-cookie") === undefined));
const withinTtl = await Promise.all(
Array.from({ length: 64 }, (_, index) => authorize(auth, cookie, `/api/map-gateway/api/map/cache?warm=${index}`)),
);
assert.equal(validationCalls, 1);
assert.ok(withinTtl.every(({ handled }) => handled === false));
});
await t.test("transient Launcher failure keeps the cookie and uses bounded read-only grace", async () => {
validationMode = "transient";
clock += 21;
const graceResults = await Promise.all(
Array.from({ length: 32 }, (_, index) => authorize(auth, cookie, `/api/map-gateway/api/map/cache?grace=${index}`)),
);
assert.equal(validationCalls, 2);
assert.ok(graceResults.every(({ handled }) => handled === false));
assert.ok(graceResults.every(({ response }) => response.getHeader("set-cookie") === undefined));
const mutation = await authorize(auth, cookie, "/api/platform-settings/cesium-ion", "PUT");
assert.equal(mutation.handled, true);
assert.equal(mutation.response.statusCode, 503);
assert.equal(JSON.parse(mutation.response.body).error, "module_foundry_auth_unavailable");
assert.equal(mutation.response.getHeader("set-cookie"), undefined);
// The short retry backoff shares the transient result instead of creating a
// control-plane retry storm from the same burst.
assert.equal(validationCalls, 2);
clock += 31;
const outsideGrace = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(outsideGrace.handled, true);
assert.equal(outsideGrace.response.statusCode, 503);
assert.equal(JSON.parse(outsideGrace.response.body).error, "module_foundry_auth_unavailable");
assert.equal(outsideGrace.response.getHeader("set-cookie"), undefined);
assert.equal(validationCalls, 3);
});
await t.test("network errors and timeouts keep the last-known-good read-only session", async () => {
validationMode = "active";
clock += 6;
const recovered = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(recovered.handled, false);
assert.equal(validationCalls, 4);
validationMode = "network";
clock += 21;
const networkFailure = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(networkFailure.handled, false);
assert.equal(networkFailure.response.getHeader("set-cookie"), undefined);
assert.equal(validationCalls, 5);
validationMode = "timeout";
clock += 6;
const timeout = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(timeout.handled, false);
assert.equal(timeout.response.getHeader("set-cookie"), undefined);
assert.equal(validationCalls, 6);
});
await t.test("explicit Launcher inactive response revokes the local session", async () => {
validationMode = "inactive";
clock += 6;
const revoked = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(revoked.handled, true);
assert.equal(revoked.response.statusCode, 401);
assert.equal(JSON.parse(revoked.response.body).error, "module_foundry_auth_required");
assert.match(String(revoked.response.getHeader("set-cookie")), /Max-Age=0/);
assert.equal(validationCalls, 7);
const afterRevoke = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(afterRevoke.handled, true);
assert.equal(afterRevoke.response.statusCode, 401);
assert.equal(validationCalls, 7);
});
await t.test("diagnostics contain outcomes but no cookie, Launcher id or token", () => {
assert.ok(diagnostics.some((record) => record.outcome === "active"));
assert.ok(diagnostics.some((record) => record.outcome === "transient"));
assert.ok(diagnostics.some((record) => record.outcome === "inactive"));
const serialized = JSON.stringify(diagnostics);
assert.equal(serialized.includes(cookie), false);
assert.equal(serialized.includes(HANDOFF_TOKEN), false);
assert.equal(serialized.includes(LAUNCHER_SESSION_ID), false);
assert.equal(serialized.includes(INTERNAL_ACCESS_TOKEN), false);
assert.equal(serialized.includes(USER.email), false);
});
await t.test("a process restart expires an opaque process-local cookie safely", async () => {
let restartedValidationCalls = 0;
const restartDiagnostics = [];
const restartedAuth = createFoundryAuth({
now: () => clock,
fetch: async () => {
restartedValidationCalls += 1;
throw new Error("must not validate an unrecoverable local cookie");
},
validationTtlMs: 20,
validationGraceMs: 30,
validationRetryMs: 5,
onDiagnostic: (record) => restartDiagnostics.push(record),
});
const result = await authorize(restartedAuth, cookie, "/api/map/runtime-config");
assert.equal(result.handled, true);
assert.equal(result.response.statusCode, 401);
assert.match(String(result.response.getHeader("set-cookie")), /Max-Age=0/);
assert.equal(restartedValidationCalls, 0);
assert.ok(restartDiagnostics.some((record) => record.reason === "local_session_missing_or_expired"));
});
});
async function createSessionCookie(auth) {
const request = {
method: "GET",
url: `/auth/nodedc/handoff?token=${encodeURIComponent(HANDOFF_TOKEN)}&returnTo=%2F`,
headers: {},
};
const response = mockResponse();
await auth.handleLauncherHandoff(request, response, new URL(request.url, "https://foundry.example.test"));
assert.equal(response.statusCode, 302);
const setCookie = String(response.getHeader("set-cookie"));
assert.match(setCookie, /^nodedc_foundry_session=/);
return setCookie.split(";", 1)[0];
}
async function authorize(auth, cookie, path, method = "GET") {
const request = { method, url: path, headers: { cookie, accept: "application/json" } };
const response = mockResponse();
const handled = await auth.ensureRequestSession(
request,
response,
new URL(path, "https://foundry.example.test"),
);
return { handled, request, response };
}
function mockResponse() {
const headers = new Map();
return {
statusCode: 200,
body: "",
setHeader(name, value) {
headers.set(String(name).toLowerCase(), value);
},
getHeader(name) {
return headers.get(String(name).toLowerCase());
},
end(body = "") {
this.body = String(body);
},
};
}
function jsonResponse(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function setEnvironment(values) {
const original = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]));
for (const [key, value] of Object.entries(values)) process.env[key] = value;
return () => {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
};
}