test_data_rule_api.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  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. def _headers(app, role):
  95. token = issue_access_token(
  96. user_id=new_governance_uid(),
  97. roles=[role],
  98. secret=app.config["SECRET_KEY"],
  99. now=datetime.now(timezone.utc),
  100. lifetime=timedelta(minutes=10),
  101. )
  102. return {"Authorization": f"Bearer {token}"}
  103. def _use_token_identity(monkeypatch):
  104. def load(token, *, secret):
  105. claims = decode_access_token(token, secret=secret)
  106. return {
  107. "id": claims["sub"],
  108. "username": "contract-test",
  109. "display_name": "Contract Test",
  110. "roles": claims["roles"],
  111. }
  112. monkeypatch.setattr(
  113. "app.core.system.auth.load_identity_from_token",
  114. load,
  115. )
  116. def test_rule_capabilities_and_validation_are_registered_and_governed(monkeypatch):
  117. from app import create_app
  118. app = create_app()
  119. _use_token_identity(monkeypatch)
  120. app.config["TESTING"] = True
  121. client = app.test_client()
  122. response = client.get("/api/rules/capabilities", headers=_headers(app, "viewer"))
  123. assert response.status_code == 200
  124. capabilities = response.get_json()["data"]
  125. assert capabilities["natural_language_authoring"] is True
  126. assert capabilities["immutable_asset_versions"] is True
  127. assert capabilities["server_side_publishing"] is True
  128. assert capabilities["production_line_release"] is True
  129. assert capabilities["data_factory_activation"] is False
  130. spec = valid_rule_spec()
  131. response = client.post(
  132. "/api/rules/validate",
  133. json={"asset_type": "rule", "spec": spec},
  134. headers=_headers(app, "editor"),
  135. )
  136. assert response.status_code == 200
  137. result = response.get_json()["data"]
  138. assert result["spec_hash"] == rule_spec_hash(spec)
  139. assert result["normalized"]["rule_uid"] == spec["rule_uid"]
  140. forbidden = client.post(
  141. "/api/rules/validate",
  142. json={"asset_type": "rule", "spec": spec},
  143. headers=_headers(app, "viewer"),
  144. )
  145. assert forbidden.status_code == 403
  146. def test_rule_interpret_uses_configured_agent_and_preserves_surface(monkeypatch):
  147. from app import create_app
  148. app = create_app()
  149. _use_token_identity(monkeypatch)
  150. app.config["TESTING"] = True
  151. agent = FakeAuthoringAgent()
  152. app.extensions["data_rule_authoring_agent"] = agent
  153. repository = FakeRuleRepository()
  154. app.extensions["data_rule_repository"] = repository
  155. client = app.test_client()
  156. response = client.post(
  157. "/api/rules/interpret",
  158. json={
  159. "source_text": "手机号去空格后必须为11位数字",
  160. "authoring_surface": "data_standard",
  161. "context": {"input_schema_ref": "bd:customer:v7"},
  162. },
  163. headers=_headers(app, "editor"),
  164. )
  165. assert response.status_code == 200
  166. assert response.get_json()["data"]["status"] == "ready"
  167. assert response.get_json()["data"]["generation_run_id"]
  168. assert agent.calls[0]["authoring_surface"] == "data_standard"
  169. assert repository.calls[0][0] == "record_generation_run"
  170. def test_production_line_resolve_preview_expands_standard_without_writing(monkeypatch):
  171. from app import create_app
  172. app = create_app()
  173. _use_token_identity(monkeypatch)
  174. app.config["TESTING"] = True
  175. client = app.test_client()
  176. standard_id = new_governance_uid()
  177. standard_rule_id = new_governance_uid()
  178. direct_rule_id = new_governance_uid()
  179. standard_rule = published_rule(standard_rule_id, assertion_only_rule())
  180. direct_rule = published_rule(direct_rule_id)
  181. response = client.post(
  182. "/api/rules/production-lines/resolve",
  183. json={
  184. "dataflow_spec": valid_dataflow_spec(standard_id, direct_rule_id),
  185. "standard_versions": {
  186. standard_id: published_standard(standard_id, standard_rule_id)
  187. },
  188. "rule_versions": {
  189. standard_rule_id: standard_rule,
  190. direct_rule_id: direct_rule,
  191. },
  192. "component_binding_ids": {
  193. "normalize_customer": new_governance_uid(),
  194. "customer_standard:mobile_format": new_governance_uid(),
  195. },
  196. },
  197. headers=_headers(app, "editor"),
  198. )
  199. assert response.status_code == 200
  200. result = response.get_json()["data"]
  201. assert result["preview"] is True
  202. assert result["release_ready"] is False
  203. assert result["package"]["package_hash"]
  204. assert result["package"]["standard_version_ids"] == [standard_id]
  205. def test_rule_api_rejects_invalid_or_unauthenticated_requests(monkeypatch):
  206. from app import create_app
  207. app = create_app()
  208. _use_token_identity(monkeypatch)
  209. app.config["TESTING"] = True
  210. client = app.test_client()
  211. assert client.get("/api/rules/capabilities").status_code == 401
  212. response = client.post(
  213. "/api/rules/validate",
  214. json={"asset_type": "rule", "spec": {"schema_version": "1.0"}},
  215. headers=_headers(app, "editor"),
  216. )
  217. assert response.status_code == 400
  218. assert "missing" not in str(response.get_json()).lower()
  219. def test_rule_and_standard_versions_are_created_then_published_by_separate_roles(
  220. monkeypatch,
  221. ):
  222. from app import create_app
  223. from tests.core.data_rules.test_contracts import valid_standard_spec
  224. app = create_app()
  225. _use_token_identity(monkeypatch)
  226. app.config["TESTING"] = True
  227. repository = FakeRuleRepository()
  228. app.extensions["data_rule_repository"] = repository
  229. client = app.test_client()
  230. rule_spec = valid_rule_spec()
  231. created = client.post(
  232. "/api/rules/rule-versions",
  233. json={
  234. "source_text": "手机号必须为11位数字",
  235. "rule_spec": rule_spec,
  236. "category": "standard_clause",
  237. },
  238. headers=_headers(app, "editor"),
  239. )
  240. assert created.status_code == 201
  241. assert created.get_json()["data"]["status"] == "validated"
  242. rule_version_id = created.get_json()["data"]["id"]
  243. forbidden = client.post(
  244. f"/api/rules/rule-versions/{rule_version_id}/publish",
  245. headers=_headers(app, "editor"),
  246. )
  247. assert forbidden.status_code == 403
  248. published = client.post(
  249. f"/api/rules/rule-versions/{rule_version_id}/publish",
  250. headers=_headers(app, "admin"),
  251. )
  252. assert published.status_code == 200
  253. assert published.get_json()["data"]["status"] == "published"
  254. standard_spec = valid_standard_spec(rule_version_id)
  255. standard = client.post(
  256. "/api/rules/standard-versions",
  257. json={
  258. "source_text": "客户手机号遵循统一格式",
  259. "standard_spec": standard_spec,
  260. },
  261. headers=_headers(app, "editor"),
  262. )
  263. assert standard.status_code == 201
  264. standard_version_id = standard.get_json()["data"]["id"]
  265. standard_published = client.post(
  266. f"/api/rules/standard-versions/{standard_version_id}/publish",
  267. headers=_headers(app, "admin"),
  268. )
  269. assert standard_published.status_code == 200
  270. assert standard_published.get_json()["data"]["status"] == "published"
  271. methods = [method for method, _kwargs in repository.calls]
  272. assert methods == [
  273. "create_rule_version",
  274. "publish_rule_version",
  275. "create_standard_version",
  276. "publish_standard_version",
  277. ]
  278. def test_create_version_rejects_client_selected_lifecycle_status(monkeypatch):
  279. from app import create_app
  280. app = create_app()
  281. _use_token_identity(monkeypatch)
  282. app.config["TESTING"] = True
  283. app.extensions["data_rule_repository"] = FakeRuleRepository()
  284. client = app.test_client()
  285. response = client.post(
  286. "/api/rules/rule-versions",
  287. json={
  288. "source_text": "手机号必须为11位数字",
  289. "rule_spec": valid_rule_spec(),
  290. "status": "published",
  291. },
  292. headers=_headers(app, "editor"),
  293. )
  294. assert response.status_code == 400
  295. def test_dataflow_release_uses_server_assets_and_release_permission(monkeypatch):
  296. from app import create_app
  297. app = create_app()
  298. _use_token_identity(monkeypatch)
  299. app.config["TESTING"] = True
  300. service = FakeReleaseService()
  301. app.extensions["production_line_release_service"] = service
  302. client = app.test_client()
  303. flow = valid_dataflow_spec()
  304. payload = {
  305. "source_text": "客户数据生产线",
  306. "dataflow_spec": flow,
  307. }
  308. forbidden = client.post(
  309. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  310. json=payload,
  311. headers=_headers(app, "editor"),
  312. )
  313. assert forbidden.status_code == 403
  314. response = client.post(
  315. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  316. json=payload,
  317. headers=_headers(app, "admin"),
  318. )
  319. assert response.status_code == 201
  320. assert response.get_json()["data"]["status"] == "released"
  321. assert service.calls[0]["dataflow_uid"] == flow["dataflow_uid"]
  322. assert "standard_versions" not in service.calls[0]
  323. assert "rule_versions" not in service.calls[0]
  324. assert "component_binding_ids" not in service.calls[0]
  325. def test_dataflow_release_rejects_client_authored_schema_hashes(monkeypatch):
  326. from app import create_app
  327. app = create_app()
  328. _use_token_identity(monkeypatch)
  329. app.config["TESTING"] = True
  330. service = FakeReleaseService()
  331. app.extensions["production_line_release_service"] = service
  332. client = app.test_client()
  333. flow = valid_dataflow_spec()
  334. response = client.post(
  335. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  336. json={
  337. "source_text": "客户数据生产线",
  338. "dataflow_spec": flow,
  339. "input_schema_hashes": {"bd:customer_raw:v2": "a" * 64},
  340. "output_schema_hash": "b" * 64,
  341. },
  342. headers=_headers(app, "admin"),
  343. )
  344. assert response.status_code == 409
  345. assert service.calls == []