1010
1111from src .publish import (
1212 PublishSettings ,
13+ ReleaseArtifacts ,
1314 build_firestore_payload ,
1415 build_release_manifest ,
1516 build_storage_layout ,
@@ -31,6 +32,21 @@ def sha256_file(path: Path) -> str:
3132 return digest .hexdigest ()
3233
3334
35+ def build_publish_settings () -> PublishSettings :
36+ return PublishSettings (
37+ enabled = False ,
38+ dry_run = True ,
39+ mode = "core_major" ,
40+ project_id = None ,
41+ cloud_bucket = None ,
42+ cloud_root_prefix = "crypto-live-pool-pipelines" ,
43+ firestore_collection = "strategy" ,
44+ firestore_document = "CRYPTO_LIVE_POOL_ROTATION_LIVE_POOL" ,
45+ source_project = "crypto-live-pool-pipelines" ,
46+ upload_current_pointer = False ,
47+ )
48+
49+
3450class ReleaseContractValidationTests (unittest .TestCase ):
3551 def build_outputs (
3652 self ,
@@ -177,6 +193,21 @@ def build_outputs(
177193 },
178194 )
179195
196+ def build_runtime_identity_outputs (self , root : Path ) -> Path :
197+ self .build_outputs (
198+ root ,
199+ include_manifest = True ,
200+ include_runtime_evidence_identity = True ,
201+ )
202+ return root / "data" / "output"
203+
204+ def load_publish_context (
205+ self , output_dir : Path
206+ ) -> tuple [ReleaseArtifacts , PublishSettings , dict [str , object ]]:
207+ artifacts = load_release_artifacts (output_dir , "core_major" )
208+ settings = build_publish_settings ()
209+ return artifacts , settings , build_storage_layout (settings , artifacts )
210+
180211 def test_validate_release_outputs_accepts_consistent_contract (self ) -> None :
181212 with tempfile .TemporaryDirectory () as tmp_dir :
182213 root = Path (tmp_dir )
@@ -236,6 +267,110 @@ def test_identity_is_canonical_across_artifact_release_and_firestore(self) -> No
236267 self .assertEqual (release_manifest ["runtime_evidence_identity" ], identity )
237268 self .assertEqual (firestore_payload ["runtime_evidence_identity" ], identity )
238269
270+ def test_firestore_payload_preserves_exact_legacy_artifact_bytes (self ) -> None :
271+ with tempfile .TemporaryDirectory () as tmp_dir :
272+ root = Path (tmp_dir )
273+ output_dir = self .build_runtime_identity_outputs (root )
274+ legacy_path = output_dir / "live_pool_legacy.json"
275+ legacy_payload = json .loads (legacy_path .read_text (encoding = "utf-8" ))
276+ exact_bytes = ("\n " + json .dumps (legacy_payload , separators = (", " , ": " )) + "\n " ).encode (
277+ "utf-8"
278+ )
279+ legacy_path .write_bytes (exact_bytes )
280+
281+ manifest_path = output_dir / "artifact_manifest.json"
282+ manifest = json .loads (manifest_path .read_text (encoding = "utf-8" ))
283+ exact_digest = hashlib .sha256 (exact_bytes ).hexdigest ()
284+ manifest ["artifacts" ]["live_pool_legacy" ]["sha256" ] = exact_digest
285+ manifest ["runtime_evidence_identity" ]["artifacts" ]["live_pool_legacy" ][
286+ "sha256"
287+ ] = exact_digest
288+ write_json (manifest_path , manifest )
289+
290+ artifacts , settings , storage_layout = self .load_publish_context (output_dir )
291+ firestore_payload = build_firestore_payload (
292+ settings ,
293+ artifacts ,
294+ storage_layout ,
295+ )
296+
297+ handoff = firestore_payload ["live_pool_legacy_exact_bytes" ]
298+ self .assertEqual (
299+ handoff ["contract_version" ],
300+ "qsl.crypto_live_pool_legacy_exact_bytes.v1" ,
301+ )
302+ self .assertEqual (handoff ["encoding" ], "utf-8" )
303+ self .assertEqual (handoff ["utf8_text" ].encode ("utf-8" ), exact_bytes )
304+
305+ def test_firestore_payload_rejects_mutated_legacy_bytes_with_unchanged_identity (self ) -> None :
306+ with tempfile .TemporaryDirectory () as tmp_dir :
307+ root = Path (tmp_dir )
308+ output_dir = self .build_runtime_identity_outputs (root )
309+ artifacts , settings , storage_layout = self .load_publish_context (output_dir )
310+ with artifacts .live_pool_legacy_path .open ("ab" ) as handle :
311+ handle .write (b"\n " )
312+
313+ with self .assertRaisesRegex (ValueError , "digest mismatch" ):
314+ build_firestore_payload (
315+ settings ,
316+ artifacts ,
317+ storage_layout ,
318+ )
319+
320+ def test_firestore_payload_rejects_top_level_convenience_field_mismatch (self ) -> None :
321+ for field in ("symbols" , "symbol_map" ):
322+ with self .subTest (field = field ), tempfile .TemporaryDirectory () as tmp_dir :
323+ root = Path (tmp_dir )
324+ output_dir = self .build_runtime_identity_outputs (root )
325+ artifacts , settings , storage_layout = self .load_publish_context (output_dir )
326+ if field == "symbols" :
327+ artifacts .live_pool_legacy [field ].pop ("TRXUSDT" )
328+ else :
329+ artifacts .live_pool_legacy [field ]["TRXUSDT" ] = {
330+ "base_asset" : "MISMATCH"
331+ }
332+
333+ with self .assertRaisesRegex (ValueError , "convenience fields" ):
334+ build_firestore_payload (
335+ settings ,
336+ artifacts ,
337+ storage_layout ,
338+ )
339+
340+ def test_firestore_payload_rejects_invalid_legacy_artifact_bytes (self ) -> None :
341+ cases = {
342+ "invalid_utf8" : (b"\xff " , "valid UTF-8" ),
343+ "invalid_json" : (b"{" , "valid JSON" ),
344+ "non_object" : (b"[]" , "JSON object" ),
345+ }
346+ for label , (invalid_bytes , expected_error ) in cases .items ():
347+ with self .subTest (label = label ), tempfile .TemporaryDirectory () as tmp_dir :
348+ root = Path (tmp_dir )
349+ output_dir = self .build_runtime_identity_outputs (root )
350+ artifacts , settings , storage_layout = self .load_publish_context (output_dir )
351+ artifacts .live_pool_legacy_path .write_bytes (invalid_bytes )
352+
353+ with self .assertRaisesRegex (ValueError , expected_error ):
354+ build_firestore_payload (
355+ settings ,
356+ artifacts ,
357+ storage_layout ,
358+ )
359+
360+ def test_firestore_payload_rejects_missing_legacy_artifact (self ) -> None :
361+ with tempfile .TemporaryDirectory () as tmp_dir :
362+ root = Path (tmp_dir )
363+ output_dir = self .build_runtime_identity_outputs (root )
364+ artifacts , settings , storage_layout = self .load_publish_context (output_dir )
365+ artifacts .live_pool_legacy_path .unlink ()
366+
367+ with self .assertRaises (FileNotFoundError ):
368+ build_firestore_payload (
369+ settings ,
370+ artifacts ,
371+ storage_layout ,
372+ )
373+
239374 def test_artifact_byte_mutation_breaks_runtime_identity_before_publish (self ) -> None :
240375 with tempfile .TemporaryDirectory () as tmp_dir :
241376 root = Path (tmp_dir )
0 commit comments