253 lines
10 KiB
JavaScript
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;
|
|
}
|
|
};
|
|
}
|