test_data_rule_api.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. from __future__ import annotations
  2. from datetime import datetime, timedelta, timezone
  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": "validated",
  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 publish_rule_version(self, **kwargs):
  51. self.calls.append(("publish_rule_version", kwargs))
  52. return {
  53. "id": kwargs["version_id"],
  54. "rule_uid": new_governance_uid(),
  55. "version_no": 1,
  56. "status": "published",
  57. "spec_hash": "a" * 64,
  58. }
  59. def create_standard_version(self, **kwargs):
  60. self.calls.append(("create_standard_version", kwargs))
  61. return {
  62. "id": new_governance_uid(),
  63. "standard_uid": kwargs["standard_spec"]["standard_uid"],
  64. "version_no": 1,
  65. "status": "validated",
  66. "spec_hash": "b" * 64,
  67. "created": True,
  68. }
  69. def publish_standard_version(self, **kwargs):
  70. self.calls.append(("publish_standard_version", kwargs))
  71. return {
  72. "id": kwargs["version_id"],
  73. "standard_uid": new_governance_uid(),
  74. "version_no": 1,
  75. "status": "published",
  76. "spec_hash": "b" * 64,
  77. }
  78. class FakeReleaseService:
  79. def __init__(self):
  80. self.calls = []
  81. def release(self, **kwargs):
  82. self.calls.append(kwargs)
  83. return {
  84. "id": new_governance_uid(),
  85. "version_no": 1,
  86. "status": "released",
  87. "package_hash": "c" * 64,
  88. "package": {
  89. "package_hash": "c" * 64,
  90. "standard_version_ids": [],
  91. "rule_version_ids": [],
  92. },
  93. }
  94. class FakeGraphSession:
  95. def __init__(self):
  96. self.calls = []
  97. def run(self, query, parameters):
  98. self.calls.append((query, parameters))
  99. return [
  100. {
  101. "domain_id": 9,
  102. "domain_key": "customer_raw",
  103. "revision": "v2",
  104. "field_name": "customer_id",
  105. "data_type": "string",
  106. "nullable": False,
  107. "precision": None,
  108. "scale": None,
  109. "timezone": None,
  110. }
  111. ]
  112. def __enter__(self):
  113. return self
  114. def __exit__(self, *_args):
  115. return False
  116. class FakeGraphDriver:
  117. def __init__(self):
  118. self.session = FakeGraphSession()
  119. def get_session(self):
  120. return self.session
  121. class SnapshotOnlyRepository:
  122. def __init__(self):
  123. self.snapshots = {}
  124. def find_schema_snapshot(self, *, schema_ref, schema_hash):
  125. return self.snapshots.get((schema_ref, schema_hash))
  126. def persist_schema_snapshot(self, *, snapshot):
  127. value = {"id": new_governance_uid(), **snapshot}
  128. self.snapshots[(snapshot["schema_ref"], snapshot["schema_hash"])] = value
  129. return value
  130. def _headers(app, role):
  131. token = issue_access_token(
  132. user_id=new_governance_uid(),
  133. roles=[role],
  134. secret=app.config["SECRET_KEY"],
  135. now=datetime.now(timezone.utc),
  136. lifetime=timedelta(minutes=10),
  137. )
  138. return {"Authorization": f"Bearer {token}"}
  139. def _use_token_identity(monkeypatch):
  140. def load(token, *, secret):
  141. claims = decode_access_token(token, secret=secret)
  142. return {
  143. "id": claims["sub"],
  144. "username": "contract-test",
  145. "display_name": "Contract Test",
  146. "roles": claims["roles"],
  147. }
  148. monkeypatch.setattr(
  149. "app.core.system.auth.load_identity_from_token",
  150. load,
  151. )
  152. def test_rule_capabilities_and_validation_are_registered_and_governed(monkeypatch):
  153. from app import create_app
  154. app = create_app()
  155. _use_token_identity(monkeypatch)
  156. app.config["TESTING"] = True
  157. client = app.test_client()
  158. response = client.get("/api/rules/capabilities", headers=_headers(app, "viewer"))
  159. assert response.status_code == 200
  160. capabilities = response.get_json()["data"]
  161. assert capabilities["natural_language_authoring"] is True
  162. assert capabilities["immutable_asset_versions"] is True
  163. assert capabilities["server_side_publishing"] is True
  164. assert capabilities["production_line_release"] is True
  165. assert capabilities["data_factory_activation"] is False
  166. spec = valid_rule_spec()
  167. response = client.post(
  168. "/api/rules/validate",
  169. json={"asset_type": "rule", "spec": spec},
  170. headers=_headers(app, "editor"),
  171. )
  172. assert response.status_code == 200
  173. result = response.get_json()["data"]
  174. assert result["spec_hash"] == rule_spec_hash(spec)
  175. assert result["normalized"]["rule_uid"] == spec["rule_uid"]
  176. forbidden = client.post(
  177. "/api/rules/validate",
  178. json={"asset_type": "rule", "spec": spec},
  179. headers=_headers(app, "viewer"),
  180. )
  181. assert forbidden.status_code == 403
  182. def test_rule_interpret_uses_configured_agent_and_preserves_surface(monkeypatch):
  183. from app import create_app
  184. app = create_app()
  185. _use_token_identity(monkeypatch)
  186. app.config["TESTING"] = True
  187. agent = FakeAuthoringAgent()
  188. app.extensions["data_rule_authoring_agent"] = agent
  189. repository = FakeRuleRepository()
  190. app.extensions["data_rule_repository"] = repository
  191. client = app.test_client()
  192. response = client.post(
  193. "/api/rules/interpret",
  194. json={
  195. "source_text": "手机号去空格后必须为11位数字",
  196. "authoring_surface": "data_standard",
  197. "context": {"input_schema_ref": "bd:customer:v7"},
  198. },
  199. headers=_headers(app, "editor"),
  200. )
  201. assert response.status_code == 200
  202. assert response.get_json()["data"]["status"] == "ready"
  203. assert response.get_json()["data"]["generation_run_id"]
  204. assert agent.calls[0]["authoring_surface"] == "data_standard"
  205. assert repository.calls[0][0] == "record_generation_run"
  206. def test_production_line_resolve_preview_expands_standard_without_writing(monkeypatch):
  207. from app import create_app
  208. app = create_app()
  209. _use_token_identity(monkeypatch)
  210. app.config["TESTING"] = True
  211. client = app.test_client()
  212. standard_id = new_governance_uid()
  213. standard_rule_id = new_governance_uid()
  214. direct_rule_id = new_governance_uid()
  215. standard_rule = published_rule(standard_rule_id, assertion_only_rule())
  216. direct_rule = published_rule(direct_rule_id)
  217. response = client.post(
  218. "/api/rules/production-lines/resolve",
  219. json={
  220. "dataflow_spec": valid_dataflow_spec(standard_id, direct_rule_id),
  221. "standard_versions": {
  222. standard_id: published_standard(standard_id, standard_rule_id)
  223. },
  224. "rule_versions": {
  225. standard_rule_id: standard_rule,
  226. direct_rule_id: direct_rule,
  227. },
  228. "component_binding_ids": {
  229. "normalize_customer": new_governance_uid(),
  230. "customer_standard:mobile_format": new_governance_uid(),
  231. },
  232. },
  233. headers=_headers(app, "editor"),
  234. )
  235. assert response.status_code == 200
  236. result = response.get_json()["data"]
  237. assert result["preview"] is True
  238. assert result["release_ready"] is False
  239. assert result["package"]["package_hash"]
  240. assert result["package"]["standard_version_ids"] == [standard_id]
  241. def test_rule_api_rejects_invalid_or_unauthenticated_requests(monkeypatch):
  242. from app import create_app
  243. app = create_app()
  244. _use_token_identity(monkeypatch)
  245. app.config["TESTING"] = True
  246. client = app.test_client()
  247. assert client.get("/api/rules/capabilities").status_code == 401
  248. response = client.post(
  249. "/api/rules/validate",
  250. json={"asset_type": "rule", "spec": {"schema_version": "1.0"}},
  251. headers=_headers(app, "editor"),
  252. )
  253. assert response.status_code == 400
  254. assert "missing" not in str(response.get_json()).lower()
  255. def test_rule_and_standard_versions_are_created_then_published_by_separate_roles(
  256. monkeypatch,
  257. ):
  258. from app import create_app
  259. from tests.core.data_rules.test_contracts import valid_standard_spec
  260. app = create_app()
  261. _use_token_identity(monkeypatch)
  262. app.config["TESTING"] = True
  263. repository = FakeRuleRepository()
  264. app.extensions["data_rule_repository"] = repository
  265. client = app.test_client()
  266. rule_spec = valid_rule_spec()
  267. created = client.post(
  268. "/api/rules/rule-versions",
  269. json={
  270. "source_text": "手机号必须为11位数字",
  271. "rule_spec": rule_spec,
  272. "category": "standard_clause",
  273. },
  274. headers=_headers(app, "editor"),
  275. )
  276. assert created.status_code == 201
  277. assert created.get_json()["data"]["status"] == "validated"
  278. rule_version_id = created.get_json()["data"]["id"]
  279. forbidden = client.post(
  280. f"/api/rules/rule-versions/{rule_version_id}/publish",
  281. headers=_headers(app, "editor"),
  282. )
  283. assert forbidden.status_code == 403
  284. published = client.post(
  285. f"/api/rules/rule-versions/{rule_version_id}/publish",
  286. headers=_headers(app, "admin"),
  287. )
  288. assert published.status_code == 200
  289. assert published.get_json()["data"]["status"] == "published"
  290. standard_spec = valid_standard_spec(rule_version_id)
  291. standard = client.post(
  292. "/api/rules/standard-versions",
  293. json={
  294. "source_text": "客户手机号遵循统一格式",
  295. "standard_spec": standard_spec,
  296. },
  297. headers=_headers(app, "editor"),
  298. )
  299. assert standard.status_code == 201
  300. standard_version_id = standard.get_json()["data"]["id"]
  301. standard_published = client.post(
  302. f"/api/rules/standard-versions/{standard_version_id}/publish",
  303. headers=_headers(app, "admin"),
  304. )
  305. assert standard_published.status_code == 200
  306. assert standard_published.get_json()["data"]["status"] == "published"
  307. methods = [method for method, _kwargs in repository.calls]
  308. assert methods == [
  309. "create_rule_version",
  310. "publish_rule_version",
  311. "create_standard_version",
  312. "publish_standard_version",
  313. ]
  314. def test_create_version_rejects_client_selected_lifecycle_status(monkeypatch):
  315. from app import create_app
  316. app = create_app()
  317. _use_token_identity(monkeypatch)
  318. app.config["TESTING"] = True
  319. app.extensions["data_rule_repository"] = FakeRuleRepository()
  320. client = app.test_client()
  321. response = client.post(
  322. "/api/rules/rule-versions",
  323. json={
  324. "source_text": "手机号必须为11位数字",
  325. "rule_spec": valid_rule_spec(),
  326. "status": "published",
  327. },
  328. headers=_headers(app, "editor"),
  329. )
  330. assert response.status_code == 400
  331. def test_create_rule_version_rejects_legacy_v1_payload_before_repository(monkeypatch):
  332. from app import create_app
  333. app = create_app()
  334. _use_token_identity(monkeypatch)
  335. app.config["TESTING"] = True
  336. repository = FakeRuleRepository()
  337. app.extensions["data_rule_repository"] = repository
  338. client = app.test_client()
  339. legacy = valid_rule_spec()
  340. legacy["schema_version"] = "1.0"
  341. response = client.post(
  342. "/api/rules/rule-versions",
  343. json={"source_text": "旧版规则不能再创建", "rule_spec": legacy},
  344. headers=_headers(app, "editor"),
  345. )
  346. assert response.status_code == 400
  347. assert repository.calls == []
  348. def test_dataflow_release_uses_server_assets_and_release_permission(monkeypatch):
  349. from app import create_app
  350. app = create_app()
  351. _use_token_identity(monkeypatch)
  352. app.config["TESTING"] = True
  353. service = FakeReleaseService()
  354. app.extensions["production_line_release_service"] = service
  355. client = app.test_client()
  356. flow = valid_dataflow_spec()
  357. payload = {
  358. "source_text": "客户数据生产线",
  359. "dataflow_spec": flow,
  360. }
  361. forbidden = client.post(
  362. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  363. json=payload,
  364. headers=_headers(app, "editor"),
  365. )
  366. assert forbidden.status_code == 403
  367. response = client.post(
  368. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  369. json=payload,
  370. headers=_headers(app, "admin"),
  371. )
  372. assert response.status_code == 201
  373. assert response.get_json()["data"]["status"] == "released"
  374. assert service.calls[0]["dataflow_uid"] == flow["dataflow_uid"]
  375. assert "standard_versions" not in service.calls[0]
  376. assert "rule_versions" not in service.calls[0]
  377. assert "component_binding_ids" not in service.calls[0]
  378. def test_dataflow_release_rejects_client_authored_schema_hashes(monkeypatch):
  379. from app import create_app
  380. app = create_app()
  381. _use_token_identity(monkeypatch)
  382. app.config["TESTING"] = True
  383. service = FakeReleaseService()
  384. app.extensions["production_line_release_service"] = service
  385. client = app.test_client()
  386. flow = valid_dataflow_spec()
  387. response = client.post(
  388. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  389. json={
  390. "source_text": "客户数据生产线",
  391. "dataflow_spec": flow,
  392. "input_schema_hashes": {"bd:customer_raw:v2": "a" * 64},
  393. "output_schema_hash": "b" * 64,
  394. },
  395. headers=_headers(app, "admin"),
  396. )
  397. assert response.status_code == 409
  398. assert service.calls == []
  399. def test_default_release_service_uses_lazy_neo4j_schema_catalog(monkeypatch):
  400. from app import create_app
  401. from app.api.data_rules.routes import _release_service
  402. app = create_app()
  403. repository = SnapshotOnlyRepository()
  404. driver = FakeGraphDriver()
  405. monkeypatch.setattr(
  406. "app.core.data_rules.schema_resolver.neo4j_driver", driver
  407. )
  408. app.extensions["data_rule_repository"] = repository
  409. with app.app_context():
  410. service = _release_service()
  411. snapshot = service.schema_resolver.resolve("bd:customer_raw:v2")
  412. assert snapshot["source_revision"] == "neo4j:9:v2"
  413. assert driver.session.calls[0][1] == {
  414. "domain_key": "customer_raw",
  415. "revision": "v2",
  416. }