test_wp13_delivery_contract.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import os
  5. import subprocess
  6. import tarfile
  7. from pathlib import Path
  8. import pytest
  9. ROOT = Path(__file__).resolve().parents[1]
  10. COMPOSE = ROOT / "deploy/docker/docker-compose.yml"
  11. OPERATIONS = ROOT / "deployment/compose"
  12. def _compose_config() -> dict:
  13. completed = subprocess.run(
  14. [
  15. "docker",
  16. "compose",
  17. "-f",
  18. str(COMPOSE),
  19. "config",
  20. "--format",
  21. "json",
  22. ],
  23. cwd=ROOT,
  24. check=True,
  25. capture_output=True,
  26. text=True,
  27. )
  28. return json.loads(completed.stdout)
  29. def _tree_hashes(root: Path) -> dict[str, str]:
  30. hashes: dict[str, str] = {}
  31. for path in sorted(root.rglob("*")):
  32. if not path.is_file():
  33. continue
  34. relative = path.relative_to(root)
  35. if "__pycache__" in relative.parts or path.suffix in {".pyc", ".pyo"}:
  36. continue
  37. hashes[relative.as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest()
  38. return hashes
  39. def test_release_copy_matches_all_sources_of_truth():
  40. for source, release in (
  41. (ROOT / "app", ROOT / "deployment/app"),
  42. (ROOT / "database", ROOT / "deployment/database"),
  43. (ROOT / "migrations", ROOT / "deployment/migrations"),
  44. ):
  45. assert _tree_hashes(release) == _tree_hashes(source)
  46. for filename in ("requirements.txt", "gunicorn_config.py", "__init__.py", "alembic.ini"):
  47. assert (ROOT / f"deployment/{filename}").read_bytes() == (
  48. ROOT / filename
  49. ).read_bytes()
  50. def test_locally_built_services_have_stable_offline_image_names():
  51. services = _compose_config()["services"]
  52. expected = {
  53. "backend": "dataops-platform-backend:local",
  54. "runner": "dataops-platform-runner:local",
  55. "frontend": "dataops-platform-frontend:local",
  56. }
  57. for service, image in expected.items():
  58. assert services[service]["image"] == image
  59. def test_published_ports_can_be_isolated_for_recovery_rehearsal():
  60. source = COMPOSE.read_text(encoding="utf-8")
  61. expected_overrides = {
  62. "DATAOPS_POSTGRES_PORT": "15432",
  63. "SOURCE_POSTGRES_PORT": "25432",
  64. "SOURCE_MYSQL_PORT": "23306",
  65. "NEO4J_HTTP_PORT": "17474",
  66. "NEO4J_BOLT_PORT": "17687",
  67. "MINIO_API_PORT": "19000",
  68. "MINIO_CONSOLE_PORT": "19001",
  69. "N8N_PORT": "15678",
  70. "KESTRA_PORT": "18080",
  71. "RUNNER_PORT": "15600",
  72. "BACKEND_PORT": "15500",
  73. "FRONTEND_PORT": "18183",
  74. }
  75. for name, default in expected_overrides.items():
  76. assert f"${{{name}:-{default}}}" in source
  77. @pytest.mark.parametrize(
  78. "script_name",
  79. (
  80. "preflight.sh",
  81. "install_offline.sh",
  82. "package_offline.sh",
  83. "backup.sh",
  84. "restore.sh",
  85. "rollback.sh",
  86. ),
  87. )
  88. def test_wp13_commands_publish_help_without_mutating_state(script_name: str):
  89. completed = subprocess.run(
  90. ["bash", str(OPERATIONS / script_name), "--help"],
  91. cwd=ROOT,
  92. capture_output=True,
  93. text=True,
  94. )
  95. assert completed.returncode == 0, completed.stderr
  96. assert "Usage:" in completed.stdout
  97. def test_offline_package_contains_runtime_payload_and_checksums(tmp_path: Path):
  98. completed = subprocess.run(
  99. [
  100. "bash",
  101. str(OPERATIONS / "package_offline.sh"),
  102. "--repo-root",
  103. str(ROOT),
  104. "--output-dir",
  105. str(tmp_path),
  106. "--version",
  107. "contract-test",
  108. "--skip-images",
  109. ],
  110. cwd=ROOT,
  111. capture_output=True,
  112. text=True,
  113. )
  114. assert completed.returncode == 0, completed.stderr
  115. archive = tmp_path / "dataops-platform-offline-contract-test.tar.gz"
  116. assert archive.is_file()
  117. with tarfile.open(archive, "r:gz") as package:
  118. names = set(package.getnames())
  119. checksum_member = package.extractfile(
  120. "dataops-platform-offline-contract-test/checksums.sha256"
  121. )
  122. assert checksum_member is not None
  123. checksums = checksum_member.read().decode("utf-8")
  124. prefix = "dataops-platform-offline-contract-test/"
  125. for relative in (
  126. "deploy/docker/docker-compose.yml",
  127. "deploy/docker/.env.example",
  128. "database/create_data_orders_table.sql",
  129. "migrations/env.py",
  130. "operations/install_offline.sh",
  131. "operations/preflight.sh",
  132. "operations/backup.sh",
  133. "operations/restore.sh",
  134. "release-manifest.txt",
  135. "checksums.sha256",
  136. ):
  137. assert prefix + relative in names
  138. plugin_migration = "migrations/versions/20260818_559_plugin_three_identity_gate.py"
  139. assert prefix + plugin_migration in names
  140. digest = hashlib.sha256((ROOT / plugin_migration).read_bytes()).hexdigest()
  141. assert f"{digest} {plugin_migration}" in checksums
  142. def test_preflight_rejects_modified_bundle_before_docker_access(tmp_path: Path):
  143. bundle = tmp_path / "bundle"
  144. bundle.mkdir()
  145. payload = bundle / "release-manifest.txt"
  146. payload.write_text("version=contract-test\n", encoding="utf-8")
  147. digest = hashlib.sha256(payload.read_bytes()).hexdigest()
  148. (bundle / "checksums.sha256").write_text(
  149. f"{digest} release-manifest.txt\n",
  150. encoding="utf-8",
  151. )
  152. payload.write_text("version=tampered\n", encoding="utf-8")
  153. env = os.environ.copy()
  154. env["PATH"] = "/usr/bin:/bin"
  155. completed = subprocess.run(
  156. [
  157. "bash",
  158. str(OPERATIONS / "preflight.sh"),
  159. "--bundle-root",
  160. str(bundle),
  161. "--checksums-only",
  162. ],
  163. cwd=ROOT,
  164. env=env,
  165. capture_output=True,
  166. text=True,
  167. )
  168. assert completed.returncode != 0
  169. assert "checksum" in (completed.stdout + completed.stderr).lower()
  170. def test_restore_requires_exact_project_confirmation_before_file_or_docker_access():
  171. completed = subprocess.run(
  172. [
  173. "bash",
  174. str(OPERATIONS / "restore.sh"),
  175. "--backup-dir",
  176. "/path/that/does/not/exist",
  177. "--project-name",
  178. "dataops-wp13-restore",
  179. "--confirm-project",
  180. "wrong-project",
  181. ],
  182. cwd=ROOT,
  183. capture_output=True,
  184. text=True,
  185. )
  186. assert completed.returncode != 0
  187. assert "confirmation" in (completed.stdout + completed.stderr).lower()
  188. def test_rollback_never_attempts_database_downgrade():
  189. source = (OPERATIONS / "rollback.sh").read_text(encoding="utf-8")
  190. assert "alembic downgrade" not in source
  191. assert "restore.sh" in source