348 lines
15 KiB
JavaScript
348 lines
15 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'],
|
|
['NdcDataProductReaderApi', 'ndcDataProductReaderApi'],
|
|
['NdcFoundryBindingApi', 'ndcFoundryBindingApi'],
|
|
];
|
|
|
|
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);
|
|
}
|
|
|
|
for (const [className, internalName] of credentialSpecs) {
|
|
const modulePath = path.join(packageRoot, 'dist', 'credentials', `${className}.credentials.js`);
|
|
const CredentialClass = require(modulePath)[className];
|
|
const credential = new CredentialClass();
|
|
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',
|
|
});
|
|
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}}');
|
|
assertProductSurfaceHasNdcBrand({
|
|
displayName: credential.displayName,
|
|
documentationUrl: credential.documentationUrl,
|
|
properties: credential.properties,
|
|
}, className);
|
|
}
|
|
|
|
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,
|
|
);
|
|
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('cursor'), false, 'snapshot endpoint has no cursor input');
|
|
assert.equal(readProperties.includes('history'), false, 'history must stay hidden until its endpoint exists');
|
|
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');
|
|
}
|
|
|
|
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 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 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 }) {
|
|
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' }; },
|
|
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;
|
|
});
|