|
3 | 3 |
|
4 | 4 | from __future__ import annotations |
5 | 5 |
|
| 6 | +import copy |
6 | 7 | import datetime as dt |
7 | 8 | import hashlib |
8 | 9 | import importlib.util |
9 | 10 | import io |
10 | 11 | import json |
| 12 | +import shutil |
11 | 13 | import subprocess |
12 | 14 | import sys |
13 | 15 | import tempfile |
|
16 | 18 | from pathlib import Path |
17 | 19 | from unittest import mock |
18 | 20 |
|
| 21 | +import release_recovery_consumer_conformance as consumer_conformance |
19 | 22 | from cli_release_verifier_contract import ( # noqa: F401 - imported for unittest discovery |
20 | 23 | CliRecoveryWorkflowSourceTest, |
21 | 24 | CliReleaseAuthorityTest, |
|
33 | 36 | RECOVERY_SCRIPT = Path(__file__).with_name("component-release-recovery.py") |
34 | 37 | CONSUMER_CONFORMANCE_SCRIPT = Path(__file__).with_name("release_recovery_consumer_conformance.py") |
35 | 38 | CONSUMER_CONTRACT_PATH = Path(__file__).with_name("release-recovery-consumer-contract.json") |
| 39 | +CONSUMER_ADAPTER_PATH = Path(__file__).with_name("release-recovery-consumer-adapter.json") |
36 | 40 | RUST_WORKFLOW_FIXTURE = Path(__file__).with_name("sdk-rust-release-plan-recovery.fixture.yml") |
37 | 41 | REPOSITORY_ROOT = Path(__file__).resolve().parents[2] |
38 | 42 | RECOVERY_WORKFLOW = REPOSITORY_ROOT / ".github/workflows/release-plan-recovery.yml" |
@@ -236,6 +240,114 @@ def test_unavailable_previous_commit_is_rejected(self): |
236 | 240 | ) |
237 | 241 |
|
238 | 242 |
|
| 243 | +class ConsumerContractIdentityRegressionTest(unittest.TestCase): |
| 244 | + def adapter_fixture( |
| 245 | + self, |
| 246 | + root: Path, |
| 247 | + ) -> tuple[dict[str, object], dict[str, object], str, Path, Path]: |
| 248 | + ci_root = root / "scripts/ci" |
| 249 | + ci_root.mkdir(parents=True) |
| 250 | + suite_path = ci_root / CONSUMER_CONFORMANCE_SCRIPT.name |
| 251 | + contract_path = ci_root / CONSUMER_CONTRACT_PATH.name |
| 252 | + consumer_path = ci_root / RECOVERY_SCRIPT.name |
| 253 | + verifier_path = ci_root / Path(__file__).name |
| 254 | + shutil.copyfile(CONSUMER_CONFORMANCE_SCRIPT, suite_path) |
| 255 | + contract = json.loads(CONSUMER_CONTRACT_PATH.read_text()) |
| 256 | + contract_raw = consumer_conformance.canonical_json(contract) |
| 257 | + contract_path.write_bytes(contract_raw) |
| 258 | + consumer_path.write_text("# consumer fixture\n") |
| 259 | + verifier_path.write_text("# verifier fixture\n") |
| 260 | + adapter = json.loads(CONSUMER_ADAPTER_PATH.read_text()) |
| 261 | + return ( |
| 262 | + adapter, |
| 263 | + contract, |
| 264 | + consumer_conformance.sha256_bytes(contract_raw), |
| 265 | + suite_path, |
| 266 | + contract_path, |
| 267 | + ) |
| 268 | + |
| 269 | + def test_matching_declared_and_invoked_contract_passes(self): |
| 270 | + with tempfile.TemporaryDirectory() as temporary: |
| 271 | + root = Path(temporary) |
| 272 | + adapter, contract, digest, suite_path, contract_path = self.adapter_fixture(root) |
| 273 | + |
| 274 | + consumer, command = consumer_conformance.validate_adapter( |
| 275 | + adapter, |
| 276 | + contract, |
| 277 | + digest, |
| 278 | + root, |
| 279 | + suite_path, |
| 280 | + contract_path, |
| 281 | + ) |
| 282 | + |
| 283 | + self.assertEqual("component-release-recovery.py", consumer.name) |
| 284 | + self.assertEqual(["{python}", "scripts/ci/test-component-release-recovery.py"], command) |
| 285 | + |
| 286 | + def test_alternate_invoked_contract_is_rejected(self): |
| 287 | + with tempfile.TemporaryDirectory() as temporary: |
| 288 | + root = Path(temporary) |
| 289 | + adapter, contract, digest, suite_path, contract_path = self.adapter_fixture(root) |
| 290 | + alternate_path = contract_path.with_name("alternate-contract.json") |
| 291 | + alternate_path.write_bytes(contract_path.read_bytes()) |
| 292 | + |
| 293 | + with self.assertRaisesRegex( |
| 294 | + consumer_conformance.ConformanceError, |
| 295 | + "invoked contract is not the adapter's declared contract", |
| 296 | + ): |
| 297 | + consumer_conformance.validate_adapter( |
| 298 | + adapter, |
| 299 | + contract, |
| 300 | + digest, |
| 301 | + root, |
| 302 | + suite_path, |
| 303 | + alternate_path, |
| 304 | + ) |
| 305 | + |
| 306 | + def test_stale_declared_contract_is_rejected(self): |
| 307 | + with tempfile.TemporaryDirectory() as temporary: |
| 308 | + root = Path(temporary) |
| 309 | + adapter, contract, digest, suite_path, contract_path = self.adapter_fixture(root) |
| 310 | + stale_path = contract_path.with_name("stale-contract.json") |
| 311 | + stale_contract = copy.deepcopy(contract) |
| 312 | + stale_contract["version"] = "1.4.0" |
| 313 | + stale_path.write_bytes(consumer_conformance.canonical_json(stale_contract)) |
| 314 | + adapter["contract"]["path"] = stale_path.relative_to(root).as_posix() |
| 315 | + |
| 316 | + with self.assertRaisesRegex( |
| 317 | + consumer_conformance.ConformanceError, |
| 318 | + "invoked contract is not the adapter's declared contract", |
| 319 | + ): |
| 320 | + consumer_conformance.validate_adapter( |
| 321 | + adapter, |
| 322 | + contract, |
| 323 | + digest, |
| 324 | + root, |
| 325 | + suite_path, |
| 326 | + contract_path, |
| 327 | + ) |
| 328 | + |
| 329 | + def test_mismatched_declared_contract_bytes_are_rejected(self): |
| 330 | + with tempfile.TemporaryDirectory() as temporary: |
| 331 | + root = Path(temporary) |
| 332 | + adapter, contract, digest, suite_path, contract_path = self.adapter_fixture(root) |
| 333 | + mismatched_contract = copy.deepcopy(contract) |
| 334 | + mismatched_contract["cases"][0]["requirement"] += " (mismatched declared bytes)" |
| 335 | + contract_path.write_bytes(consumer_conformance.canonical_json(mismatched_contract)) |
| 336 | + |
| 337 | + with self.assertRaisesRegex( |
| 338 | + consumer_conformance.ConformanceError, |
| 339 | + "declared contract does not match its version and digest pins", |
| 340 | + ): |
| 341 | + consumer_conformance.validate_adapter( |
| 342 | + adapter, |
| 343 | + contract, |
| 344 | + digest, |
| 345 | + root, |
| 346 | + suite_path, |
| 347 | + contract_path, |
| 348 | + ) |
| 349 | + |
| 350 | + |
239 | 351 | def load_recovery_for_retry_tests(): |
240 | 352 | loaded = globals().get("recovery") |
241 | 353 | if loaded is not None: |
|
0 commit comments