NODEDC_DESIGN_GUIDELINE/server/foundry-binding-api.test.mjs

312 lines
13 KiB
JavaScript

import assert from "node:assert/strict";
import { createServer } from "node:http";
import { chmod, mkdtemp, rename, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
FOUNDRY_BINDING_CATALOG_ACTION,
FOUNDRY_BINDING_CATALOG_PATH,
FOUNDRY_BINDING_GRANT_SCHEMA_VERSION,
FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
FOUNDRY_BINDING_UPSERT_ACTION,
FOUNDRY_BINDING_UPSERT_PATH,
createFoundryBindingGrantToken,
foundryBindingGrantFileName,
handleFoundryBindingApiRequest,
} from "./foundry-binding-api.mjs";
const NOW = Date.parse("2026-07-15T12:00:00.000Z");
const TARGET = Object.freeze({
applicationId: "11111111-1111-4111-8111-111111111111",
pageId: "map-page-1",
bindingId: "fleet-live-points",
dataProductId: "fleet.positions.current.v1",
slotId: "points",
semanticTypes: ["map.moving_object"],
fieldProjection: ["course", "name", "speed"],
});
test("Foundry binding workload API is scoped, revocable and replay-safe at its operation boundary", async (t) => {
const grantsDir = await mkdtemp(join(tmpdir(), "nodedc-foundry-binding-grants-"));
const token = createFoundryBindingGrantToken();
const calls = { preflight: [], upsert: [] };
let readerGrantReady = true;
const options = {
grantsDir,
production: false,
now: () => NOW,
async preflightReaderGrant(input) {
calls.preflight.push(structuredClone(input));
return readerGrantReady;
},
async upsertBinding(input, actor) {
calls.upsert.push({ input: structuredClone(input), actor: structuredClone(actor) });
return {
application: { privateManifestMaterial: "must-not-leak" },
page: { unrelatedBindings: ["must-not-leak"] },
binding: { ...input.binding, delivery: "snapshot+patch" },
idempotency: { replayed: calls.upsert.length > 1 },
};
},
};
await writeGrant(grantsDir, token, grantFor(token));
const server = createServer((request, response) => {
void handleFoundryBindingApiRequest(request, response, options);
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
await rm(grantsDir, { recursive: true, force: true });
});
await t.test("catalog exposes only grant-allowlisted products", async () => {
const response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token });
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store, max-age=0");
assert.equal(response.headers.get("pragma"), "no-cache");
assert.equal(response.headers.get("vary"), "authorization");
assert.deepEqual(response.body, {
ok: true,
dataProducts: [{ id: TARGET.dataProductId, semanticTypes: TARGET.semanticTypes }],
});
});
await t.test("POST forwards the stable idempotency key and grant actor without leaking full manifests", async () => {
const payload = upsertPayload();
const first = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, {
token,
method: "POST",
body: payload,
headers: { "X-NODEDC-Actor": "attacker-supplied-actor" },
});
const second = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body: payload });
assert.equal(first.status, 200);
assert.equal(second.status, 200);
assert.equal(first.body.idempotency.replayed, false);
assert.equal(second.body.idempotency.replayed, true);
assert.equal(first.body.application, undefined);
assert.equal(first.body.page, undefined);
assert.deepEqual(calls.upsert.map((call) => call.input.idempotencyKey), [payload.idempotencyKey, payload.idempotencyKey]);
assert.deepEqual(calls.upsert.map((call) => call.actor), [
{ actorId: "engine-agent-42", ownerKey: "workspace-42" },
{ actorId: "engine-agent-42", ownerKey: "workspace-42" },
]);
assert.deepEqual(calls.preflight[0], {
applicationId: TARGET.applicationId,
pageId: TARGET.pageId,
bindingId: TARGET.bindingId,
dataProductId: TARGET.dataProductId,
actor: { actorId: "engine-agent-42", ownerKey: "workspace-42" },
grantId: "foundry-binding-grant-42",
});
});
await t.test("action scope and reader-grant preflight fail closed", async () => {
const catalogDeniedToken = createFoundryBindingGrantToken();
await writeGrant(grantsDir, catalogDeniedToken, grantFor(catalogDeniedToken, {
actions: [FOUNDRY_BINDING_UPSERT_ACTION],
}));
let response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: catalogDeniedToken });
assert.equal(response.status, 403);
assert.equal(response.body.error, "foundry_binding_action_forbidden");
const baselineUpserts = calls.upsert.length;
readerGrantReady = false;
response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, {
token,
method: "POST",
body: upsertPayload(),
});
readerGrantReady = true;
assert.equal(response.status, 409);
assert.equal(response.body.error, "data_product_reader_grant_not_ready");
assert.equal(calls.upsert.length, baselineUpserts);
});
await t.test("exact target scope rejects any changed dimension before callbacks", async () => {
const baselinePreflight = calls.preflight.length;
const baselineUpsert = calls.upsert.length;
const mutations = [
(body) => { body.applicationId = "22222222-2222-4222-8222-222222222222"; },
(body) => { body.pageId = "map-page-2"; },
(body) => { body.binding.id = "fleet-live-points-2"; },
(body) => { body.binding.dataProductId = "fleet.positions.current.v2"; },
(body) => { body.binding.slotId = "traces"; },
(body) => { body.binding.semanticTypes = ["map.route"]; },
(body) => { body.binding.fieldProjection = ["name"]; },
];
for (const mutate of mutations) {
const body = upsertPayload();
mutate(body);
const response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body });
assert.equal(response.status, 403);
assert.equal(response.body.error, "foundry_binding_target_forbidden");
}
assert.equal(calls.preflight.length, baselinePreflight);
assert.equal(calls.upsert.length, baselineUpsert);
});
await t.test("wire schema enforces the canonical UUID and identifier grammar", async () => {
const mutations = [
(body) => { body.applicationId = "00000000-0000-0000-0000-000000000000"; },
(body) => { body.binding.id = "Fleet-Live-Points"; },
(body) => { body.binding.dataProductId = "Fleet.Positions.Current.V1"; },
(body) => { body.binding.slotId = "-points"; },
(body) => { body.binding.semanticTypes = ["Map.Moving_Object"]; },
(body) => { body.binding.fieldProjection = ["Display_Name"]; },
(body) => { body.idempotencyKey = "X"; },
(body) => { body.binding.dataProductId = ` ${TARGET.dataProductId}`; },
];
for (const mutate of mutations) {
const body = upsertPayload();
mutate(body);
const response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body });
assert.equal(response.status, 400);
assert.equal(response.body.error, "foundry_binding_request_invalid");
}
});
await t.test("secret material, transport scope, browser origin and shared tokens are rejected", async () => {
const wrongSchema = upsertPayload();
wrongSchema.schemaVersion = "nodedc.foundry.binding-upsert/v2";
let response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body: wrongSchema });
assert.equal(response.status, 400);
assert.equal(response.body.error, "foundry_binding_schema_version_unsupported");
const withSecret = upsertPayload();
withSecret.binding.token = "ndc_edprb_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body: withSecret });
assert.equal(response.status, 400);
assert.equal(response.body.error, "foundry_binding_secret_material_forbidden");
const withProvider = upsertPayload();
withProvider.binding.providerId = "gelios";
response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body: withProvider });
assert.equal(response.status, 400);
assert.equal(response.body.error, "foundry_binding_transport_or_scope_forbidden");
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, {
token,
headers: { Origin: "https://foundry.nodedc.ru" },
});
assert.equal(response.status, 403);
assert.equal(response.body.error, "foundry_binding_browser_origin_forbidden");
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { rawAuthorization: "Bearer shared-platform-token" });
assert.equal(response.status, 401);
assert.equal(response.body.error, "foundry_binding_unauthorized");
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, {
headers: { Cookie: "nodedc_foundry_session=browser-session" },
});
assert.equal(response.status, 401);
assert.equal(response.body.error, "foundry_binding_unauthorized");
});
await t.test("writable and symlink grant records are rejected", async () => {
const writableToken = createFoundryBindingGrantToken();
await writeGrant(grantsDir, writableToken, grantFor(writableToken));
const writablePath = join(grantsDir, foundryBindingGrantFileName(writableToken));
await chmod(writablePath, 0o600);
let response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: writableToken });
assert.equal(response.status, 503);
assert.equal(response.body.error, "foundry_binding_grant_file_invalid");
const symlinkToken = createFoundryBindingGrantToken();
const sourcePath = join(grantsDir, `source-${randomSuffix()}`);
await writeFile(sourcePath, `${JSON.stringify(grantFor(symlinkToken))}\n`, { mode: 0o400 });
await chmod(sourcePath, 0o400);
await symlink(sourcePath, join(grantsDir, foundryBindingGrantFileName(symlinkToken)));
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: symlinkToken });
assert.equal(response.status, 503);
assert.equal(response.body.error, "foundry_binding_grant_file_invalid");
});
await t.test("grant expiry is enforced", async () => {
const expiredToken = createFoundryBindingGrantToken();
await writeGrant(grantsDir, expiredToken, grantFor(expiredToken, {
issuedAt: "2026-07-14T10:00:00.000Z",
expiresAt: "2026-07-15T11:59:59.000Z",
}));
const response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: expiredToken });
assert.equal(response.status, 401);
assert.equal(response.body.error, "foundry_binding_grant_expired");
});
await t.test("revocation and rotation take effect on the next request", async () => {
let response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token });
assert.equal(response.status, 200);
await writeGrant(grantsDir, token, grantFor(token, { active: false }));
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token });
assert.equal(response.status, 401);
assert.equal(response.body.error, "foundry_binding_grant_inactive");
const rotatedToken = createFoundryBindingGrantToken();
await writeGrant(grantsDir, rotatedToken, grantFor(rotatedToken));
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: rotatedToken });
assert.equal(response.status, 200);
});
});
function grantFor(token, overrides = {}) {
return {
schemaVersion: FOUNDRY_BINDING_GRANT_SCHEMA_VERSION,
grantId: "foundry-binding-grant-42",
tokenHash: foundryBindingGrantFileName(token),
active: true,
issuedAt: "2026-07-15T11:00:00.000Z",
expiresAt: "2026-07-16T12:00:00.000Z",
actorId: "engine-agent-42",
ownerKey: "workspace-42",
actions: [FOUNDRY_BINDING_CATALOG_ACTION, FOUNDRY_BINDING_UPSERT_ACTION],
targets: [structuredClone(TARGET)],
...overrides,
};
}
function upsertPayload() {
return {
schemaVersion: FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
applicationId: TARGET.applicationId,
pageId: TARGET.pageId,
idempotencyKey: "foundry-binding-3b7e28b52f7212a843fa5aa4809b03ea",
binding: {
id: TARGET.bindingId,
dataProductId: TARGET.dataProductId,
slotId: TARGET.slotId,
semanticTypes: [...TARGET.semanticTypes],
fieldProjection: [...TARGET.fieldProjection],
},
};
}
async function writeGrant(grantsDir, token, grant) {
const target = join(grantsDir, foundryBindingGrantFileName(token));
const temp = `${target}.${randomSuffix()}.tmp`;
await writeFile(temp, `${JSON.stringify(grant)}\n`, { mode: 0o400 });
await chmod(temp, 0o400);
await rename(temp, target);
}
function randomSuffix() {
return Math.random().toString(16).slice(2);
}
async function request(baseUrl, path, { token, rawAuthorization, method = "GET", body, headers = {} } = {}) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(rawAuthorization ? { Authorization: rawAuthorization } : {}),
...(body ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: response.status, headers: response.headers, body: await response.json() };
}