test_data_rule_api.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. from __future__ import annotations
  2. from datetime import UTC, datetime, timedelta
  3. from app.core.common.identifiers import new_governance_uid
  4. from app.core.data_rules.contracts import rule_spec_hash
  5. from app.core.system.tokens import decode_access_token, issue_access_token
  6. from tests.core.data_rules.test_contracts import (
  7. valid_dataflow_spec,
  8. valid_rule_spec,
  9. )
  10. from tests.core.data_rules.test_production_line import (
  11. assertion_only_rule,
  12. published_rule,
  13. published_standard,
  14. )
  15. class FakeAuthoringAgent:
  16. def __init__(self):
  17. self.calls = []
  18. def interpret(self, **kwargs):
  19. self.calls.append(kwargs)
  20. return {
  21. "status": "ready",
  22. "source_text": kwargs["source_text"],
  23. "candidate_hash": "a" * 64,
  24. "context_hash": "b" * 64,
  25. "candidate": {
  26. "candidate_type": "rule",
  27. "rule_spec": valid_rule_spec(),
  28. },
  29. }
  30. class FakeRuleRepository:
  31. def __init__(self):
  32. self.calls = []
  33. def create_rule_version(self, **kwargs):
  34. self.calls.append(("create_rule_version", kwargs))
  35. return {
  36. "id": new_governance_uid(),
  37. "rule_uid": kwargs["rule_spec"]["rule_uid"],
  38. "version_no": 1,
  39. "status": "draft",
  40. "spec_hash": rule_spec_hash(kwargs["rule_spec"]),
  41. "created": True,
  42. }
  43. def record_generation_run(self, **kwargs):
  44. self.calls.append(("record_generation_run", kwargs))
  45. return {
  46. "id": new_governance_uid(),
  47. "correlation_id": new_governance_uid(),
  48. "decision": kwargs["evidence"]["status"],
  49. }
  50. def resolve_validation_context(self, context):
  51. self.calls.append(("resolve_validation_context", {"context": context}))
  52. return {
  53. "schema_snapshot_id": new_governance_uid(),
  54. "schema_hash": "c" * 64,
  55. "fields": [{"name": "mobile", "type": "string"}],
  56. "sample_artifact_ref": None,
  57. "sample_artifact_digest": None,
  58. }
  59. def publish_rule_version(self, **kwargs):
  60. self.calls.append(("publish_rule_version", kwargs))
  61. return {
  62. "id": kwargs["version_id"],
  63. "rule_uid": new_governance_uid(),
  64. "version_no": 1,
  65. "status": "published",
  66. "spec_hash": "a" * 64,
  67. }
  68. def create_standard_version(self, **kwargs):
  69. self.calls.append(("create_standard_version", kwargs))
  70. return {
  71. "id": new_governance_uid(),
  72. "standard_uid": kwargs["standard_spec"]["standard_uid"],
  73. "version_no": 1,
  74. "status": "validated",
  75. "spec_hash": "b" * 64,
  76. "created": True,
  77. }
  78. def publish_standard_version(self, **kwargs):
  79. self.calls.append(("publish_standard_version", kwargs))
  80. return {
  81. "id": kwargs["version_id"],
  82. "standard_uid": new_governance_uid(),
  83. "version_no": 1,
  84. "status": "published",
  85. "spec_hash": "b" * 64,
  86. }
  87. class FakeReleaseService:
  88. def __init__(self):
  89. self.calls = []
  90. def release(self, **kwargs):
  91. self.calls.append(kwargs)
  92. return {
  93. "id": new_governance_uid(),
  94. "version_no": 1,
  95. "status": "released",
  96. "package_hash": "c" * 64,
  97. "package": {
  98. "package_hash": "c" * 64,
  99. "standard_version_ids": [],
  100. "rule_version_ids": [],
  101. },
  102. }
  103. class FakePublicationService:
  104. def __init__(self, repository):
  105. self.repository = repository
  106. self.plan_id = new_governance_uid()
  107. self.version_id = None
  108. def create_draft(self, **kwargs):
  109. self.repository.calls.append(("create_rule_version", kwargs))
  110. self.version_id = new_governance_uid()
  111. return {
  112. "id": self.version_id,
  113. "rule_uid": kwargs["rule_spec"]["rule_uid"],
  114. "version_no": 1,
  115. "status": "draft",
  116. "spec_hash": rule_spec_hash(kwargs["rule_spec"]),
  117. "generation_run_id": new_governance_uid(),
  118. "created": True,
  119. }
  120. def validate(self, version_id, actor_uid):
  121. self.repository.calls.append(
  122. ("validate_rule_version", {"version_id": version_id, "actor_uid": actor_uid})
  123. )
  124. return {
  125. "version_id": version_id,
  126. "version_status": "draft",
  127. "plan_id": self.plan_id,
  128. "plan_status": "compiled",
  129. "plan_hash": "d" * 64,
  130. }
  131. def test(self, version_id, actor_uid, *, plan_id):
  132. self.repository.calls.append(
  133. (
  134. "test_rule_version",
  135. {
  136. "version_id": version_id,
  137. "actor_uid": actor_uid,
  138. "plan_id": plan_id,
  139. },
  140. )
  141. )
  142. return {
  143. "version_id": version_id,
  144. "version_status": "validated",
  145. "plan_id": plan_id,
  146. "plan_status": "tested",
  147. "plan_hash": "d" * 64,
  148. }
  149. def publish(self, version_id, actor_uid):
  150. self.repository.calls.append(
  151. ("publish_rule_version", {"version_id": version_id, "actor_uid": actor_uid})
  152. )
  153. return {
  154. "id": version_id,
  155. "status": "published",
  156. "plan_id": self.plan_id,
  157. "plan_status": "published",
  158. }
  159. def evidence(self, version_id):
  160. return {"version_id": version_id, "version_status": "validated"}
  161. def catalog(self, *, query, limit):
  162. return []
  163. class FakeGraphSession:
  164. def __init__(self):
  165. self.calls = []
  166. def run(self, query, parameters):
  167. self.calls.append((query, parameters))
  168. return [
  169. {
  170. "domain_id": 9,
  171. "domain_key": "customer_raw",
  172. "revision": "v2",
  173. "field_name": "customer_id",
  174. "data_type": "string",
  175. "nullable": False,
  176. "precision": None,
  177. "scale": None,
  178. "timezone": None,
  179. }
  180. ]
  181. def __enter__(self):
  182. return self
  183. def __exit__(self, *_args):
  184. return False
  185. class FakeGraphDriver:
  186. def __init__(self):
  187. self.session = FakeGraphSession()
  188. def get_session(self):
  189. return self.session
  190. class SnapshotOnlyRepository:
  191. def __init__(self):
  192. self.snapshots = {}
  193. def find_schema_snapshot(self, *, schema_ref, schema_hash):
  194. return self.snapshots.get((schema_ref, schema_hash))
  195. def persist_schema_snapshot(self, *, snapshot):
  196. value = {"id": new_governance_uid(), **snapshot}
  197. self.snapshots[(snapshot["schema_ref"], snapshot["schema_hash"])] = value
  198. return value
  199. def _headers(app, role):
  200. token = issue_access_token(
  201. user_id=new_governance_uid(),
  202. roles=[role],
  203. secret=app.config["SECRET_KEY"],
  204. now=datetime.now(UTC),
  205. lifetime=timedelta(minutes=10),
  206. )
  207. return {"Authorization": f"Bearer {token}"}
  208. def _use_token_identity(monkeypatch):
  209. def load(token, *, secret):
  210. claims = decode_access_token(token, secret=secret)
  211. return {
  212. "id": claims["sub"],
  213. "username": "contract-test",
  214. "display_name": "Contract Test",
  215. "roles": claims["roles"],
  216. }
  217. monkeypatch.setattr(
  218. "app.core.system.auth.load_identity_from_token",
  219. load,
  220. )
  221. def test_rule_capabilities_and_validation_are_registered_and_governed(monkeypatch):
  222. from app import create_app
  223. app = create_app()
  224. _use_token_identity(monkeypatch)
  225. app.config["TESTING"] = True
  226. client = app.test_client()
  227. response = client.get("/api/rules/capabilities", headers=_headers(app, "viewer"))
  228. assert response.status_code == 200
  229. capabilities = response.get_json()["data"]
  230. assert capabilities["natural_language_authoring"] is True
  231. assert capabilities["immutable_asset_versions"] is True
  232. assert capabilities["server_side_publishing"] is True
  233. assert capabilities["production_line_release"] is True
  234. assert capabilities["data_factory_activation"] is False
  235. spec = valid_rule_spec()
  236. response = client.post(
  237. "/api/rules/validate",
  238. json={"asset_type": "rule", "spec": spec},
  239. headers=_headers(app, "editor"),
  240. )
  241. assert response.status_code == 200
  242. result = response.get_json()["data"]
  243. assert result["spec_hash"] == rule_spec_hash(spec)
  244. assert result["normalized"]["rule_uid"] == spec["rule_uid"]
  245. forbidden = client.post(
  246. "/api/rules/validate",
  247. json={"asset_type": "rule", "spec": spec},
  248. headers=_headers(app, "viewer"),
  249. )
  250. assert forbidden.status_code == 403
  251. def test_rule_interpret_uses_configured_agent_and_preserves_surface(monkeypatch):
  252. from app import create_app
  253. app = create_app()
  254. _use_token_identity(monkeypatch)
  255. app.config["TESTING"] = True
  256. agent = FakeAuthoringAgent()
  257. app.extensions["data_rule_authoring_agent"] = agent
  258. repository = FakeRuleRepository()
  259. app.extensions["data_rule_repository"] = repository
  260. client = app.test_client()
  261. response = client.post(
  262. "/api/rules/interpret",
  263. json={
  264. "source_text": "手机号去空格后必须为11位数字",
  265. "authoring_surface": "data_standard",
  266. "context": {"input_schema_ref": "bd:customer:v7"},
  267. },
  268. headers=_headers(app, "editor"),
  269. )
  270. assert response.status_code == 200
  271. assert response.get_json()["data"]["status"] == "ready"
  272. assert response.get_json()["data"]["generation_run_id"]
  273. assert response.get_json()["data"]["generation_receipt"]
  274. assert agent.calls[0]["authoring_surface"] == "data_standard"
  275. assert repository.calls[0][0] == "resolve_validation_context"
  276. assert repository.calls[1][0] == "record_generation_run"
  277. def test_production_line_resolve_preview_expands_standard_without_writing(monkeypatch):
  278. from app import create_app
  279. app = create_app()
  280. _use_token_identity(monkeypatch)
  281. app.config["TESTING"] = True
  282. client = app.test_client()
  283. standard_id = new_governance_uid()
  284. standard_rule_id = new_governance_uid()
  285. direct_rule_id = new_governance_uid()
  286. standard_rule = published_rule(standard_rule_id, assertion_only_rule())
  287. direct_rule = published_rule(direct_rule_id)
  288. response = client.post(
  289. "/api/rules/production-lines/resolve",
  290. json={
  291. "dataflow_spec": valid_dataflow_spec(standard_id, direct_rule_id),
  292. "standard_versions": {
  293. standard_id: published_standard(standard_id, standard_rule_id)
  294. },
  295. "rule_versions": {
  296. standard_rule_id: standard_rule,
  297. direct_rule_id: direct_rule,
  298. },
  299. "component_binding_ids": {
  300. "normalize_customer": new_governance_uid(),
  301. "customer_standard:mobile_format": new_governance_uid(),
  302. },
  303. },
  304. headers=_headers(app, "editor"),
  305. )
  306. assert response.status_code == 200
  307. result = response.get_json()["data"]
  308. assert result["preview"] is True
  309. assert result["release_ready"] is False
  310. assert result["package"]["package_hash"]
  311. assert result["package"]["standard_version_ids"] == [standard_id]
  312. def test_rule_api_rejects_invalid_or_unauthenticated_requests(monkeypatch):
  313. from app import create_app
  314. app = create_app()
  315. _use_token_identity(monkeypatch)
  316. app.config["TESTING"] = True
  317. client = app.test_client()
  318. assert client.get("/api/rules/capabilities").status_code == 401
  319. response = client.post(
  320. "/api/rules/validate",
  321. json={"asset_type": "rule", "spec": {"schema_version": "1.0"}},
  322. headers=_headers(app, "editor"),
  323. )
  324. assert response.status_code == 400
  325. assert "missing" not in str(response.get_json()).lower()
  326. def test_rule_and_standard_versions_are_created_then_published_by_separate_roles(
  327. monkeypatch,
  328. ):
  329. from app import create_app
  330. from tests.core.data_rules.test_contracts import valid_standard_spec
  331. app = create_app()
  332. _use_token_identity(monkeypatch)
  333. app.config["TESTING"] = True
  334. repository = FakeRuleRepository()
  335. app.extensions["data_rule_repository"] = repository
  336. publication = FakePublicationService(repository)
  337. app.extensions["rule_publication_service"] = publication
  338. client = app.test_client()
  339. rule_spec = valid_rule_spec()
  340. created = client.post(
  341. "/api/rules/rule-versions",
  342. json={
  343. "source_text": "手机号必须为11位数字",
  344. "rule_spec": rule_spec,
  345. "category": "standard_clause",
  346. "generation_receipt": "signed-test-receipt",
  347. },
  348. headers=_headers(app, "editor"),
  349. )
  350. assert created.status_code == 201
  351. assert created.get_json()["data"]["status"] == "draft"
  352. rule_version_id = created.get_json()["data"]["id"]
  353. compiled = client.post(
  354. f"/api/rules/rule-versions/{rule_version_id}/validate",
  355. headers=_headers(app, "editor"),
  356. )
  357. assert compiled.status_code == 200
  358. plan_id = compiled.get_json()["data"]["plan_id"]
  359. tested = client.post(
  360. f"/api/rules/rule-versions/{rule_version_id}/test",
  361. json={"plan_id": plan_id},
  362. headers=_headers(app, "editor"),
  363. )
  364. assert tested.status_code == 200
  365. assert tested.get_json()["data"]["version_status"] == "validated"
  366. forbidden = client.post(
  367. f"/api/rules/rule-versions/{rule_version_id}/publish",
  368. headers=_headers(app, "editor"),
  369. )
  370. assert forbidden.status_code == 403
  371. published = client.post(
  372. f"/api/rules/rule-versions/{rule_version_id}/publish",
  373. headers=_headers(app, "admin"),
  374. )
  375. assert published.status_code == 200
  376. assert published.get_json()["data"]["status"] == "published"
  377. standard_spec = valid_standard_spec(rule_version_id)
  378. standard = client.post(
  379. "/api/rules/standard-versions",
  380. json={
  381. "source_text": "客户手机号遵循统一格式",
  382. "standard_spec": standard_spec,
  383. },
  384. headers=_headers(app, "editor"),
  385. )
  386. assert standard.status_code == 201
  387. standard_version_id = standard.get_json()["data"]["id"]
  388. standard_published = client.post(
  389. f"/api/rules/standard-versions/{standard_version_id}/publish",
  390. headers=_headers(app, "admin"),
  391. )
  392. assert standard_published.status_code == 200
  393. assert standard_published.get_json()["data"]["status"] == "published"
  394. methods = [method for method, _kwargs in repository.calls]
  395. assert methods == [
  396. "create_rule_version",
  397. "validate_rule_version",
  398. "test_rule_version",
  399. "publish_rule_version",
  400. "create_standard_version",
  401. "publish_standard_version",
  402. ]
  403. def test_create_version_rejects_client_selected_lifecycle_status(monkeypatch):
  404. from app import create_app
  405. app = create_app()
  406. _use_token_identity(monkeypatch)
  407. app.config["TESTING"] = True
  408. repository = FakeRuleRepository()
  409. app.extensions["data_rule_repository"] = repository
  410. app.extensions["rule_publication_service"] = FakePublicationService(repository)
  411. client = app.test_client()
  412. response = client.post(
  413. "/api/rules/rule-versions",
  414. json={
  415. "source_text": "手机号必须为11位数字",
  416. "rule_spec": valid_rule_spec(),
  417. "generation_receipt": "signed-test-receipt",
  418. "status": "published",
  419. },
  420. headers=_headers(app, "editor"),
  421. )
  422. assert response.status_code == 400
  423. def test_rule_gates_reject_caller_supplied_compile_or_test_evidence(monkeypatch):
  424. from app import create_app
  425. app = create_app()
  426. _use_token_identity(monkeypatch)
  427. app.config["TESTING"] = True
  428. repository = FakeRuleRepository()
  429. app.extensions["rule_publication_service"] = FakePublicationService(repository)
  430. client = app.test_client()
  431. version_id = new_governance_uid()
  432. forged_compile = client.post(
  433. f"/api/rules/rule-versions/{version_id}/validate",
  434. json={"status": "success", "plan_hash": "a" * 64},
  435. headers=_headers(app, "editor"),
  436. )
  437. forged_test = client.post(
  438. f"/api/rules/rule-versions/{version_id}/test",
  439. json={
  440. "plan_id": new_governance_uid(),
  441. "evidence": {"status": "success"},
  442. },
  443. headers=_headers(app, "editor"),
  444. )
  445. assert forged_compile.status_code == 409
  446. assert forged_test.status_code == 409
  447. assert repository.calls == []
  448. def test_create_rule_version_rejects_legacy_v1_payload_before_repository(monkeypatch):
  449. from app import create_app
  450. app = create_app()
  451. _use_token_identity(monkeypatch)
  452. app.config["TESTING"] = True
  453. repository = FakeRuleRepository()
  454. app.extensions["data_rule_repository"] = repository
  455. client = app.test_client()
  456. legacy = valid_rule_spec()
  457. legacy["schema_version"] = "1.0"
  458. response = client.post(
  459. "/api/rules/rule-versions",
  460. json={"source_text": "旧版规则不能再创建", "rule_spec": legacy},
  461. headers=_headers(app, "editor"),
  462. )
  463. assert response.status_code == 400
  464. assert repository.calls == []
  465. def test_dataflow_release_uses_server_assets_and_release_permission(monkeypatch):
  466. from app import create_app
  467. app = create_app()
  468. _use_token_identity(monkeypatch)
  469. app.config["TESTING"] = True
  470. service = FakeReleaseService()
  471. app.extensions["production_line_release_service"] = service
  472. client = app.test_client()
  473. flow = valid_dataflow_spec()
  474. payload = {
  475. "source_text": "客户数据生产线",
  476. "dataflow_spec": flow,
  477. }
  478. forbidden = client.post(
  479. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  480. json=payload,
  481. headers=_headers(app, "editor"),
  482. )
  483. assert forbidden.status_code == 403
  484. response = client.post(
  485. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  486. json=payload,
  487. headers=_headers(app, "admin"),
  488. )
  489. assert response.status_code == 201
  490. assert response.get_json()["data"]["status"] == "released"
  491. assert service.calls[0]["dataflow_uid"] == flow["dataflow_uid"]
  492. assert "standard_versions" not in service.calls[0]
  493. assert "rule_versions" not in service.calls[0]
  494. assert "component_binding_ids" not in service.calls[0]
  495. def test_dataflow_release_rejects_client_authored_schema_hashes(monkeypatch):
  496. from app import create_app
  497. app = create_app()
  498. _use_token_identity(monkeypatch)
  499. app.config["TESTING"] = True
  500. service = FakeReleaseService()
  501. app.extensions["production_line_release_service"] = service
  502. client = app.test_client()
  503. flow = valid_dataflow_spec()
  504. response = client.post(
  505. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  506. json={
  507. "source_text": "客户数据生产线",
  508. "dataflow_spec": flow,
  509. "input_schema_hashes": {"bd:customer_raw:v2": "a" * 64},
  510. "output_schema_hash": "b" * 64,
  511. },
  512. headers=_headers(app, "admin"),
  513. )
  514. assert response.status_code == 409
  515. assert service.calls == []
  516. def test_default_release_service_uses_lazy_neo4j_schema_catalog(monkeypatch):
  517. from app import create_app
  518. from app.api.data_rules.routes import _release_service
  519. app = create_app()
  520. repository = SnapshotOnlyRepository()
  521. driver = FakeGraphDriver()
  522. monkeypatch.setattr(
  523. "app.core.data_rules.schema_resolver.neo4j_driver", driver
  524. )
  525. app.extensions["data_rule_repository"] = repository
  526. with app.app_context():
  527. service = _release_service()
  528. snapshot = service.schema_resolver.resolve("bd:customer_raw:v2")
  529. assert snapshot["source_revision"] == "neo4j:9:v2"
  530. assert driver.session.calls[0][1] == {
  531. "domain_key": "customer_raw",
  532. "revision": "v2",
  533. }