test_artifacts.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. from __future__ import annotations
  2. import io
  3. from datetime import UTC, datetime
  4. from types import SimpleNamespace
  5. import polars as pl
  6. import pytest
  7. from app.core.common.identifiers import new_governance_uid
  8. class Response(io.BytesIO):
  9. def release_conn(self):
  10. return None
  11. class FakeMinio:
  12. def __init__(self):
  13. self.buckets = {"dataops-rules"}
  14. self.objects = {}
  15. def bucket_exists(self, bucket):
  16. return bucket in self.buckets
  17. def make_bucket(self, bucket):
  18. self.buckets.add(bucket)
  19. def put_object(
  20. self,
  21. bucket,
  22. key,
  23. data,
  24. length,
  25. *,
  26. content_type,
  27. metadata,
  28. ):
  29. payload = data.read(length)
  30. self.objects[(bucket, key)] = {
  31. "payload": payload,
  32. "content_type": content_type,
  33. "metadata": {
  34. f"x-amz-meta-{name.lower()}": str(value)
  35. for name, value in metadata.items()
  36. },
  37. }
  38. def stat_object(self, bucket, key):
  39. item = self.objects[(bucket, key)]
  40. return SimpleNamespace(
  41. size=len(item["payload"]),
  42. content_type=item["content_type"],
  43. metadata=item["metadata"],
  44. )
  45. def get_object(self, bucket, key):
  46. return Response(self.objects[(bucket, key)]["payload"])
  47. def _store(client, *, clock=None, max_rows=100):
  48. from app.runner.artifacts import ArtifactStore
  49. return ArtifactStore(
  50. client,
  51. bucket="dataops-rules",
  52. max_artifact_bytes=1024 * 1024,
  53. max_rows=max_rows,
  54. memory_limit_bytes=4 * 1024 * 1024,
  55. max_ttl_seconds=3600,
  56. clock=clock,
  57. )
  58. def test_artifact_store_generates_key_and_round_trips_digest_bound_lazyframe():
  59. client = FakeMinio()
  60. store = _store(client)
  61. correlation_id = new_governance_uid()
  62. artifact = store.write(
  63. pl.DataFrame(
  64. {
  65. "customer_id": [1, 2],
  66. "name": ["Alice", "Bob"],
  67. }
  68. ).lazy(),
  69. correlation_id,
  70. 300,
  71. )
  72. assert artifact["artifact_ref"].startswith(
  73. f"minio://dataops-rules/rules/{correlation_id}/"
  74. )
  75. assert artifact["artifact_ref"].endswith(".parquet")
  76. assert artifact["digest"]
  77. assert artifact["row_count"] == 2
  78. assert artifact["schema_hash"]
  79. assert artifact["expires_at"].endswith("Z")
  80. assert "dataops-test" not in repr(artifact)
  81. assert store.describe(artifact["artifact_ref"]) == artifact
  82. frame = store.read(artifact["artifact_ref"], artifact["digest"])
  83. assert isinstance(frame, pl.LazyFrame)
  84. assert frame.collect().to_dicts() == [
  85. {"customer_id": 1, "name": "Alice"},
  86. {"customer_id": 2, "name": "Bob"},
  87. ]
  88. @pytest.mark.parametrize(
  89. ("mutation", "message"),
  90. [
  91. (
  92. lambda item: item["metadata"].update(
  93. {"x-amz-meta-sha256": "0" * 64}
  94. ),
  95. "digest",
  96. ),
  97. (
  98. lambda item: item["metadata"].update(
  99. {"x-amz-meta-schema-sha256": "0" * 64}
  100. ),
  101. "schema",
  102. ),
  103. (
  104. lambda item: item["metadata"].update(
  105. {"x-amz-meta-expires-at": "2000-01-01T00:00:00Z"}
  106. ),
  107. "expired",
  108. ),
  109. (
  110. lambda item: item.update({"content_type": "text/plain"}),
  111. "content type",
  112. ),
  113. ],
  114. )
  115. def test_artifact_store_rejects_tampered_digest_schema_ttl_and_content(
  116. mutation, message
  117. ):
  118. client = FakeMinio()
  119. now = datetime(2026, 7, 23, 10, 0, tzinfo=UTC)
  120. store = _store(client, clock=lambda: now)
  121. artifact = store.write(
  122. pl.DataFrame({"id": [1]}).lazy(),
  123. new_governance_uid(),
  124. 300,
  125. )
  126. key = artifact["artifact_ref"].split("/", 3)[-1]
  127. mutation(client.objects[("dataops-rules", key)])
  128. with pytest.raises(ValueError, match=message):
  129. store.read(artifact["artifact_ref"], artifact["digest"])
  130. def test_artifact_store_rejects_rows_size_ttl_and_unowned_references():
  131. client = FakeMinio()
  132. store = _store(client, max_rows=2)
  133. with pytest.raises(ValueError, match="row"):
  134. store.write(
  135. pl.DataFrame({"id": [1, 2, 3]}).lazy(),
  136. new_governance_uid(),
  137. 60,
  138. )
  139. with pytest.raises(ValueError, match="TTL"):
  140. store.write(
  141. pl.DataFrame({"id": [1]}).lazy(),
  142. new_governance_uid(),
  143. 7200,
  144. )
  145. with pytest.raises(ValueError, match="artifact reference"):
  146. store.read(
  147. "minio://other-bucket/rules/unsafe/value.parquet",
  148. "0" * 64,
  149. )
  150. def test_artifact_store_does_not_return_ref_for_corrupted_server_content():
  151. class CorruptingMinio(FakeMinio):
  152. def put_object(self, bucket, key, *args, **kwargs):
  153. super().put_object(bucket, key, *args, **kwargs)
  154. payload = self.objects[(bucket, key)]["payload"]
  155. self.objects[(bucket, key)]["payload"] = bytes(
  156. [payload[0] ^ 1]
  157. ) + payload[1:]
  158. store = _store(CorruptingMinio())
  159. with pytest.raises(ValueError, match="digest|size"):
  160. store.write(
  161. pl.DataFrame({"id": [1]}).lazy(),
  162. new_governance_uid(),
  163. 60,
  164. )