test_data_rule_api.py 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060
  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 FakePublicationService:
  162. def __init__(self, repository):
  163. self.repository = repository
  164. self.plan_id = new_governance_uid()
  165. self.version_id = None
  166. def create_draft(self, **kwargs):
  167. self.repository.calls.append(("create_rule_version", kwargs))
  168. self.version_id = new_governance_uid()
  169. return {
  170. "id": self.version_id,
  171. "rule_uid": kwargs["rule_spec"]["rule_uid"],
  172. "version_no": 1,
  173. "status": "draft",
  174. "spec_hash": rule_spec_hash(kwargs["rule_spec"]),
  175. "generation_run_id": new_governance_uid(),
  176. "created": True,
  177. }
  178. def validate(self, version_id, actor_uid):
  179. self.repository.calls.append(
  180. (
  181. "validate_rule_version",
  182. {"version_id": version_id, "actor_uid": actor_uid},
  183. )
  184. )
  185. return {
  186. "version_id": version_id,
  187. "version_status": "draft",
  188. "plan_id": self.plan_id,
  189. "plan_status": "compiled",
  190. "plan_hash": "d" * 64,
  191. }
  192. def test(self, version_id, actor_uid, *, plan_id):
  193. self.repository.calls.append(
  194. (
  195. "test_rule_version",
  196. {
  197. "version_id": version_id,
  198. "actor_uid": actor_uid,
  199. "plan_id": plan_id,
  200. },
  201. )
  202. )
  203. return {
  204. "version_id": version_id,
  205. "version_status": "validated",
  206. "plan_id": plan_id,
  207. "plan_status": "tested",
  208. "plan_hash": "d" * 64,
  209. }
  210. def publish(self, version_id, actor_uid):
  211. self.repository.calls.append(
  212. (
  213. "publish_rule_version",
  214. {"version_id": version_id, "actor_uid": actor_uid},
  215. )
  216. )
  217. return {
  218. "id": version_id,
  219. "status": "published",
  220. "plan_id": self.plan_id,
  221. "plan_status": "published",
  222. }
  223. def evidence(self, version_id):
  224. return {"version_id": version_id, "version_status": "validated"}
  225. def catalog(self, *, query, limit):
  226. return []
  227. class FakeGraphSession:
  228. def __init__(self):
  229. self.calls = []
  230. def run(self, query, parameters):
  231. self.calls.append((query, parameters))
  232. return [
  233. {
  234. "domain_id": 9,
  235. "domain_key": "customer_raw",
  236. "revision": "v2",
  237. "field_name": "customer_id",
  238. "data_type": "string",
  239. "nullable": False,
  240. "precision": None,
  241. "scale": None,
  242. "timezone": None,
  243. }
  244. ]
  245. def __enter__(self):
  246. return self
  247. def __exit__(self, *_args):
  248. return False
  249. class FakeGraphDriver:
  250. def __init__(self):
  251. self.session = FakeGraphSession()
  252. def get_session(self):
  253. return self.session
  254. class SnapshotOnlyRepository:
  255. def __init__(self):
  256. self.snapshots = {}
  257. def find_schema_snapshot(self, *, schema_ref, schema_hash):
  258. return self.snapshots.get((schema_ref, schema_hash))
  259. def persist_schema_snapshot(self, *, snapshot):
  260. value = {"id": new_governance_uid(), **snapshot}
  261. self.snapshots[(snapshot["schema_ref"], snapshot["schema_hash"])] = (
  262. value
  263. )
  264. return value
  265. def _headers(app, role):
  266. token = issue_access_token(
  267. user_id=new_governance_uid(),
  268. roles=[role],
  269. secret=app.config["SECRET_KEY"],
  270. now=datetime.now(UTC),
  271. lifetime=timedelta(minutes=10),
  272. )
  273. return {"Authorization": f"Bearer {token}"}
  274. def _use_token_identity(monkeypatch):
  275. def load(token, *, secret):
  276. claims = decode_access_token(token, secret=secret)
  277. return {
  278. "id": claims["sub"],
  279. "username": "contract-test",
  280. "display_name": "Contract Test",
  281. "roles": claims["roles"],
  282. }
  283. monkeypatch.setattr(
  284. "app.core.system.auth.load_identity_from_token",
  285. load,
  286. )
  287. def test_rule_capabilities_and_validation_are_registered_and_governed(
  288. monkeypatch,
  289. ):
  290. from app import create_app
  291. app = create_app()
  292. _use_token_identity(monkeypatch)
  293. app.config["TESTING"] = True
  294. app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
  295. "dedicated-test-receipt-secret-with-entropy"
  296. )
  297. client = app.test_client()
  298. response = client.get(
  299. "/api/rules/capabilities", headers=_headers(app, "viewer")
  300. )
  301. assert response.status_code == 200
  302. capabilities = response.get_json()["data"]
  303. assert capabilities["natural_language_authoring"] is True
  304. assert capabilities["immutable_asset_versions"] is True
  305. assert capabilities["server_side_publishing"] is True
  306. assert capabilities["production_line_release"] is True
  307. assert capabilities["data_factory_activation"] is False
  308. spec = valid_rule_spec()
  309. response = client.post(
  310. "/api/rules/validate",
  311. json={"asset_type": "rule", "spec": spec},
  312. headers=_headers(app, "editor"),
  313. )
  314. assert response.status_code == 200
  315. result = response.get_json()["data"]
  316. assert result["spec_hash"] == rule_spec_hash(spec)
  317. assert result["normalized"]["rule_uid"] == spec["rule_uid"]
  318. forbidden = client.post(
  319. "/api/rules/validate",
  320. json={"asset_type": "rule", "spec": spec},
  321. headers=_headers(app, "viewer"),
  322. )
  323. assert forbidden.status_code == 403
  324. def test_rule_interpret_uses_configured_agent_and_preserves_surface(
  325. monkeypatch,
  326. ):
  327. from app import create_app
  328. app = create_app()
  329. _use_token_identity(monkeypatch)
  330. app.config["TESTING"] = True
  331. app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
  332. "dedicated-test-receipt-secret-with-entropy"
  333. )
  334. agent = FakeAuthoringAgent()
  335. app.extensions["data_rule_authoring_agent"] = agent
  336. repository = FakeRuleRepository()
  337. app.extensions["data_rule_repository"] = repository
  338. client = app.test_client()
  339. response = client.post(
  340. "/api/rules/interpret",
  341. json={
  342. "source_text": "手机号去空格后必须为11位数字",
  343. "authoring_surface": "data_standard",
  344. "context": {
  345. "input_schema_snapshot_id": new_governance_uid(),
  346. "output_schema_snapshot_id": new_governance_uid(),
  347. "input_sample_artifact_ref": ("minio://trusted/input.parquet"),
  348. "golden_output_artifact_ref": None,
  349. },
  350. },
  351. headers=_headers(app, "editor"),
  352. )
  353. assert response.status_code == 200
  354. assert response.get_json()["data"]["status"] == "ready"
  355. assert response.get_json()["data"]["generation_run_id"]
  356. assert response.get_json()["data"]["generation_receipt"]
  357. assert agent.calls[0]["authoring_surface"] == "data_standard"
  358. assert repository.calls[0][0] == "resolve_validation_context"
  359. assert repository.calls[1][0] == "record_generation_run"
  360. def test_rule_interpret_preflights_receipt_signer_before_model_call(
  361. monkeypatch,
  362. ):
  363. from app import create_app
  364. app = create_app()
  365. _use_token_identity(monkeypatch)
  366. app.config["TESTING"] = True
  367. app.config["RULE_GENERATION_RECEIPT_SECRET"] = None
  368. agent = FakeAuthoringAgent()
  369. repository = FakeRuleRepository()
  370. app.extensions["data_rule_authoring_agent"] = agent
  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 == 503
  388. assert agent.calls == []
  389. assert repository.calls == []
  390. def test_data_standard_interpret_rejects_non_rule_candidate_before_audit(
  391. monkeypatch,
  392. ):
  393. from app import create_app
  394. class StandardAgent:
  395. def interpret(self, **kwargs):
  396. return {
  397. "status": "ready",
  398. "source_text": kwargs["source_text"],
  399. "candidate_hash": "a" * 64,
  400. "context_hash": "b" * 64,
  401. "candidate": {
  402. "candidate_type": "standard",
  403. "standard_spec": valid_standard_spec(),
  404. },
  405. }
  406. app = create_app()
  407. _use_token_identity(monkeypatch)
  408. app.config["TESTING"] = True
  409. app.config["RULE_GENERATION_RECEIPT_SECRET"] = "x" * 40
  410. repository = FakeRuleRepository()
  411. app.extensions["data_rule_repository"] = repository
  412. app.extensions["data_rule_authoring_agent"] = StandardAgent()
  413. client = app.test_client()
  414. response = client.post(
  415. "/api/rules/interpret",
  416. json={
  417. "source_text": "手机号必须为11位",
  418. "authoring_surface": "data_standard",
  419. "context": {
  420. "input_schema_snapshot_id": new_governance_uid(),
  421. "output_schema_snapshot_id": new_governance_uid(),
  422. "input_sample_artifact_ref": "minio://trusted/input.parquet",
  423. "golden_output_artifact_ref": None,
  424. },
  425. },
  426. headers=_headers(app, "editor"),
  427. )
  428. assert response.status_code == 400
  429. assert not any(call[0] == "record_generation_run" for call in repository.calls)
  430. def test_rule_interpret_and_validate_reject_unknown_fields(monkeypatch):
  431. from app import create_app
  432. app = create_app()
  433. _use_token_identity(monkeypatch)
  434. app.config["TESTING"] = True
  435. app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
  436. "dedicated-test-receipt-secret-with-entropy"
  437. )
  438. repository = FakeRuleRepository()
  439. app.extensions["data_rule_repository"] = repository
  440. app.extensions["data_rule_authoring_agent"] = FakeAuthoringAgent()
  441. client = app.test_client()
  442. headers = _headers(app, "editor")
  443. interpreted = client.post(
  444. "/api/rules/interpret",
  445. json={
  446. "source_text": "手机号必须为11位数字",
  447. "authoring_surface": "data_standard",
  448. "context": {},
  449. "status": "published",
  450. },
  451. headers=headers,
  452. )
  453. validated = client.post(
  454. "/api/rules/validate",
  455. json={
  456. "asset_type": "rule",
  457. "spec": valid_rule_spec(),
  458. "evidence": {"status": "success"},
  459. },
  460. headers=headers,
  461. )
  462. assert interpreted.status_code == 400
  463. assert validated.status_code == 400
  464. assert repository.calls == []
  465. def test_published_rule_catalog_uses_canonical_closed_contract(monkeypatch):
  466. from app import create_app
  467. app = create_app()
  468. _use_token_identity(monkeypatch)
  469. app.config["TESTING"] = True
  470. repository = FakeRuleRepository()
  471. app.extensions["data_rule_repository"] = repository
  472. client = app.test_client()
  473. headers = _headers(app, "viewer")
  474. response = client.get(
  475. "/api/rules/catalog?query=mobile&asset_type=rule&limit=10&offset=20",
  476. headers=headers,
  477. )
  478. rejected = client.get(
  479. "/api/rules/catalog?query=mobile&status=published",
  480. headers=headers,
  481. )
  482. assert response.status_code == 200
  483. assert response.get_json()["data"] == {
  484. "items": repository.catalog_items,
  485. "total": 1,
  486. "limit": 10,
  487. "offset": 20,
  488. }
  489. assert repository.calls[-1] == (
  490. "search_published_assets",
  491. {
  492. "query": "mobile",
  493. "asset_type": "rule",
  494. "limit": 10,
  495. "offset": 20,
  496. },
  497. )
  498. assert rejected.status_code == 400
  499. def test_unified_catalog_defaults_to_all_assets_and_preserves_rule_alias(
  500. monkeypatch,
  501. ):
  502. from app import create_app
  503. app = create_app()
  504. _use_token_identity(monkeypatch)
  505. app.config["TESTING"] = True
  506. repository = FakeRuleRepository()
  507. app.extensions["data_rule_repository"] = repository
  508. client = app.test_client()
  509. headers = _headers(app, "viewer")
  510. canonical = client.get("/api/rules/catalog", headers=headers)
  511. alias = client.get("/api/rules/catalog/rule-versions", headers=headers)
  512. assert canonical.status_code == 200
  513. assert repository.calls[0] == (
  514. "search_published_assets",
  515. {"query": "", "asset_type": None, "limit": 50, "offset": 0},
  516. )
  517. assert alias.status_code == 200
  518. assert repository.calls[1][1]["asset_type"] == "rule"
  519. def test_catalog_passes_flow_context_and_hydrates_exact_off_page_version(
  520. monkeypatch,
  521. ):
  522. import json
  523. from app import create_app
  524. app = create_app()
  525. _use_token_identity(monkeypatch)
  526. app.config["TESTING"] = True
  527. repository = FakeRuleRepository()
  528. app.extensions["data_rule_repository"] = repository
  529. client = app.test_client()
  530. headers = _headers(app, "viewer")
  531. inputs = ["bd:customer:v7"]
  532. output = "bd:customer_clean:v3"
  533. query = (
  534. f"input_schema_refs={json.dumps(inputs)}"
  535. f"&output_schema_ref={output}"
  536. )
  537. listed = client.get(f"/api/rules/catalog?{query}", headers=headers)
  538. version_id = repository.catalog_items[0]["version_id"]
  539. exact = client.get(
  540. f"/api/rules/catalog/assets/rule/{version_id}?{query}",
  541. headers=headers,
  542. )
  543. assert listed.status_code == 200
  544. assert exact.status_code == 200
  545. assert repository.calls[0][1]["input_schema_refs"] == inputs
  546. assert repository.calls[0][1]["output_schema_ref"] == output
  547. assert repository.calls[1][0] == "get_published_asset"
  548. assert repository.calls[1][1]["asset_type"] == "rule"
  549. assert repository.calls[1][1]["version_id"] == version_id
  550. assert repository.calls[1][1]["input_schema_refs"] == inputs
  551. assert repository.calls[1][1]["output_schema_ref"] == output
  552. assert repository.calls[1][1]["schema_resolver"] is not None
  553. def test_catalog_and_evidence_queries_are_closed_bounded_and_rules_read_only(
  554. monkeypatch,
  555. ):
  556. from app import create_app
  557. app = create_app()
  558. _use_token_identity(monkeypatch)
  559. app.config["TESTING"] = True
  560. repository = FakeRuleRepository()
  561. app.extensions["data_rule_repository"] = repository
  562. client = app.test_client()
  563. viewer = _headers(app, "viewer")
  564. version_id = new_governance_uid()
  565. evidence = client.get(
  566. f"/api/rules/catalog/assets/rule/{version_id}/evidence",
  567. headers=viewer,
  568. )
  569. assert evidence.status_code == 200
  570. value = evidence.get_json()["data"]
  571. assert set(value["stages"]) == {
  572. "generation",
  573. "logical_compile",
  574. "dry_run",
  575. "publication",
  576. "physical",
  577. }
  578. assert "source_text" not in str(value)
  579. assert "sample" not in str(value)
  580. assert repository.calls[-1] == (
  581. "get_asset_evidence",
  582. {"asset_type": "rule", "version_id": version_id},
  583. )
  584. assert (
  585. client.get(
  586. "/api/rules/catalog?asset_type=dataflow", headers=viewer
  587. ).status_code
  588. == 400
  589. )
  590. assert (
  591. client.get("/api/rules/catalog?limit=101", headers=viewer).status_code
  592. == 400
  593. )
  594. assert (
  595. client.get("/api/rules/catalog?offset=-1", headers=viewer).status_code
  596. == 400
  597. )
  598. assert (
  599. client.get(
  600. f"/api/rules/catalog/assets/rule/{version_id}/evidence"
  601. ).status_code
  602. == 401
  603. )
  604. def test_legacy_rule_evidence_path_uses_same_safe_repository_contract(
  605. monkeypatch,
  606. ):
  607. from app import create_app
  608. app = create_app()
  609. _use_token_identity(monkeypatch)
  610. app.config["TESTING"] = True
  611. repository = FakeRuleRepository()
  612. app.extensions["data_rule_repository"] = repository
  613. client = app.test_client()
  614. version_id = new_governance_uid()
  615. response = client.get(
  616. f"/api/rules/rule-versions/{version_id}/evidence",
  617. headers=_headers(app, "viewer"),
  618. )
  619. assert response.status_code == 200
  620. assert repository.calls[-1] == (
  621. "get_asset_evidence",
  622. {"asset_type": "rule", "version_id": version_id},
  623. )
  624. def test_production_line_resolve_preview_expands_standard_without_writing(
  625. monkeypatch,
  626. ):
  627. from app import create_app
  628. app = create_app()
  629. _use_token_identity(monkeypatch)
  630. app.config["TESTING"] = True
  631. client = app.test_client()
  632. standard_id = new_governance_uid()
  633. standard_rule_id = new_governance_uid()
  634. direct_rule_id = new_governance_uid()
  635. standard_rule = published_rule(standard_rule_id, assertion_only_rule())
  636. direct_rule = published_rule(direct_rule_id)
  637. response = client.post(
  638. "/api/rules/production-lines/resolve",
  639. json={
  640. "dataflow_spec": valid_dataflow_spec(standard_id, direct_rule_id),
  641. "standard_versions": {
  642. standard_id: published_standard(standard_id, standard_rule_id)
  643. },
  644. "rule_versions": {
  645. standard_rule_id: standard_rule,
  646. direct_rule_id: direct_rule,
  647. },
  648. "component_binding_ids": {
  649. "normalize_customer": new_governance_uid(),
  650. "customer_standard:mobile_format": new_governance_uid(),
  651. },
  652. },
  653. headers=_headers(app, "editor"),
  654. )
  655. assert response.status_code == 200
  656. result = response.get_json()["data"]
  657. assert result["preview"] is True
  658. assert result["release_ready"] is False
  659. assert result["package"]["package_hash"]
  660. assert result["package"]["standard_version_ids"] == [standard_id]
  661. def test_rule_api_rejects_invalid_or_unauthenticated_requests(monkeypatch):
  662. from app import create_app
  663. app = create_app()
  664. _use_token_identity(monkeypatch)
  665. app.config["TESTING"] = True
  666. client = app.test_client()
  667. assert client.get("/api/rules/capabilities").status_code == 401
  668. response = client.post(
  669. "/api/rules/validate",
  670. json={"asset_type": "rule", "spec": {"schema_version": "1.0"}},
  671. headers=_headers(app, "editor"),
  672. )
  673. assert response.status_code == 400
  674. assert "missing" not in str(response.get_json()).lower()
  675. def test_rule_and_standard_versions_are_created_then_published_by_separate_roles(
  676. monkeypatch,
  677. ):
  678. from app import create_app
  679. from tests.core.data_rules.test_contracts import valid_standard_spec
  680. app = create_app()
  681. _use_token_identity(monkeypatch)
  682. app.config["TESTING"] = True
  683. repository = FakeRuleRepository()
  684. app.extensions["data_rule_repository"] = repository
  685. publication = FakePublicationService(repository)
  686. app.extensions["rule_publication_service"] = publication
  687. client = app.test_client()
  688. rule_spec = valid_rule_spec()
  689. created = client.post(
  690. "/api/rules/rule-versions",
  691. json={
  692. "source_text": "手机号必须为11位数字",
  693. "rule_spec": rule_spec,
  694. "category": "standard_clause",
  695. "generation_receipt": "signed-test-receipt",
  696. },
  697. headers=_headers(app, "editor"),
  698. )
  699. assert created.status_code == 201
  700. assert created.get_json()["data"]["status"] == "draft"
  701. rule_version_id = created.get_json()["data"]["id"]
  702. compiled = client.post(
  703. f"/api/rules/rule-versions/{rule_version_id}/validate",
  704. headers=_headers(app, "editor"),
  705. )
  706. assert compiled.status_code == 200
  707. plan_id = compiled.get_json()["data"]["plan_id"]
  708. tested = client.post(
  709. f"/api/rules/rule-versions/{rule_version_id}/test",
  710. json={"plan_id": plan_id},
  711. headers=_headers(app, "editor"),
  712. )
  713. assert tested.status_code == 200
  714. assert tested.get_json()["data"]["version_status"] == "validated"
  715. forbidden = client.post(
  716. f"/api/rules/rule-versions/{rule_version_id}/publish",
  717. headers=_headers(app, "editor"),
  718. )
  719. assert forbidden.status_code == 403
  720. published = client.post(
  721. f"/api/rules/rule-versions/{rule_version_id}/publish",
  722. headers=_headers(app, "admin"),
  723. )
  724. assert published.status_code == 200
  725. assert published.get_json()["data"]["status"] == "published"
  726. standard_spec = valid_standard_spec(rule_version_id)
  727. standard = client.post(
  728. "/api/rules/standard-versions",
  729. json={
  730. "source_text": "客户手机号遵循统一格式",
  731. "standard_spec": standard_spec,
  732. },
  733. headers=_headers(app, "editor"),
  734. )
  735. assert standard.status_code == 201
  736. standard_version_id = standard.get_json()["data"]["id"]
  737. standard_published = client.post(
  738. f"/api/rules/standard-versions/{standard_version_id}/publish",
  739. headers=_headers(app, "admin"),
  740. )
  741. assert standard_published.status_code == 200
  742. assert standard_published.get_json()["data"]["status"] == "published"
  743. methods = [method for method, _kwargs in repository.calls]
  744. assert methods == [
  745. "create_rule_version",
  746. "validate_rule_version",
  747. "test_rule_version",
  748. "publish_rule_version",
  749. "create_standard_version",
  750. "publish_standard_version",
  751. ]
  752. def test_create_version_rejects_client_selected_lifecycle_status(monkeypatch):
  753. from app import create_app
  754. app = create_app()
  755. _use_token_identity(monkeypatch)
  756. app.config["TESTING"] = True
  757. repository = FakeRuleRepository()
  758. app.extensions["data_rule_repository"] = repository
  759. app.extensions["rule_publication_service"] = FakePublicationService(
  760. repository
  761. )
  762. client = app.test_client()
  763. response = client.post(
  764. "/api/rules/rule-versions",
  765. json={
  766. "source_text": "手机号必须为11位数字",
  767. "rule_spec": valid_rule_spec(),
  768. "generation_receipt": "signed-test-receipt",
  769. "status": "published",
  770. },
  771. headers=_headers(app, "editor"),
  772. )
  773. assert response.status_code == 400
  774. def test_generation_receipt_signer_requires_dedicated_secret(monkeypatch):
  775. from app import create_app
  776. from app.api.data_rules.routes import _receipt_signer
  777. monkeypatch.delenv("RULE_GENERATION_RECEIPT_SECRET", raising=False)
  778. app = create_app()
  779. app.config["RULE_GENERATION_RECEIPT_SECRET"] = None
  780. with (
  781. app.app_context(),
  782. pytest.raises(RuntimeError, match="receipt secret"),
  783. ):
  784. _receipt_signer()
  785. app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
  786. "dedicated-test-receipt-secret-with-entropy"
  787. )
  788. with app.app_context():
  789. signer = _receipt_signer()
  790. assert signer is not None
  791. def test_rule_gates_reject_caller_supplied_compile_or_test_evidence(
  792. monkeypatch,
  793. ):
  794. from app import create_app
  795. app = create_app()
  796. _use_token_identity(monkeypatch)
  797. app.config["TESTING"] = True
  798. repository = FakeRuleRepository()
  799. app.extensions["rule_publication_service"] = FakePublicationService(
  800. repository
  801. )
  802. client = app.test_client()
  803. version_id = new_governance_uid()
  804. forged_compile = client.post(
  805. f"/api/rules/rule-versions/{version_id}/validate",
  806. json={"status": "success", "plan_hash": "a" * 64},
  807. headers=_headers(app, "editor"),
  808. )
  809. forged_test = client.post(
  810. f"/api/rules/rule-versions/{version_id}/test",
  811. json={
  812. "plan_id": new_governance_uid(),
  813. "evidence": {"status": "success"},
  814. },
  815. headers=_headers(app, "editor"),
  816. )
  817. assert forged_compile.status_code == 409
  818. assert forged_test.status_code == 409
  819. assert repository.calls == []
  820. def test_create_rule_version_rejects_legacy_v1_payload_before_repository(
  821. monkeypatch,
  822. ):
  823. from app import create_app
  824. app = create_app()
  825. _use_token_identity(monkeypatch)
  826. app.config["TESTING"] = True
  827. repository = FakeRuleRepository()
  828. app.extensions["data_rule_repository"] = repository
  829. client = app.test_client()
  830. legacy = valid_rule_spec()
  831. legacy["schema_version"] = "1.0"
  832. response = client.post(
  833. "/api/rules/rule-versions",
  834. json={"source_text": "旧版规则不能再创建", "rule_spec": legacy},
  835. headers=_headers(app, "editor"),
  836. )
  837. assert response.status_code == 400
  838. assert repository.calls == []
  839. def test_dataflow_release_uses_server_assets_and_release_permission(
  840. monkeypatch,
  841. ):
  842. from app import create_app
  843. app = create_app()
  844. _use_token_identity(monkeypatch)
  845. app.config["TESTING"] = True
  846. service = FakeReleaseService()
  847. app.extensions["production_line_release_service"] = service
  848. client = app.test_client()
  849. flow = valid_dataflow_spec()
  850. payload = {
  851. "source_text": "客户数据生产线",
  852. "dataflow_spec": flow,
  853. }
  854. forbidden = client.post(
  855. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  856. json=payload,
  857. headers=_headers(app, "editor"),
  858. )
  859. assert forbidden.status_code == 403
  860. response = client.post(
  861. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  862. json=payload,
  863. headers=_headers(app, "admin"),
  864. )
  865. assert response.status_code == 201
  866. assert response.get_json()["data"]["status"] == "released"
  867. assert service.calls[0]["dataflow_uid"] == flow["dataflow_uid"]
  868. assert "standard_versions" not in service.calls[0]
  869. assert "rule_versions" not in service.calls[0]
  870. assert "component_binding_ids" not in service.calls[0]
  871. def test_dataflow_release_rejects_client_authored_schema_hashes(monkeypatch):
  872. from app import create_app
  873. app = create_app()
  874. _use_token_identity(monkeypatch)
  875. app.config["TESTING"] = True
  876. service = FakeReleaseService()
  877. app.extensions["production_line_release_service"] = service
  878. client = app.test_client()
  879. flow = valid_dataflow_spec()
  880. response = client.post(
  881. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  882. json={
  883. "source_text": "客户数据生产线",
  884. "dataflow_spec": flow,
  885. "input_schema_hashes": {"bd:customer_raw:v2": "a" * 64},
  886. "output_schema_hash": "b" * 64,
  887. },
  888. headers=_headers(app, "admin"),
  889. )
  890. assert response.status_code == 409
  891. assert service.calls == []
  892. def test_default_release_service_uses_lazy_neo4j_schema_catalog(monkeypatch):
  893. from app import create_app
  894. from app.api.data_rules.routes import _release_service
  895. app = create_app()
  896. repository = SnapshotOnlyRepository()
  897. driver = FakeGraphDriver()
  898. monkeypatch.setattr(
  899. "app.core.data_rules.schema_resolver.neo4j_driver", driver
  900. )
  901. app.extensions["data_rule_repository"] = repository
  902. with app.app_context():
  903. service = _release_service()
  904. snapshot = service.schema_resolver.resolve("bd:customer_raw:v2")
  905. assert snapshot["source_revision"] == "neo4j:9:v2"
  906. assert driver.session.calls[0][1] == {
  907. "domain_key": "customer_raw",
  908. "revision": "v2",
  909. }