feat(n8n): add rotating provider access credential
This commit is contained in:
parent
3c5d8f6cef
commit
0611a88971
|
|
@ -13,19 +13,30 @@ const engineRoot = resolve(platformRoot, "../NODEDC_ENGINE_INFRA");
|
|||
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(here, "../deploy-artifacts"));
|
||||
const stageArtifact = resolve(
|
||||
process.env.NODEDC_N8N_EXTENSION_STAGE_ARTIFACT
|
||||
|| join(artifactRoot, "nodedc-n8n-private-extension-n8n-nodes-ndc-history-read-20260717-004.tgz"),
|
||||
|| join(artifactRoot, "nodedc-n8n-private-extension-n8n-nodes-ndc-provider-rotating-access-20260718-006.tgz"),
|
||||
);
|
||||
const predecessorArtifact = resolve(
|
||||
process.env.NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT
|
||||
|| join(artifactRoot, "nodedc-engine-n8n-private-extension-20260717-005.tgz"),
|
||||
);
|
||||
|
||||
const stageArtifactSha256 = "a78f8250704e4893eab727cdb862b69ce45a84310fbb73ecd3dfd968b72d12ee";
|
||||
const releaseId = "0.1.3-3354149b5245e39a";
|
||||
const packageVersion = "0.1.3";
|
||||
const packageSha256 = "3354149b5245e39a10f6f03a4b43796602d7e57ea1f91dcc6cb6774ead97501d";
|
||||
const stageArtifactSha256 = "2699e53c482f2d2bec6d13709a7e9b9a0bb3791ad2018c142e9bc4e7bf371e4a";
|
||||
const predecessorArtifactSha256 = "f0f687ddcc1bc006db56c75e73c8213e99298c31c2d881cde078e5ddc9600172";
|
||||
const releaseId = "0.1.4-59dc9f7882721d6a";
|
||||
const packageVersion = "0.1.4";
|
||||
const packageSha256 = "59dc9f7882721d6a5e2ae84ffa6aa8cd9bc5432f5904d24e70e363573ed2757f";
|
||||
const n8nVersion = "2.3.2";
|
||||
const baseImage = "docker.n8n.io/n8nio/n8n:2.3.2";
|
||||
const architecture = "amd64";
|
||||
const generatedAt = "2026-07-17T21:00:00.000Z";
|
||||
const predecessorGitRevision = "96ad46e3c62943f818233481957c62e5088ae35c";
|
||||
const previouslyIssuedTransitionIds = new Set(["20260715-002", "20260716-003", "20260717-004"]);
|
||||
const generatedAt = "2026-07-18T21:00:00.000Z";
|
||||
const previouslyIssuedTransitionIds = new Set([
|
||||
"20260715-002",
|
||||
"20260716-003",
|
||||
"20260717-004",
|
||||
"20260717-005",
|
||||
"20260718-006",
|
||||
"20260718-007",
|
||||
]);
|
||||
const transitionId = readTransitionId(process.argv.slice(2), process.env.NODEDC_N8N_TRANSITION_ID);
|
||||
const activationId = `engine-n8n-private-extension-${transitionId}`;
|
||||
const rollbackId = `engine-n8n-private-extension-rollback-${transitionId}`;
|
||||
|
|
@ -51,7 +62,9 @@ const expectedCredentialTypes = [
|
|||
"ndcDataProductWriterApi",
|
||||
"ndcDataProductReaderApi",
|
||||
"ndcFoundryBindingApi",
|
||||
"ndcProviderRotatingAccessApi",
|
||||
];
|
||||
const predecessorCredentialTypes = expectedCredentialTypes.slice(0, 3);
|
||||
const nodeModules = [
|
||||
["dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", "NdcDataProductPublish"],
|
||||
["dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js", "NdcDataProductRead"],
|
||||
|
|
@ -61,12 +74,14 @@ const credentialModules = [
|
|||
["dist/credentials/NdcDataProductWriterApi.credentials.js", "NdcDataProductWriterApi"],
|
||||
["dist/credentials/NdcDataProductReaderApi.credentials.js", "NdcDataProductReaderApi"],
|
||||
["dist/credentials/NdcFoundryBindingApi.credentials.js", "NdcFoundryBindingApi"],
|
||||
["dist/credentials/NdcProviderRotatingAccessApi.credentials.js", "NdcProviderRotatingAccessApi"],
|
||||
];
|
||||
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${activationId}.tgz`));
|
||||
await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${rollbackId}.tgz`));
|
||||
assertSha(await readFile(stageArtifact), stageArtifactSha256, "staging artifact");
|
||||
assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, "predecessor artifact");
|
||||
assertEngineBaseline(await readFile(join(engineRoot, "docker-compose.yml"), "utf8"));
|
||||
|
||||
const work = await mkdtemp(join(tmpdir(), "nodedc-engine-n8n-sealed-"));
|
||||
|
|
@ -111,10 +126,10 @@ try {
|
|||
assertExact(privateNodes.map((item) => item.name), expectedNodeTypes, "node types");
|
||||
assertExact(privateCredentials.map((item) => item.name), expectedCredentialTypes, "credential types");
|
||||
|
||||
const predecessorDescriptor = JSON.parse(gitFile(descriptorRel));
|
||||
const predecessorNodes = JSON.parse(gitFile(nodesCatalogRel));
|
||||
const predecessorCredentials = JSON.parse(gitFile(credentialsCatalogRel));
|
||||
const predecessorMeta = JSON.parse(gitFile(metaRel));
|
||||
const predecessorDescriptor = JSON.parse(predecessorArtifactFile(descriptorRel));
|
||||
const predecessorNodes = JSON.parse(predecessorArtifactFile(nodesCatalogRel));
|
||||
const predecessorCredentials = JSON.parse(predecessorArtifactFile(credentialsCatalogRel));
|
||||
const predecessorMeta = JSON.parse(predecessorArtifactFile(metaRel));
|
||||
assertPredecessorCatalogs(
|
||||
predecessorDescriptor,
|
||||
predecessorNodes,
|
||||
|
|
@ -148,8 +163,18 @@ try {
|
|||
packageVersion: predecessorDescriptor.packageVersion,
|
||||
packageSha256: predecessorDescriptor.packageSha256,
|
||||
};
|
||||
const activationDescriptor = descriptor(target, predecessor.releaseId, predecessor.releaseId);
|
||||
const rollbackDescriptor = descriptor(predecessor, releaseId, releaseId);
|
||||
const activationDescriptor = descriptor(
|
||||
target,
|
||||
predecessor.releaseId,
|
||||
predecessor.releaseId,
|
||||
expectedCredentialTypes,
|
||||
);
|
||||
const rollbackDescriptor = descriptor(
|
||||
predecessor,
|
||||
releaseId,
|
||||
releaseId,
|
||||
predecessorCredentialTypes,
|
||||
);
|
||||
const override = composeOverride(target);
|
||||
const rollbackOverride = composeOverride(predecessor);
|
||||
|
||||
|
|
@ -204,7 +229,7 @@ try {
|
|||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function descriptor(target, expectedCurrent, rollbackBaseline) {
|
||||
function descriptor(target, expectedCurrent, rollbackBaseline, credentialTypes) {
|
||||
return {
|
||||
schemaVersion: "nodedc.engine-n8n-private-extension-transition/v1",
|
||||
action: "activate",
|
||||
|
|
@ -221,7 +246,7 @@ function descriptor(target, expectedCurrent, rollbackBaseline) {
|
|||
topologyServices: ["n8n"],
|
||||
expectedCurrent,
|
||||
expectedNodeTypes,
|
||||
expectedCredentialTypes,
|
||||
expectedCredentialTypes: credentialTypes,
|
||||
rollbackBaseline,
|
||||
};
|
||||
}
|
||||
|
|
@ -327,15 +352,15 @@ function assertPackage(value) {
|
|||
|
||||
function assertPredecessorCatalogs(descriptorValue, nodes, credentials, meta) {
|
||||
if (descriptorValue?.action !== "activate"
|
||||
|| descriptorValue?.releaseId !== "0.1.2-05e4b38b14b4a019"
|
||||
|| descriptorValue?.packageVersion !== "0.1.2"
|
||||
|| descriptorValue?.packageSha256 !== "05e4b38b14b4a019ce1f6eee27b9e320094cb3560903bd68074966b3a1267af5") {
|
||||
|| descriptorValue?.releaseId !== "0.1.3-3354149b5245e39a"
|
||||
|| descriptorValue?.packageVersion !== "0.1.3"
|
||||
|| descriptorValue?.packageSha256 !== "3354149b5245e39a10f6f03a4b43796602d7e57ea1f91dcc6cb6774ead97501d") {
|
||||
throw new Error("predecessor_descriptor_mismatch");
|
||||
}
|
||||
if (!Array.isArray(nodes) || nodes.length !== 437
|
||||
|| !Array.isArray(credentials) || credentials.length !== 388
|
||||
|| meta?.n8nVersion !== n8nVersion
|
||||
|| meta?.source !== "n8n-core+n8n-nodes-ndc@0.1.2"
|
||||
|| meta?.source !== "n8n-core+n8n-nodes-ndc@0.1.3"
|
||||
|| meta?.nodeCount !== 437
|
||||
|| meta?.credentialCount !== 388) {
|
||||
throw new Error("predecessor_catalog_mismatch");
|
||||
|
|
@ -346,8 +371,8 @@ function assertPredecessorCatalogs(descriptorValue, nodes, credentials, meta) {
|
|||
"predecessor node types",
|
||||
);
|
||||
assertExact(
|
||||
credentials.filter((item) => expectedCredentialTypes.includes(String(item?.name || ""))).map((item) => item.name),
|
||||
expectedCredentialTypes,
|
||||
credentials.filter((item) => predecessorCredentialTypes.includes(String(item?.name || ""))).map((item) => item.name),
|
||||
predecessorCredentialTypes,
|
||||
"predecessor credential types",
|
||||
);
|
||||
}
|
||||
|
|
@ -381,8 +406,17 @@ function assertSha(bytes, expected, label) {
|
|||
if (actual !== expected) throw new Error(`${label.replaceAll(" ", "_")}_sha256_mismatch:${actual}`);
|
||||
}
|
||||
|
||||
function gitFile(rel) {
|
||||
return run("git", ["show", `${predecessorGitRevision}:${rel}`], engineRoot).stdout;
|
||||
function predecessorArtifactFile(rel) {
|
||||
const script = [
|
||||
"import pathlib,sys,tarfile",
|
||||
"archive=pathlib.Path(sys.argv[1])",
|
||||
"member='payload/'+sys.argv[2]",
|
||||
"with tarfile.open(archive,'r:gz') as source:",
|
||||
" extracted=source.extractfile(member)",
|
||||
" if extracted is None: raise SystemExit('predecessor member missing')",
|
||||
" sys.stdout.buffer.write(extracted.read())",
|
||||
].join("\n");
|
||||
return run("python3", ["-c", script, predecessorArtifact, rel]).stdout;
|
||||
}
|
||||
|
||||
async function writeJson(path, value) {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ const platformRoot = resolve(scriptDir, "../..");
|
|||
const packageRoot = resolve(platformRoot, "packages/n8n-nodes-ndc");
|
||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||
const requireModule = createRequire(import.meta.url);
|
||||
const expectedPackageVersion = "0.1.3";
|
||||
const [patchId = "n8n-nodes-ndc-history-read-20260717-004", ...extra] = process.argv.slice(2);
|
||||
const expectedPackageVersion = "0.1.4";
|
||||
const [patchId = "n8n-nodes-ndc-provider-rotating-access-20260718-006", ...extra] = process.argv.slice(2);
|
||||
const expectedRuntimeNodes = [
|
||||
{
|
||||
file: "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
||||
|
|
@ -37,11 +37,13 @@ const expectedN8nCredentials = [
|
|||
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
||||
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
||||
"dist/credentials/NdcFoundryBindingApi.credentials.js",
|
||||
"dist/credentials/NdcProviderRotatingAccessApi.credentials.js",
|
||||
];
|
||||
const expectedCredentialTypes = [
|
||||
"ndcDataProductWriterApi",
|
||||
"ndcDataProductReaderApi",
|
||||
"ndcFoundryBindingApi",
|
||||
"ndcProviderRotatingAccessApi",
|
||||
];
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@ ENGINE_N8N_RELEASE_CATALOG_JSON_SHA256 = {
|
|||
"credentials": "a26824ffc0e15db857c792153de3417febc0dbba4880380d6c8cd544b3c80f4d",
|
||||
"meta": "b8aa60c0d919573626fa0694a1e4ae9ede015325a5d312e3e05d5ac293084aa7",
|
||||
},
|
||||
"0.1.4-59dc9f7882721d6a": {
|
||||
"nodes": "b0a5215699a6e691b8cfc505ab457d5632ef6d83adb3537520d0b60760ba16b2",
|
||||
"credentials": "af8ada839070a4c24b9b10981a751c2b2db651dced1392333d7a4ef13de4564e",
|
||||
"meta": "8fadbc594c5d14b1d53ab69b677f00823949410b9a37f4e43ecc321e89d68983",
|
||||
},
|
||||
}
|
||||
ENGINE_N8N_INACTIVE_NODES_CATALOG_JSON_SHA256 = "b70d9d8130d498c55de46a5d0758c844f1242b70803457b2291ab3a9de8056f2"
|
||||
ENGINE_N8N_INACTIVE_CREDENTIALS_CATALOG_JSON_SHA256 = "680e9f52aac791efbd38e3bd99bd51ef5cded9756d867897c6c2755850e87b50"
|
||||
|
|
@ -236,6 +241,7 @@ N8N_PRIVATE_EXTENSION_CREDENTIALS = (
|
|||
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
||||
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
||||
"dist/credentials/NdcFoundryBindingApi.credentials.js",
|
||||
"dist/credentials/NdcProviderRotatingAccessApi.credentials.js",
|
||||
)
|
||||
ENGINE_N8N_NODE_TYPES = (
|
||||
"n8n-nodes-ndc.ndcDataProductPublish",
|
||||
|
|
@ -246,7 +252,13 @@ ENGINE_N8N_CREDENTIAL_TYPES = (
|
|||
"ndcDataProductWriterApi",
|
||||
"ndcDataProductReaderApi",
|
||||
"ndcFoundryBindingApi",
|
||||
"ndcProviderRotatingAccessApi",
|
||||
)
|
||||
ENGINE_N8N_CREDENTIAL_TYPES_BY_RELEASE = {
|
||||
"0.1.2-05e4b38b14b4a019": ENGINE_N8N_CREDENTIAL_TYPES[:3],
|
||||
"0.1.3-3354149b5245e39a": ENGINE_N8N_CREDENTIAL_TYPES[:3],
|
||||
"0.1.4-59dc9f7882721d6a": ENGINE_N8N_CREDENTIAL_TYPES,
|
||||
}
|
||||
ENGINE_CREDENTIAL_SINK_ARTIFACT_ENTRIES = (
|
||||
"nodedc-source/server/credentialPolicies/ndcPrivateNode.js",
|
||||
"nodedc-source/server/credentialSink",
|
||||
|
|
@ -2191,6 +2203,13 @@ def is_engine_n8n_transition(component, entries):
|
|||
return component == "engine" and entries is not None and ENGINE_N8N_TRANSITION_DESCRIPTOR_REL in entries
|
||||
|
||||
|
||||
def engine_n8n_credential_types_for_release(release_id):
|
||||
expected = ENGINE_N8N_CREDENTIAL_TYPES_BY_RELEASE.get(release_id)
|
||||
if expected is None:
|
||||
die("Engine n8n credential catalog release is not registered")
|
||||
return expected
|
||||
|
||||
|
||||
def read_engine_n8n_transition_descriptor(path, label="Engine n8n transition descriptor"):
|
||||
descriptor = read_strict_json(path, label)
|
||||
require_exact_json_keys(
|
||||
|
|
@ -2259,7 +2278,9 @@ def read_engine_n8n_transition_descriptor(path, label="Engine n8n transition des
|
|||
die("Engine n8n activation rollback baseline mismatch")
|
||||
if descriptor.get("expectedNodeTypes") != list(ENGINE_N8N_NODE_TYPES):
|
||||
die("Engine n8n activation node type set mismatch")
|
||||
if descriptor.get("expectedCredentialTypes") != list(ENGINE_N8N_CREDENTIAL_TYPES):
|
||||
if descriptor.get("expectedCredentialTypes") != list(
|
||||
engine_n8n_credential_types_for_release(release_id)
|
||||
):
|
||||
die("Engine n8n activation credential type set mismatch")
|
||||
else:
|
||||
if descriptor.get("expectedCurrent") != release_id:
|
||||
|
|
@ -2332,7 +2353,11 @@ def validate_engine_n8n_catalog_payload(payload_dir, descriptor):
|
|||
if isinstance(item, dict) and str(item.get("name") or "") in ENGINE_N8N_CREDENTIAL_TYPES
|
||||
]
|
||||
expected_node_types = list(ENGINE_N8N_NODE_TYPES) if action == "activate" else []
|
||||
expected_credential_types = list(ENGINE_N8N_CREDENTIAL_TYPES) if action == "activate" else []
|
||||
expected_credential_types = (
|
||||
list(engine_n8n_credential_types_for_release(descriptor["releaseId"]))
|
||||
if action == "activate"
|
||||
else []
|
||||
)
|
||||
if [item.get("name") for item in private_nodes] != expected_node_types:
|
||||
die("Engine n8n pinned node catalog exact set mismatch")
|
||||
if [item.get("name") for item in private_credentials] != expected_credential_types:
|
||||
|
|
@ -2340,7 +2365,7 @@ def validate_engine_n8n_catalog_payload(payload_dir, descriptor):
|
|||
if any("usableAsTool" in item for item in private_nodes):
|
||||
die("Engine n8n pinned node catalog would generate tool variants")
|
||||
if action == "activate":
|
||||
if len(nodes) != 437 or len(credentials) != 388:
|
||||
if len(nodes) != 437 or len(credentials) != 385 + len(expected_credential_types):
|
||||
die("Engine n8n activation catalog count mismatch")
|
||||
if any(not str(item.get("displayName") or "").startswith("NDC ") for item in private_nodes):
|
||||
die("Engine n8n private node visible name mismatch")
|
||||
|
|
@ -5694,7 +5719,11 @@ def preflight_engine_n8n_transition(descriptor, enforce_expected_current):
|
|||
current_types = current_catalog["node_types"]
|
||||
current_credentials = current_catalog["credential_types"]
|
||||
expected_current_types = list(ENGINE_N8N_NODE_TYPES) if current_state != "verified_inactive" else []
|
||||
expected_current_credentials = list(ENGINE_N8N_CREDENTIAL_TYPES) if current_state != "verified_inactive" else []
|
||||
expected_current_credentials = (
|
||||
list(engine_n8n_credential_types_for_release(current_state))
|
||||
if current_state != "verified_inactive"
|
||||
else []
|
||||
)
|
||||
if current_types != expected_current_types:
|
||||
die("Engine n8n current live private-node set does not match its transition state")
|
||||
if current_credentials != expected_current_credentials:
|
||||
|
|
@ -5929,7 +5958,11 @@ def accept_engine_n8n_runtime(descriptor):
|
|||
die("Engine n8n private extension loader log acceptance failed")
|
||||
|
||||
expected_types = list(ENGINE_N8N_NODE_TYPES) if descriptor["action"] == "activate" else []
|
||||
expected_credentials = list(ENGINE_N8N_CREDENTIAL_TYPES) if descriptor["action"] == "activate" else []
|
||||
expected_credentials = (
|
||||
list(engine_n8n_credential_types_for_release(descriptor["releaseId"]))
|
||||
if descriptor["action"] == "activate"
|
||||
else []
|
||||
)
|
||||
loader_catalog = engine_n8n_private_loader_catalog(container_id)
|
||||
if loader_catalog["node_types"] != expected_types:
|
||||
die("Engine n8n live package-loader node catalog acceptance failed")
|
||||
|
|
|
|||
|
|
@ -16,17 +16,24 @@ from unittest import mock
|
|||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
||||
ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA"
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||
RUNNER_PATH = Path(
|
||||
os.environ.get("NODEDC_DEPLOY_RUNNER_UNDER_TEST", SCRIPT_DIR / "nodedc-deploy")
|
||||
).resolve()
|
||||
BUILDER_PATH = SCRIPT_DIR / "build-engine-n8n-private-extension-artifact.mjs"
|
||||
STAGE_ARTIFACT = (
|
||||
PLATFORM_ROOT
|
||||
/ "infra/deploy-artifacts"
|
||||
/ "nodedc-n8n-private-extension-n8n-nodes-ndc-history-read-20260717-004.tgz"
|
||||
/ "nodedc-n8n-private-extension-n8n-nodes-ndc-provider-rotating-access-20260718-006.tgz"
|
||||
)
|
||||
TRANSITION_ID = "20260717-005"
|
||||
SEALED_RELEASE_ID = "0.1.3-3354149b5245e39a"
|
||||
SEALED_PACKAGE_SHA256 = "3354149b5245e39a10f6f03a4b43796602d7e57ea1f91dcc6cb6774ead97501d"
|
||||
PREDECESSOR_RELEASE_ID = "0.1.2-05e4b38b14b4a019"
|
||||
PREDECESSOR_ARTIFACT = (
|
||||
PLATFORM_ROOT
|
||||
/ "infra/deploy-artifacts"
|
||||
/ "nodedc-engine-n8n-private-extension-20260717-005.tgz"
|
||||
)
|
||||
TRANSITION_ID = "20260718-008"
|
||||
SEALED_RELEASE_ID = "0.1.4-59dc9f7882721d6a"
|
||||
SEALED_PACKAGE_SHA256 = "59dc9f7882721d6a5e2ae84ffa6aa8cd9bc5432f5904d24e70e363573ed2757f"
|
||||
PREDECESSOR_RELEASE_ID = "0.1.3-3354149b5245e39a"
|
||||
BUILDER_ENGINE_PATHS = (
|
||||
"nodedc-source/server/assets/n8n/schema/v2.3.2/nodes.catalog.json",
|
||||
"nodedc-source/server/assets/n8n/schema/v2.3.2/credentials.catalog.json",
|
||||
|
|
@ -45,14 +52,16 @@ EXPECTED_CREDENTIALS = [
|
|||
"ndcDataProductWriterApi",
|
||||
"ndcDataProductReaderApi",
|
||||
"ndcFoundryBindingApi",
|
||||
"ndcProviderRotatingAccessApi",
|
||||
]
|
||||
PREDECESSOR_CREDENTIALS = EXPECTED_CREDENTIALS[:3]
|
||||
PRIVATE_EXTENSION_CANON_FUNCTION_SHA256 = {
|
||||
"expected_engine_n8n_compose_override": "8ee93f3e7c407f5557c711119236449a440d73a7fcac1bbfa50a09e6a13c1cff",
|
||||
"engine_n8n_package_loader_probe_script": "2b3b3c96fad6e5e48ebea74426b21bc7eea6b6fe0e786b537da3366d18aedd9b",
|
||||
"engine_n8n_private_loader_catalog": "a2ae389b4ef215900cf967b863d01056bd2a8eec55554566d0f6005e4e0bcbac",
|
||||
"validate_engine_n8n_base_compose_source": "adacc5b20e52684cbc427362ddba5a6d2e13ef2091a89859e2b8f7bbf2e20458",
|
||||
"preflight_engine_n8n_transition": "3d574acb081fb752e45cc0c3286d3ae4596dcee83a85e2c3fee81ac65ec796e0",
|
||||
"accept_engine_n8n_runtime": "1a2614a465161e3833e84aca402817682da1a538bdb2aecc4af66b665497338d",
|
||||
"preflight_engine_n8n_transition": "25bf33b3be0916a9dd82f064a710060eaf6bd7d87b3614ee0065af07ddbb446e",
|
||||
"accept_engine_n8n_runtime": "bc49275adb2d5ab4fd98abd07b9059a197d023e03b40145b6fc910b70b2f738b",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -107,6 +116,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
|||
env.pop("NODEDC_N8N_TRANSITION_ID", None)
|
||||
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output)
|
||||
env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT)
|
||||
env["NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT"] = str(PREDECESSOR_ARTIFACT)
|
||||
command = ["node", str(BUILDER_PATH)]
|
||||
if index == 0:
|
||||
command.append(TRANSITION_ID)
|
||||
|
|
@ -165,8 +175,8 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
|||
def test_transition_id_rejects_missing_invalid_and_previously_issued_values(self):
|
||||
cases = (
|
||||
([], "transition_id_required"),
|
||||
(["20260717-004"], "transition_id_already_issued"),
|
||||
(["engine-n8n-private-extension-20260717-005"], "transition_id_invalid"),
|
||||
(["20260718-007"], "transition_id_already_issued"),
|
||||
(["engine-n8n-private-extension-20260718-008"], "transition_id_invalid"),
|
||||
(["20260230-004"], "transition_id_invalid"),
|
||||
(["20260717-000"], "transition_id_invalid"),
|
||||
([TRANSITION_ID, "unexpected-second-id"], "transition_id_argument_count_invalid"),
|
||||
|
|
@ -191,6 +201,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
|||
env.pop("NODEDC_N8N_TRANSITION_ID", None)
|
||||
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(self.artifact(0, "activation").parent)
|
||||
env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT)
|
||||
env["NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT"] = str(PREDECESSOR_ARTIFACT)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER_PATH), TRANSITION_ID],
|
||||
cwd=PLATFORM_ROOT,
|
||||
|
|
@ -233,7 +244,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
|||
self.assertEqual([item["name"] for item in private_nodes], EXPECTED_NODES)
|
||||
self.assertEqual([item["name"] for item in private_credentials], EXPECTED_CREDENTIALS)
|
||||
self.assertTrue(all("usableAsTool" not in item for item in private_nodes))
|
||||
self.assertEqual((len(nodes), len(credentials)), (437, 388))
|
||||
self.assertEqual((len(nodes), len(credentials)), (437, 389))
|
||||
|
||||
def test_rollback_catalog_restores_verified_predecessor_release(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
@ -253,8 +264,8 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
|||
EXPECTED_NODES,
|
||||
)
|
||||
self.assertEqual(
|
||||
[item.get("name") for item in credentials if item.get("name") in EXPECTED_CREDENTIALS],
|
||||
EXPECTED_CREDENTIALS,
|
||||
[item.get("name") for item in credentials if item.get("name") in PREDECESSOR_CREDENTIALS],
|
||||
PREDECESSOR_CREDENTIALS,
|
||||
)
|
||||
self.assertEqual((len(nodes), len(credentials)), (437, 388))
|
||||
|
||||
|
|
@ -271,7 +282,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
|||
self.assertEqual(override, RUNNER.expected_engine_n8n_compose_override(descriptor))
|
||||
self.assertEqual(
|
||||
hashlib.sha256(override.encode("utf-8")).hexdigest(),
|
||||
"256142c14ae295bf9be4d74fdc8dcd6e134e4ded8a264f15e66b38e85503b589",
|
||||
"d6d1249e93b5bed5e697715b6b773c7efa4ac234f12eab57d868407e6bb5390e",
|
||||
)
|
||||
self.assertIn("pull_policy: never", override)
|
||||
self.assertIn("N8N_USER_FOLDER: /home/node", override)
|
||||
|
|
@ -563,7 +574,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
|||
|
||||
def test_sealed_release_exact_set_includes_implicit_directories(self):
|
||||
release_relative = Path(
|
||||
"payload/releases/n8n-nodes-ndc/0.1.3-3354149b5245e39a"
|
||||
"payload/releases/n8n-nodes-ndc/0.1.4-59dc9f7882721d6a"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
work = Path(directory)
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@ from pathlib import Path
|
|||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||
RUNNER_PATH = Path(
|
||||
os.environ.get("NODEDC_DEPLOY_RUNNER_UNDER_TEST", SCRIPT_DIR / "nodedc-deploy")
|
||||
).resolve()
|
||||
BUILDER_PATH = SCRIPT_DIR / "build-n8n-private-extension-artifact.mjs"
|
||||
PATCH_ID = "n8n-nodes-ndc-history-read-20260717-004"
|
||||
PATCH_ID = "n8n-nodes-ndc-provider-rotating-access-20260718-006"
|
||||
EXPECTED_NODES = [
|
||||
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
||||
"dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js",
|
||||
|
|
@ -32,11 +34,13 @@ EXPECTED_CREDENTIALS = [
|
|||
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
||||
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
||||
"dist/credentials/NdcFoundryBindingApi.credentials.js",
|
||||
"dist/credentials/NdcProviderRotatingAccessApi.credentials.js",
|
||||
]
|
||||
EXPECTED_CREDENTIAL_TYPES = [
|
||||
"ndcDataProductWriterApi",
|
||||
"ndcDataProductReaderApi",
|
||||
"ndcFoundryBindingApi",
|
||||
"ndcProviderRotatingAccessApi",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -106,7 +110,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase):
|
|||
manifest, entries, _payload = RUNNER.load_artifact(self.artifacts[0], Path(directory))
|
||||
self.assertEqual(manifest["component"], "n8n-private-extension")
|
||||
self.assertEqual(len(entries), 1)
|
||||
self.assertRegex(entries[0], r"^releases/n8n-nodes-ndc/0\.1\.3-[a-f0-9]{16}$")
|
||||
self.assertRegex(entries[0], r"^releases/n8n-nodes-ndc/0\.1\.4-[a-f0-9]{16}$")
|
||||
|
||||
with tarfile.open(self.artifacts[0], "r:gz") as archive:
|
||||
names = archive.getnames()
|
||||
|
|
@ -118,7 +122,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase):
|
|||
with tarfile.open(fileobj=io.BytesIO(package), mode="r:gz") as archive:
|
||||
package_json_member = archive.getmember("package/package.json")
|
||||
package_json = json.loads(archive.extractfile(package_json_member).read())
|
||||
self.assertEqual(package_json["version"], "0.1.3")
|
||||
self.assertEqual(package_json["version"], "0.1.4")
|
||||
self.assertEqual(package_json["n8n"]["nodes"], EXPECTED_NODES)
|
||||
self.assertEqual(package_json["n8n"]["credentials"], EXPECTED_CREDENTIALS)
|
||||
for node_path in EXPECTED_NODES:
|
||||
|
|
@ -154,7 +158,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase):
|
|||
"requiresPreActivationVerification": True,
|
||||
}
|
||||
self.assertEqual(release["schemaVersion"], "nodedc.n8n-private-extension-release/v2")
|
||||
self.assertEqual(release["package"]["version"], "0.1.3")
|
||||
self.assertEqual(release["package"]["version"], "0.1.4")
|
||||
self.assertEqual(release["activation"]["rollbackBaselinePolicy"], expected_policy)
|
||||
self.assertEqual(rollback["schemaVersion"], "nodedc.n8n-private-extension-rollback/v2")
|
||||
self.assertEqual(rollback["baselinePolicy"], expected_policy)
|
||||
|
|
|
|||
|
|
@ -30,8 +30,16 @@ The defaults are `http://external-data-plane:18106` and
|
|||
`http://nodedc-module-foundry:3333`. They are provider-neutral Platform service
|
||||
addresses, not workflow configuration.
|
||||
|
||||
The three credential types contain only one password-protected opaque
|
||||
capability. Writer, reader, and Foundry capabilities are intentionally distinct.
|
||||
The writer, reader, and Foundry credential types contain only one
|
||||
password-protected opaque capability and are intentionally distinct. The
|
||||
separate `NDC Provider Rotating Access API` credential keeps the Gelios REST access
|
||||
and refresh pair inside native Engine Credentials. It exchanges the refresh
|
||||
token only against the fixed Gelios refresh endpoint, persists both rotated
|
||||
tokens through the supported expirable-credential lifecycle, and injects only
|
||||
the current access token into provider requests. Concurrent refresh attempts in
|
||||
the single-service L2 runtime are coalesced and briefly replay the same rotated
|
||||
pair so a stale caller cannot immediately spend the invalidated predecessor
|
||||
refresh token again.
|
||||
Provider identity, product version, ontology revision, persistence policy, and
|
||||
tenant scope are materialized by the receiving service from the grant. The node
|
||||
never accepts them from a workflow.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
IHttpRequestHelper,
|
||||
IHttpRequestOptions,
|
||||
INodeProperties,
|
||||
Icon,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
const GELIOS_API_BASE_URL = 'https://api.geliospro.com';
|
||||
const GELIOS_REFRESH_URL = `${GELIOS_API_BASE_URL}/api/v1/auth/refresh`;
|
||||
const RECENT_ROTATION_TTL_MS = 30_000;
|
||||
const MAX_RECENT_ROTATIONS = 64;
|
||||
|
||||
interface RotatedTokens extends ICredentialDataDecryptedObject {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessExpiresAt: string;
|
||||
}
|
||||
|
||||
interface RecentRotation {
|
||||
expiresAt: number;
|
||||
tokens: RotatedTokens;
|
||||
}
|
||||
|
||||
const refreshInFlight = new Map<string, Promise<RotatedTokens>>();
|
||||
const recentRotations = new Map<string, RecentRotation>();
|
||||
|
||||
export class NdcProviderRotatingAccessApi implements ICredentialType {
|
||||
name = 'ndcProviderRotatingAccessApi';
|
||||
displayName = 'NDC Provider Rotating Access API';
|
||||
icon: Icon = {
|
||||
light: 'file:../icons/ndc.svg',
|
||||
dark: 'file:../icons/ndc.dark.svg',
|
||||
};
|
||||
documentationUrl = '';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Refresh Token',
|
||||
name: 'refreshToken',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'Rotating refresh token issued by the approved provider profile',
|
||||
},
|
||||
{
|
||||
displayName: 'Access Token',
|
||||
name: 'accessToken',
|
||||
type: 'hidden',
|
||||
typeOptions: {
|
||||
expirable: true,
|
||||
password: true,
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Access Token Expires At',
|
||||
name: 'accessExpiresAt',
|
||||
type: 'hidden',
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
async preAuthentication(
|
||||
this: IHttpRequestHelper,
|
||||
credentials: ICredentialDataDecryptedObject,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
const refreshToken = credentials.refreshToken;
|
||||
if (typeof refreshToken !== 'string' || refreshToken.trim() === '') {
|
||||
throw new Error('provider_refresh_token_required');
|
||||
}
|
||||
|
||||
const key = createHash('sha256').update(refreshToken, 'utf8').digest('hex');
|
||||
const now = Date.now();
|
||||
pruneRecentRotations(now);
|
||||
const recent = recentRotations.get(key);
|
||||
if (recent && recent.expiresAt > now) return { ...recent.tokens };
|
||||
|
||||
let pending = refreshInFlight.get(key);
|
||||
if (!pending) {
|
||||
pending = rotateTokens(this, refreshToken)
|
||||
.then((tokens) => {
|
||||
rememberRotation(key, tokens);
|
||||
return tokens;
|
||||
})
|
||||
.finally(() => refreshInFlight.delete(key));
|
||||
refreshInFlight.set(key, pending);
|
||||
}
|
||||
|
||||
return { ...(await pending) };
|
||||
}
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
Authorization: '=Bearer {{$credentials.accessToken}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
get test(): ICredentialTestRequest {
|
||||
return {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: `${GELIOS_API_BASE_URL}/api/v1/auth`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateTokens(
|
||||
helper: IHttpRequestHelper,
|
||||
refreshToken: string,
|
||||
): Promise<RotatedTokens> {
|
||||
let response: unknown;
|
||||
try {
|
||||
response = await helper.helpers.httpRequest({
|
||||
method: 'POST',
|
||||
url: GELIOS_REFRESH_URL,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { refresh_token: refreshToken },
|
||||
json: true,
|
||||
} satisfies IHttpRequestOptions);
|
||||
} catch {
|
||||
throw new Error('provider_access_refresh_failed');
|
||||
}
|
||||
|
||||
if (!isRecord(response)) throw new Error('provider_access_refresh_failed');
|
||||
const accessToken = response.access_token;
|
||||
const nextRefreshToken = response.refresh_token;
|
||||
const tokenType = response.token_type;
|
||||
const expiresIn = response.expires_in;
|
||||
if (
|
||||
typeof accessToken !== 'string'
|
||||
|| accessToken.trim() === ''
|
||||
|| typeof nextRefreshToken !== 'string'
|
||||
|| nextRefreshToken.trim() === ''
|
||||
|| typeof tokenType !== 'string'
|
||||
|| tokenType.toLowerCase() !== 'bearer'
|
||||
|| !Number.isSafeInteger(expiresIn)
|
||||
|| Number(expiresIn) <= 0
|
||||
) {
|
||||
throw new Error('provider_access_refresh_failed');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: nextRefreshToken,
|
||||
accessExpiresAt: new Date(Date.now() + Number(expiresIn) * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function pruneRecentRotations(now: number): void {
|
||||
for (const [key, value] of recentRotations) {
|
||||
if (value.expiresAt <= now) recentRotations.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberRotation(key: string, tokens: RotatedTokens): void {
|
||||
while (recentRotations.size >= MAX_RECENT_ROTATIONS) {
|
||||
const oldest = recentRotations.keys().next().value as string | undefined;
|
||||
if (!oldest) break;
|
||||
recentRotations.delete(oldest);
|
||||
}
|
||||
recentRotations.set(key, {
|
||||
expiresAt: Date.now() + RECENT_ROTATION_TTL_MS,
|
||||
tokens: { ...tokens },
|
||||
});
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "n8n-nodes-ndc",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "n8n-nodes-ndc",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"license": "UNLICENSED",
|
||||
"devDependencies": {
|
||||
"@n8n/node-cli": "0.39.3",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-nodes-ndc",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"description": "Private NODE.DC nodes for scoped data products and Foundry bindings.",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
|
|
@ -24,7 +24,8 @@
|
|||
"credentials": [
|
||||
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
||||
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
||||
"dist/credentials/NdcFoundryBindingApi.credentials.js"
|
||||
"dist/credentials/NdcFoundryBindingApi.credentials.js",
|
||||
"dist/credentials/NdcProviderRotatingAccessApi.credentials.js"
|
||||
],
|
||||
"nodes": [
|
||||
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ const nodeSpecs = [
|
|||
['NdcFoundryBinding', 'ndcFoundryBinding', 'NDC Foundry Binding'],
|
||||
];
|
||||
const credentialSpecs = [
|
||||
['NdcDataProductWriterApi', 'ndcDataProductWriterApi'],
|
||||
['NdcDataProductReaderApi', 'ndcDataProductReaderApi'],
|
||||
['NdcFoundryBindingApi', 'ndcFoundryBindingApi'],
|
||||
['NdcDataProductWriterApi', 'ndcDataProductWriterApi', 'opaque'],
|
||||
['NdcDataProductReaderApi', 'ndcDataProductReaderApi', 'opaque'],
|
||||
['NdcFoundryBindingApi', 'ndcFoundryBindingApi', 'opaque'],
|
||||
['NdcProviderRotatingAccessApi', 'ndcProviderRotatingAccessApi', 'rotating'],
|
||||
];
|
||||
|
||||
const forbiddenNodeParameter = /(url|provider|tenant|connection|token|secret|password|credential)/i;
|
||||
|
|
@ -67,19 +68,34 @@ async function main() {
|
|||
assertProductSurfaceHasNdcBrand(node.description, className);
|
||||
}
|
||||
|
||||
for (const [className, internalName] of credentialSpecs) {
|
||||
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,
|
||||
|
|
@ -87,6 +103,8 @@ async function main() {
|
|||
}, 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`);
|
||||
|
|
@ -187,6 +205,57 @@ async function main() {
|
|||
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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue