312 lines
13 KiB
JavaScript
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() };
|
|
}
|