test_data_rule_api.py 29 KB

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