test_data_rule_api.py 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063
  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] == (
  548. "get_published_asset",
  549. {
  550. "asset_type": "rule",
  551. "version_id": version_id,
  552. "input_schema_refs": inputs,
  553. "output_schema_ref": output,
  554. },
  555. )
  556. def test_catalog_and_evidence_queries_are_closed_bounded_and_rules_read_only(
  557. monkeypatch,
  558. ):
  559. from app import create_app
  560. app = create_app()
  561. _use_token_identity(monkeypatch)
  562. app.config["TESTING"] = True
  563. repository = FakeRuleRepository()
  564. app.extensions["data_rule_repository"] = repository
  565. client = app.test_client()
  566. viewer = _headers(app, "viewer")
  567. version_id = new_governance_uid()
  568. evidence = client.get(
  569. f"/api/rules/catalog/assets/rule/{version_id}/evidence",
  570. headers=viewer,
  571. )
  572. assert evidence.status_code == 200
  573. value = evidence.get_json()["data"]
  574. assert set(value["stages"]) == {
  575. "generation",
  576. "logical_compile",
  577. "dry_run",
  578. "publication",
  579. "physical",
  580. }
  581. assert "source_text" not in str(value)
  582. assert "sample" not in str(value)
  583. assert repository.calls[-1] == (
  584. "get_asset_evidence",
  585. {"asset_type": "rule", "version_id": version_id},
  586. )
  587. assert (
  588. client.get(
  589. "/api/rules/catalog?asset_type=dataflow", headers=viewer
  590. ).status_code
  591. == 400
  592. )
  593. assert (
  594. client.get("/api/rules/catalog?limit=101", headers=viewer).status_code
  595. == 400
  596. )
  597. assert (
  598. client.get("/api/rules/catalog?offset=-1", headers=viewer).status_code
  599. == 400
  600. )
  601. assert (
  602. client.get(
  603. f"/api/rules/catalog/assets/rule/{version_id}/evidence"
  604. ).status_code
  605. == 401
  606. )
  607. def test_legacy_rule_evidence_path_uses_same_safe_repository_contract(
  608. monkeypatch,
  609. ):
  610. from app import create_app
  611. app = create_app()
  612. _use_token_identity(monkeypatch)
  613. app.config["TESTING"] = True
  614. repository = FakeRuleRepository()
  615. app.extensions["data_rule_repository"] = repository
  616. client = app.test_client()
  617. version_id = new_governance_uid()
  618. response = client.get(
  619. f"/api/rules/rule-versions/{version_id}/evidence",
  620. headers=_headers(app, "viewer"),
  621. )
  622. assert response.status_code == 200
  623. assert repository.calls[-1] == (
  624. "get_asset_evidence",
  625. {"asset_type": "rule", "version_id": version_id},
  626. )
  627. def test_production_line_resolve_preview_expands_standard_without_writing(
  628. monkeypatch,
  629. ):
  630. from app import create_app
  631. app = create_app()
  632. _use_token_identity(monkeypatch)
  633. app.config["TESTING"] = True
  634. client = app.test_client()
  635. standard_id = new_governance_uid()
  636. standard_rule_id = new_governance_uid()
  637. direct_rule_id = new_governance_uid()
  638. standard_rule = published_rule(standard_rule_id, assertion_only_rule())
  639. direct_rule = published_rule(direct_rule_id)
  640. response = client.post(
  641. "/api/rules/production-lines/resolve",
  642. json={
  643. "dataflow_spec": valid_dataflow_spec(standard_id, direct_rule_id),
  644. "standard_versions": {
  645. standard_id: published_standard(standard_id, standard_rule_id)
  646. },
  647. "rule_versions": {
  648. standard_rule_id: standard_rule,
  649. direct_rule_id: direct_rule,
  650. },
  651. "component_binding_ids": {
  652. "normalize_customer": new_governance_uid(),
  653. "customer_standard:mobile_format": new_governance_uid(),
  654. },
  655. },
  656. headers=_headers(app, "editor"),
  657. )
  658. assert response.status_code == 200
  659. result = response.get_json()["data"]
  660. assert result["preview"] is True
  661. assert result["release_ready"] is False
  662. assert result["package"]["package_hash"]
  663. assert result["package"]["standard_version_ids"] == [standard_id]
  664. def test_rule_api_rejects_invalid_or_unauthenticated_requests(monkeypatch):
  665. from app import create_app
  666. app = create_app()
  667. _use_token_identity(monkeypatch)
  668. app.config["TESTING"] = True
  669. client = app.test_client()
  670. assert client.get("/api/rules/capabilities").status_code == 401
  671. response = client.post(
  672. "/api/rules/validate",
  673. json={"asset_type": "rule", "spec": {"schema_version": "1.0"}},
  674. headers=_headers(app, "editor"),
  675. )
  676. assert response.status_code == 400
  677. assert "missing" not in str(response.get_json()).lower()
  678. def test_rule_and_standard_versions_are_created_then_published_by_separate_roles(
  679. monkeypatch,
  680. ):
  681. from app import create_app
  682. from tests.core.data_rules.test_contracts import valid_standard_spec
  683. app = create_app()
  684. _use_token_identity(monkeypatch)
  685. app.config["TESTING"] = True
  686. repository = FakeRuleRepository()
  687. app.extensions["data_rule_repository"] = repository
  688. publication = FakePublicationService(repository)
  689. app.extensions["rule_publication_service"] = publication
  690. client = app.test_client()
  691. rule_spec = valid_rule_spec()
  692. created = client.post(
  693. "/api/rules/rule-versions",
  694. json={
  695. "source_text": "手机号必须为11位数字",
  696. "rule_spec": rule_spec,
  697. "category": "standard_clause",
  698. "generation_receipt": "signed-test-receipt",
  699. },
  700. headers=_headers(app, "editor"),
  701. )
  702. assert created.status_code == 201
  703. assert created.get_json()["data"]["status"] == "draft"
  704. rule_version_id = created.get_json()["data"]["id"]
  705. compiled = client.post(
  706. f"/api/rules/rule-versions/{rule_version_id}/validate",
  707. headers=_headers(app, "editor"),
  708. )
  709. assert compiled.status_code == 200
  710. plan_id = compiled.get_json()["data"]["plan_id"]
  711. tested = client.post(
  712. f"/api/rules/rule-versions/{rule_version_id}/test",
  713. json={"plan_id": plan_id},
  714. headers=_headers(app, "editor"),
  715. )
  716. assert tested.status_code == 200
  717. assert tested.get_json()["data"]["version_status"] == "validated"
  718. forbidden = client.post(
  719. f"/api/rules/rule-versions/{rule_version_id}/publish",
  720. headers=_headers(app, "editor"),
  721. )
  722. assert forbidden.status_code == 403
  723. published = client.post(
  724. f"/api/rules/rule-versions/{rule_version_id}/publish",
  725. headers=_headers(app, "admin"),
  726. )
  727. assert published.status_code == 200
  728. assert published.get_json()["data"]["status"] == "published"
  729. standard_spec = valid_standard_spec(rule_version_id)
  730. standard = client.post(
  731. "/api/rules/standard-versions",
  732. json={
  733. "source_text": "客户手机号遵循统一格式",
  734. "standard_spec": standard_spec,
  735. },
  736. headers=_headers(app, "editor"),
  737. )
  738. assert standard.status_code == 201
  739. standard_version_id = standard.get_json()["data"]["id"]
  740. standard_published = client.post(
  741. f"/api/rules/standard-versions/{standard_version_id}/publish",
  742. headers=_headers(app, "admin"),
  743. )
  744. assert standard_published.status_code == 200
  745. assert standard_published.get_json()["data"]["status"] == "published"
  746. methods = [method for method, _kwargs in repository.calls]
  747. assert methods == [
  748. "create_rule_version",
  749. "validate_rule_version",
  750. "test_rule_version",
  751. "publish_rule_version",
  752. "create_standard_version",
  753. "publish_standard_version",
  754. ]
  755. def test_create_version_rejects_client_selected_lifecycle_status(monkeypatch):
  756. from app import create_app
  757. app = create_app()
  758. _use_token_identity(monkeypatch)
  759. app.config["TESTING"] = True
  760. repository = FakeRuleRepository()
  761. app.extensions["data_rule_repository"] = repository
  762. app.extensions["rule_publication_service"] = FakePublicationService(
  763. repository
  764. )
  765. client = app.test_client()
  766. response = client.post(
  767. "/api/rules/rule-versions",
  768. json={
  769. "source_text": "手机号必须为11位数字",
  770. "rule_spec": valid_rule_spec(),
  771. "generation_receipt": "signed-test-receipt",
  772. "status": "published",
  773. },
  774. headers=_headers(app, "editor"),
  775. )
  776. assert response.status_code == 400
  777. def test_generation_receipt_signer_requires_dedicated_secret(monkeypatch):
  778. from app import create_app
  779. from app.api.data_rules.routes import _receipt_signer
  780. monkeypatch.delenv("RULE_GENERATION_RECEIPT_SECRET", raising=False)
  781. app = create_app()
  782. app.config["RULE_GENERATION_RECEIPT_SECRET"] = None
  783. with (
  784. app.app_context(),
  785. pytest.raises(RuntimeError, match="receipt secret"),
  786. ):
  787. _receipt_signer()
  788. app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
  789. "dedicated-test-receipt-secret-with-entropy"
  790. )
  791. with app.app_context():
  792. signer = _receipt_signer()
  793. assert signer is not None
  794. def test_rule_gates_reject_caller_supplied_compile_or_test_evidence(
  795. monkeypatch,
  796. ):
  797. from app import create_app
  798. app = create_app()
  799. _use_token_identity(monkeypatch)
  800. app.config["TESTING"] = True
  801. repository = FakeRuleRepository()
  802. app.extensions["rule_publication_service"] = FakePublicationService(
  803. repository
  804. )
  805. client = app.test_client()
  806. version_id = new_governance_uid()
  807. forged_compile = client.post(
  808. f"/api/rules/rule-versions/{version_id}/validate",
  809. json={"status": "success", "plan_hash": "a" * 64},
  810. headers=_headers(app, "editor"),
  811. )
  812. forged_test = client.post(
  813. f"/api/rules/rule-versions/{version_id}/test",
  814. json={
  815. "plan_id": new_governance_uid(),
  816. "evidence": {"status": "success"},
  817. },
  818. headers=_headers(app, "editor"),
  819. )
  820. assert forged_compile.status_code == 409
  821. assert forged_test.status_code == 409
  822. assert repository.calls == []
  823. def test_create_rule_version_rejects_legacy_v1_payload_before_repository(
  824. monkeypatch,
  825. ):
  826. from app import create_app
  827. app = create_app()
  828. _use_token_identity(monkeypatch)
  829. app.config["TESTING"] = True
  830. repository = FakeRuleRepository()
  831. app.extensions["data_rule_repository"] = repository
  832. client = app.test_client()
  833. legacy = valid_rule_spec()
  834. legacy["schema_version"] = "1.0"
  835. response = client.post(
  836. "/api/rules/rule-versions",
  837. json={"source_text": "旧版规则不能再创建", "rule_spec": legacy},
  838. headers=_headers(app, "editor"),
  839. )
  840. assert response.status_code == 400
  841. assert repository.calls == []
  842. def test_dataflow_release_uses_server_assets_and_release_permission(
  843. monkeypatch,
  844. ):
  845. from app import create_app
  846. app = create_app()
  847. _use_token_identity(monkeypatch)
  848. app.config["TESTING"] = True
  849. service = FakeReleaseService()
  850. app.extensions["production_line_release_service"] = service
  851. client = app.test_client()
  852. flow = valid_dataflow_spec()
  853. payload = {
  854. "source_text": "客户数据生产线",
  855. "dataflow_spec": flow,
  856. }
  857. forbidden = client.post(
  858. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  859. json=payload,
  860. headers=_headers(app, "editor"),
  861. )
  862. assert forbidden.status_code == 403
  863. response = client.post(
  864. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  865. json=payload,
  866. headers=_headers(app, "admin"),
  867. )
  868. assert response.status_code == 201
  869. assert response.get_json()["data"]["status"] == "released"
  870. assert service.calls[0]["dataflow_uid"] == flow["dataflow_uid"]
  871. assert "standard_versions" not in service.calls[0]
  872. assert "rule_versions" not in service.calls[0]
  873. assert "component_binding_ids" not in service.calls[0]
  874. def test_dataflow_release_rejects_client_authored_schema_hashes(monkeypatch):
  875. from app import create_app
  876. app = create_app()
  877. _use_token_identity(monkeypatch)
  878. app.config["TESTING"] = True
  879. service = FakeReleaseService()
  880. app.extensions["production_line_release_service"] = service
  881. client = app.test_client()
  882. flow = valid_dataflow_spec()
  883. response = client.post(
  884. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  885. json={
  886. "source_text": "客户数据生产线",
  887. "dataflow_spec": flow,
  888. "input_schema_hashes": {"bd:customer_raw:v2": "a" * 64},
  889. "output_schema_hash": "b" * 64,
  890. },
  891. headers=_headers(app, "admin"),
  892. )
  893. assert response.status_code == 409
  894. assert service.calls == []
  895. def test_default_release_service_uses_lazy_neo4j_schema_catalog(monkeypatch):
  896. from app import create_app
  897. from app.api.data_rules.routes import _release_service
  898. app = create_app()
  899. repository = SnapshotOnlyRepository()
  900. driver = FakeGraphDriver()
  901. monkeypatch.setattr(
  902. "app.core.data_rules.schema_resolver.neo4j_driver", driver
  903. )
  904. app.extensions["data_rule_repository"] = repository
  905. with app.app_context():
  906. service = _release_service()
  907. snapshot = service.schema_resolver.resolve("bd:customer_raw:v2")
  908. assert snapshot["source_revision"] == "neo4j:9:v2"
  909. assert driver.session.calls[0][1] == {
  910. "domain_key": "customer_raw",
  911. "revision": "v2",
  912. }