#!/usr/bin/env python3 import importlib.machinery import importlib.util import json import os import subprocess import tarfile import tempfile import unittest from pathlib import Path 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" 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-release-20260716-003.tgz" ) EXPECTED_NODES = [ "n8n-nodes-ndc.ndcDataProductPublish", "n8n-nodes-ndc.ndcDataProductRead", "n8n-nodes-ndc.ndcFoundryBinding", ] EXPECTED_CREDENTIALS = [ "ndcDataProductWriterApi", "ndcDataProductReaderApi", "ndcFoundryBindingApi", ] def load_runner(): loader = importlib.machinery.SourceFileLoader("nodedc_engine_deploy_under_test", str(RUNNER_PATH)) spec = importlib.util.spec_from_loader(loader.name, loader) module = importlib.util.module_from_spec(spec) loader.exec_module(module) return module RUNNER = load_runner() class EngineN8nPrivateExtensionTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.temporary = tempfile.TemporaryDirectory(prefix="nodedc-engine-n8n-policy-") cls.root = Path(cls.temporary.name) cls.results = [] for index in range(2): output = cls.root / f"build-{index}" output.mkdir() env = os.environ.copy() env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output) env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT) result = subprocess.run( ["node", str(BUILDER_PATH)], cwd=PLATFORM_ROOT, env=env, check=True, capture_output=True, text=True, ) cls.results.append(json.loads(result.stdout)) @classmethod def tearDownClass(cls): cls.temporary.cleanup() def artifact(self, build_index, kind): return Path(self.results[build_index][kind]["artifact"]) def test_engine_artifacts_are_byte_reproducible(self): for kind in ("activation", "rollback"): first = self.artifact(0, kind).read_bytes() second = self.artifact(1, kind).read_bytes() self.assertEqual(first, second) self.assertEqual(self.results[0][kind]["sha256"], self.results[1][kind]["sha256"]) self.assertEqual(first[4:8], b"\0\0\0\0") self.assertEqual(first[3] & 0x08, 0) def test_activation_and_rollback_pass_strict_runner_policy(self): expected = {"activation": ("activate", 7), "rollback": ("rollback-inactive", 4)} for kind, (action, entry_count) in expected.items(): with self.subTest(kind=kind), tempfile.TemporaryDirectory() as directory: manifest, entries, payload = RUNNER.load_artifact( self.artifact(0, kind), Path(directory), ) descriptor = RUNNER.read_engine_n8n_transition_descriptor( payload / RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL ) self.assertEqual(manifest["component"], "engine") self.assertEqual(descriptor["action"], action) self.assertEqual(len(entries), entry_count) self.assertEqual(RUNNER.component_services("engine", entries), ("n8n",)) self.assertEqual(RUNNER.component_builds("engine", entries), ()) self.assertFalse(RUNNER.component_publish_dist("engine", entries)) def test_activation_catalog_is_exact_three_without_tool_variants(self): with tempfile.TemporaryDirectory() as directory: _manifest, _entries, payload = RUNNER.load_artifact( self.artifact(0, "activation"), Path(directory), ) nodes = json.loads((payload / RUNNER.ENGINE_N8N_NODES_CATALOG_REL).read_text()) credentials = json.loads((payload / RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL).read_text()) private_nodes = [item for item in nodes if item.get("name", "").startswith("n8n-nodes-ndc.")] private_credentials = [item for item in credentials if item.get("name") in EXPECTED_CREDENTIALS] 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)) def test_rollback_catalog_restores_verified_inactive_baseline(self): with tempfile.TemporaryDirectory() as directory: _manifest, _entries, payload = RUNNER.load_artifact( self.artifact(0, "rollback"), Path(directory), ) nodes = json.loads((payload / RUNNER.ENGINE_N8N_NODES_CATALOG_REL).read_text()) credentials = json.loads((payload / RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL).read_text()) self.assertEqual([item for item in nodes if item.get("name", "").startswith("n8n-nodes-ndc.")], []) self.assertEqual([item for item in credentials if item.get("name") in EXPECTED_CREDENTIALS], []) self.assertEqual((len(nodes), len(credentials)), (434, 385)) def test_compose_override_is_runner_derived_and_offline(self): with tempfile.TemporaryDirectory() as directory: _manifest, _entries, payload = RUNNER.load_artifact( self.artifact(0, "activation"), Path(directory), ) descriptor = RUNNER.read_engine_n8n_transition_descriptor( payload / RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL ) override = (payload / RUNNER.ENGINE_N8N_COMPOSE_OVERRIDE_REL).read_text() self.assertEqual(override, RUNNER.expected_engine_n8n_compose_override(descriptor)) self.assertIn("pull_policy: never", override) self.assertIn("N8N_USER_FOLDER: /home/node", override) self.assertIn(":/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc:ro", override) self.assertNotIn("N8N_CUSTOM_EXTENSIONS", override) self.assertNotIn("build:", override) def test_unknown_descriptor_key_is_rejected(self): with tempfile.TemporaryDirectory() as directory: work = Path(directory) RUNNER.safe_extract(self.artifact(0, "activation"), work) descriptor_path = work / "payload" / RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL descriptor = json.loads(descriptor_path.read_text()) descriptor["unexpected"] = True descriptor_path.write_text(json.dumps(descriptor)) with self.assertRaisesRegex(RUNNER.DeployError, "keys mismatch"): RUNNER.validate_engine_n8n_transition( work / "payload", RUNNER.parse_files_list(work / "files.txt"), ) def test_core_catalog_substitution_is_rejected_even_when_counts_match(self): cases = ( ("activation", RUNNER.ENGINE_N8N_NODES_CATALOG_REL, "node"), ("activation", RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL, "credential"), ("rollback", RUNNER.ENGINE_N8N_NODES_CATALOG_REL, "node"), ("rollback", RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL, "credential"), ) for kind, relative_path, label in cases: with self.subTest(kind=kind, catalog=label), tempfile.TemporaryDirectory() as directory: work = Path(directory) RUNNER.safe_extract(self.artifact(0, kind), work) catalog_path = work / "payload" / relative_path catalog = json.loads(catalog_path.read_text()) if label == "node": candidate = next( item for item in catalog if not item.get("name", "").startswith("n8n-nodes-ndc.") ) candidate["name"] = "n8n-nodes-unreviewed.hiddenNode" else: candidate = next( item for item in catalog if item.get("name") not in EXPECTED_CREDENTIALS ) candidate["name"] = "unreviewedCredential" catalog_path.write_text(json.dumps(catalog, indent=2) + "\n") with self.assertRaisesRegex(RUNNER.DeployError, "catalog sha256 mismatch"): RUNNER.validate_engine_n8n_transition( work / "payload", RUNNER.parse_files_list(work / "files.txt"), ) def test_old_engine_artifact_is_rejected_by_descriptor_gate(self): old = PLATFORM_ROOT / "infra/deploy-artifacts/nodedc-engine-n8n-private-extension-20260715-002.tgz" if not old.is_file(): self.skipTest("rejected historical artifact not present") with tempfile.TemporaryDirectory() as directory: with self.assertRaisesRegex(RUNNER.DeployError, "canonical transition descriptor"): RUNNER.load_artifact(old, Path(directory)) def test_engine_source_keeps_base_compose_and_separate_override(self): original_root = RUNNER.COMPONENTS["engine"]["payload_root"] original_compose = RUNNER.COMPONENTS["engine"]["compose_root"] try: RUNNER.COMPONENTS["engine"]["payload_root"] = ENGINE_ROOT RUNNER.COMPONENTS["engine"]["compose_root"] = ENGINE_ROOT RUNNER.validate_engine_n8n_base_compose_source() finally: RUNNER.COMPONENTS["engine"]["payload_root"] = original_root RUNNER.COMPONENTS["engine"]["compose_root"] = original_compose def test_additional_n8n_runtime_service_is_rejected(self): original_root = RUNNER.COMPONENTS["engine"]["payload_root"] original_compose = RUNNER.COMPONENTS["engine"]["compose_root"] try: with tempfile.TemporaryDirectory() as directory: engine_root = Path(directory) compose = (ENGINE_ROOT / "docker-compose.yml").read_text() compose += ( "\n n8n-worker-2:\n" " image: docker.n8n.io/n8nio/n8n:${N8N_IMAGE_TAG:-2.3.2}\n" ) (engine_root / "docker-compose.yml").write_text(compose) RUNNER.COMPONENTS["engine"]["payload_root"] = engine_root RUNNER.COMPONENTS["engine"]["compose_root"] = engine_root with self.assertRaisesRegex(RUNNER.DeployError, "exact service topology mismatch"): RUNNER.validate_engine_n8n_base_compose_source() finally: RUNNER.COMPONENTS["engine"]["payload_root"] = original_root RUNNER.COMPONENTS["engine"]["compose_root"] = original_compose def test_sealed_release_exact_set_includes_implicit_directories(self): release_relative = Path( "payload/releases/n8n-nodes-ndc/0.1.2-05e4b38b14b4a019" ) with tempfile.TemporaryDirectory() as directory: work = Path(directory) outer = work / "outer" RUNNER.safe_extract(STAGE_ARTIFACT, outer) staged_release = outer / release_relative sealed_release = work / "sealed" sealed_release.mkdir() for filename in ("package.tgz", "release.json", "rollback.json"): (sealed_release / filename).write_bytes( (staged_release / filename).read_bytes() ) expected_paths = { "package", "package.tgz", "release.json", "rollback.json", } with tarfile.open(staged_release / "package.tgz", "r:gz") as archive: for member in archive: RUNNER.add_engine_n8n_sealed_member_paths( expected_paths, member.name, ) target = sealed_release.joinpath(*Path(member.name).parts) if member.isdir(): target.mkdir(parents=True, exist_ok=True) continue target.parent.mkdir(parents=True, exist_ok=True) source = archive.extractfile(member) self.assertIsNotNone(source) target.write_bytes(source.read()) actual_paths = { path.relative_to(sealed_release).as_posix() for path in sealed_release.rglob("*") } self.assertEqual(actual_paths, expected_paths) self.assertIn("package/dist/nodes", expected_paths) self.assertIn("package/dist/credentials", expected_paths) def test_tar_members_have_no_appledouble_or_special_types(self): for kind in ("activation", "rollback"): with tarfile.open(self.artifact(0, kind), "r:gz") as archive: for member in archive: self.assertFalse(Path(member.name).name.startswith("._")) self.assertTrue(member.isfile() or member.isdir()) if __name__ == "__main__": unittest.main(verbosity=2)