test_phase3_wp04_delivery_contract.py 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165
  1. """Delivery contracts for the P3-WP04 enterprise edge engineering baseline."""
  2. from __future__ import annotations
  3. import fcntl
  4. import hashlib
  5. import importlib.util
  6. import json
  7. import os
  8. import shutil
  9. import stat
  10. import subprocess
  11. import sys
  12. import tarfile
  13. from datetime import UTC, datetime, timedelta
  14. from pathlib import Path
  15. from types import SimpleNamespace
  16. import jsonschema
  17. import pytest
  18. import yaml
  19. from cryptography import x509
  20. from cryptography.hazmat.primitives import hashes, serialization
  21. from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
  22. from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
  23. from app.core.edge_gateway.contracts import canonical_json_bytes, canonical_sha256
  24. ROOT = Path(__file__).resolve().parents[1]
  25. CLI = ROOT / "scripts" / "edge_gateway_admin.py"
  26. COMPOSE = ROOT / "deploy" / "edge" / "docker-compose.edge.yml"
  27. ENV_EXAMPLE = ROOT / "deploy" / "edge" / "edge.env.example"
  28. OPENAPI = ROOT / "docs" / "architecture" / "OPENAPI.yaml"
  29. FAILURES = ROOT / "docs" / "validation" / "P3_WP04_FAILURE_INJECTION.json"
  30. EDGE_CONFIG_EXAMPLE = ROOT / "deploy" / "edge" / "edge.config.example.json"
  31. EDGE_CONFIG_SCHEMA = ROOT / "deploy" / "edge" / "edge.config.schema.json"
  32. RELEASE_SECRET_SCANNER = ROOT / "deployment" / "scripts" / "scan_release_secrets.py"
  33. def _load_cli_module():
  34. spec = importlib.util.spec_from_file_location(
  35. f"edge_gateway_admin_test_{os.getpid()}_{id(object())}", CLI
  36. )
  37. assert spec is not None and spec.loader is not None
  38. module = importlib.util.module_from_spec(spec)
  39. spec.loader.exec_module(module)
  40. return module
  41. def _run(*args: str, expect: int = 0) -> tuple[dict[str, object], str]:
  42. completed = subprocess.run(
  43. [sys.executable, str(CLI), *args],
  44. cwd=ROOT,
  45. capture_output=True,
  46. text=True,
  47. check=False,
  48. env={**os.environ, "PYTHONPATH": str(ROOT)},
  49. )
  50. assert completed.returncode == expect, completed.stderr
  51. payload = json.loads(completed.stdout)
  52. return payload, completed.stderr
  53. def _init(root: Path) -> dict[str, object]:
  54. return _run(
  55. "init", "--root", str(root), "--runtime-uid", str(os.getuid()),
  56. "--runtime-gid", str(os.getgid()),
  57. )[0]
  58. def _certificate_for(
  59. key_path: Path, certificate_path: Path, ca_path: Path, crl_path: Path
  60. ) -> str:
  61. key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
  62. ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  63. ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "edge-test-ca")])
  64. now = datetime.now(UTC)
  65. ca = (
  66. x509.CertificateBuilder()
  67. .subject_name(ca_name)
  68. .issuer_name(ca_name)
  69. .public_key(ca_key.public_key())
  70. .serial_number(x509.random_serial_number())
  71. .not_valid_before(now - timedelta(minutes=1))
  72. .not_valid_after(now + timedelta(days=365))
  73. .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
  74. .sign(ca_key, hashes.SHA256())
  75. )
  76. name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "edge-test")])
  77. certificate = (
  78. x509.CertificateBuilder()
  79. .subject_name(name)
  80. .issuer_name(ca.subject)
  81. .public_key(key.public_key())
  82. .serial_number(x509.random_serial_number())
  83. .not_valid_before(now - timedelta(minutes=1))
  84. .not_valid_after(now + timedelta(days=30))
  85. .add_extension(
  86. x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=True
  87. )
  88. .sign(ca_key, hashes.SHA256())
  89. )
  90. certificate_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
  91. ca_path.write_bytes(ca.public_bytes(serialization.Encoding.PEM))
  92. crl = (
  93. x509.CertificateRevocationListBuilder()
  94. .issuer_name(ca.subject)
  95. .last_update(now - timedelta(minutes=1))
  96. .next_update(now + timedelta(days=7))
  97. .sign(ca_key, hashes.SHA256())
  98. )
  99. crl_path.write_bytes(crl.public_bytes(serialization.Encoding.PEM))
  100. return certificate.fingerprint(hashes.SHA256()).hex()
  101. def _release_manifest(
  102. private: ed25519.Ed25519PrivateKey,
  103. *,
  104. release_id: str,
  105. version: str,
  106. rollback_version: str,
  107. artifact: Path,
  108. status: str = "installed",
  109. ) -> dict[str, object]:
  110. unsigned = {
  111. "artifact_digest": hashlib.sha256(artifact.read_bytes()).hexdigest(),
  112. "artifact_name": artifact.name,
  113. "deadline_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat().replace("+00:00", "Z"),
  114. "key_id": "release-key-1",
  115. "release_id": release_id,
  116. "rollback_version": rollback_version,
  117. "signature_algorithm": "Ed25519",
  118. "status": status,
  119. "version": version,
  120. }
  121. return {
  122. **unsigned,
  123. "manifest_digest": canonical_sha256(unsigned),
  124. "signature": private.sign(canonical_json_bytes(unsigned)).hex(),
  125. }
  126. def _verify_release(
  127. root: Path, manifest: dict[str, object], artifact: Path, public_hex: str
  128. ) -> dict[str, object]:
  129. source = root / "releases" / f"source-{manifest['release_id']}.json"
  130. source.write_text(json.dumps(manifest))
  131. return _run(
  132. "release-check", "--root", str(root), "--manifest", str(source),
  133. "--artifact", str(artifact), "--trusted-key", f"release-key-1={public_hex}",
  134. "--current-version", str(manifest["rollback_version"]),
  135. )[0]
  136. def _signed_status(
  137. private: ed25519.Ed25519PrivateKey,
  138. *,
  139. gateway_id: str = "gw-test",
  140. generation: int = 1,
  141. certificate_sha256: str = "b" * 64,
  142. status: str = "revoked",
  143. expires_delta: timedelta = timedelta(hours=1),
  144. ) -> dict[str, object]:
  145. unsigned = {
  146. "certificate_sha256": certificate_sha256,
  147. "expires_at": (datetime.now(UTC) + expires_delta).isoformat().replace("+00:00", "Z"),
  148. "gateway_id": gateway_id,
  149. "generation": generation,
  150. "issued_at": (datetime.now(UTC) - timedelta(seconds=1)).isoformat().replace("+00:00", "Z"),
  151. "key_id": "status-key-1",
  152. "signature_algorithm": "Ed25519",
  153. "status": status,
  154. }
  155. return {
  156. **unsigned,
  157. "envelope_digest": canonical_sha256(unsigned),
  158. "signature": private.sign(canonical_json_bytes(unsigned)).hex(),
  159. }
  160. def test_admin_init_csr_and_registration_material_never_expose_private_key(tmp_path):
  161. root = tmp_path / "edge-root"
  162. initialized = _init(root)
  163. init_stderr = ""
  164. assert initialized["status"] == "initialized"
  165. assert stat.S_IMODE(root.stat().st_mode) == 0o700
  166. assert {"queue", "artifacts", "releases", "secrets", "certificates"} <= {
  167. path.name for path in root.iterdir()
  168. }
  169. created, csr_stderr = _run(
  170. "csr", "--root", str(root), "--common-name", "edge-test"
  171. )
  172. key_path = Path(str(created["private_key_path"]))
  173. csr_path = Path(str(created["csr_path"]))
  174. assert key_path.is_file() and csr_path.is_file()
  175. assert stat.S_IMODE(key_path.stat().st_mode) == 0o600
  176. assert x509.load_pem_x509_csr(csr_path.read_bytes()).is_signature_valid
  177. certificate_path = root / "certificates" / "client.pem"
  178. ca_path = root / "certificates" / "client-ca.pem"
  179. crl_path = root / "certificates" / "client-ca.crl.pem"
  180. digest = _certificate_for(key_path, certificate_path, ca_path, crl_path)
  181. registered, register_stderr = _run(
  182. "register-manifest",
  183. "--root", str(root),
  184. "--certificate", str(certificate_path),
  185. "--ca-bundle", str(ca_path),
  186. "--crl", str(crl_path),
  187. "--gateway-id", "gw-test",
  188. "--environment", "production",
  189. "--network-zone", "zone-a",
  190. "--policy-digest", "a" * 64,
  191. "--control-origin", "https://control.example.test",
  192. )
  193. manifest_path = root / "registration" / "registration.json"
  194. assert registered["certificate_sha256"] == digest
  195. registration = json.loads(manifest_path.read_text())
  196. assert registration["certificate_sha256"] == digest
  197. combined = "\n".join(
  198. [json.dumps(initialized), json.dumps(created), json.dumps(registered),
  199. init_stderr, csr_stderr, register_stderr, manifest_path.read_text()]
  200. )
  201. assert "BEGIN PRIVATE KEY" not in combined
  202. assert key_path.read_text() not in combined
  203. assert "credential" not in registration
  204. assert not any("path" in key for key in registration)
  205. assert registration["ca_chain_verified"] is True
  206. assert registration["crl_verified"] is True
  207. assert registered["replayed"] is False
  208. replayed, _ = _run(
  209. "register-manifest", "--root", str(root),
  210. "--certificate", str(certificate_path), "--ca-bundle", str(ca_path),
  211. "--crl", str(crl_path), "--gateway-id", "gw-test",
  212. "--environment", "production", "--network-zone", "zone-a",
  213. "--policy-digest", "a" * 64,
  214. "--control-origin", "https://control.example.test",
  215. )
  216. assert replayed["replayed"] is True
  217. conflict, _ = _run(
  218. "register-manifest", "--root", str(root),
  219. "--certificate", str(certificate_path), "--ca-bundle", str(ca_path),
  220. "--crl", str(crl_path), "--gateway-id", "gw-other",
  221. "--environment", "production", "--network-zone", "zone-a",
  222. "--policy-digest", "a" * 64,
  223. "--control-origin", "https://control.example.test", expect=3,
  224. )
  225. assert conflict["error"]["code"] == "IDEMPOTENCY_CONFLICT"
  226. def test_init_rejects_broad_or_nonempty_roots_without_mutation(tmp_path):
  227. spec = importlib.util.spec_from_file_location("edge_gateway_admin_root_test", CLI)
  228. assert spec is not None and spec.loader is not None
  229. admin = importlib.util.module_from_spec(spec)
  230. spec.loader.exec_module(admin)
  231. protected = [Path("/"), Path.home(), Path("/opt"), Path("/var"), Path("/usr"),
  232. Path("/etc"), Path("/tmp")]
  233. snapshots = {
  234. path: (path.stat().st_mode, path.stat().st_uid, path.stat().st_gid)
  235. for path in protected if path.exists()
  236. }
  237. for path in snapshots:
  238. with pytest.raises(admin.AdminError, match="PATH_POLICY"):
  239. admin._root(str(path), create=False)
  240. assert snapshots == {
  241. path: (path.stat().st_mode, path.stat().st_uid, path.stat().st_gid)
  242. for path in snapshots
  243. }
  244. nonempty = tmp_path / "existing-nonempty"
  245. nonempty.mkdir()
  246. sentinel = nonempty / "do-not-touch.txt"
  247. sentinel.write_text("preserve")
  248. rejected, _ = _run(
  249. "init", "--root", str(nonempty), "--runtime-uid", str(os.getuid()),
  250. "--runtime-gid", str(os.getgid()), expect=2,
  251. )
  252. assert rejected["error"]["code"] == "PATH_POLICY"
  253. assert sentinel.read_text() == "preserve"
  254. assert set(nonempty.iterdir()) == {sentinel}
  255. def test_mutating_commands_share_fail_closed_process_lock(tmp_path):
  256. root = tmp_path / "edge-root"
  257. _init(root)
  258. lock_path = root / "state" / "admin.lock"
  259. descriptor = os.open(
  260. lock_path,
  261. os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
  262. 0o600,
  263. )
  264. try:
  265. fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
  266. blocked, _ = _run(
  267. "csr", "--root", str(root), "--common-name", "edge-test", expect=3,
  268. )
  269. assert blocked["error"]["code"] == "CONCURRENT_OPERATION"
  270. assert not (root / "secrets" / "client.key").exists()
  271. finally:
  272. os.close(descriptor)
  273. status_payload, _ = _run("status", "--root", str(root))
  274. assert status_payload["status"] == "read_only"
  275. assert status_payload["active_leases"] == 0
  276. rendered = json.dumps(status_payload)
  277. assert str(root) not in rendered
  278. assert "credential" not in rendered.casefold()
  279. @pytest.mark.parametrize(
  280. "version",
  281. ["1.0", "1.0.0-alpha", "1.0.0+build", "01.0.0", "2147483648.0.0"],
  282. )
  283. def test_release_rejects_noncanonical_or_unbounded_versions(tmp_path, version):
  284. root = tmp_path / "edge-root"
  285. _init(root)
  286. artifact = root / "releases" / "edge.bin"
  287. artifact.write_bytes(b"version policy")
  288. key = ed25519.Ed25519PrivateKey.generate()
  289. public_hex = key.public_key().public_bytes(
  290. serialization.Encoding.Raw, serialization.PublicFormat.Raw
  291. ).hex()
  292. manifest = _release_manifest(
  293. key, release_id="version-policy", version=version,
  294. rollback_version="0.9.0", artifact=artifact, status="offered",
  295. )
  296. source = root / "releases" / "version-policy.json"
  297. source.write_text(json.dumps(manifest))
  298. rejected, _ = _run(
  299. "release-check", "--root", str(root), "--manifest", str(source),
  300. "--artifact", str(artifact), "--trusted-key", f"release-key-1={public_hex}",
  301. "--current-version", "0.9.0", expect=3,
  302. )
  303. assert rejected["error"]["code"] == "RELEASE_REJECTED"
  304. assert not list((root / "releases" / "verified").glob(".bundle-*"))
  305. @pytest.mark.parametrize(
  306. "version", ["1.0", "1.0.0-alpha", "1.0.0+build", "1.2.3.4", "01.0.0", "2147483648.0.0"],
  307. )
  308. def test_control_plane_release_version_matches_edge_cli_policy(version):
  309. from app.core.edge_gateway.service import (
  310. EdgeGatewayValidationError,
  311. _release_version,
  312. )
  313. with pytest.raises(EdgeGatewayValidationError):
  314. _release_version(version, "version")
  315. def test_admin_atomic_outputs_inherit_runtime_parent_owner(monkeypatch, tmp_path):
  316. module = _load_cli_module()
  317. parent = tmp_path / "runtime-owned"
  318. parent.mkdir(mode=0o700)
  319. directory_chowns: list[tuple[Path, int, int]] = []
  320. descriptor_chowns: list[tuple[int, int, int]] = []
  321. monkeypatch.setattr(module.os, "geteuid", lambda: 0)
  322. monkeypatch.setattr(
  323. module.os, "chown",
  324. lambda path, uid, gid, **_kwargs: directory_chowns.append((Path(path), uid, gid)),
  325. )
  326. monkeypatch.setattr(
  327. module.os, "fchown",
  328. lambda fd, uid, gid: descriptor_chowns.append((fd, uid, gid)),
  329. )
  330. child = module._mkdir(parent, "state")
  331. expected = (parent.stat().st_uid, parent.stat().st_gid)
  332. assert directory_chowns == [(child, *expected)]
  333. monkeypatch.setattr(
  334. module.os, "fstat", lambda _fd: SimpleNamespace(st_uid=-1, st_gid=-1)
  335. )
  336. module._inherit_parent_owner(99, parent)
  337. assert any((uid, gid) == expected for _fd, uid, gid in descriptor_chowns)
  338. def test_release_check_rejects_hardlinked_artifact(tmp_path):
  339. root = tmp_path / "edge-root"
  340. _init(root)
  341. original = root / "releases" / "original.bin"
  342. original.write_bytes(b"hardlink must fail")
  343. artifact = root / "releases" / "edge.bin"
  344. os.link(original, artifact)
  345. key = ed25519.Ed25519PrivateKey.generate()
  346. public_hex = key.public_key().public_bytes(
  347. serialization.Encoding.Raw, serialization.PublicFormat.Raw
  348. ).hex()
  349. manifest = _release_manifest(
  350. key, release_id="hardlink-release", version="1.0.0",
  351. rollback_version="0.9.0", artifact=artifact, status="offered",
  352. )
  353. source = root / "releases" / "hardlink.json"
  354. source.write_text(json.dumps(manifest))
  355. rejected, _ = _run(
  356. "release-check", "--root", str(root), "--manifest", str(source),
  357. "--artifact", str(artifact), "--trusted-key", f"release-key-1={public_hex}",
  358. "--current-version", "0.9.0", expect=2,
  359. )
  360. assert rejected["error"]["code"] == "PATH_POLICY"
  361. def test_admin_requires_absolute_root_and_rejects_symlink_escape(tmp_path):
  362. payload, _ = _run(
  363. "init", "--root", "relative-edge-root", "--runtime-uid", str(os.getuid()),
  364. "--runtime-gid", str(os.getgid()), expect=2,
  365. )
  366. assert payload == {"error": {"code": "INVALID_INPUT"}, "status": "error"}
  367. root = tmp_path / "root"
  368. outside = tmp_path / "outside"
  369. outside.mkdir()
  370. root.mkdir()
  371. (root / "secrets").symlink_to(outside, target_is_directory=True)
  372. payload, _ = _run(
  373. "init", "--root", str(root), "--runtime-uid", str(os.getuid()),
  374. "--runtime-gid", str(os.getgid()), expect=2,
  375. )
  376. assert payload["error"]["code"] == "PATH_POLICY"
  377. mismatched = tmp_path / "owner-mismatch"
  378. owner_error, _ = _run(
  379. "init", "--root", str(mismatched),
  380. "--runtime-uid", str(os.getuid() + 1),
  381. "--runtime-gid", str(os.getgid()), expect=2,
  382. )
  383. assert owner_error["error"]["code"] == "OWNER_POLICY"
  384. def test_rotate_revoke_and_rollback_are_idempotent_and_fail_closed(
  385. tmp_path, monkeypatch
  386. ):
  387. root = tmp_path / "edge-root"
  388. _init(root)
  389. rotated, _ = _run(
  390. "rotate", "--root", str(root), "--common-name", "edge-test",
  391. "--request-id", "rotate-1",
  392. "--expected-current-certificate-digest", "b" * 64,
  393. )
  394. assert rotated["status"] == "pending_server_approval"
  395. assert Path(str(rotated["private_key_path"])).stat().st_mode & 0o077 == 0
  396. replayed, _ = _run(
  397. "rotate", "--root", str(root), "--common-name", "edge-test",
  398. "--request-id", "rotate-1",
  399. "--expected-current-certificate-digest", "b" * 64,
  400. )
  401. assert replayed == rotated
  402. conflict, _ = _run(
  403. "rotate", "--root", str(root), "--common-name", "edge-other",
  404. "--request-id", "rotate-1",
  405. "--expected-current-certificate-digest", "b" * 64, expect=3,
  406. )
  407. assert conflict["error"]["code"] == "IDEMPOTENCY_CONFLICT"
  408. status_key = ed25519.Ed25519PrivateKey.generate()
  409. status_public = status_key.public_key().public_bytes(
  410. serialization.Encoding.Raw, serialization.PublicFormat.Raw
  411. ).hex()
  412. signed_status = _signed_status(status_key)
  413. status_path = root / "state" / "server-revocation.json"
  414. status_path.write_text(json.dumps(signed_status))
  415. revoked, _ = _run(
  416. "revoke-status", "--root", str(root), "--server-status", str(status_path),
  417. "--trusted-key", f"status-key-1={status_public}",
  418. "--expected-gateway-id", "gw-test", "--expected-generation", "1",
  419. "--expected-certificate-digest", "b" * 64,
  420. )
  421. assert revoked["local_stop_required"] is True
  422. assert (root / "state" / "REVOKED_STOP").is_file()
  423. forged = _signed_status(status_key, gateway_id="gw-other")
  424. status_path.write_text(json.dumps(forged))
  425. rejected, _ = _run(
  426. "revoke-status", "--root", str(root), "--server-status", str(status_path),
  427. "--trusted-key", f"status-key-1={status_public}",
  428. "--expected-gateway-id", "gw-test", "--expected-generation", "1",
  429. "--expected-certificate-digest", "b" * 64, expect=3,
  430. )
  431. assert rejected["error"]["code"] == "STATUS_REJECTED"
  432. expired = _signed_status(status_key, expires_delta=timedelta(seconds=-1))
  433. status_path.write_text(json.dumps(expired))
  434. expired_result, _ = _run(
  435. "revoke-status", "--root", str(root), "--server-status", str(status_path),
  436. "--trusted-key", f"status-key-1={status_public}",
  437. "--expected-gateway-id", "gw-test", "--expected-generation", "1",
  438. "--expected-certificate-digest", "b" * 64, expect=3,
  439. )
  440. assert expired_result["error"]["code"] == "STATUS_REJECTED"
  441. bad_signature = _signed_status(status_key)
  442. bad_signature["signature"] = "0" * 128
  443. status_path.write_text(json.dumps(bad_signature))
  444. forged_result, _ = _run(
  445. "revoke-status", "--root", str(root), "--server-status", str(status_path),
  446. "--trusted-key", f"status-key-1={status_public}",
  447. "--expected-gateway-id", "gw-test", "--expected-generation", "1",
  448. "--expected-certificate-digest", "b" * 64, expect=3,
  449. )
  450. assert forged_result["error"]["code"] == "STATUS_REJECTED"
  451. active = _signed_status(status_key, status="active")
  452. status_path.write_text(json.dumps(active))
  453. terminal, _ = _run(
  454. "revoke-status", "--root", str(root), "--server-status", str(status_path),
  455. "--trusted-key", f"status-key-1={status_public}",
  456. "--expected-gateway-id", "gw-test", "--expected-generation", "1",
  457. "--expected-certificate-digest", "b" * 64,
  458. )
  459. assert terminal["status"] == "revoked"
  460. assert terminal["local_stop_required"] is True
  461. release_key = ed25519.Ed25519PrivateKey.generate()
  462. public_hex = release_key.public_key().public_bytes(
  463. serialization.Encoding.Raw, serialization.PublicFormat.Raw
  464. ).hex()
  465. target_artifact = root / "releases" / "edge-1.0.0.tar"
  466. target_artifact.write_bytes(b"prior verified release")
  467. target = _release_manifest(
  468. release_key, release_id="prior-release", version="1.0.0",
  469. rollback_version="0.9.0", artifact=target_artifact,
  470. )
  471. target_checked = _verify_release(root, target, target_artifact, public_hex)
  472. current_artifact = root / "releases" / "edge-2.0.0.tar"
  473. current_artifact.write_bytes(b"current verified release")
  474. current = _release_manifest(
  475. release_key, release_id="current-release", version="2.0.0",
  476. rollback_version="1.0.0", artifact=current_artifact,
  477. )
  478. current_checked = _verify_release(root, current, current_artifact, public_hex)
  479. current_bundle = json.loads(
  480. Path(str(current_checked["verified_bundle_path"]), "bundle.json").read_text()
  481. )
  482. pointer = {
  483. "artifact_name": current["artifact_name"],
  484. "bundle_digest": current_bundle["bundle_digest"],
  485. "manifest_digest": current["manifest_digest"],
  486. "schema_version": 1,
  487. "version": current["version"],
  488. }
  489. (root / "releases" / "current.json").write_text(json.dumps(pointer))
  490. spec = importlib.util.spec_from_file_location("edge_gateway_admin_fsync_test", CLI)
  491. assert spec is not None and spec.loader is not None
  492. admin = importlib.util.module_from_spec(spec)
  493. spec.loader.exec_module(admin)
  494. real_fsync_directory = admin._fsync_directory
  495. failed = False
  496. def fail_after_pointer_replace(path):
  497. nonlocal failed
  498. if Path(path) == root / "releases" and not failed:
  499. failed = True
  500. raise OSError("injected directory fsync failure")
  501. return real_fsync_directory(path)
  502. monkeypatch.setattr(admin, "_fsync_directory", fail_after_pointer_replace)
  503. with pytest.raises(OSError, match="injected"):
  504. admin.command_rollback(SimpleNamespace(
  505. root=str(root), yes=True,
  506. expected_current_digest=str(current["manifest_digest"]),
  507. target_digest=str(target_checked["manifest_digest"]),
  508. trusted_key=[f"release-key-1={public_hex}"],
  509. ))
  510. assert (root / "state" / "rollback-intent.json").is_file()
  511. rolled_back, _ = _run(
  512. "rollback", "--root", str(root), "--yes",
  513. "--expected-current-digest", str(current["manifest_digest"]),
  514. "--target-digest", str(target_checked["manifest_digest"]),
  515. "--trusted-key", f"release-key-1={public_hex}",
  516. )
  517. assert rolled_back["status"] == "rolled_back"
  518. assert rolled_back["replayed"] is True
  519. assert rolled_back["version"] == "1.0.0"
  520. committed = json.loads((root / "releases" / "current.json").read_text())
  521. assert committed["manifest_digest"] == target["manifest_digest"]
  522. replayed_rollback, _ = _run(
  523. "rollback", "--root", str(root), "--yes",
  524. "--expected-current-digest", str(current["manifest_digest"]),
  525. "--target-digest", str(target_checked["manifest_digest"]),
  526. "--trusted-key", f"release-key-1={public_hex}",
  527. )
  528. assert replayed_rollback["replayed"] is True
  529. target_bundle = json.loads(
  530. Path(str(target_checked["verified_bundle_path"]), "bundle.json").read_text()
  531. )
  532. target_pointer = {
  533. "artifact_name": target["artifact_name"],
  534. "bundle_digest": target_bundle["bundle_digest"],
  535. "manifest_digest": target["manifest_digest"],
  536. "schema_version": 1,
  537. "version": target["version"],
  538. }
  539. rollback_request_digest = admin._protocol_digest({
  540. "expected_current_digest": str(current["manifest_digest"]),
  541. "target_digest": str(target["manifest_digest"]),
  542. })
  543. matching_intent = {
  544. "current_pointer": pointer,
  545. "request_digest": rollback_request_digest,
  546. "target_pointer": target_pointer,
  547. }
  548. intent_path = root / "state" / "rollback-intent.json"
  549. intent_path.write_text(json.dumps({**matching_intent, "request_digest": "f" * 64}))
  550. mismatched, _ = _run(
  551. "rollback", "--root", str(root), "--yes",
  552. "--expected-current-digest", str(current["manifest_digest"]),
  553. "--target-digest", str(target["manifest_digest"]),
  554. "--trusted-key", f"release-key-1={public_hex}", expect=3,
  555. )
  556. assert mismatched["error"]["code"] == "STATE_CONFLICT"
  557. assert intent_path.is_file()
  558. intent_path.write_text(json.dumps(matching_intent))
  559. real_unlink = Path.unlink
  560. unlink_failed = False
  561. def fail_intent_unlink(path, *args, **kwargs):
  562. nonlocal unlink_failed
  563. if path == intent_path and not unlink_failed:
  564. unlink_failed = True
  565. raise OSError("injected intent unlink failure")
  566. return real_unlink(path, *args, **kwargs)
  567. monkeypatch.setattr(Path, "unlink", fail_intent_unlink)
  568. with pytest.raises(OSError, match="unlink"):
  569. admin.command_rollback(SimpleNamespace(
  570. root=str(root), yes=True,
  571. expected_current_digest=str(current["manifest_digest"]),
  572. target_digest=str(target["manifest_digest"]),
  573. trusted_key=[f"release-key-1={public_hex}"],
  574. ))
  575. assert intent_path.is_file()
  576. monkeypatch.setattr(Path, "unlink", real_unlink)
  577. state_fsync_failed = False
  578. def fail_state_fsync_once(path):
  579. nonlocal state_fsync_failed
  580. if Path(path) == root / "state" and not state_fsync_failed:
  581. state_fsync_failed = True
  582. raise OSError("injected state fsync failure")
  583. return real_fsync_directory(path)
  584. monkeypatch.setattr(admin, "_fsync_directory", fail_state_fsync_once)
  585. with pytest.raises(OSError, match="state fsync"):
  586. admin.command_rollback(SimpleNamespace(
  587. root=str(root), yes=True,
  588. expected_current_digest=str(current["manifest_digest"]),
  589. target_digest=str(target["manifest_digest"]),
  590. trusted_key=[f"release-key-1={public_hex}"],
  591. ))
  592. assert not intent_path.exists()
  593. monkeypatch.setattr(admin, "_fsync_directory", real_fsync_directory)
  594. recovered_replay = admin.command_rollback(SimpleNamespace(
  595. root=str(root), yes=True,
  596. expected_current_digest=str(current["manifest_digest"]),
  597. target_digest=str(target["manifest_digest"]),
  598. trusted_key=[f"release-key-1={public_hex}"],
  599. ))
  600. assert recovered_replay["replayed"] is True
  601. older_artifact = root / "releases" / "edge-0.9.0.tar"
  602. older_artifact.write_bytes(b"older verified release")
  603. older = _release_manifest(
  604. release_key, release_id="older-release", version="0.9.0",
  605. rollback_version="0.8.0", artifact=older_artifact,
  606. )
  607. older_checked = _verify_release(root, older, older_artifact, public_hex)
  608. next_rollback, _ = _run(
  609. "rollback", "--root", str(root), "--yes",
  610. "--expected-current-digest", str(target["manifest_digest"]),
  611. "--target-digest", str(older_checked["manifest_digest"]),
  612. "--trusted-key", f"release-key-1={public_hex}",
  613. )
  614. assert next_rollback["status"] == "rolled_back"
  615. assert next_rollback["version"] == "0.9.0"
  616. def test_release_check_verifies_ed25519_artifact_digest_and_replay(tmp_path):
  617. root = tmp_path / "edge-root"
  618. _init(root)
  619. artifact = root / "releases" / "edge-2.0.0.tar"
  620. artifact.write_bytes(b"verified edge release")
  621. private = ed25519.Ed25519PrivateKey.generate()
  622. public_hex = private.public_key().public_bytes(
  623. serialization.Encoding.Raw, serialization.PublicFormat.Raw
  624. ).hex()
  625. unsigned = {
  626. "artifact_digest": hashlib.sha256(artifact.read_bytes()).hexdigest(),
  627. "artifact_name": artifact.name,
  628. "deadline_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat().replace("+00:00", "Z"),
  629. "key_id": "release-key-1",
  630. "release_id": "rel-1",
  631. "rollback_version": "1.0.0",
  632. "signature_algorithm": "Ed25519",
  633. "status": "offered",
  634. "version": "2.0.0",
  635. }
  636. manifest = {
  637. **unsigned,
  638. "manifest_digest": canonical_sha256(unsigned),
  639. "signature": private.sign(canonical_json_bytes(unsigned)).hex(),
  640. }
  641. manifest_path = root / "releases" / "manifest.json"
  642. manifest_path.write_text(json.dumps(manifest))
  643. checked, _ = _run(
  644. "release-check", "--root", str(root), "--manifest", str(manifest_path),
  645. "--artifact", str(artifact), "--trusted-key", f"release-key-1={public_hex}",
  646. "--current-version", "1.0.0",
  647. )
  648. assert checked["status"] == "verified"
  649. assert checked["manifest_digest"] == manifest["manifest_digest"]
  650. bundle = Path(str(checked["verified_bundle_path"]))
  651. assert (bundle / "manifest.json").is_file()
  652. assert (bundle / artifact.name).read_bytes() == artifact.read_bytes()
  653. assert (bundle / "bundle.json").is_file()
  654. exact, _ = _run(
  655. "release-check", "--root", str(root), "--manifest", str(manifest_path),
  656. "--artifact", str(artifact), "--trusted-key", f"release-key-1={public_hex}",
  657. "--current-version", "1.0.0",
  658. )
  659. assert exact["replayed"] is True
  660. changed_unsigned = {**unsigned, "status": "accepted"}
  661. changed_replay = {
  662. **changed_unsigned,
  663. "manifest_digest": canonical_sha256(changed_unsigned),
  664. "signature": private.sign(canonical_json_bytes(changed_unsigned)).hex(),
  665. }
  666. manifest_path.write_text(json.dumps(changed_replay))
  667. replay_rejected, _ = _run(
  668. "release-check", "--root", str(root), "--manifest", str(manifest_path),
  669. "--artifact", str(artifact), "--trusted-key", f"release-key-1={public_hex}",
  670. "--current-version", "1.0.0", expect=3,
  671. )
  672. assert replay_rejected["error"]["code"] == "RELEASE_REJECTED"
  673. tampered = dict(manifest)
  674. tampered["version"] = "2.0.1"
  675. manifest_path.write_text(json.dumps(tampered))
  676. rejected, _ = _run(
  677. "release-check", "--root", str(root), "--manifest", str(manifest_path),
  678. "--artifact", str(artifact), "--trusted-key", f"release-key-1={public_hex}",
  679. "--current-version", "1.0.0", expect=3,
  680. )
  681. assert rejected["error"]["code"] == "RELEASE_REJECTED"
  682. def test_rollback_rejects_forged_verified_manifest_and_tampered_artifact(tmp_path):
  683. root = tmp_path / "edge-root"
  684. _init(root)
  685. key = ed25519.Ed25519PrivateKey.generate()
  686. public_hex = key.public_key().public_bytes(
  687. serialization.Encoding.Raw, serialization.PublicFormat.Raw
  688. ).hex()
  689. target_artifact = root / "releases" / "target.bin"
  690. target_artifact.write_bytes(b"target")
  691. target = _release_manifest(
  692. key, release_id="target", version="1.0.0", rollback_version="0.9.0",
  693. artifact=target_artifact,
  694. )
  695. target_checked = _verify_release(root, target, target_artifact, public_hex)
  696. current_artifact = root / "releases" / "current.bin"
  697. current_artifact.write_bytes(b"current")
  698. current = _release_manifest(
  699. key, release_id="current", version="2.0.0", rollback_version="1.0.0",
  700. artifact=current_artifact,
  701. )
  702. current_checked = _verify_release(root, current, current_artifact, public_hex)
  703. current_bundle = Path(str(current_checked["verified_bundle_path"]))
  704. bundle_record = json.loads((current_bundle / "bundle.json").read_text())
  705. (root / "releases" / "current.json").write_text(json.dumps({
  706. "artifact_name": current["artifact_name"],
  707. "bundle_digest": bundle_record["bundle_digest"],
  708. "manifest_digest": current["manifest_digest"],
  709. "schema_version": 1,
  710. "version": current["version"],
  711. }))
  712. for expected_digest, target_digest in (
  713. ("c" * 64, str(target["manifest_digest"])),
  714. (str(current["manifest_digest"]), "d" * 64),
  715. ):
  716. wrong, _ = _run(
  717. "rollback", "--root", str(root), "--yes",
  718. "--expected-current-digest", expected_digest,
  719. "--target-digest", target_digest,
  720. "--trusted-key", f"release-key-1={public_hex}", expect=3,
  721. )
  722. assert wrong["error"]["code"] in {"RELEASE_REJECTED", "STATE_CONFLICT"}
  723. target_bundle = Path(str(target_checked["verified_bundle_path"]))
  724. manifest_path = target_bundle / "manifest.json"
  725. manifest_record = json.loads(manifest_path.read_text())
  726. manifest_path.write_text(json.dumps({**manifest_record, "signature": "0" * 128}))
  727. forged, _ = _run(
  728. "rollback", "--root", str(root), "--yes",
  729. "--expected-current-digest", str(current["manifest_digest"]),
  730. "--target-digest", str(target["manifest_digest"]),
  731. "--trusted-key", f"release-key-1={public_hex}", expect=3,
  732. )
  733. assert forged["error"]["code"] == "RELEASE_REJECTED"
  734. manifest_path.write_text(json.dumps(manifest_record))
  735. (target_bundle / str(target["artifact_name"])).write_bytes(b"tampered")
  736. rejected, _ = _run(
  737. "rollback", "--root", str(root), "--yes",
  738. "--expected-current-digest", str(current["manifest_digest"]),
  739. "--target-digest", str(target["manifest_digest"]),
  740. "--trusted-key", f"release-key-1={public_hex}", expect=3,
  741. )
  742. assert rejected["error"]["code"] == "RELEASE_REJECTED"
  743. assert json.loads((root / "releases" / "current.json").read_text())["manifest_digest"] == current["manifest_digest"]
  744. def test_edge_compose_is_pull_only_and_hardened():
  745. model = yaml.safe_load(COMPOSE.read_text())
  746. assert set(model["services"]) == {"edge-gateway"}
  747. service = model["services"]["edge-gateway"]
  748. assert service["build"] == {
  749. "context": "../..", "dockerfile": "deploy/edge/Dockerfile.edge"
  750. }
  751. assert service["pull_policy"] == "${EDGE_PULL_POLICY:-build}"
  752. assert service["command"] == ["python", "-m", "app.edge_gateway"]
  753. assert "ports" not in service and "expose" not in service
  754. assert service["user"] not in {"0", 0, "root"}
  755. assert service["read_only"] is True
  756. assert service["cap_drop"] == ["ALL"]
  757. assert "no-new-privileges:true" in service["security_opt"]
  758. assert service["restart"] == "unless-stopped"
  759. assert service["pids_limit"] <= 256
  760. assert service["mem_limit"]
  761. assert service["cpus"]
  762. assert service["healthcheck"]["test"][0] == "CMD"
  763. assert service["user"] == "${EDGE_RUNTIME_UID:?}:${EDGE_RUNTIME_GID:?}"
  764. assert any(
  765. "uid=${EDGE_RUNTIME_UID:?},gid=${EDGE_RUNTIME_GID:?}" in item
  766. for item in service["tmpfs"]
  767. )
  768. volumes = "\n".join(service["volumes"])
  769. for marker in ("queue", "artifacts", "releases", "secrets", "certificates"):
  770. assert marker in volumes
  771. assert "/certificates:ro" in volumes and "/secrets:ro" in volumes
  772. env_text = ENV_EXAMPLE.read_text()
  773. assert "EDGE_CONTROL_ORIGIN=https://" in env_text
  774. assert "EDGE_ALLOWED_CONTROL_HOSTS=" in env_text
  775. assert "EDGE_RUNTIME_UID=" in env_text and "EDGE_RUNTIME_GID=" in env_text
  776. assert "EDGE_CLIENT_PRIVATE_KEY_PATH=/run/edge/secrets/client.key" in env_text
  777. assert service["environment"]["EDGE_POLL_INTERVAL_SECONDS"] == (
  778. "${EDGE_POLL_INTERVAL_SECONDS:-5}"
  779. )
  780. assert "EDGE_CREDENTIAL=" not in env_text
  781. assert "BEGIN PRIVATE KEY" not in env_text
  782. def test_real_cli_private_material_is_readable_by_configured_container_uid(tmp_path):
  783. image = subprocess.run(
  784. ["docker", "image", "inspect", "nginx:alpine"], capture_output=True
  785. )
  786. if image.returncode != 0:
  787. pytest.skip("local nginx:alpine image is required; this test never pulls")
  788. root = tmp_path / "edge-root"
  789. _init(root)
  790. _run("csr", "--root", str(root), "--common-name", "edge-test")
  791. completed = subprocess.run(
  792. [
  793. "docker", "run", "--rm", "--user", f"{os.getuid()}:{os.getgid()}",
  794. "-v", f"{root / 'secrets'}:/run/edge/secrets:ro",
  795. "nginx:alpine", "sh", "-c", "test -r /run/edge/secrets/client.key",
  796. ],
  797. capture_output=True,
  798. text=True,
  799. check=False,
  800. )
  801. assert completed.returncode == 0, completed.stderr
  802. def test_openapi_contains_closed_edge_contract_without_secret_response_fields():
  803. document = yaml.safe_load(OPENAPI.read_text())
  804. paths = document["paths"]
  805. expected = {
  806. "/api/datasource/edge/enrollments",
  807. "/api/datasource/edge/register",
  808. "/api/datasource/edge/gateways",
  809. "/api/datasource/edge/gateways/{gateway_id}/heartbeat",
  810. "/api/datasource/edge/gateways/{gateway_id}/tasks/pull",
  811. "/api/datasource/edge/gateways/{gateway_id}/tasks/{task_id}/outcome",
  812. "/api/datasource/edge/gateways/{gateway_id}/events",
  813. "/api/datasource/edge/gateways/{gateway_id}/reconcile",
  814. "/api/datasource/edge/gateways/{gateway_id}/releases",
  815. "/api/datasource/edge/gateways/{gateway_id}/releases/ack",
  816. }
  817. assert expected <= set(paths)
  818. machine = paths["/api/datasource/edge/gateways/{gateway_id}/heartbeat"]["post"]
  819. assert machine["x-max-request-bytes"] == 262_144
  820. assert machine["x-max-json-depth"] == 32
  821. assert machine["x-max-json-nodes"] == 5_000
  822. assert {parameter["name"] for parameter in machine["parameters"]} >= {
  823. "gateway_id", "X-Edge-Credential", "X-Edge-Certificate-SHA256",
  824. "X-DataOps-Edge-Client-Verify", "X-DataOps-Edge-Client-Cert",
  825. }
  826. assert machine["responses"]["200"]["headers"]["Cache-Control"]["schema"]["const"] == "no-store"
  827. assert machine["security"] == [{"mutualTLS": [], "edgeCredential": []}]
  828. assert paths["/api/datasource/edge/enrollments"]["post"]["security"] == [
  829. {"bearerAuth": []}
  830. ]
  831. assert paths["/api/datasource/edge/gateways/{gateway_id}/rotate"]["post"][
  832. "security"
  833. ] == [{"bearerAuth": []}]
  834. assert "requestBody" not in paths["/api/datasource/edge/tasks/{task_id}/cancel"][
  835. "post"
  836. ]
  837. schemas = document["components"]["schemas"]
  838. for name in (
  839. "EdgeEnrollmentRequest", "EdgeRegisterRequest", "EdgeSignedTaskEnvelope",
  840. "EdgeEventRequest", "EdgeReconcileResponse", "EdgeReleaseManifest",
  841. "EdgeTaskOutcomeRequest", "EdgeErrorEnvelope",
  842. ):
  843. assert name in schemas
  844. rendered = json.dumps(schemas).casefold()
  845. assert "private_key" not in rendered
  846. assert "credential_secret" not in rendered
  847. assert "lineage" in schemas["EdgeTaskContract"]["properties"]["classification"]["enum"]
  848. assert schemas["EdgeSafeSummary"]["x-max-policy-depth"] == 12
  849. assert schemas["EdgeRegisterRequest"]["properties"]["version"] == {
  850. "$ref": "#/components/schemas/EdgeAgentVersion"
  851. }
  852. assert schemas["EdgeHeartbeatRequest"]["properties"]["version"] == {
  853. "$ref": "#/components/schemas/EdgeAgentVersion"
  854. }
  855. assert schemas["EdgeReleaseEnvelope"]["properties"]["data"]["properties"][
  856. "version"
  857. ] == {"$ref": "#/components/schemas/EdgeVersion"}
  858. assert schemas["EdgeReleaseEnvelope"]["properties"]["data"]["properties"][
  859. "rollback_version"
  860. ] == {"$ref": "#/components/schemas/EdgeVersion"}
  861. list_parameters = {
  862. item["name"]: item["schema"]
  863. for item in paths["/api/datasource/edge/gateways"]["get"]["parameters"]
  864. }
  865. assert list_parameters["limit"] == {
  866. "type": "integer", "minimum": 1, "maximum": 100
  867. }
  868. assert list_parameters["offset"] == {
  869. "type": "integer", "minimum": 0, "maximum": 10_000
  870. }
  871. heartbeat_sample = {
  872. "environment": "production", "network_zone": "zone-a", "generation": 1,
  873. "version": "1.0.0", "safe_summary": {"queue_depth": 0},
  874. }
  875. release_data = schemas["EdgeReleaseEnvelope"]["properties"]["data"]
  876. assert release_data["properties"]["manifest"] == {
  877. "$ref": "#/components/schemas/EdgeReleaseManifest"
  878. }
  879. security = document["components"]["securitySchemes"]
  880. assert security["mutualTLS"] == {"type": "mutualTLS"}
  881. assert security["edgeCredential"]["name"] == "X-Edge-Credential"
  882. def local_refs(value):
  883. if isinstance(value, dict):
  884. return {
  885. key: (
  886. f"#/$defs/{item.rsplit('/', 1)[-1]}"
  887. if key == "$ref"
  888. and isinstance(item, str)
  889. and item.startswith("#/components/schemas/")
  890. else local_refs(item)
  891. )
  892. for key, item in value.items()
  893. }
  894. if isinstance(value, list):
  895. return [local_refs(item) for item in value]
  896. return value
  897. definitions = local_refs(schemas)
  898. heartbeat_validator = jsonschema.Draft202012Validator({
  899. "$defs": definitions, "$ref": "#/$defs/EdgeHeartbeatRequest"
  900. })
  901. heartbeat_validator.validate(heartbeat_sample)
  902. safe_validator = jsonschema.Draft202012Validator({
  903. "$defs": definitions, "$ref": "#/$defs/EdgeSafeSummary"
  904. })
  905. safe_validator.validate({"queue_depth": 0, "status": "healthy"})
  906. with pytest.raises(jsonschema.ValidationError):
  907. safe_validator.validate({"raw_rows": [{"secret": "x"}]})
  908. with pytest.raises(jsonschema.ValidationError):
  909. safe_validator.validate({"RawRows": [{"value": 1}]})
  910. with pytest.raises(jsonschema.ValidationError):
  911. safe_validator.validate({"PASSWORD": "masked"})
  912. too_deep: object = "leaf"
  913. for _ in range(13):
  914. too_deep = {"level": too_deep}
  915. with pytest.raises(jsonschema.ValidationError):
  916. safe_validator.validate({"summary": too_deep})
  917. with pytest.raises(jsonschema.ValidationError):
  918. safe_validator.validate({"message": "x" * 4097})
  919. cursor_validator = jsonschema.Draft202012Validator({
  920. "$defs": definitions, "$ref": "#/$defs/EdgeCursor"
  921. })
  922. with pytest.raises(jsonschema.ValidationError):
  923. cursor_validator.validate("../invalid cursor")
  924. def test_docs_and_machine_evidence_preserve_enterprise_uat_boundary():
  925. required = [
  926. ROOT / "deploy" / "edge" / "README.md",
  927. ROOT / "docs" / "runbooks" / "EDGE_GATEWAY_OPERATIONS.md",
  928. ROOT / "docs" / "phase3" / "P3_WP04_EDGE_GATEWAY.md",
  929. ROOT / "docs" / "validation" / "P3_WP04_EDGE_GATEWAY_EVIDENCE.md",
  930. ]
  931. combined = "\n".join(path.read_text() for path in required)
  932. assert "ENGINEERING_COMPLETE_ENTERPRISE_EDGE_UAT_BLOCKED" in combined
  933. assert "TBD_EXTERNAL" in combined
  934. for topic in (
  935. "默认拒绝", "数据分级", "证书轮换", "证书撤销", "断网", "重连",
  936. "取消", "回滚", "诊断", "备份", "恢复", "CRL", "v1", "v2",
  937. ):
  938. assert topic in combined
  939. assert "真实企业来源已通过" not in combined
  940. failures = json.loads(FAILURES.read_text())
  941. assert failures["work_package"] == "P3-WP04"
  942. assert failures["engineering_status"] == "ENGINEERING_COMPLETE_ENTERPRISE_EDGE_UAT_BLOCKED"
  943. assert failures["cases"]
  944. required_keys = {
  945. "case_id", "scope", "preconditions", "steps", "safety_boundary",
  946. "expected", "evidence", "rollback", "status",
  947. }
  948. assert all(required_keys <= set(case) for case in failures["cases"])
  949. assert any(case["status"] == "TBD_EXTERNAL" for case in failures["cases"])
  950. def test_operator_runbook_contains_executable_install_recovery_and_uat_gates():
  951. runbook = (ROOT / "docs/runbooks/EDGE_GATEWAY_OPERATIONS.md").read_text()
  952. for marker in (
  953. "Docker Engine 26", "Docker Compose 2.27", "Python 3.11",
  954. "sha256sum -c SHA256SUMS", "cosign verify-blob",
  955. "edge.config.schema.json", "docker image inspect",
  956. "health.json", "120 秒", "X-Edge-Enrollment", "X-Edge-Credential",
  957. "/tasks/{task_id}/outcome", "/reconcile", "/releases/ack",
  958. "edge_gateway_admin.py status", "人工介入阈值", "legal hold",
  959. "delete receipt", "sqlite3", "VACUUM INTO", "quick_check",
  960. "rollback-intent.json", "state/rollback-history/", "DB_ROLE_INIT_DATABASE_URL",
  961. "MIGRATION_DATABASE_URL", "has_schema_privilege", "CRL_CHECK_LEAF",
  962. "nginx -s reload", "TBD_EXTERNAL", "runtime-self-check",
  963. "format_checker=jsonschema.FormatChecker()", "healthy=0",
  964. "julianday(retention_until)<=julianday('now')",
  965. "PGSERVICE=dataops_migrator",
  966. ):
  967. assert marker in runbook
  968. assert "当前 queue schema 是 v1" not in runbook
  969. assert "20260809_477 (head)" not in runbook
  970. def test_edge_config_example_matches_closed_runtime_file_schema():
  971. schema = json.loads(EDGE_CONFIG_SCHEMA.read_text())
  972. example = json.loads(EDGE_CONFIG_EXAMPLE.read_text())
  973. jsonschema.Draft202012Validator.check_schema(schema)
  974. jsonschema.Draft202012Validator(schema).validate(example)
  975. assert schema["additionalProperties"] is False
  976. assert set(schema["required"]) == set(schema["properties"])
  977. def test_generator_is_reproducible_for_edge_contract(tmp_path):
  978. output = tmp_path / "openapi.yaml"
  979. subprocess.run(
  980. [sys.executable, str(ROOT / "scripts" / "generate_openapi.py"), "--output", str(output)],
  981. cwd=ROOT, check=True, capture_output=True, text=True,
  982. )
  983. assert output.read_bytes() == OPENAPI.read_bytes()
  984. def test_release_archive_contains_exact_edge_cli_compose_and_runbook(tmp_path):
  985. assert (ROOT / "deployment/scripts/edge_gateway_admin.py").read_bytes() == CLI.read_bytes()
  986. completed = subprocess.run(
  987. ["bash", str(ROOT / "deployment/package_release.sh")],
  988. cwd=ROOT,
  989. capture_output=True,
  990. text=True,
  991. check=False,
  992. env={
  993. **os.environ,
  994. "DATAOPS_RELEASE_OUTPUT_DIR": str(tmp_path),
  995. "DATAOPS_RELEASE_VERSION": "wp04-test",
  996. },
  997. )
  998. assert completed.returncode == 0, completed.stderr
  999. archive = tmp_path / "dataops-platform-release-wp04-test.tar.gz"
  1000. with tarfile.open(archive) as bundle:
  1001. names = set(bundle.getnames())
  1002. required = {
  1003. "dataops-platform/scripts/edge_gateway_admin.py",
  1004. "dataops-platform/deploy/edge/docker-compose.edge.yml",
  1005. "dataops-platform/deploy/edge/Dockerfile.edge",
  1006. "dataops-platform/deploy/edge/edge.config.example.json",
  1007. "dataops-platform/deploy/edge/edge.config.schema.json",
  1008. "dataops-platform/deploy/edge/edge.env.example",
  1009. "dataops-platform/deploy/edge/README.md",
  1010. "dataops-platform/docs/runbooks/EDGE_GATEWAY_OPERATIONS.md",
  1011. "dataops-platform/docs/architecture/OPENAPI.yaml",
  1012. "dataops-platform/docs/validation/P3_WP04_FAILURE_INJECTION.json",
  1013. "dataops-platform/SHA256SUMS",
  1014. }
  1015. assert required <= names
  1016. cli_member = bundle.extractfile("dataops-platform/scripts/edge_gateway_admin.py")
  1017. assert cli_member is not None and cli_member.read() == CLI.read_bytes()
  1018. assert not any("__pycache__" in name or name.endswith(".pyc") for name in names)
  1019. readme_member = bundle.extractfile("dataops-platform/deploy/edge/README.md")
  1020. assert readme_member is not None
  1021. archive_readme = readme_member.read().decode("utf-8")
  1022. assert "../../docs/runbooks/EDGE_GATEWAY_OPERATIONS.md" in archive_readme
  1023. checksum_member = bundle.extractfile("dataops-platform/SHA256SUMS")
  1024. assert checksum_member is not None
  1025. checksum_lines = checksum_member.read().decode("utf-8").splitlines()
  1026. assert checksum_lines
  1027. for line in checksum_lines:
  1028. digest, relative = line.split(" ", 1)
  1029. member = bundle.extractfile(f"dataops-platform/{relative.removeprefix('./')}")
  1030. assert member is not None
  1031. assert hashlib.sha256(member.read()).hexdigest() == digest
  1032. for name in names:
  1033. member = bundle.extractfile(name)
  1034. if member is None:
  1035. continue
  1036. content = member.read()
  1037. assert b"-----BEGIN PRIVATE KEY-----" not in content
  1038. assert b"-----BEGIN RSA PRIVATE KEY-----" not in content
  1039. if shutil.which("docker") is not None:
  1040. extracted = tmp_path / "extracted"
  1041. extracted.mkdir()
  1042. with tarfile.open(archive) as bundle:
  1043. bundle.extractall(extracted, filter="data")
  1044. release_root = extracted / "dataops-platform"
  1045. build = subprocess.run(
  1046. [
  1047. "docker", "build", "-f", "deploy/edge/Dockerfile.edge",
  1048. "-t", "dataops-edge:wp04-archive-smoke", ".",
  1049. ],
  1050. cwd=release_root,
  1051. capture_output=True,
  1052. text=True,
  1053. check=False,
  1054. )
  1055. assert build.returncode == 0, build.stderr[-4000:]
  1056. def test_release_secret_scanner_rejects_binary_and_extended_secret_shapes(tmp_path):
  1057. clean = tmp_path / "clean"
  1058. clean.mkdir()
  1059. (clean / "README.md").write_text("credential is mounted from a secret file\n")
  1060. accepted = subprocess.run(
  1061. [sys.executable, str(RELEASE_SECRET_SCANNER), str(clean)],
  1062. capture_output=True,
  1063. text=True,
  1064. check=False,
  1065. )
  1066. assert accepted.returncode == 0, accepted.stderr
  1067. cases = {
  1068. "encrypted.pem": b"-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAAA\n",
  1069. "identity.p12": b"0\x82\x01\x00binary-pkcs12",
  1070. "config.txt": b"credential = actual-production-value-123456\n",
  1071. }
  1072. for name, content in cases.items():
  1073. candidate = tmp_path / name
  1074. candidate.mkdir()
  1075. (candidate / name).write_bytes(content)
  1076. rejected = subprocess.run(
  1077. [sys.executable, str(RELEASE_SECRET_SCANNER), str(candidate)],
  1078. capture_output=True,
  1079. text=True,
  1080. check=False,
  1081. )
  1082. assert rejected.returncode != 0, name