test_phase3_wp04_delivery_contract.py 49 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178
  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_release_snapshot_comparison_ignores_atime_but_rejects_content_mutation():
  362. admin = _load_cli_module()
  363. before = SimpleNamespace(
  364. st_dev=1, st_ino=2, st_mode=0o100600, st_nlink=1, st_uid=501,
  365. st_gid=20, st_size=10, st_atime_ns=100, st_mtime_ns=200, st_ctime_ns=300,
  366. )
  367. atime_only = SimpleNamespace(**{**before.__dict__, "st_atime_ns": 101})
  368. content_mutation = SimpleNamespace(**{**before.__dict__, "st_size": 11})
  369. assert admin._same_file_snapshot(before, atime_only) is True
  370. assert admin._same_file_snapshot(before, content_mutation) is False
  371. def test_admin_requires_absolute_root_and_rejects_symlink_escape(tmp_path):
  372. payload, _ = _run(
  373. "init", "--root", "relative-edge-root", "--runtime-uid", str(os.getuid()),
  374. "--runtime-gid", str(os.getgid()), expect=2,
  375. )
  376. assert payload == {"error": {"code": "INVALID_INPUT"}, "status": "error"}
  377. root = tmp_path / "root"
  378. outside = tmp_path / "outside"
  379. outside.mkdir()
  380. root.mkdir()
  381. (root / "secrets").symlink_to(outside, target_is_directory=True)
  382. payload, _ = _run(
  383. "init", "--root", str(root), "--runtime-uid", str(os.getuid()),
  384. "--runtime-gid", str(os.getgid()), expect=2,
  385. )
  386. assert payload["error"]["code"] == "PATH_POLICY"
  387. mismatched = tmp_path / "owner-mismatch"
  388. owner_error, _ = _run(
  389. "init", "--root", str(mismatched),
  390. "--runtime-uid", str(os.getuid() + 1),
  391. "--runtime-gid", str(os.getgid()), expect=2,
  392. )
  393. assert owner_error["error"]["code"] == "OWNER_POLICY"
  394. def test_rotate_revoke_and_rollback_are_idempotent_and_fail_closed(
  395. tmp_path, monkeypatch
  396. ):
  397. root = tmp_path / "edge-root"
  398. _init(root)
  399. rotated, _ = _run(
  400. "rotate", "--root", str(root), "--common-name", "edge-test",
  401. "--request-id", "rotate-1",
  402. "--expected-current-certificate-digest", "b" * 64,
  403. )
  404. assert rotated["status"] == "pending_server_approval"
  405. assert Path(str(rotated["private_key_path"])).stat().st_mode & 0o077 == 0
  406. replayed, _ = _run(
  407. "rotate", "--root", str(root), "--common-name", "edge-test",
  408. "--request-id", "rotate-1",
  409. "--expected-current-certificate-digest", "b" * 64,
  410. )
  411. assert replayed == rotated
  412. conflict, _ = _run(
  413. "rotate", "--root", str(root), "--common-name", "edge-other",
  414. "--request-id", "rotate-1",
  415. "--expected-current-certificate-digest", "b" * 64, expect=3,
  416. )
  417. assert conflict["error"]["code"] == "IDEMPOTENCY_CONFLICT"
  418. status_key = ed25519.Ed25519PrivateKey.generate()
  419. status_public = status_key.public_key().public_bytes(
  420. serialization.Encoding.Raw, serialization.PublicFormat.Raw
  421. ).hex()
  422. signed_status = _signed_status(status_key)
  423. status_path = root / "state" / "server-revocation.json"
  424. status_path.write_text(json.dumps(signed_status))
  425. revoked, _ = _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,
  430. )
  431. assert revoked["local_stop_required"] is True
  432. assert (root / "state" / "REVOKED_STOP").is_file()
  433. forged = _signed_status(status_key, gateway_id="gw-other")
  434. status_path.write_text(json.dumps(forged))
  435. rejected, _ = _run(
  436. "revoke-status", "--root", str(root), "--server-status", str(status_path),
  437. "--trusted-key", f"status-key-1={status_public}",
  438. "--expected-gateway-id", "gw-test", "--expected-generation", "1",
  439. "--expected-certificate-digest", "b" * 64, expect=3,
  440. )
  441. assert rejected["error"]["code"] == "STATUS_REJECTED"
  442. expired = _signed_status(status_key, expires_delta=timedelta(seconds=-1))
  443. status_path.write_text(json.dumps(expired))
  444. expired_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 expired_result["error"]["code"] == "STATUS_REJECTED"
  451. bad_signature = _signed_status(status_key)
  452. bad_signature["signature"] = "0" * 128
  453. status_path.write_text(json.dumps(bad_signature))
  454. forged_result, _ = _run(
  455. "revoke-status", "--root", str(root), "--server-status", str(status_path),
  456. "--trusted-key", f"status-key-1={status_public}",
  457. "--expected-gateway-id", "gw-test", "--expected-generation", "1",
  458. "--expected-certificate-digest", "b" * 64, expect=3,
  459. )
  460. assert forged_result["error"]["code"] == "STATUS_REJECTED"
  461. active = _signed_status(status_key, status="active")
  462. status_path.write_text(json.dumps(active))
  463. terminal, _ = _run(
  464. "revoke-status", "--root", str(root), "--server-status", str(status_path),
  465. "--trusted-key", f"status-key-1={status_public}",
  466. "--expected-gateway-id", "gw-test", "--expected-generation", "1",
  467. "--expected-certificate-digest", "b" * 64,
  468. )
  469. assert terminal["status"] == "revoked"
  470. assert terminal["local_stop_required"] is True
  471. release_key = ed25519.Ed25519PrivateKey.generate()
  472. public_hex = release_key.public_key().public_bytes(
  473. serialization.Encoding.Raw, serialization.PublicFormat.Raw
  474. ).hex()
  475. target_artifact = root / "releases" / "edge-1.0.0.tar"
  476. target_artifact.write_bytes(b"prior verified release")
  477. target = _release_manifest(
  478. release_key, release_id="prior-release", version="1.0.0",
  479. rollback_version="0.9.0", artifact=target_artifact,
  480. )
  481. target_checked = _verify_release(root, target, target_artifact, public_hex)
  482. current_artifact = root / "releases" / "edge-2.0.0.tar"
  483. current_artifact.write_bytes(b"current verified release")
  484. current = _release_manifest(
  485. release_key, release_id="current-release", version="2.0.0",
  486. rollback_version="1.0.0", artifact=current_artifact,
  487. )
  488. current_checked = _verify_release(root, current, current_artifact, public_hex)
  489. current_bundle = json.loads(
  490. Path(str(current_checked["verified_bundle_path"]), "bundle.json").read_text()
  491. )
  492. pointer = {
  493. "artifact_name": current["artifact_name"],
  494. "bundle_digest": current_bundle["bundle_digest"],
  495. "manifest_digest": current["manifest_digest"],
  496. "schema_version": 1,
  497. "version": current["version"],
  498. }
  499. (root / "releases" / "current.json").write_text(json.dumps(pointer))
  500. spec = importlib.util.spec_from_file_location("edge_gateway_admin_fsync_test", CLI)
  501. assert spec is not None and spec.loader is not None
  502. admin = importlib.util.module_from_spec(spec)
  503. spec.loader.exec_module(admin)
  504. real_fsync_directory = admin._fsync_directory
  505. failed = False
  506. def fail_after_pointer_replace(path):
  507. nonlocal failed
  508. if Path(path) == root / "releases" and not failed:
  509. failed = True
  510. raise OSError("injected directory fsync failure")
  511. return real_fsync_directory(path)
  512. monkeypatch.setattr(admin, "_fsync_directory", fail_after_pointer_replace)
  513. with pytest.raises(OSError, match="injected"):
  514. admin.command_rollback(SimpleNamespace(
  515. root=str(root), yes=True,
  516. expected_current_digest=str(current["manifest_digest"]),
  517. target_digest=str(target_checked["manifest_digest"]),
  518. trusted_key=[f"release-key-1={public_hex}"],
  519. ))
  520. assert (root / "state" / "rollback-intent.json").is_file()
  521. rolled_back, _ = _run(
  522. "rollback", "--root", str(root), "--yes",
  523. "--expected-current-digest", str(current["manifest_digest"]),
  524. "--target-digest", str(target_checked["manifest_digest"]),
  525. "--trusted-key", f"release-key-1={public_hex}",
  526. )
  527. assert rolled_back["status"] == "rolled_back"
  528. assert rolled_back["replayed"] is True
  529. assert rolled_back["version"] == "1.0.0"
  530. committed = json.loads((root / "releases" / "current.json").read_text())
  531. assert committed["manifest_digest"] == target["manifest_digest"]
  532. replayed_rollback, _ = _run(
  533. "rollback", "--root", str(root), "--yes",
  534. "--expected-current-digest", str(current["manifest_digest"]),
  535. "--target-digest", str(target_checked["manifest_digest"]),
  536. "--trusted-key", f"release-key-1={public_hex}",
  537. )
  538. assert replayed_rollback["replayed"] is True
  539. target_bundle = json.loads(
  540. Path(str(target_checked["verified_bundle_path"]), "bundle.json").read_text()
  541. )
  542. target_pointer = {
  543. "artifact_name": target["artifact_name"],
  544. "bundle_digest": target_bundle["bundle_digest"],
  545. "manifest_digest": target["manifest_digest"],
  546. "schema_version": 1,
  547. "version": target["version"],
  548. }
  549. rollback_request_digest = admin._protocol_digest({
  550. "expected_current_digest": str(current["manifest_digest"]),
  551. "target_digest": str(target["manifest_digest"]),
  552. })
  553. matching_intent = {
  554. "current_pointer": pointer,
  555. "request_digest": rollback_request_digest,
  556. "target_pointer": target_pointer,
  557. }
  558. intent_path = root / "state" / "rollback-intent.json"
  559. intent_path.write_text(json.dumps({**matching_intent, "request_digest": "f" * 64}))
  560. mismatched, _ = _run(
  561. "rollback", "--root", str(root), "--yes",
  562. "--expected-current-digest", str(current["manifest_digest"]),
  563. "--target-digest", str(target["manifest_digest"]),
  564. "--trusted-key", f"release-key-1={public_hex}", expect=3,
  565. )
  566. assert mismatched["error"]["code"] == "STATE_CONFLICT"
  567. assert intent_path.is_file()
  568. intent_path.write_text(json.dumps(matching_intent))
  569. real_unlink = Path.unlink
  570. unlink_failed = False
  571. def fail_intent_unlink(path, *args, **kwargs):
  572. nonlocal unlink_failed
  573. if path == intent_path and not unlink_failed:
  574. unlink_failed = True
  575. raise OSError("injected intent unlink failure")
  576. return real_unlink(path, *args, **kwargs)
  577. monkeypatch.setattr(Path, "unlink", fail_intent_unlink)
  578. with pytest.raises(OSError, match="unlink"):
  579. admin.command_rollback(SimpleNamespace(
  580. root=str(root), yes=True,
  581. expected_current_digest=str(current["manifest_digest"]),
  582. target_digest=str(target["manifest_digest"]),
  583. trusted_key=[f"release-key-1={public_hex}"],
  584. ))
  585. assert intent_path.is_file()
  586. monkeypatch.setattr(Path, "unlink", real_unlink)
  587. state_fsync_failed = False
  588. def fail_state_fsync_once(path):
  589. nonlocal state_fsync_failed
  590. if Path(path) == root / "state" and not state_fsync_failed:
  591. state_fsync_failed = True
  592. raise OSError("injected state fsync failure")
  593. return real_fsync_directory(path)
  594. monkeypatch.setattr(admin, "_fsync_directory", fail_state_fsync_once)
  595. with pytest.raises(OSError, match="state fsync"):
  596. admin.command_rollback(SimpleNamespace(
  597. root=str(root), yes=True,
  598. expected_current_digest=str(current["manifest_digest"]),
  599. target_digest=str(target["manifest_digest"]),
  600. trusted_key=[f"release-key-1={public_hex}"],
  601. ))
  602. assert not intent_path.exists()
  603. monkeypatch.setattr(admin, "_fsync_directory", real_fsync_directory)
  604. recovered_replay = admin.command_rollback(SimpleNamespace(
  605. root=str(root), yes=True,
  606. expected_current_digest=str(current["manifest_digest"]),
  607. target_digest=str(target["manifest_digest"]),
  608. trusted_key=[f"release-key-1={public_hex}"],
  609. ))
  610. assert recovered_replay["replayed"] is True
  611. older_artifact = root / "releases" / "edge-0.9.0.tar"
  612. older_artifact.write_bytes(b"older verified release")
  613. older = _release_manifest(
  614. release_key, release_id="older-release", version="0.9.0",
  615. rollback_version="0.8.0", artifact=older_artifact,
  616. )
  617. older_checked = _verify_release(root, older, older_artifact, public_hex)
  618. next_rollback, _ = _run(
  619. "rollback", "--root", str(root), "--yes",
  620. "--expected-current-digest", str(target["manifest_digest"]),
  621. "--target-digest", str(older_checked["manifest_digest"]),
  622. "--trusted-key", f"release-key-1={public_hex}",
  623. )
  624. assert next_rollback["status"] == "rolled_back"
  625. assert next_rollback["version"] == "0.9.0"
  626. def test_release_check_verifies_ed25519_artifact_digest_and_replay(tmp_path):
  627. root = tmp_path / "edge-root"
  628. _init(root)
  629. artifact = root / "releases" / "edge-2.0.0.tar"
  630. artifact.write_bytes(b"verified edge release")
  631. private = ed25519.Ed25519PrivateKey.generate()
  632. public_hex = private.public_key().public_bytes(
  633. serialization.Encoding.Raw, serialization.PublicFormat.Raw
  634. ).hex()
  635. unsigned = {
  636. "artifact_digest": hashlib.sha256(artifact.read_bytes()).hexdigest(),
  637. "artifact_name": artifact.name,
  638. "deadline_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat().replace("+00:00", "Z"),
  639. "key_id": "release-key-1",
  640. "release_id": "rel-1",
  641. "rollback_version": "1.0.0",
  642. "signature_algorithm": "Ed25519",
  643. "status": "offered",
  644. "version": "2.0.0",
  645. }
  646. manifest = {
  647. **unsigned,
  648. "manifest_digest": canonical_sha256(unsigned),
  649. "signature": private.sign(canonical_json_bytes(unsigned)).hex(),
  650. }
  651. manifest_path = root / "releases" / "manifest.json"
  652. manifest_path.write_text(json.dumps(manifest))
  653. checked, _ = _run(
  654. "release-check", "--root", str(root), "--manifest", str(manifest_path),
  655. "--artifact", str(artifact), "--trusted-key", f"release-key-1={public_hex}",
  656. "--current-version", "1.0.0",
  657. )
  658. assert checked["status"] == "verified"
  659. assert checked["manifest_digest"] == manifest["manifest_digest"]
  660. bundle = Path(str(checked["verified_bundle_path"]))
  661. assert (bundle / "manifest.json").is_file()
  662. assert (bundle / artifact.name).read_bytes() == artifact.read_bytes()
  663. assert (bundle / "bundle.json").is_file()
  664. exact, _ = _run(
  665. "release-check", "--root", str(root), "--manifest", str(manifest_path),
  666. "--artifact", str(artifact), "--trusted-key", f"release-key-1={public_hex}",
  667. "--current-version", "1.0.0",
  668. )
  669. assert exact["replayed"] is True
  670. changed_unsigned = {**unsigned, "status": "accepted"}
  671. changed_replay = {
  672. **changed_unsigned,
  673. "manifest_digest": canonical_sha256(changed_unsigned),
  674. "signature": private.sign(canonical_json_bytes(changed_unsigned)).hex(),
  675. }
  676. manifest_path.write_text(json.dumps(changed_replay))
  677. replay_rejected, _ = _run(
  678. "release-check", "--root", str(root), "--manifest", str(manifest_path),
  679. "--artifact", str(artifact), "--trusted-key", f"release-key-1={public_hex}",
  680. "--current-version", "1.0.0", expect=3,
  681. )
  682. assert replay_rejected["error"]["code"] == "RELEASE_REJECTED"
  683. tampered = dict(manifest)
  684. tampered["version"] = "2.0.1"
  685. manifest_path.write_text(json.dumps(tampered))
  686. rejected, _ = _run(
  687. "release-check", "--root", str(root), "--manifest", str(manifest_path),
  688. "--artifact", str(artifact), "--trusted-key", f"release-key-1={public_hex}",
  689. "--current-version", "1.0.0", expect=3,
  690. )
  691. assert rejected["error"]["code"] == "RELEASE_REJECTED"
  692. def test_rollback_rejects_forged_verified_manifest_and_tampered_artifact(tmp_path):
  693. root = tmp_path / "edge-root"
  694. _init(root)
  695. key = ed25519.Ed25519PrivateKey.generate()
  696. public_hex = key.public_key().public_bytes(
  697. serialization.Encoding.Raw, serialization.PublicFormat.Raw
  698. ).hex()
  699. target_artifact = root / "releases" / "target.bin"
  700. target_artifact.write_bytes(b"target")
  701. target = _release_manifest(
  702. key, release_id="target", version="1.0.0", rollback_version="0.9.0",
  703. artifact=target_artifact,
  704. )
  705. target_checked = _verify_release(root, target, target_artifact, public_hex)
  706. current_artifact = root / "releases" / "current.bin"
  707. current_artifact.write_bytes(b"current")
  708. current = _release_manifest(
  709. key, release_id="current", version="2.0.0", rollback_version="1.0.0",
  710. artifact=current_artifact,
  711. )
  712. current_checked = _verify_release(root, current, current_artifact, public_hex)
  713. current_bundle = Path(str(current_checked["verified_bundle_path"]))
  714. bundle_record = json.loads((current_bundle / "bundle.json").read_text())
  715. (root / "releases" / "current.json").write_text(json.dumps({
  716. "artifact_name": current["artifact_name"],
  717. "bundle_digest": bundle_record["bundle_digest"],
  718. "manifest_digest": current["manifest_digest"],
  719. "schema_version": 1,
  720. "version": current["version"],
  721. }))
  722. for expected_digest, target_digest in (
  723. ("c" * 64, str(target["manifest_digest"])),
  724. (str(current["manifest_digest"]), "d" * 64),
  725. ):
  726. wrong, _ = _run(
  727. "rollback", "--root", str(root), "--yes",
  728. "--expected-current-digest", expected_digest,
  729. "--target-digest", target_digest,
  730. "--trusted-key", f"release-key-1={public_hex}", expect=3,
  731. )
  732. assert wrong["error"]["code"] in {"RELEASE_REJECTED", "STATE_CONFLICT"}
  733. target_bundle = Path(str(target_checked["verified_bundle_path"]))
  734. manifest_path = target_bundle / "manifest.json"
  735. manifest_record = json.loads(manifest_path.read_text())
  736. manifest_path.write_text(json.dumps({**manifest_record, "signature": "0" * 128}))
  737. forged, _ = _run(
  738. "rollback", "--root", str(root), "--yes",
  739. "--expected-current-digest", str(current["manifest_digest"]),
  740. "--target-digest", str(target["manifest_digest"]),
  741. "--trusted-key", f"release-key-1={public_hex}", expect=3,
  742. )
  743. assert forged["error"]["code"] == "RELEASE_REJECTED"
  744. manifest_path.write_text(json.dumps(manifest_record))
  745. (target_bundle / str(target["artifact_name"])).write_bytes(b"tampered")
  746. rejected, _ = _run(
  747. "rollback", "--root", str(root), "--yes",
  748. "--expected-current-digest", str(current["manifest_digest"]),
  749. "--target-digest", str(target["manifest_digest"]),
  750. "--trusted-key", f"release-key-1={public_hex}", expect=3,
  751. )
  752. assert rejected["error"]["code"] == "RELEASE_REJECTED"
  753. assert json.loads((root / "releases" / "current.json").read_text())["manifest_digest"] == current["manifest_digest"]
  754. def test_edge_compose_is_pull_only_and_hardened():
  755. model = yaml.safe_load(COMPOSE.read_text())
  756. assert set(model["services"]) == {"edge-gateway"}
  757. service = model["services"]["edge-gateway"]
  758. assert service["build"] == {
  759. "context": "../..", "dockerfile": "deploy/edge/Dockerfile.edge"
  760. }
  761. assert service["pull_policy"] == "${EDGE_PULL_POLICY:-build}"
  762. assert service["command"] == ["python", "-m", "app.edge_gateway"]
  763. assert "ports" not in service and "expose" not in service
  764. assert service["user"] not in {"0", 0, "root"}
  765. assert service["read_only"] is True
  766. assert service["cap_drop"] == ["ALL"]
  767. assert "no-new-privileges:true" in service["security_opt"]
  768. assert service["restart"] == "unless-stopped"
  769. assert service["pids_limit"] <= 256
  770. assert service["mem_limit"]
  771. assert service["cpus"]
  772. assert service["healthcheck"]["test"][0] == "CMD"
  773. assert service["user"] == "${EDGE_RUNTIME_UID:?}:${EDGE_RUNTIME_GID:?}"
  774. assert any(
  775. "uid=${EDGE_RUNTIME_UID:?},gid=${EDGE_RUNTIME_GID:?}" in item
  776. for item in service["tmpfs"]
  777. )
  778. volumes = "\n".join(service["volumes"])
  779. for marker in ("queue", "artifacts", "releases", "secrets", "certificates"):
  780. assert marker in volumes
  781. assert "/certificates:ro" in volumes and "/secrets:ro" in volumes
  782. env_text = ENV_EXAMPLE.read_text()
  783. assert "EDGE_CONTROL_ORIGIN=https://" in env_text
  784. assert "EDGE_ALLOWED_CONTROL_HOSTS=" in env_text
  785. assert "EDGE_RUNTIME_UID=" in env_text and "EDGE_RUNTIME_GID=" in env_text
  786. assert "EDGE_CLIENT_PRIVATE_KEY_PATH=/run/edge/secrets/client.key" in env_text
  787. assert service["environment"]["EDGE_POLL_INTERVAL_SECONDS"] == (
  788. "${EDGE_POLL_INTERVAL_SECONDS:-5}"
  789. )
  790. assert "EDGE_CREDENTIAL=" not in env_text
  791. assert "BEGIN PRIVATE KEY" not in env_text
  792. def test_real_cli_private_material_is_readable_by_configured_container_uid(tmp_path):
  793. image = subprocess.run(
  794. ["docker", "image", "inspect", "nginx:alpine"], capture_output=True
  795. )
  796. if image.returncode != 0:
  797. pytest.skip("local nginx:alpine image is required; this test never pulls")
  798. root = tmp_path / "edge-root"
  799. _init(root)
  800. _run("csr", "--root", str(root), "--common-name", "edge-test")
  801. completed = subprocess.run(
  802. [
  803. "docker", "run", "--rm", "--user", f"{os.getuid()}:{os.getgid()}",
  804. "-v", f"{root / 'secrets'}:/run/edge/secrets:ro",
  805. "nginx:alpine", "sh", "-c", "test -r /run/edge/secrets/client.key",
  806. ],
  807. capture_output=True,
  808. text=True,
  809. check=False,
  810. )
  811. assert completed.returncode == 0, completed.stderr
  812. def test_openapi_contains_closed_edge_contract_without_secret_response_fields():
  813. document = yaml.safe_load(OPENAPI.read_text())
  814. paths = document["paths"]
  815. expected = {
  816. "/api/datasource/edge/enrollments",
  817. "/api/datasource/edge/register",
  818. "/api/datasource/edge/gateways",
  819. "/api/datasource/edge/gateways/{gateway_id}/heartbeat",
  820. "/api/datasource/edge/gateways/{gateway_id}/tasks/pull",
  821. "/api/datasource/edge/gateways/{gateway_id}/tasks/{task_id}/outcome",
  822. "/api/datasource/edge/gateways/{gateway_id}/events",
  823. "/api/datasource/edge/gateways/{gateway_id}/reconcile",
  824. "/api/datasource/edge/gateways/{gateway_id}/releases",
  825. "/api/datasource/edge/gateways/{gateway_id}/releases/ack",
  826. }
  827. assert expected <= set(paths)
  828. machine = paths["/api/datasource/edge/gateways/{gateway_id}/heartbeat"]["post"]
  829. assert machine["x-max-request-bytes"] == 262_144
  830. assert machine["x-max-json-depth"] == 32
  831. assert machine["x-max-json-nodes"] == 5_000
  832. assert {parameter["name"] for parameter in machine["parameters"]} >= {
  833. "gateway_id", "X-Edge-Credential", "X-Edge-Certificate-SHA256",
  834. "X-DataOps-Edge-Client-Verify", "X-DataOps-Edge-Client-Cert",
  835. }
  836. assert machine["responses"]["200"]["headers"]["Cache-Control"]["schema"]["const"] == "no-store"
  837. assert machine["security"] == [{"mutualTLS": [], "edgeCredential": []}]
  838. assert paths["/api/datasource/edge/enrollments"]["post"]["security"] == [
  839. {"bearerAuth": []}
  840. ]
  841. assert paths["/api/datasource/edge/gateways/{gateway_id}/rotate"]["post"][
  842. "security"
  843. ] == [{"bearerAuth": []}]
  844. assert "requestBody" not in paths["/api/datasource/edge/tasks/{task_id}/cancel"][
  845. "post"
  846. ]
  847. schemas = document["components"]["schemas"]
  848. for name in (
  849. "EdgeEnrollmentRequest", "EdgeRegisterRequest", "EdgeSignedTaskEnvelope",
  850. "EdgeEventRequest", "EdgeReconcileResponse", "EdgeReleaseManifest",
  851. "EdgeTaskOutcomeRequest", "EdgeErrorEnvelope",
  852. ):
  853. assert name in schemas
  854. rendered = json.dumps(schemas).casefold()
  855. assert "private_key" not in rendered
  856. assert "credential_secret" not in rendered
  857. assert "lineage" in schemas["EdgeTaskContract"]["properties"]["classification"]["enum"]
  858. assert schemas["EdgeSafeSummary"]["x-max-policy-depth"] == 12
  859. assert schemas["EdgeRegisterRequest"]["properties"]["version"] == {
  860. "$ref": "#/components/schemas/EdgeAgentVersion"
  861. }
  862. assert schemas["EdgeHeartbeatRequest"]["properties"]["version"] == {
  863. "$ref": "#/components/schemas/EdgeAgentVersion"
  864. }
  865. assert schemas["EdgeReleaseEnvelope"]["properties"]["data"]["properties"][
  866. "version"
  867. ] == {"$ref": "#/components/schemas/EdgeVersion"}
  868. assert schemas["EdgeReleaseEnvelope"]["properties"]["data"]["properties"][
  869. "rollback_version"
  870. ] == {"$ref": "#/components/schemas/EdgeVersion"}
  871. list_parameters = {
  872. item["name"]: item["schema"]
  873. for item in paths["/api/datasource/edge/gateways"]["get"]["parameters"]
  874. }
  875. assert list_parameters["limit"] == {
  876. "type": "integer", "minimum": 1, "maximum": 100
  877. }
  878. assert list_parameters["offset"] == {
  879. "type": "integer", "minimum": 0, "maximum": 10_000
  880. }
  881. heartbeat_sample = {
  882. "environment": "production", "network_zone": "zone-a", "generation": 1,
  883. "version": "1.0.0", "safe_summary": {"queue_depth": 0},
  884. }
  885. release_data = schemas["EdgeReleaseEnvelope"]["properties"]["data"]
  886. assert release_data["properties"]["manifest"] == {
  887. "$ref": "#/components/schemas/EdgeReleaseManifest"
  888. }
  889. security = document["components"]["securitySchemes"]
  890. assert security["mutualTLS"] == {"type": "mutualTLS"}
  891. assert security["edgeCredential"]["name"] == "X-Edge-Credential"
  892. def local_refs(value):
  893. if isinstance(value, dict):
  894. return {
  895. key: (
  896. f"#/$defs/{item.rsplit('/', 1)[-1]}"
  897. if key == "$ref"
  898. and isinstance(item, str)
  899. and item.startswith("#/components/schemas/")
  900. else local_refs(item)
  901. )
  902. for key, item in value.items()
  903. }
  904. if isinstance(value, list):
  905. return [local_refs(item) for item in value]
  906. return value
  907. definitions = local_refs(schemas)
  908. heartbeat_validator = jsonschema.Draft202012Validator({
  909. "$defs": definitions, "$ref": "#/$defs/EdgeHeartbeatRequest"
  910. })
  911. heartbeat_validator.validate(heartbeat_sample)
  912. safe_validator = jsonschema.Draft202012Validator({
  913. "$defs": definitions, "$ref": "#/$defs/EdgeSafeSummary"
  914. })
  915. safe_validator.validate({"queue_depth": 0, "status": "healthy"})
  916. with pytest.raises(jsonschema.ValidationError):
  917. safe_validator.validate({"raw_rows": [{"secret": "x"}]})
  918. with pytest.raises(jsonschema.ValidationError):
  919. safe_validator.validate({"RawRows": [{"value": 1}]})
  920. with pytest.raises(jsonschema.ValidationError):
  921. safe_validator.validate({"PASSWORD": "masked"})
  922. too_deep: object = "leaf"
  923. for _ in range(13):
  924. too_deep = {"level": too_deep}
  925. with pytest.raises(jsonschema.ValidationError):
  926. safe_validator.validate({"summary": too_deep})
  927. with pytest.raises(jsonschema.ValidationError):
  928. safe_validator.validate({"message": "x" * 4097})
  929. cursor_validator = jsonschema.Draft202012Validator({
  930. "$defs": definitions, "$ref": "#/$defs/EdgeCursor"
  931. })
  932. with pytest.raises(jsonschema.ValidationError):
  933. cursor_validator.validate("../invalid cursor")
  934. def test_docs_and_machine_evidence_preserve_enterprise_uat_boundary():
  935. required = [
  936. ROOT / "deploy" / "edge" / "README.md",
  937. ROOT / "docs" / "runbooks" / "EDGE_GATEWAY_OPERATIONS.md",
  938. ROOT / "docs" / "phase3" / "P3_WP04_EDGE_GATEWAY.md",
  939. ROOT / "docs" / "validation" / "P3_WP04_EDGE_GATEWAY_EVIDENCE.md",
  940. ]
  941. combined = "\n".join(path.read_text() for path in required)
  942. assert "ENGINEERING_COMPLETE_ENTERPRISE_EDGE_UAT_BLOCKED" in combined
  943. assert "TBD_EXTERNAL" in combined
  944. for topic in (
  945. "默认拒绝", "数据分级", "证书轮换", "证书撤销", "断网", "重连",
  946. "取消", "回滚", "诊断", "备份", "恢复", "CRL", "v1", "v2",
  947. ):
  948. assert topic in combined
  949. assert "真实企业来源已通过" not in combined
  950. failures = json.loads(FAILURES.read_text())
  951. assert failures["work_package"] == "P3-WP04"
  952. assert failures["engineering_status"] == "ENGINEERING_COMPLETE_ENTERPRISE_EDGE_UAT_BLOCKED"
  953. assert failures["cases"]
  954. required_keys = {
  955. "case_id", "scope", "preconditions", "steps", "safety_boundary",
  956. "expected", "evidence", "rollback", "status",
  957. }
  958. assert all(required_keys <= set(case) for case in failures["cases"])
  959. assert any(case["status"] == "TBD_EXTERNAL" for case in failures["cases"])
  960. def test_operator_runbook_contains_executable_install_recovery_and_uat_gates():
  961. runbook = (ROOT / "docs/runbooks/EDGE_GATEWAY_OPERATIONS.md").read_text()
  962. for marker in (
  963. "Docker Engine 26", "Docker Compose 2.27", "Python 3.11",
  964. "sha256sum -c SHA256SUMS", "cosign verify-blob",
  965. "edge.config.schema.json", "docker image inspect",
  966. "health.json", "120 秒", "X-Edge-Enrollment", "X-Edge-Credential",
  967. "/tasks/{task_id}/outcome", "/reconcile", "/releases/ack",
  968. "edge_gateway_admin.py status", "人工介入阈值", "legal hold",
  969. "delete receipt", "sqlite3", "VACUUM INTO", "quick_check",
  970. "rollback-intent.json", "state/rollback-history/", "DB_ROLE_INIT_DATABASE_URL",
  971. "MIGRATION_DATABASE_URL", "has_schema_privilege", "CRL_CHECK_LEAF",
  972. "nginx -s reload", "TBD_EXTERNAL", "runtime-self-check",
  973. "format_checker=jsonschema.FormatChecker()", "healthy=0",
  974. "julianday(retention_until)<=julianday('now')",
  975. "PGSERVICE=dataops_migrator",
  976. ):
  977. assert marker in runbook
  978. assert "当前 queue schema 是 v1" not in runbook
  979. assert "20260809_477 (head)" not in runbook
  980. def test_edge_config_example_matches_closed_runtime_file_schema():
  981. schema = json.loads(EDGE_CONFIG_SCHEMA.read_text())
  982. example = json.loads(EDGE_CONFIG_EXAMPLE.read_text())
  983. jsonschema.Draft202012Validator.check_schema(schema)
  984. jsonschema.Draft202012Validator(schema).validate(example)
  985. assert schema["additionalProperties"] is False
  986. assert set(schema["required"]) == set(schema["properties"])
  987. def test_generator_is_reproducible_for_edge_contract(tmp_path):
  988. output = tmp_path / "openapi.yaml"
  989. subprocess.run(
  990. [sys.executable, str(ROOT / "scripts" / "generate_openapi.py"), "--output", str(output)],
  991. cwd=ROOT, check=True, capture_output=True, text=True,
  992. )
  993. assert output.read_bytes() == OPENAPI.read_bytes()
  994. def test_release_archive_contains_exact_edge_cli_compose_and_runbook(tmp_path):
  995. assert (ROOT / "deployment/scripts/edge_gateway_admin.py").read_bytes() == CLI.read_bytes()
  996. completed = subprocess.run(
  997. ["bash", str(ROOT / "deployment/package_release.sh")],
  998. cwd=ROOT,
  999. capture_output=True,
  1000. text=True,
  1001. check=False,
  1002. env={
  1003. **os.environ,
  1004. "DATAOPS_RELEASE_OUTPUT_DIR": str(tmp_path),
  1005. "DATAOPS_RELEASE_VERSION": "wp04-test",
  1006. },
  1007. )
  1008. assert completed.returncode == 0, completed.stderr
  1009. archive = tmp_path / "dataops-platform-release-wp04-test.tar.gz"
  1010. with tarfile.open(archive) as bundle:
  1011. names = set(bundle.getnames())
  1012. required = {
  1013. "dataops-platform/scripts/edge_gateway_admin.py",
  1014. "dataops-platform/deploy/edge/docker-compose.edge.yml",
  1015. "dataops-platform/deploy/edge/Dockerfile.edge",
  1016. "dataops-platform/deploy/edge/edge.config.example.json",
  1017. "dataops-platform/deploy/edge/edge.config.schema.json",
  1018. "dataops-platform/deploy/edge/edge.env.example",
  1019. "dataops-platform/deploy/edge/README.md",
  1020. "dataops-platform/docs/runbooks/EDGE_GATEWAY_OPERATIONS.md",
  1021. "dataops-platform/docs/architecture/OPENAPI.yaml",
  1022. "dataops-platform/docs/validation/P3_WP04_FAILURE_INJECTION.json",
  1023. "dataops-platform/SHA256SUMS",
  1024. }
  1025. assert required <= names
  1026. cli_member = bundle.extractfile("dataops-platform/scripts/edge_gateway_admin.py")
  1027. assert cli_member is not None and cli_member.read() == CLI.read_bytes()
  1028. assert not any("__pycache__" in name or name.endswith(".pyc") for name in names)
  1029. readme_member = bundle.extractfile("dataops-platform/deploy/edge/README.md")
  1030. assert readme_member is not None
  1031. archive_readme = readme_member.read().decode("utf-8")
  1032. assert "../../docs/runbooks/EDGE_GATEWAY_OPERATIONS.md" in archive_readme
  1033. checksum_member = bundle.extractfile("dataops-platform/SHA256SUMS")
  1034. assert checksum_member is not None
  1035. checksum_lines = checksum_member.read().decode("utf-8").splitlines()
  1036. assert checksum_lines
  1037. for line in checksum_lines:
  1038. digest, relative = line.split(" ", 1)
  1039. member = bundle.extractfile(f"dataops-platform/{relative.removeprefix('./')}")
  1040. assert member is not None
  1041. assert hashlib.sha256(member.read()).hexdigest() == digest
  1042. for name in names:
  1043. member = bundle.extractfile(name)
  1044. if member is None:
  1045. continue
  1046. content = member.read()
  1047. assert b"-----BEGIN PRIVATE KEY-----" not in content
  1048. assert b"-----BEGIN RSA PRIVATE KEY-----" not in content
  1049. if shutil.which("docker") is not None:
  1050. extracted = tmp_path / "extracted"
  1051. extracted.mkdir()
  1052. with tarfile.open(archive) as bundle:
  1053. bundle.extractall(extracted, filter="data")
  1054. release_root = extracted / "dataops-platform"
  1055. build = subprocess.run(
  1056. [
  1057. "docker", "build", "-f", "deploy/edge/Dockerfile.edge",
  1058. "-t", "dataops-edge:wp04-archive-smoke", ".",
  1059. ],
  1060. cwd=release_root,
  1061. capture_output=True,
  1062. text=True,
  1063. check=False,
  1064. )
  1065. assert build.returncode == 0, build.stderr[-4000:]
  1066. def test_release_secret_scanner_rejects_binary_and_extended_secret_shapes(tmp_path):
  1067. clean = tmp_path / "clean"
  1068. clean.mkdir()
  1069. (clean / "README.md").write_text("credential is mounted from a secret file\n")
  1070. accepted = subprocess.run(
  1071. [sys.executable, str(RELEASE_SECRET_SCANNER), str(clean)],
  1072. capture_output=True,
  1073. text=True,
  1074. check=False,
  1075. )
  1076. assert accepted.returncode == 0, accepted.stderr
  1077. cases = {
  1078. "encrypted.pem": b"-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAAA\n",
  1079. "identity.p12": b"0\x82\x01\x00binary-pkcs12",
  1080. "config.txt": b"credential = actual-production-value-123456\n",
  1081. }
  1082. for name, content in cases.items():
  1083. candidate = tmp_path / name
  1084. candidate.mkdir()
  1085. (candidate / name).write_bytes(content)
  1086. rejected = subprocess.run(
  1087. [sys.executable, str(RELEASE_SECRET_SCANNER), str(candidate)],
  1088. capture_output=True,
  1089. text=True,
  1090. check=False,
  1091. )
  1092. assert rejected.returncode != 0, name