578 lines
24 KiB
JavaScript
578 lines
24 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { pathToFileURL } = require('node:url');
|
|
|
|
const packageRoot = path.resolve(__dirname, '..');
|
|
const packageJson = require(path.join(packageRoot, 'package.json'));
|
|
|
|
const nodeSpecs = [
|
|
['NdcDataProductPublish', 'ndcDataProductPublish', 'NDC Data Product Publish'],
|
|
['NdcDataProductRead', 'ndcDataProductRead', 'NDC Data Product Read'],
|
|
['NdcFoundryBinding', 'ndcFoundryBinding', 'NDC Foundry Binding'],
|
|
];
|
|
const credentialSpecs = [
|
|
['NdcDataProductWriterApi', 'ndcDataProductWriterApi', 'opaque'],
|
|
['NdcDataProductReaderApi', 'ndcDataProductReaderApi', 'opaque'],
|
|
['NdcFoundryBindingApi', 'ndcFoundryBindingApi', 'opaque'],
|
|
['NdcProviderRotatingAccessApi', 'ndcProviderRotatingAccessApi', 'rotating'],
|
|
];
|
|
|
|
const forbiddenNodeParameter = /(url|provider|tenant|connection|token|secret|password|credential)/i;
|
|
const forbiddenBindingKey = /(provider|tenant|connection|endpoint|url|credential|token|secret|payload)/i;
|
|
const forbiddenProductBrand = /n8n(?:-nodes-ndc)?/i;
|
|
const ndcNodeIcon = {
|
|
light: 'file:../../icons/ndc.svg',
|
|
dark: 'file:../../icons/ndc.dark.svg',
|
|
};
|
|
|
|
async function main() {
|
|
assert.equal(packageJson.name, 'n8n-nodes-ndc');
|
|
assert.equal(packageJson.dependencies, undefined, 'private NDC nodes must not add runtime supply-chain dependencies');
|
|
for (const lifecycle of ['preinstall', 'install', 'postinstall']) {
|
|
assert.equal(packageJson.scripts?.[lifecycle], undefined, `${lifecycle} lifecycle script is forbidden`);
|
|
}
|
|
assert.equal(packageJson.n8n.nodes.length, nodeSpecs.length, 'every registered custom node must be covered by policy');
|
|
assert.deepEqual(
|
|
[...packageJson.n8n.nodes].sort(),
|
|
nodeSpecs
|
|
.map(([className]) => `dist/nodes/${className}/${className}.node.js`)
|
|
.sort(),
|
|
'registered custom nodes and policy specs must stay in lockstep',
|
|
);
|
|
const nodes = new Map();
|
|
|
|
for (const [className, internalName, displayName] of nodeSpecs) {
|
|
const modulePath = path.join(packageRoot, 'dist', 'nodes', className, `${className}.node.js`);
|
|
const NodeClass = require(modulePath)[className];
|
|
const node = new NodeClass();
|
|
nodes.set(className, node);
|
|
assert.match(node.description.displayName, /^NDC /);
|
|
assert.match(node.description.defaults.name, /^NDC /);
|
|
assert.equal(node.description.displayName, displayName);
|
|
assert.equal(node.description.defaults.name, displayName);
|
|
assert.deepEqual(node.description.icon, ndcNodeIcon);
|
|
assert.match(node.description.name, /^ndc[A-Z]/);
|
|
assert.equal(node.description.name, internalName);
|
|
assert.equal(`${packageJson.name}.${node.description.name}`, `${packageJson.name}.${internalName}`);
|
|
assert.notEqual(
|
|
node.description.usableAsTool,
|
|
true,
|
|
`${className} must not generate an additional *Tool runtime type`,
|
|
);
|
|
for (const property of node.description.properties) {
|
|
assert.doesNotMatch(property.name, forbiddenNodeParameter, `${className}.${property.name} is transport-specific`);
|
|
}
|
|
assert.equal(node.description.credentials.length, 1);
|
|
assert.match(node.description.credentials[0].name, /^ndc[A-Z]/);
|
|
assertProductSurfaceHasNdcBrand(node.description, className);
|
|
}
|
|
|
|
const credentials = new Map();
|
|
for (const [className, internalName, kind] of credentialSpecs) {
|
|
const modulePath = path.join(packageRoot, 'dist', 'credentials', `${className}.credentials.js`);
|
|
const CredentialClass = require(modulePath)[className];
|
|
const credential = new CredentialClass();
|
|
credentials.set(className, credential);
|
|
assert.match(credential.displayName, /^NDC /);
|
|
assert.equal(credential.name, internalName);
|
|
assert.deepEqual(credential.icon, {
|
|
light: 'file:../icons/ndc.svg',
|
|
dark: 'file:../icons/ndc.dark.svg',
|
|
});
|
|
if (kind === 'opaque') {
|
|
assert.deepEqual(credential.properties.map((property) => property.name), ['capability']);
|
|
assert.equal(credential.properties[0].typeOptions.password, true);
|
|
assert.equal(credential.authenticate.properties.headers.Authorization, '=Bearer {{$credentials.capability}}');
|
|
} else {
|
|
assert.deepEqual(
|
|
credential.properties.map((property) => property.name),
|
|
['refreshToken', 'accessToken', 'accessExpiresAt'],
|
|
);
|
|
assert.equal(credential.properties[0].typeOptions.password, true);
|
|
assert.equal(credential.properties[1].type, 'hidden');
|
|
assert.equal(credential.properties[1].typeOptions.password, true);
|
|
assert.equal(credential.properties[1].typeOptions.expirable, true);
|
|
assert.equal(credential.authenticate.properties.headers.Authorization, '=Bearer {{$credentials.accessToken}}');
|
|
assert.equal(credential.test.request.url, 'https://api.geliospro.com/api/v1/auth');
|
|
}
|
|
assertProductSurfaceHasNdcBrand({
|
|
displayName: credential.displayName,
|
|
documentationUrl: credential.documentationUrl,
|
|
properties: credential.properties,
|
|
}, className);
|
|
}
|
|
|
|
await assertProviderRotatingCredential(credentials.get('NdcProviderRotatingAccessApi'));
|
|
|
|
for (const icon of ['ndc.svg', 'ndc.dark.svg']) {
|
|
const iconPath = path.join(packageRoot, 'dist', 'icons', icon);
|
|
assert.equal(fs.existsSync(iconPath), true, `${icon} was not copied`);
|
|
assert.match(fs.readFileSync(iconPath, 'utf8'), /aria-label="NDC"/, `${icon} must expose the NDC brand`);
|
|
}
|
|
|
|
const contracts = require(path.join(packageRoot, 'dist', 'nodes', 'shared', 'contracts.js'));
|
|
const constants = require(path.join(packageRoot, 'dist', 'nodes', 'shared', 'constants.js'));
|
|
const http = require(path.join(packageRoot, 'dist', 'nodes', 'shared', 'http.js'));
|
|
const externalContract = await import(pathToFileURL(
|
|
path.resolve(packageRoot, '..', 'external-provider-contract', 'src', 'index.mjs'),
|
|
).href);
|
|
const items = [
|
|
{
|
|
json: {
|
|
sourceId: 'fleet.unit.42',
|
|
semanticType: 'map.moving_object',
|
|
observedAt: '2026-07-15T12:00:00.000Z',
|
|
attributes: { speedKph: 12 },
|
|
geometry: { type: 'Point', coordinates: [37.61, 55.75] },
|
|
},
|
|
},
|
|
];
|
|
const publish = contracts.buildPublishPayload(
|
|
items,
|
|
'execution-42',
|
|
'workflow-7',
|
|
'node-3',
|
|
'fleet.positions.current.v1',
|
|
0,
|
|
);
|
|
assert.deepEqual(externalContract.validateDataProductPublish(publish), { ok: true, errors: [] });
|
|
assert.deepEqual(
|
|
publish,
|
|
contracts.buildPublishPayload(items, 'execution-42', 'workflow-7', 'node-3', 'fleet.positions.current.v1', 0),
|
|
'same execution chunk must produce the same idempotency envelope',
|
|
);
|
|
assert.notEqual(
|
|
publish.batch.idempotencyKey,
|
|
contracts.buildPublishPayload(items, 'execution-42', 'workflow-7', 'node-3', 'fleet.positions.current.v2', 0).batch.idempotencyKey,
|
|
);
|
|
const zoneItems = [{
|
|
json: {
|
|
sourceId: 'gelios-zone-42',
|
|
semanticType: 'map.zone',
|
|
observedAt: '2026-07-15T12:10:00.000Z',
|
|
attributes: { display_name: 'Zone 42' },
|
|
geometry: {
|
|
type: 'Polygon',
|
|
coordinates: [[[37.60, 55.74], [37.62, 55.74], [37.62, 55.76], [37.60, 55.74]]],
|
|
},
|
|
},
|
|
}];
|
|
const replacement = contracts.buildPublishPayload(
|
|
zoneItems,
|
|
'execution-43',
|
|
'workflow-7',
|
|
'node-zones',
|
|
'map.zones.current.v1',
|
|
0,
|
|
'replace',
|
|
);
|
|
assert.equal(externalContract.validateDataProductPublish(replacement).ok, true);
|
|
assert.equal(replacement.batch.mode, 'replace');
|
|
assert.equal(replacement.batch.generationAt, zoneItems[0].json.observedAt);
|
|
const replacementReplay = contracts.buildPublishPayload(
|
|
zoneItems,
|
|
'execution-replay',
|
|
'workflow-other',
|
|
'node-other',
|
|
'map.zones.current.v1',
|
|
0,
|
|
'replace',
|
|
);
|
|
assert.equal(
|
|
replacementReplay.batch.runId,
|
|
replacement.batch.runId,
|
|
'the same complete generation must keep one run identity across executions',
|
|
);
|
|
assert.equal(
|
|
replacementReplay.batch.idempotencyKey,
|
|
replacement.batch.idempotencyKey,
|
|
'the same complete generation must replay through the data plane idempotently',
|
|
);
|
|
const changedReplacement = contracts.buildPublishPayload(
|
|
[{ json: { ...zoneItems[0].json, attributes: { display_name: 'Zone 42 changed' } } }],
|
|
'execution-replay',
|
|
'workflow-other',
|
|
'node-other',
|
|
'map.zones.current.v1',
|
|
0,
|
|
'replace',
|
|
);
|
|
assert.notEqual(
|
|
changedReplacement.batch.idempotencyKey,
|
|
replacement.batch.idempotencyKey,
|
|
'different content at one declared generation must not alias an accepted replay',
|
|
);
|
|
const replacementBatch = contracts.buildPublishPayload(
|
|
[{ json: { generationAt: zoneItems[0].json.observedAt, facts: zoneItems.map((item) => item.json) } }],
|
|
'execution-44',
|
|
'workflow-7',
|
|
'node-zones',
|
|
'map.zones.current.v1',
|
|
0,
|
|
'replace',
|
|
);
|
|
assert.deepEqual(replacementBatch.facts, replacement.facts);
|
|
assert.equal(replacementBatch.batch.generationAt, zoneItems[0].json.observedAt);
|
|
assert.equal(replacementBatch.batch.idempotencyKey, replacement.batch.idempotencyKey);
|
|
const emptyReplacement = contracts.buildPublishPayload(
|
|
[{ json: { generationAt: '2026-07-15T13:00:00.000Z', facts: [] } }],
|
|
'execution-45',
|
|
'workflow-7',
|
|
'node-zones',
|
|
'map.zones.current.v1',
|
|
0,
|
|
'replace',
|
|
);
|
|
assert.deepEqual(emptyReplacement.facts, []);
|
|
assert.equal(externalContract.validateDataProductPublish(emptyReplacement).ok, true);
|
|
assert.throws(
|
|
() => contracts.buildPublishPayload(
|
|
[{ json: { generationAt: '2026-07-15T13:00:00.000Z', facts: zoneItems.map((item) => item.json) } }],
|
|
'execution-46',
|
|
'workflow-7',
|
|
'node-zones',
|
|
'map.zones.current.v1',
|
|
0,
|
|
'replace',
|
|
),
|
|
/replace_generationAt_observedAt_mismatch/,
|
|
);
|
|
assert.throws(
|
|
() => contracts.buildPublishPayload(
|
|
[{ json: { ...items[0].json, attributes: { accessToken: 'forbidden' } } }],
|
|
'execution-42',
|
|
'workflow-7',
|
|
'node-3',
|
|
'fleet.positions.current.v1',
|
|
0,
|
|
),
|
|
/secret_material_forbidden/,
|
|
);
|
|
|
|
const foundry = contracts.buildFoundryBindingPayload({
|
|
applicationId: '11111111-1111-4111-8111-111111111111',
|
|
pageId: 'map',
|
|
binding: {
|
|
id: 'fleet-live-points',
|
|
dataProductId: 'fleet.positions.current.v1',
|
|
slotId: 'points',
|
|
semanticTypes: ['map.moving_object'],
|
|
fieldProjection: ['coordinates.longitude', 'coordinates.latitude'],
|
|
},
|
|
});
|
|
assertNoForbiddenKeys(foundry, forbiddenBindingKey);
|
|
assert.equal(externalContract.validateFoundryBindingUpsert(foundry).ok, true);
|
|
assert.equal(foundry.schemaVersion, externalContract.FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION);
|
|
assert.equal(foundry.binding.dataProductId, 'fleet.positions.current.v1');
|
|
assert.equal(foundry.binding.slotId, 'points');
|
|
assert.match(foundry.idempotencyKey, /^foundry-binding-[a-f0-9]{32}$/);
|
|
|
|
assert.equal(constants.DATA_PRODUCT_WRITER_CATALOG_PATH, '/internal/data-plane/v1/writer/data-products');
|
|
assert.equal(constants.DATA_PRODUCT_READER_CATALOG_PATH, '/internal/data-plane/v1/reader/data-products');
|
|
assert.equal(constants.DATA_PRODUCT_BASE_PATH, '/internal/data-plane/v1/data-products');
|
|
assert.equal(constants.DATA_PLANE_DEFAULT_BASE_URL, 'http://external-data-plane:18106');
|
|
assert.equal(constants.FOUNDRY_DEFAULT_BASE_URL, 'http://nodedc-module-foundry:3333');
|
|
const previousDefaultProbe = process.env.NDC_DATA_PLANE_BASE_URL;
|
|
delete process.env.NDC_DATA_PLANE_BASE_URL;
|
|
assert.equal(
|
|
http.serviceBaseUrl(constants.DATA_PLANE_BASE_URL_ENV, constants.DATA_PLANE_DEFAULT_BASE_URL),
|
|
'http://external-data-plane:18106',
|
|
);
|
|
restoreEnvironment('NDC_DATA_PLANE_BASE_URL', previousDefaultProbe);
|
|
|
|
const readProperties = nodes.get('NdcDataProductRead').description.properties.map((property) => property.name);
|
|
assert.equal(readProperties.includes('mode'), true, 'read mode must expose snapshot/history explicitly');
|
|
assert.equal(readProperties.includes('cursor'), true, 'history endpoint must expose its opaque continuation cursor');
|
|
assert.equal(readProperties.includes('sourceIds'), true, 'history filtering remains provider-neutral');
|
|
const pageSizeProperty = nodes.get('NdcDataProductRead').description.properties.find((property) => property.name === 'pageSize');
|
|
assert.equal(pageSizeProperty.typeOptions.minValue, 1);
|
|
assert.equal(pageSizeProperty.typeOptions.maxValue, 5000, 'bounded snapshot v1 must not expose more than 5000 facts');
|
|
assert.equal(pageSizeProperty.default <= pageSizeProperty.typeOptions.maxValue, true);
|
|
const slotProperty = nodes.get('NdcFoundryBinding').description.properties.find((property) => property.name === 'slotId');
|
|
assert.equal(slotProperty.default, 'points');
|
|
|
|
await assertNodeHttpContracts(nodes, externalContract, constants);
|
|
|
|
console.log('n8n-nodes-ndc package policy: ok');
|
|
}
|
|
|
|
async function assertProviderRotatingCredential(credential) {
|
|
let requestCount = 0;
|
|
let releaseRequest;
|
|
const request = new Promise((resolve) => { releaseRequest = resolve; });
|
|
const helper = {
|
|
helpers: {
|
|
async httpRequest(options) {
|
|
requestCount += 1;
|
|
assert.equal(options.method, 'POST');
|
|
assert.equal(options.url, 'https://api.geliospro.com/api/v1/auth/refresh');
|
|
assert.deepEqual(options.body, { refresh_token: 'refresh-old-single-flight' });
|
|
assert.equal(options.headers.Authorization, undefined);
|
|
return request;
|
|
},
|
|
},
|
|
};
|
|
const credentials = { refreshToken: 'refresh-old-single-flight', accessToken: '' };
|
|
const first = credential.preAuthentication.call(helper, credentials);
|
|
const second = credential.preAuthentication.call(helper, credentials);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(requestCount, 1, 'rotating refresh must be single-flight inside the one-service L2 runtime');
|
|
releaseRequest({
|
|
access_token: 'access-new',
|
|
refresh_token: 'refresh-new',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
});
|
|
const [firstTokens, secondTokens] = await Promise.all([first, second]);
|
|
assert.deepEqual(firstTokens, secondTokens);
|
|
assert.equal(firstTokens.accessToken, 'access-new');
|
|
assert.equal(firstTokens.refreshToken, 'refresh-new');
|
|
assert.match(firstTokens.accessExpiresAt, /^\d{4}-\d{2}-\d{2}T/);
|
|
|
|
const replay = await credential.preAuthentication.call(helper, credentials);
|
|
assert.deepEqual(replay, firstTokens);
|
|
assert.equal(requestCount, 1, 'a stale concurrent caller must reuse the recent rotation result');
|
|
|
|
const leakedRefresh = 'refresh-token-must-not-leak';
|
|
await assert.rejects(
|
|
credential.preAuthentication.call({
|
|
helpers: {
|
|
async httpRequest() {
|
|
throw new Error(`remote rejected ${leakedRefresh}`);
|
|
},
|
|
},
|
|
}, { refreshToken: leakedRefresh, accessToken: '' }),
|
|
(error) => error?.message === 'provider_access_refresh_failed'
|
|
&& !error.message.includes(leakedRefresh),
|
|
);
|
|
}
|
|
|
|
function assertProductSurfaceHasNdcBrand(surface, className) {
|
|
const visibleStrings = [];
|
|
collectVisibleStrings(surface, visibleStrings);
|
|
for (const value of visibleStrings) {
|
|
assert.doesNotMatch(value, forbiddenProductBrand, `${className} leaks a technical package/runtime brand: ${value}`);
|
|
}
|
|
}
|
|
|
|
function collectVisibleStrings(value, output, key = '') {
|
|
if (typeof value === 'string') {
|
|
if (!['name', 'type', 'value'].includes(key)) output.push(value);
|
|
return;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) collectVisibleStrings(item, output, key);
|
|
return;
|
|
}
|
|
if (!value || typeof value !== 'object') return;
|
|
for (const [childKey, childValue] of Object.entries(value)) {
|
|
if (['credentials', 'icon'].includes(childKey)) continue;
|
|
collectVisibleStrings(childValue, output, childKey);
|
|
}
|
|
}
|
|
|
|
async function assertNodeHttpContracts(nodes, externalContract, constants) {
|
|
const previousDataPlaneUrl = process.env.NDC_DATA_PLANE_BASE_URL;
|
|
const previousFoundryUrl = process.env.NDC_FOUNDRY_BASE_URL;
|
|
process.env.NDC_DATA_PLANE_BASE_URL = 'http://data-plane.test/';
|
|
process.env.NDC_FOUNDRY_BASE_URL = 'http://foundry.test/';
|
|
try {
|
|
const publishCalls = [];
|
|
const publishContext = executionContext({
|
|
parameters: { dataProductId: 'fleet.positions.current.v1', sequence: 0 },
|
|
input: [{ json: canonicalFact() }],
|
|
response: { ok: true, publishedFactCount: 1 },
|
|
calls: publishCalls,
|
|
});
|
|
const publishResult = await nodes.get('NdcDataProductPublish').execute.call(publishContext);
|
|
assert.equal(publishCalls[0].credentialName, 'ndcDataProductWriterApi');
|
|
assert.equal(publishCalls[0].options.method, 'POST');
|
|
assert.equal(
|
|
publishCalls[0].options.url,
|
|
'http://data-plane.test/internal/data-plane/v1/data-products/fleet.positions.current.v1/publish',
|
|
);
|
|
assert.equal(externalContract.validateDataProductPublish(publishCalls[0].options.body).ok, true);
|
|
assert.equal(publishResult[0][0].json.publishedFactCount, 1);
|
|
|
|
const replacementCalls = [];
|
|
const replacementContext = executionContext({
|
|
typeVersion: 2,
|
|
parameters: { dataProductId: 'map.zones.current.v1', publishMode: 'replace', sequence: 0 },
|
|
input: [{
|
|
json: {
|
|
generationAt: '2026-07-15T12:10:00.000Z',
|
|
facts: [{
|
|
sourceId: 'gelios-zone-42',
|
|
semanticType: 'map.zone',
|
|
observedAt: '2026-07-15T12:10:00.000Z',
|
|
attributes: { display_name: 'Zone 42' },
|
|
geometry: { type: 'Polygon', coordinates: [[[37.60, 55.74], [37.62, 55.74], [37.62, 55.76], [37.60, 55.74]]] },
|
|
}],
|
|
},
|
|
}],
|
|
response: { ok: true, publishedFactCount: 1, currentRemovedCount: 0 },
|
|
calls: replacementCalls,
|
|
});
|
|
await nodes.get('NdcDataProductPublish').execute.call(replacementContext);
|
|
assert.equal(replacementCalls[0].options.body.batch.mode, 'replace');
|
|
assert.equal(externalContract.validateDataProductPublish(replacementCalls[0].options.body).ok, true);
|
|
|
|
const readCalls = [];
|
|
const readContext = executionContext({
|
|
parameters: { dataProductId: 'fleet.positions.current.v1', pageSize: 1000 },
|
|
response: {
|
|
schemaVersion: 'nodedc.data-product.snapshot/v1',
|
|
dataProduct: { id: 'fleet.positions.current.v1', version: '1.0.0' },
|
|
generatedAt: '2026-07-15T12:00:01.000Z',
|
|
cursor: '1',
|
|
facts: [{ ...canonicalFact(), receivedAt: '2026-07-15T12:00:00.100Z' }],
|
|
},
|
|
calls: readCalls,
|
|
});
|
|
const readResult = await nodes.get('NdcDataProductRead').execute.call(readContext);
|
|
assert.equal(readCalls[0].credentialName, 'ndcDataProductReaderApi');
|
|
assert.equal(readCalls[0].options.method, 'GET');
|
|
assert.equal(
|
|
readCalls[0].options.url,
|
|
'http://data-plane.test/internal/data-plane/v1/data-products/fleet.positions.current.v1/snapshot',
|
|
);
|
|
assert.deepEqual(readCalls[0].options.qs, { limit: 1000 });
|
|
assert.equal(readResult[0][0].json.sourceId, 'fleet.unit.42');
|
|
|
|
const historyCalls = [];
|
|
const historyResponse = {
|
|
schemaVersion: 'nodedc.data-product.history/v1',
|
|
dataProduct: { id: 'fleet.positions.current.v1', version: '1.0.0' },
|
|
generatedAt: '2026-07-15T12:05:00.000Z',
|
|
query: {
|
|
from: '2026-07-15T12:00:00.000Z',
|
|
to: '2026-07-15T12:05:00.000Z',
|
|
resolutionMs: 60000,
|
|
sourceIds: ['fleet.unit.42'],
|
|
order: 'asc',
|
|
},
|
|
facts: [{ ...canonicalFact(), receivedAt: '2026-07-15T12:00:00.100Z', bucketStart: '2026-07-15T12:00:00.000Z' }],
|
|
nextCursor: 'opaque_cursor',
|
|
};
|
|
const historyContext = executionContext({
|
|
parameters: {
|
|
mode: 'history',
|
|
dataProductId: 'fleet.positions.current.v1',
|
|
from: '2026-07-15T12:00:00.000Z',
|
|
to: '2026-07-15T12:05:00.000Z',
|
|
resolutionMs: 60000,
|
|
sourceIds: 'fleet.unit.42',
|
|
limit: 1000,
|
|
cursor: '',
|
|
},
|
|
response: historyResponse,
|
|
calls: historyCalls,
|
|
});
|
|
const historyResult = await nodes.get('NdcDataProductRead').execute.call(historyContext);
|
|
assert.equal(historyCalls[0].credentialName, 'ndcDataProductReaderApi');
|
|
assert.equal(historyCalls[0].options.method, 'GET');
|
|
assert.equal(
|
|
historyCalls[0].options.url,
|
|
'http://data-plane.test/internal/data-plane/v1/data-products/fleet.positions.current.v1/history',
|
|
);
|
|
assert.deepEqual(historyCalls[0].options.qs, {
|
|
from: '2026-07-15T12:00:00.000Z',
|
|
to: '2026-07-15T12:05:00.000Z',
|
|
resolutionMs: 60000,
|
|
sourceIds: 'fleet.unit.42',
|
|
limit: 1000,
|
|
});
|
|
assert.deepEqual(historyResult[0][0].json, historyResponse);
|
|
|
|
const foundryCalls = [];
|
|
const foundryContext = executionContext({
|
|
parameters: {
|
|
applicationId: '11111111-1111-4111-8111-111111111111',
|
|
pageId: 'map',
|
|
bindingId: 'fleet-live-points',
|
|
dataProductId: 'fleet.positions.current.v1',
|
|
slotId: 'points',
|
|
semanticTypes: ['map.moving_object'],
|
|
fieldProjection: ['coordinates.longitude'],
|
|
},
|
|
response: { ok: true, binding: { id: 'fleet-live-points' } },
|
|
calls: foundryCalls,
|
|
});
|
|
await nodes.get('NdcFoundryBinding').execute.call(foundryContext);
|
|
assert.equal(foundryCalls[0].credentialName, 'ndcFoundryBindingApi');
|
|
assert.equal(foundryCalls[0].options.method, 'POST');
|
|
assert.equal(foundryCalls[0].options.url, 'http://foundry.test/internal/foundry/v1/data-product-bindings');
|
|
assertNoForbiddenKeys(foundryCalls[0].options.body, forbiddenBindingKey);
|
|
|
|
const catalogCalls = [];
|
|
const catalogContext = {
|
|
helpers: {
|
|
async httpRequestWithAuthentication(credentialName, options) {
|
|
catalogCalls.push({ credentialName, options });
|
|
return { dataProducts: [{ id: 'fleet.positions.current.v1', version: '1.0.0' }] };
|
|
},
|
|
},
|
|
};
|
|
const writerOptions = await nodes.get('NdcDataProductPublish').methods.loadOptions.getDataProducts.call(catalogContext);
|
|
const readerOptions = await nodes.get('NdcDataProductRead').methods.loadOptions.getDataProducts.call(catalogContext);
|
|
assert.deepEqual(writerOptions, [{ name: 'fleet.positions.current.v1 · 1.0.0', value: 'fleet.positions.current.v1' }]);
|
|
assert.deepEqual(readerOptions, writerOptions);
|
|
assert.equal(catalogCalls[0].options.url, `http://data-plane.test${constants.DATA_PRODUCT_WRITER_CATALOG_PATH}`);
|
|
assert.equal(catalogCalls[1].options.url, `http://data-plane.test${constants.DATA_PRODUCT_READER_CATALOG_PATH}`);
|
|
} finally {
|
|
restoreEnvironment('NDC_DATA_PLANE_BASE_URL', previousDataPlaneUrl);
|
|
restoreEnvironment('NDC_FOUNDRY_BASE_URL', previousFoundryUrl);
|
|
}
|
|
}
|
|
|
|
function executionContext({ parameters, input = [{ json: {} }], response, calls, typeVersion = 1 }) {
|
|
return {
|
|
getNodeParameter(name, _index, defaultValue) {
|
|
return Object.prototype.hasOwnProperty.call(parameters, name) ? parameters[name] : defaultValue;
|
|
},
|
|
getInputData() { return input; },
|
|
getExecutionId() { return 'execution-42'; },
|
|
getWorkflow() { return { id: 'workflow-7' }; },
|
|
getNode() { return { id: 'node-3', typeVersion }; },
|
|
helpers: {
|
|
async httpRequestWithAuthentication(credentialName, options) {
|
|
calls.push({ credentialName, options });
|
|
return response;
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function canonicalFact() {
|
|
return {
|
|
sourceId: 'fleet.unit.42',
|
|
semanticType: 'map.moving_object',
|
|
observedAt: '2026-07-15T12:00:00.000Z',
|
|
attributes: { speedKph: 12 },
|
|
geometry: { type: 'Point', coordinates: [37.61, 55.75] },
|
|
};
|
|
}
|
|
|
|
function restoreEnvironment(name, value) {
|
|
if (value === undefined) delete process.env[name];
|
|
else process.env[name] = value;
|
|
}
|
|
|
|
function assertNoForbiddenKeys(value, pattern, pathPrefix = 'body') {
|
|
if (Array.isArray(value)) {
|
|
value.forEach((entry, index) => assertNoForbiddenKeys(entry, pattern, `${pathPrefix}[${index}]`));
|
|
return;
|
|
}
|
|
if (!value || typeof value !== 'object') return;
|
|
for (const [key, child] of Object.entries(value)) {
|
|
assert.doesNotMatch(key, pattern, `${pathPrefix}.${key} contains transport material`);
|
|
assertNoForbiddenKeys(child, pattern, `${pathPrefix}.${key}`);
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|