test_development_api.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. from __future__ import annotations
  2. from dataclasses import replace
  3. import pytest
  4. class FakeDevelopmentService:
  5. def __init__(self, record):
  6. self.record = record
  7. self.actions = []
  8. def create_job(self, payload, actor_uid):
  9. self.actions.append(("create", payload, actor_uid))
  10. return replace(self.record, actor_uid=actor_uid), True
  11. def list_jobs(self, filters=None):
  12. self.actions.append(("list", filters or {}))
  13. return [self.record]
  14. def get_job(self, uid):
  15. self.actions.append(("get", uid))
  16. return self.record
  17. def retry(self, uid):
  18. self.actions.append(("retry", uid))
  19. return replace(self.record, status="queued", last_error=None)
  20. def cancel(self, uid):
  21. self.actions.append(("cancel", uid))
  22. return replace(self.record, status="cancelled")
  23. class FakeSourceRegistrar:
  24. def __init__(self):
  25. self.actions = []
  26. def ensure(self, source_uid, actor_uid):
  27. self.actions.append((source_uid, actor_uid))
  28. return object(), True
  29. class FakeCatalogExecutor:
  30. def __init__(self, record):
  31. self.record = record
  32. self.actions = []
  33. def execute(self, uid):
  34. self.actions.append(uid)
  35. return replace(
  36. self.record,
  37. status="awaiting_review",
  38. attempt_count=1,
  39. statistics={
  40. "snapshot_uid": "snapshot-1",
  41. "asset_count": 2,
  42. "field_count": 8,
  43. "evidence_count": 8,
  44. },
  45. )
  46. class FakeCatalogSnapshotRepository:
  47. def list(self, job_uid):
  48. from app.core.data_research.catalog.models import CatalogSnapshotRecord
  49. return [
  50. CatalogSnapshotRecord(
  51. uid="snapshot-1",
  52. job_uid=job_uid,
  53. source_uid="00000000-0000-0000-0000-000000000001",
  54. attempt=1,
  55. database_type="postgresql",
  56. content_hash="b" * 64,
  57. snapshot={
  58. "data_source_uid": "00000000-0000-0000-0000-000000000001",
  59. "database_type": "postgresql",
  60. "assets": [],
  61. },
  62. evidence_count=8,
  63. )
  64. ]
  65. class FakeEvidenceService:
  66. def list_for_job(self, job_uid):
  67. return [
  68. {
  69. "uid": "evidence-1",
  70. "job_uid": job_uid,
  71. "locator": {
  72. "kind": "database.column",
  73. "schema": "asset",
  74. "table": "equipment",
  75. "column": "equipment_code",
  76. },
  77. "excerpt": "password=[redacted]",
  78. "confidence": 1.0,
  79. }
  80. ]
  81. @pytest.fixture()
  82. def development_client(monkeypatch):
  83. from flask import request
  84. from app import create_app
  85. from app.api.data_development import routes
  86. from app.core.data_research.models import IngestionJobRecord
  87. from app.core.system import permissions
  88. record = IngestionJobRecord(
  89. uid="00000000-0000-0000-0000-000000000010",
  90. source_uid="00000000-0000-0000-0000-000000000001",
  91. artifact_uid="00000000-0000-0000-0000-000000000002",
  92. job_type="file_extract",
  93. parser_version="csv-v1",
  94. idempotency_key="a" * 64,
  95. actor_uid="editor-1",
  96. parameters={"schema": "public"},
  97. )
  98. service = FakeDevelopmentService(record)
  99. registrar = FakeSourceRegistrar()
  100. executor = FakeCatalogExecutor(record)
  101. def identity():
  102. header = request.headers.get("Authorization", "")
  103. if header == "Bearer viewer":
  104. return {"id": "viewer-1", "roles": ["viewer"]}
  105. if header == "Bearer editor":
  106. return {"id": "editor-1", "roles": ["editor"]}
  107. if header == "Bearer other-editor":
  108. return {"id": "editor-2", "roles": ["editor"]}
  109. if header == "Bearer admin":
  110. return {"id": "admin-1", "roles": ["admin"]}
  111. return None
  112. monkeypatch.setattr(permissions, "authenticate_request", identity)
  113. monkeypatch.setattr(routes, "get_ingestion_service", lambda: service)
  114. monkeypatch.setattr(
  115. routes,
  116. "get_database_source_registration_service",
  117. lambda: registrar,
  118. )
  119. monkeypatch.setattr(
  120. routes,
  121. "get_catalog_ingestion_executor",
  122. lambda: executor,
  123. )
  124. monkeypatch.setattr(
  125. routes,
  126. "get_catalog_snapshot_repository",
  127. lambda: FakeCatalogSnapshotRepository(),
  128. )
  129. monkeypatch.setattr(
  130. routes,
  131. "get_evidence_service",
  132. lambda: FakeEvidenceService(),
  133. )
  134. app = create_app()
  135. app.config.update(TESTING=True)
  136. service.registrar = registrar
  137. service.executor = executor
  138. return app.test_client(), service
  139. def job_payload():
  140. return {
  141. "source_uid": "00000000-0000-0000-0000-000000000001",
  142. "artifact_uid": "00000000-0000-0000-0000-000000000002",
  143. "job_type": "file_extract",
  144. "parser_version": "csv-v1",
  145. "parameters": {"schema": "public"},
  146. "password": "must-not-return",
  147. }
  148. def test_ingestion_api_requires_authentication_and_run_permission(development_client):
  149. client, _service = development_client
  150. assert client.post("/api/development/v1/ingestion-jobs", json=job_payload()).status_code == 401
  151. assert client.post(
  152. "/api/development/v1/ingestion-jobs",
  153. json=job_payload(),
  154. headers={"Authorization": "Bearer viewer"},
  155. ).status_code == 403
  156. def test_editor_creates_and_lists_secret_free_jobs(development_client):
  157. client, service = development_client
  158. created = client.post(
  159. "/api/development/v1/ingestion-jobs",
  160. json=job_payload(),
  161. headers={"Authorization": "Bearer editor"},
  162. )
  163. listed = client.get(
  164. "/api/development/v1/ingestion-jobs?status=created",
  165. headers={"Authorization": "Bearer editor"},
  166. )
  167. assert created.status_code == 201
  168. assert created.get_json()["data"]["uid"].endswith("0010")
  169. assert "must-not-return" not in created.get_data(as_text=True)
  170. assert listed.status_code == 200
  171. assert listed.get_json()["data"]["total"] == 1
  172. assert service.actions[-1] == ("list", {"status": "created"})
  173. def test_retry_is_admin_only_and_cancel_is_owner_or_admin(development_client):
  174. client, _service = development_client
  175. uid = "00000000-0000-0000-0000-000000000010"
  176. editor_retry = client.post(
  177. f"/api/development/v1/ingestion-jobs/{uid}/retry",
  178. headers={"Authorization": "Bearer editor"},
  179. )
  180. admin_retry = client.post(
  181. f"/api/development/v1/ingestion-jobs/{uid}/retry",
  182. headers={"Authorization": "Bearer admin"},
  183. )
  184. other_cancel = client.post(
  185. f"/api/development/v1/ingestion-jobs/{uid}/cancel",
  186. headers={"Authorization": "Bearer other-editor"},
  187. )
  188. owner_cancel = client.post(
  189. f"/api/development/v1/ingestion-jobs/{uid}/cancel",
  190. headers={"Authorization": "Bearer editor"},
  191. )
  192. assert editor_retry.status_code == 403
  193. assert admin_retry.status_code == 200
  194. assert other_cancel.status_code == 403
  195. assert owner_cancel.status_code == 200
  196. def test_catalog_job_registers_source_then_executes_with_report(
  197. development_client,
  198. ):
  199. client, service = development_client
  200. payload = {
  201. **job_payload(),
  202. "artifact_uid": None,
  203. "job_type": "catalog_collect",
  204. "parser_version": "catalog-v1",
  205. }
  206. created = client.post(
  207. "/api/development/v1/ingestion-jobs",
  208. json=payload,
  209. headers={"Authorization": "Bearer editor"},
  210. )
  211. executed = client.post(
  212. "/api/development/v1/ingestion-jobs/"
  213. "00000000-0000-0000-0000-000000000010/execute",
  214. headers={"Authorization": "Bearer editor"},
  215. )
  216. assert created.status_code == 201
  217. assert service.registrar.actions == [
  218. ("00000000-0000-0000-0000-000000000001", "editor-1")
  219. ]
  220. assert executed.status_code == 200
  221. assert executed.get_json()["data"]["status"] == "awaiting_review"
  222. assert executed.get_json()["data"]["attempt_count"] == 1
  223. assert service.executor.actions == [
  224. "00000000-0000-0000-0000-000000000010"
  225. ]
  226. def test_catalog_snapshots_and_evidence_are_readable_without_secrets(
  227. development_client,
  228. ):
  229. client, _service = development_client
  230. uid = "00000000-0000-0000-0000-000000000010"
  231. snapshots = client.get(
  232. f"/api/development/v1/ingestion-jobs/{uid}/catalog-snapshots",
  233. headers={"Authorization": "Bearer viewer"},
  234. )
  235. evidence = client.get(
  236. f"/api/development/v1/ingestion-jobs/{uid}/evidence",
  237. headers={"Authorization": "Bearer viewer"},
  238. )
  239. assert snapshots.status_code == 200
  240. assert snapshots.get_json()["data"]["records"][0]["attempt"] == 1
  241. assert evidence.status_code == 200
  242. text = evidence.get_data(as_text=True)
  243. assert "clear-secret" not in text
  244. assert "equipment_code" in text