test_data_rule_api.py 12 KB

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