test_data_rule_api.py 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195
  1. from __future__ import annotations
  2. from datetime import UTC, datetime, timedelta
  3. import pytest
  4. from app.core.common.identifiers import new_governance_uid
  5. from app.core.data_rules.contracts import rule_spec_hash
  6. from app.core.system.tokens import decode_access_token, issue_access_token
  7. from tests.core.data_rules.test_contracts import (
  8. valid_dataflow_spec,
  9. valid_rule_spec,
  10. valid_standard_spec,
  11. )
  12. from tests.core.data_rules.test_production_line import (
  13. assertion_only_rule,
  14. published_rule,
  15. published_standard,
  16. )
  17. class FakeAuthoringAgent:
  18. def __init__(self):
  19. self.calls = []
  20. def interpret(self, **kwargs):
  21. self.calls.append(kwargs)
  22. return {
  23. "status": "ready",
  24. "source_text": kwargs["source_text"],
  25. "candidate_hash": "a" * 64,
  26. "context_hash": "b" * 64,
  27. "candidate": {
  28. "candidate_type": "rule",
  29. "rule_spec": valid_rule_spec(),
  30. },
  31. }
  32. class FakeRuleRepository:
  33. def __init__(self):
  34. self.calls = []
  35. self.catalog_items = [
  36. {
  37. "asset_type": "rule",
  38. "version_id": new_governance_uid(),
  39. "asset_uid": new_governance_uid(),
  40. "name": "手机号规范化",
  41. "version": 3,
  42. "owner": new_governance_uid(),
  43. "status": "published",
  44. "schema_compatibility": {
  45. "input": "a" * 64,
  46. "output": "b" * 64,
  47. },
  48. "impact_count": 2,
  49. "backend": "polars_batch",
  50. "latest_evidence": {
  51. "compile": {"status": "success"},
  52. "test": {"status": "success"},
  53. },
  54. }
  55. ]
  56. def create_rule_version(self, **kwargs):
  57. self.calls.append(("create_rule_version", kwargs))
  58. return {
  59. "id": new_governance_uid(),
  60. "rule_uid": kwargs["rule_spec"]["rule_uid"],
  61. "version_no": 1,
  62. "status": "draft",
  63. "spec_hash": rule_spec_hash(kwargs["rule_spec"]),
  64. "created": True,
  65. }
  66. def record_generation_run(self, **kwargs):
  67. self.calls.append(("record_generation_run", kwargs))
  68. return {
  69. "id": new_governance_uid(),
  70. "correlation_id": new_governance_uid(),
  71. "decision": kwargs["evidence"]["status"],
  72. }
  73. def resolve_validation_context(self, context):
  74. self.calls.append(("resolve_validation_context", {"context": context}))
  75. return {
  76. "input_schema_snapshot_id": new_governance_uid(),
  77. "input_schema_hash": "c" * 64,
  78. "input_fields": [{"name": "mobile", "type": "string"}],
  79. "output_schema_snapshot_id": new_governance_uid(),
  80. "output_schema_hash": "d" * 64,
  81. "output_fields": [{"name": "mobile", "type": "string"}],
  82. "input_sample_artifact_ref": "minio://trusted/input.parquet",
  83. "input_sample_artifact_digest": "e" * 64,
  84. "golden_output_artifact_ref": None,
  85. "golden_output_artifact_digest": None,
  86. }
  87. def publish_rule_version(self, **kwargs):
  88. self.calls.append(("publish_rule_version", kwargs))
  89. return {
  90. "id": kwargs["version_id"],
  91. "rule_uid": new_governance_uid(),
  92. "version_no": 1,
  93. "status": "published",
  94. "spec_hash": "a" * 64,
  95. }
  96. def create_standard_version(self, **kwargs):
  97. self.calls.append(("create_standard_version", kwargs))
  98. return {
  99. "id": new_governance_uid(),
  100. "standard_uid": kwargs["standard_spec"]["standard_uid"],
  101. "version_no": 1,
  102. "status": "validated",
  103. "spec_hash": "b" * 64,
  104. "created": True,
  105. }
  106. def publish_standard_version(self, **kwargs):
  107. self.calls.append(("publish_standard_version", kwargs))
  108. return {
  109. "id": kwargs["version_id"],
  110. "standard_uid": new_governance_uid(),
  111. "version_no": 1,
  112. "status": "published",
  113. "spec_hash": "b" * 64,
  114. }
  115. def search_published_assets(self, **kwargs):
  116. self.calls.append(("search_published_assets", kwargs))
  117. return {
  118. "items": self.catalog_items,
  119. "total": 1,
  120. "limit": kwargs["limit"],
  121. "offset": kwargs["offset"],
  122. }
  123. def get_asset_evidence(self, **kwargs):
  124. self.calls.append(("get_asset_evidence", kwargs))
  125. return {
  126. "asset_type": kwargs["asset_type"],
  127. "version_id": kwargs["version_id"],
  128. "stages": {
  129. "generation": {"status": "ready"},
  130. "logical_compile": {"status": "success"},
  131. "dry_run": {"status": "success"},
  132. "publication": {"status": "published"},
  133. "physical": [
  134. {
  135. "backend": "polars_batch",
  136. "compile": {"status": "success"},
  137. "test": {"status": "success"},
  138. }
  139. ],
  140. },
  141. }
  142. def get_published_asset(self, **kwargs):
  143. self.calls.append(("get_published_asset", kwargs))
  144. return self.catalog_items[0]
  145. class FakeReleaseService:
  146. def __init__(self):
  147. self.calls = []
  148. def release(self, **kwargs):
  149. self.calls.append(kwargs)
  150. return {
  151. "id": new_governance_uid(),
  152. "version_no": 1,
  153. "status": "released",
  154. "package_hash": "c" * 64,
  155. "package": {
  156. "package_hash": "c" * 64,
  157. "standard_version_ids": [],
  158. "rule_version_ids": [],
  159. },
  160. }
  161. class FakeDeploymentService:
  162. def __init__(self):
  163. self.calls = []
  164. self.deployment_id = new_governance_uid()
  165. self.evidence_id = new_governance_uid()
  166. def create(self, version_id, **kwargs):
  167. self.calls.append(("create", version_id, kwargs))
  168. return {"id": self.deployment_id, "status": "draft"}
  169. def deploy_disabled(self, deployment_id, actor_uid, **kwargs):
  170. self.calls.append(("deploy_disabled", deployment_id, actor_uid, kwargs))
  171. return {"id": deployment_id, "status": "disabled"}
  172. def run_canary(self, deployment_id, inputs, actor_uid, **kwargs):
  173. self.calls.append(
  174. ("run_canary", deployment_id, inputs, actor_uid, kwargs)
  175. )
  176. return {
  177. "id": self.evidence_id,
  178. "deployment_id": deployment_id,
  179. "status": "passed",
  180. }
  181. def activate(self, deployment_id, evidence_id, actor_uid, **kwargs):
  182. self.calls.append(
  183. ("activate", deployment_id, evidence_id, actor_uid, kwargs)
  184. )
  185. return {"id": deployment_id, "status": "active"}
  186. def rollback(self, deployment_id, actor_uid, **kwargs):
  187. self.calls.append(("rollback", deployment_id, actor_uid, kwargs))
  188. return {
  189. "rolled_back": {"id": deployment_id, "status": "rolled_back"},
  190. "active": {"id": new_governance_uid(), "status": "active"},
  191. }
  192. def list(self, environment=None):
  193. self.calls.append(("list", environment))
  194. return [{"id": self.deployment_id, "status": "draft"}]
  195. class FakePublicationService:
  196. def __init__(self, repository):
  197. self.repository = repository
  198. self.plan_id = new_governance_uid()
  199. self.version_id = None
  200. def create_draft(self, **kwargs):
  201. self.repository.calls.append(("create_rule_version", kwargs))
  202. self.version_id = new_governance_uid()
  203. return {
  204. "id": self.version_id,
  205. "rule_uid": kwargs["rule_spec"]["rule_uid"],
  206. "version_no": 1,
  207. "status": "draft",
  208. "spec_hash": rule_spec_hash(kwargs["rule_spec"]),
  209. "generation_run_id": new_governance_uid(),
  210. "created": True,
  211. }
  212. def validate(self, version_id, actor_uid):
  213. self.repository.calls.append(
  214. (
  215. "validate_rule_version",
  216. {"version_id": version_id, "actor_uid": actor_uid},
  217. )
  218. )
  219. return {
  220. "version_id": version_id,
  221. "version_status": "draft",
  222. "plan_id": self.plan_id,
  223. "plan_status": "compiled",
  224. "plan_hash": "d" * 64,
  225. }
  226. def test(self, version_id, actor_uid, *, plan_id):
  227. self.repository.calls.append(
  228. (
  229. "test_rule_version",
  230. {
  231. "version_id": version_id,
  232. "actor_uid": actor_uid,
  233. "plan_id": plan_id,
  234. },
  235. )
  236. )
  237. return {
  238. "version_id": version_id,
  239. "version_status": "validated",
  240. "plan_id": plan_id,
  241. "plan_status": "tested",
  242. "plan_hash": "d" * 64,
  243. }
  244. def publish(self, version_id, actor_uid):
  245. self.repository.calls.append(
  246. (
  247. "publish_rule_version",
  248. {"version_id": version_id, "actor_uid": actor_uid},
  249. )
  250. )
  251. return {
  252. "id": version_id,
  253. "status": "published",
  254. "plan_id": self.plan_id,
  255. "plan_status": "published",
  256. }
  257. def evidence(self, version_id):
  258. return {"version_id": version_id, "version_status": "validated"}
  259. def catalog(self, *, query, limit):
  260. return []
  261. class FakeGraphSession:
  262. def __init__(self):
  263. self.calls = []
  264. def run(self, query, parameters):
  265. self.calls.append((query, parameters))
  266. return [
  267. {
  268. "domain_id": 9,
  269. "domain_key": "customer_raw",
  270. "revision": "v2",
  271. "field_name": "customer_id",
  272. "data_type": "string",
  273. "nullable": False,
  274. "precision": None,
  275. "scale": None,
  276. "timezone": None,
  277. }
  278. ]
  279. def __enter__(self):
  280. return self
  281. def __exit__(self, *_args):
  282. return False
  283. class FakeGraphDriver:
  284. def __init__(self):
  285. self.session = FakeGraphSession()
  286. def get_session(self):
  287. return self.session
  288. class SnapshotOnlyRepository:
  289. def __init__(self):
  290. self.snapshots = {}
  291. def find_schema_snapshot(self, *, schema_ref, schema_hash):
  292. return self.snapshots.get((schema_ref, schema_hash))
  293. def persist_schema_snapshot(self, *, snapshot):
  294. value = {"id": new_governance_uid(), **snapshot}
  295. self.snapshots[(snapshot["schema_ref"], snapshot["schema_hash"])] = (
  296. value
  297. )
  298. return value
  299. def _headers(app, role):
  300. token = issue_access_token(
  301. user_id=new_governance_uid(),
  302. roles=[role],
  303. secret=app.config["SECRET_KEY"],
  304. now=datetime.now(UTC),
  305. lifetime=timedelta(minutes=10),
  306. )
  307. return {"Authorization": f"Bearer {token}"}
  308. def _use_token_identity(monkeypatch):
  309. def load(token, *, secret):
  310. claims = decode_access_token(token, secret=secret)
  311. return {
  312. "id": claims["sub"],
  313. "username": "contract-test",
  314. "display_name": "Contract Test",
  315. "roles": claims["roles"],
  316. }
  317. monkeypatch.setattr(
  318. "app.core.system.auth.load_identity_from_token",
  319. load,
  320. )
  321. def test_rule_capabilities_and_validation_are_registered_and_governed(
  322. monkeypatch,
  323. ):
  324. from app import create_app
  325. app = create_app()
  326. _use_token_identity(monkeypatch)
  327. app.config["TESTING"] = True
  328. app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
  329. "dedicated-test-receipt-secret-with-entropy"
  330. )
  331. client = app.test_client()
  332. response = client.get(
  333. "/api/rules/capabilities", headers=_headers(app, "viewer")
  334. )
  335. assert response.status_code == 200
  336. capabilities = response.get_json()["data"]
  337. assert capabilities["natural_language_authoring"] is True
  338. assert capabilities["immutable_asset_versions"] is True
  339. assert capabilities["server_side_publishing"] is True
  340. assert capabilities["production_line_release"] is True
  341. assert capabilities["data_factory_activation"] is False
  342. spec = valid_rule_spec()
  343. response = client.post(
  344. "/api/rules/validate",
  345. json={"asset_type": "rule", "spec": spec},
  346. headers=_headers(app, "editor"),
  347. )
  348. assert response.status_code == 200
  349. result = response.get_json()["data"]
  350. assert result["spec_hash"] == rule_spec_hash(spec)
  351. assert result["normalized"]["rule_uid"] == spec["rule_uid"]
  352. forbidden = client.post(
  353. "/api/rules/validate",
  354. json={"asset_type": "rule", "spec": spec},
  355. headers=_headers(app, "viewer"),
  356. )
  357. assert forbidden.status_code == 403
  358. def test_rule_interpret_uses_configured_agent_and_preserves_surface(
  359. monkeypatch,
  360. ):
  361. from app import create_app
  362. app = create_app()
  363. _use_token_identity(monkeypatch)
  364. app.config["TESTING"] = True
  365. app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
  366. "dedicated-test-receipt-secret-with-entropy"
  367. )
  368. agent = FakeAuthoringAgent()
  369. app.extensions["data_rule_authoring_agent"] = agent
  370. repository = FakeRuleRepository()
  371. app.extensions["data_rule_repository"] = repository
  372. client = app.test_client()
  373. response = client.post(
  374. "/api/rules/interpret",
  375. json={
  376. "source_text": "手机号去空格后必须为11位数字",
  377. "authoring_surface": "data_standard",
  378. "context": {
  379. "input_schema_snapshot_id": new_governance_uid(),
  380. "output_schema_snapshot_id": new_governance_uid(),
  381. "input_sample_artifact_ref": ("minio://trusted/input.parquet"),
  382. "golden_output_artifact_ref": None,
  383. },
  384. },
  385. headers=_headers(app, "editor"),
  386. )
  387. assert response.status_code == 200
  388. assert response.get_json()["data"]["status"] == "ready"
  389. assert response.get_json()["data"]["generation_run_id"]
  390. assert response.get_json()["data"]["generation_receipt"]
  391. assert agent.calls[0]["authoring_surface"] == "data_standard"
  392. assert repository.calls[0][0] == "resolve_validation_context"
  393. assert repository.calls[1][0] == "record_generation_run"
  394. def test_rule_interpret_preflights_receipt_signer_before_model_call(
  395. monkeypatch,
  396. ):
  397. from app import create_app
  398. app = create_app()
  399. _use_token_identity(monkeypatch)
  400. app.config["TESTING"] = True
  401. app.config["RULE_GENERATION_RECEIPT_SECRET"] = None
  402. agent = FakeAuthoringAgent()
  403. repository = FakeRuleRepository()
  404. app.extensions["data_rule_authoring_agent"] = agent
  405. app.extensions["data_rule_repository"] = repository
  406. client = app.test_client()
  407. response = client.post(
  408. "/api/rules/interpret",
  409. json={
  410. "source_text": "手机号去空格后必须为11位数字",
  411. "authoring_surface": "data_standard",
  412. "context": {
  413. "input_schema_snapshot_id": new_governance_uid(),
  414. "output_schema_snapshot_id": new_governance_uid(),
  415. "input_sample_artifact_ref": ("minio://trusted/input.parquet"),
  416. "golden_output_artifact_ref": None,
  417. },
  418. },
  419. headers=_headers(app, "editor"),
  420. )
  421. assert response.status_code == 503
  422. assert agent.calls == []
  423. assert repository.calls == []
  424. def test_data_standard_interpret_rejects_non_rule_candidate_before_audit(
  425. monkeypatch,
  426. ):
  427. from app import create_app
  428. class StandardAgent:
  429. def interpret(self, **kwargs):
  430. return {
  431. "status": "ready",
  432. "source_text": kwargs["source_text"],
  433. "candidate_hash": "a" * 64,
  434. "context_hash": "b" * 64,
  435. "candidate": {
  436. "candidate_type": "standard",
  437. "standard_spec": valid_standard_spec(),
  438. },
  439. }
  440. app = create_app()
  441. _use_token_identity(monkeypatch)
  442. app.config["TESTING"] = True
  443. app.config["RULE_GENERATION_RECEIPT_SECRET"] = "x" * 40
  444. repository = FakeRuleRepository()
  445. app.extensions["data_rule_repository"] = repository
  446. app.extensions["data_rule_authoring_agent"] = StandardAgent()
  447. client = app.test_client()
  448. response = client.post(
  449. "/api/rules/interpret",
  450. json={
  451. "source_text": "手机号必须为11位",
  452. "authoring_surface": "data_standard",
  453. "context": {
  454. "input_schema_snapshot_id": new_governance_uid(),
  455. "output_schema_snapshot_id": new_governance_uid(),
  456. "input_sample_artifact_ref": "minio://trusted/input.parquet",
  457. "golden_output_artifact_ref": None,
  458. },
  459. },
  460. headers=_headers(app, "editor"),
  461. )
  462. assert response.status_code == 400
  463. assert not any(call[0] == "record_generation_run" for call in repository.calls)
  464. def test_rule_interpret_and_validate_reject_unknown_fields(monkeypatch):
  465. from app import create_app
  466. app = create_app()
  467. _use_token_identity(monkeypatch)
  468. app.config["TESTING"] = True
  469. app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
  470. "dedicated-test-receipt-secret-with-entropy"
  471. )
  472. repository = FakeRuleRepository()
  473. app.extensions["data_rule_repository"] = repository
  474. app.extensions["data_rule_authoring_agent"] = FakeAuthoringAgent()
  475. client = app.test_client()
  476. headers = _headers(app, "editor")
  477. interpreted = client.post(
  478. "/api/rules/interpret",
  479. json={
  480. "source_text": "手机号必须为11位数字",
  481. "authoring_surface": "data_standard",
  482. "context": {},
  483. "status": "published",
  484. },
  485. headers=headers,
  486. )
  487. validated = client.post(
  488. "/api/rules/validate",
  489. json={
  490. "asset_type": "rule",
  491. "spec": valid_rule_spec(),
  492. "evidence": {"status": "success"},
  493. },
  494. headers=headers,
  495. )
  496. assert interpreted.status_code == 400
  497. assert validated.status_code == 400
  498. assert repository.calls == []
  499. def test_published_rule_catalog_uses_canonical_closed_contract(monkeypatch):
  500. from app import create_app
  501. app = create_app()
  502. _use_token_identity(monkeypatch)
  503. app.config["TESTING"] = True
  504. repository = FakeRuleRepository()
  505. app.extensions["data_rule_repository"] = repository
  506. client = app.test_client()
  507. headers = _headers(app, "viewer")
  508. response = client.get(
  509. "/api/rules/catalog?query=mobile&asset_type=rule&limit=10&offset=20",
  510. headers=headers,
  511. )
  512. rejected = client.get(
  513. "/api/rules/catalog?query=mobile&status=published",
  514. headers=headers,
  515. )
  516. assert response.status_code == 200
  517. assert response.get_json()["data"] == {
  518. "items": repository.catalog_items,
  519. "total": 1,
  520. "limit": 10,
  521. "offset": 20,
  522. }
  523. assert repository.calls[-1] == (
  524. "search_published_assets",
  525. {
  526. "query": "mobile",
  527. "asset_type": "rule",
  528. "limit": 10,
  529. "offset": 20,
  530. },
  531. )
  532. assert rejected.status_code == 400
  533. def test_unified_catalog_defaults_to_all_assets_and_preserves_rule_alias(
  534. monkeypatch,
  535. ):
  536. from app import create_app
  537. app = create_app()
  538. _use_token_identity(monkeypatch)
  539. app.config["TESTING"] = True
  540. repository = FakeRuleRepository()
  541. app.extensions["data_rule_repository"] = repository
  542. client = app.test_client()
  543. headers = _headers(app, "viewer")
  544. canonical = client.get("/api/rules/catalog", headers=headers)
  545. alias = client.get("/api/rules/catalog/rule-versions", headers=headers)
  546. assert canonical.status_code == 200
  547. assert repository.calls[0] == (
  548. "search_published_assets",
  549. {"query": "", "asset_type": None, "limit": 50, "offset": 0},
  550. )
  551. assert alias.status_code == 200
  552. assert repository.calls[1][1]["asset_type"] == "rule"
  553. def test_catalog_passes_flow_context_and_hydrates_exact_off_page_version(
  554. monkeypatch,
  555. ):
  556. import json
  557. from app import create_app
  558. app = create_app()
  559. _use_token_identity(monkeypatch)
  560. app.config["TESTING"] = True
  561. repository = FakeRuleRepository()
  562. app.extensions["data_rule_repository"] = repository
  563. client = app.test_client()
  564. headers = _headers(app, "viewer")
  565. inputs = ["bd:customer:v7"]
  566. output = "bd:customer_clean:v3"
  567. query = (
  568. f"input_schema_refs={json.dumps(inputs)}"
  569. f"&output_schema_ref={output}"
  570. )
  571. listed = client.get(f"/api/rules/catalog?{query}", headers=headers)
  572. version_id = repository.catalog_items[0]["version_id"]
  573. exact = client.get(
  574. f"/api/rules/catalog/assets/rule/{version_id}?{query}",
  575. headers=headers,
  576. )
  577. assert listed.status_code == 200
  578. assert exact.status_code == 200
  579. assert repository.calls[0][1]["input_schema_refs"] == inputs
  580. assert repository.calls[0][1]["output_schema_ref"] == output
  581. assert repository.calls[1][0] == "get_published_asset"
  582. assert repository.calls[1][1]["asset_type"] == "rule"
  583. assert repository.calls[1][1]["version_id"] == version_id
  584. assert repository.calls[1][1]["input_schema_refs"] == inputs
  585. assert repository.calls[1][1]["output_schema_ref"] == output
  586. assert repository.calls[1][1]["schema_resolver"] is not None
  587. def test_catalog_and_evidence_queries_are_closed_bounded_and_rules_read_only(
  588. monkeypatch,
  589. ):
  590. from app import create_app
  591. app = create_app()
  592. _use_token_identity(monkeypatch)
  593. app.config["TESTING"] = True
  594. repository = FakeRuleRepository()
  595. app.extensions["data_rule_repository"] = repository
  596. client = app.test_client()
  597. viewer = _headers(app, "viewer")
  598. version_id = new_governance_uid()
  599. evidence = client.get(
  600. f"/api/rules/catalog/assets/rule/{version_id}/evidence",
  601. headers=viewer,
  602. )
  603. assert evidence.status_code == 200
  604. value = evidence.get_json()["data"]
  605. assert set(value["stages"]) == {
  606. "generation",
  607. "logical_compile",
  608. "dry_run",
  609. "publication",
  610. "physical",
  611. }
  612. assert "source_text" not in str(value)
  613. assert "sample" not in str(value)
  614. assert repository.calls[-1] == (
  615. "get_asset_evidence",
  616. {"asset_type": "rule", "version_id": version_id},
  617. )
  618. assert (
  619. client.get(
  620. "/api/rules/catalog?asset_type=dataflow", headers=viewer
  621. ).status_code
  622. == 400
  623. )
  624. assert (
  625. client.get("/api/rules/catalog?limit=101", headers=viewer).status_code
  626. == 400
  627. )
  628. assert (
  629. client.get("/api/rules/catalog?offset=-1", headers=viewer).status_code
  630. == 400
  631. )
  632. assert (
  633. client.get(
  634. f"/api/rules/catalog/assets/rule/{version_id}/evidence"
  635. ).status_code
  636. == 401
  637. )
  638. def test_legacy_rule_evidence_path_uses_same_safe_repository_contract(
  639. monkeypatch,
  640. ):
  641. from app import create_app
  642. app = create_app()
  643. _use_token_identity(monkeypatch)
  644. app.config["TESTING"] = True
  645. repository = FakeRuleRepository()
  646. app.extensions["data_rule_repository"] = repository
  647. client = app.test_client()
  648. version_id = new_governance_uid()
  649. response = client.get(
  650. f"/api/rules/rule-versions/{version_id}/evidence",
  651. headers=_headers(app, "viewer"),
  652. )
  653. assert response.status_code == 200
  654. assert repository.calls[-1] == (
  655. "get_asset_evidence",
  656. {"asset_type": "rule", "version_id": version_id},
  657. )
  658. def test_production_line_resolve_preview_expands_standard_without_writing(
  659. monkeypatch,
  660. ):
  661. from app import create_app
  662. app = create_app()
  663. _use_token_identity(monkeypatch)
  664. app.config["TESTING"] = True
  665. client = app.test_client()
  666. standard_id = new_governance_uid()
  667. standard_rule_id = new_governance_uid()
  668. direct_rule_id = new_governance_uid()
  669. standard_rule = published_rule(standard_rule_id, assertion_only_rule())
  670. direct_rule = published_rule(direct_rule_id)
  671. response = client.post(
  672. "/api/rules/production-lines/resolve",
  673. json={
  674. "dataflow_spec": valid_dataflow_spec(standard_id, direct_rule_id),
  675. "standard_versions": {
  676. standard_id: published_standard(standard_id, standard_rule_id)
  677. },
  678. "rule_versions": {
  679. standard_rule_id: standard_rule,
  680. direct_rule_id: direct_rule,
  681. },
  682. "component_binding_ids": {
  683. "normalize_customer": new_governance_uid(),
  684. "customer_standard:mobile_format": new_governance_uid(),
  685. },
  686. },
  687. headers=_headers(app, "editor"),
  688. )
  689. assert response.status_code == 200
  690. result = response.get_json()["data"]
  691. assert result["preview"] is True
  692. assert result["release_ready"] is False
  693. assert result["package"]["package_hash"]
  694. assert result["package"]["standard_version_ids"] == [standard_id]
  695. def test_rule_api_rejects_invalid_or_unauthenticated_requests(monkeypatch):
  696. from app import create_app
  697. app = create_app()
  698. _use_token_identity(monkeypatch)
  699. app.config["TESTING"] = True
  700. client = app.test_client()
  701. assert client.get("/api/rules/capabilities").status_code == 401
  702. response = client.post(
  703. "/api/rules/validate",
  704. json={"asset_type": "rule", "spec": {"schema_version": "1.0"}},
  705. headers=_headers(app, "editor"),
  706. )
  707. assert response.status_code == 400
  708. assert "missing" not in str(response.get_json()).lower()
  709. def test_rule_and_standard_versions_are_created_then_published_by_separate_roles(
  710. monkeypatch,
  711. ):
  712. from app import create_app
  713. from tests.core.data_rules.test_contracts import valid_standard_spec
  714. app = create_app()
  715. _use_token_identity(monkeypatch)
  716. app.config["TESTING"] = True
  717. repository = FakeRuleRepository()
  718. app.extensions["data_rule_repository"] = repository
  719. publication = FakePublicationService(repository)
  720. app.extensions["rule_publication_service"] = publication
  721. client = app.test_client()
  722. rule_spec = valid_rule_spec()
  723. created = client.post(
  724. "/api/rules/rule-versions",
  725. json={
  726. "source_text": "手机号必须为11位数字",
  727. "rule_spec": rule_spec,
  728. "category": "standard_clause",
  729. "generation_receipt": "signed-test-receipt",
  730. },
  731. headers=_headers(app, "editor"),
  732. )
  733. assert created.status_code == 201
  734. assert created.get_json()["data"]["status"] == "draft"
  735. rule_version_id = created.get_json()["data"]["id"]
  736. compiled = client.post(
  737. f"/api/rules/rule-versions/{rule_version_id}/validate",
  738. headers=_headers(app, "editor"),
  739. )
  740. assert compiled.status_code == 200
  741. plan_id = compiled.get_json()["data"]["plan_id"]
  742. tested = client.post(
  743. f"/api/rules/rule-versions/{rule_version_id}/test",
  744. json={"plan_id": plan_id},
  745. headers=_headers(app, "editor"),
  746. )
  747. assert tested.status_code == 200
  748. assert tested.get_json()["data"]["version_status"] == "validated"
  749. forbidden = client.post(
  750. f"/api/rules/rule-versions/{rule_version_id}/publish",
  751. headers=_headers(app, "editor"),
  752. )
  753. assert forbidden.status_code == 403
  754. published = client.post(
  755. f"/api/rules/rule-versions/{rule_version_id}/publish",
  756. headers=_headers(app, "admin"),
  757. )
  758. assert published.status_code == 200
  759. assert published.get_json()["data"]["status"] == "published"
  760. standard_spec = valid_standard_spec(rule_version_id)
  761. standard = client.post(
  762. "/api/rules/standard-versions",
  763. json={
  764. "source_text": "客户手机号遵循统一格式",
  765. "standard_spec": standard_spec,
  766. },
  767. headers=_headers(app, "editor"),
  768. )
  769. assert standard.status_code == 201
  770. standard_version_id = standard.get_json()["data"]["id"]
  771. standard_published = client.post(
  772. f"/api/rules/standard-versions/{standard_version_id}/publish",
  773. headers=_headers(app, "admin"),
  774. )
  775. assert standard_published.status_code == 200
  776. assert standard_published.get_json()["data"]["status"] == "published"
  777. methods = [method for method, _kwargs in repository.calls]
  778. assert methods == [
  779. "create_rule_version",
  780. "validate_rule_version",
  781. "test_rule_version",
  782. "publish_rule_version",
  783. "create_standard_version",
  784. "publish_standard_version",
  785. ]
  786. def test_create_version_rejects_client_selected_lifecycle_status(monkeypatch):
  787. from app import create_app
  788. app = create_app()
  789. _use_token_identity(monkeypatch)
  790. app.config["TESTING"] = True
  791. repository = FakeRuleRepository()
  792. app.extensions["data_rule_repository"] = repository
  793. app.extensions["rule_publication_service"] = FakePublicationService(
  794. repository
  795. )
  796. client = app.test_client()
  797. response = client.post(
  798. "/api/rules/rule-versions",
  799. json={
  800. "source_text": "手机号必须为11位数字",
  801. "rule_spec": valid_rule_spec(),
  802. "generation_receipt": "signed-test-receipt",
  803. "status": "published",
  804. },
  805. headers=_headers(app, "editor"),
  806. )
  807. assert response.status_code == 400
  808. def test_generation_receipt_signer_requires_dedicated_secret(monkeypatch):
  809. from app import create_app
  810. from app.api.data_rules.routes import _receipt_signer
  811. monkeypatch.delenv("RULE_GENERATION_RECEIPT_SECRET", raising=False)
  812. app = create_app()
  813. app.config["RULE_GENERATION_RECEIPT_SECRET"] = None
  814. with (
  815. app.app_context(),
  816. pytest.raises(RuntimeError, match="receipt secret"),
  817. ):
  818. _receipt_signer()
  819. app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
  820. "dedicated-test-receipt-secret-with-entropy"
  821. )
  822. with app.app_context():
  823. signer = _receipt_signer()
  824. assert signer is not None
  825. def test_rule_gates_reject_caller_supplied_compile_or_test_evidence(
  826. monkeypatch,
  827. ):
  828. from app import create_app
  829. app = create_app()
  830. _use_token_identity(monkeypatch)
  831. app.config["TESTING"] = True
  832. repository = FakeRuleRepository()
  833. app.extensions["rule_publication_service"] = FakePublicationService(
  834. repository
  835. )
  836. client = app.test_client()
  837. version_id = new_governance_uid()
  838. forged_compile = client.post(
  839. f"/api/rules/rule-versions/{version_id}/validate",
  840. json={"status": "success", "plan_hash": "a" * 64},
  841. headers=_headers(app, "editor"),
  842. )
  843. forged_test = client.post(
  844. f"/api/rules/rule-versions/{version_id}/test",
  845. json={
  846. "plan_id": new_governance_uid(),
  847. "evidence": {"status": "success"},
  848. },
  849. headers=_headers(app, "editor"),
  850. )
  851. assert forged_compile.status_code == 409
  852. assert forged_test.status_code == 409
  853. assert repository.calls == []
  854. def test_data_factory_lifecycle_routes_are_closed_and_independently_governed(
  855. monkeypatch,
  856. ):
  857. from app import create_app
  858. app = create_app()
  859. _use_token_identity(monkeypatch)
  860. app.config["TESTING"] = True
  861. app.config["DATA_FACTORY_ACTIVATION_ENABLED"] = True
  862. service = FakeDeploymentService()
  863. app.extensions["dataflow_deployment_service"] = service
  864. client = app.test_client()
  865. admin = _headers(app, "admin")
  866. version_id = new_governance_uid()
  867. binding_id = new_governance_uid()
  868. created = client.post(
  869. "/api/rules/deployments",
  870. json={
  871. "dataflow_version_id": version_id,
  872. "environment": "production",
  873. "binding_snapshot": {
  874. "input": {"binding_id": binding_id},
  875. "output": {"binding_id": new_governance_uid()},
  876. },
  877. "schedule_plan": {"schema_version": "1.0"},
  878. "reason": "production rollout",
  879. "idempotency_key": "create-1",
  880. },
  881. headers=admin,
  882. )
  883. assert created.status_code == 201
  884. deployment_id = created.get_json()["data"]["id"]
  885. disabled = client.post(
  886. f"/api/rules/deployments/{deployment_id}/deploy-disabled",
  887. json={"reason": "disabled first", "idempotency_key": "deploy-1"},
  888. headers=admin,
  889. )
  890. assert disabled.status_code == 200
  891. canary = client.post(
  892. f"/api/rules/deployments/{deployment_id}/canary",
  893. json={
  894. "inputs": {"biz_date": "2026-07-24"},
  895. "reason": "trial production",
  896. "idempotency_key": "canary-1",
  897. },
  898. headers=admin,
  899. )
  900. assert canary.status_code == 200
  901. evidence_id = canary.get_json()["data"]["id"]
  902. active = client.post(
  903. f"/api/rules/deployments/{deployment_id}/activate",
  904. json={
  905. "evidence_id": evidence_id,
  906. "reason": "mass production",
  907. "idempotency_key": "activate-1",
  908. },
  909. headers=admin,
  910. )
  911. assert active.status_code == 200
  912. rolled_back = client.post(
  913. f"/api/rules/deployments/{deployment_id}/rollback",
  914. json={"reason": "restore stable", "idempotency_key": "rollback-1"},
  915. headers=admin,
  916. )
  917. assert rolled_back.status_code == 200
  918. forbidden = client.post(
  919. f"/api/rules/deployments/{deployment_id}/canary",
  920. json={"inputs": {}, "idempotency_key": "viewer"},
  921. headers=_headers(app, "viewer"),
  922. )
  923. assert forbidden.status_code == 403
  924. inline_code = client.post(
  925. f"/api/rules/deployments/{deployment_id}/canary",
  926. json={
  927. "inputs": {},
  928. "idempotency_key": "unsafe",
  929. "sql": "delete from customer",
  930. },
  931. headers=admin,
  932. )
  933. assert inline_code.status_code == 409
  934. assert [call[0] for call in service.calls] == [
  935. "create",
  936. "deploy_disabled",
  937. "run_canary",
  938. "activate",
  939. "rollback",
  940. ]
  941. def test_create_rule_version_rejects_legacy_v1_payload_before_repository(
  942. monkeypatch,
  943. ):
  944. from app import create_app
  945. app = create_app()
  946. _use_token_identity(monkeypatch)
  947. app.config["TESTING"] = True
  948. repository = FakeRuleRepository()
  949. app.extensions["data_rule_repository"] = repository
  950. client = app.test_client()
  951. legacy = valid_rule_spec()
  952. legacy["schema_version"] = "1.0"
  953. response = client.post(
  954. "/api/rules/rule-versions",
  955. json={"source_text": "旧版规则不能再创建", "rule_spec": legacy},
  956. headers=_headers(app, "editor"),
  957. )
  958. assert response.status_code == 400
  959. assert repository.calls == []
  960. def test_dataflow_release_uses_server_assets_and_release_permission(
  961. monkeypatch,
  962. ):
  963. from app import create_app
  964. app = create_app()
  965. _use_token_identity(monkeypatch)
  966. app.config["TESTING"] = True
  967. service = FakeReleaseService()
  968. app.extensions["production_line_release_service"] = service
  969. client = app.test_client()
  970. flow = valid_dataflow_spec()
  971. payload = {
  972. "source_text": "客户数据生产线",
  973. "dataflow_spec": flow,
  974. }
  975. forbidden = client.post(
  976. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  977. json=payload,
  978. headers=_headers(app, "editor"),
  979. )
  980. assert forbidden.status_code == 403
  981. response = client.post(
  982. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  983. json=payload,
  984. headers=_headers(app, "admin"),
  985. )
  986. assert response.status_code == 201
  987. assert response.get_json()["data"]["status"] == "released"
  988. assert service.calls[0]["dataflow_uid"] == flow["dataflow_uid"]
  989. assert "standard_versions" not in service.calls[0]
  990. assert "rule_versions" not in service.calls[0]
  991. assert "component_binding_ids" not in service.calls[0]
  992. def test_dataflow_release_rejects_client_authored_schema_hashes(monkeypatch):
  993. from app import create_app
  994. app = create_app()
  995. _use_token_identity(monkeypatch)
  996. app.config["TESTING"] = True
  997. service = FakeReleaseService()
  998. app.extensions["production_line_release_service"] = service
  999. client = app.test_client()
  1000. flow = valid_dataflow_spec()
  1001. response = client.post(
  1002. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  1003. json={
  1004. "source_text": "客户数据生产线",
  1005. "dataflow_spec": flow,
  1006. "input_schema_hashes": {"bd:customer_raw:v2": "a" * 64},
  1007. "output_schema_hash": "b" * 64,
  1008. },
  1009. headers=_headers(app, "admin"),
  1010. )
  1011. assert response.status_code == 409
  1012. assert service.calls == []
  1013. def test_default_release_service_uses_lazy_neo4j_schema_catalog(monkeypatch):
  1014. from app import create_app
  1015. from app.api.data_rules.routes import _release_service
  1016. app = create_app()
  1017. repository = SnapshotOnlyRepository()
  1018. driver = FakeGraphDriver()
  1019. monkeypatch.setattr(
  1020. "app.core.data_rules.schema_resolver.neo4j_driver", driver
  1021. )
  1022. app.extensions["data_rule_repository"] = repository
  1023. with app.app_context():
  1024. service = _release_service()
  1025. snapshot = service.schema_resolver.resolve("bd:customer_raw:v2")
  1026. assert snapshot["source_revision"] == "neo4j:9:v2"
  1027. assert driver.session.calls[0][1] == {
  1028. "domain_key": "customer_raw",
  1029. "revision": "v2",
  1030. }