NODEDC_PLATFORM/packages/n8n-nodes-ndc/test/package-policy.test.cjs

463 lines
20 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,
);
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 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 }) {
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;
});