test_data_rule_api.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776
  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. 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": "draft",
  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 resolve_validation_context(self, context):
  52. self.calls.append(("resolve_validation_context", {"context": context}))
  53. return {
  54. "input_schema_snapshot_id": new_governance_uid(),
  55. "input_schema_hash": "c" * 64,
  56. "input_fields": [{"name": "mobile", "type": "string"}],
  57. "output_schema_snapshot_id": new_governance_uid(),
  58. "output_schema_hash": "d" * 64,
  59. "output_fields": [{"name": "mobile", "type": "string"}],
  60. "input_sample_artifact_ref": "minio://trusted/input.parquet",
  61. "input_sample_artifact_digest": "e" * 64,
  62. "golden_output_artifact_ref": None,
  63. "golden_output_artifact_digest": None,
  64. }
  65. def publish_rule_version(self, **kwargs):
  66. self.calls.append(("publish_rule_version", kwargs))
  67. return {
  68. "id": kwargs["version_id"],
  69. "rule_uid": new_governance_uid(),
  70. "version_no": 1,
  71. "status": "published",
  72. "spec_hash": "a" * 64,
  73. }
  74. def create_standard_version(self, **kwargs):
  75. self.calls.append(("create_standard_version", kwargs))
  76. return {
  77. "id": new_governance_uid(),
  78. "standard_uid": kwargs["standard_spec"]["standard_uid"],
  79. "version_no": 1,
  80. "status": "validated",
  81. "spec_hash": "b" * 64,
  82. "created": True,
  83. }
  84. def publish_standard_version(self, **kwargs):
  85. self.calls.append(("publish_standard_version", kwargs))
  86. return {
  87. "id": kwargs["version_id"],
  88. "standard_uid": new_governance_uid(),
  89. "version_no": 1,
  90. "status": "published",
  91. "spec_hash": "b" * 64,
  92. }
  93. class FakeReleaseService:
  94. def __init__(self):
  95. self.calls = []
  96. def release(self, **kwargs):
  97. self.calls.append(kwargs)
  98. return {
  99. "id": new_governance_uid(),
  100. "version_no": 1,
  101. "status": "released",
  102. "package_hash": "c" * 64,
  103. "package": {
  104. "package_hash": "c" * 64,
  105. "standard_version_ids": [],
  106. "rule_version_ids": [],
  107. },
  108. }
  109. class FakePublicationService:
  110. def __init__(self, repository):
  111. self.repository = repository
  112. self.plan_id = new_governance_uid()
  113. self.version_id = None
  114. def create_draft(self, **kwargs):
  115. self.repository.calls.append(("create_rule_version", kwargs))
  116. self.version_id = new_governance_uid()
  117. return {
  118. "id": self.version_id,
  119. "rule_uid": kwargs["rule_spec"]["rule_uid"],
  120. "version_no": 1,
  121. "status": "draft",
  122. "spec_hash": rule_spec_hash(kwargs["rule_spec"]),
  123. "generation_run_id": new_governance_uid(),
  124. "created": True,
  125. }
  126. def validate(self, version_id, actor_uid):
  127. self.repository.calls.append(
  128. ("validate_rule_version", {"version_id": version_id, "actor_uid": actor_uid})
  129. )
  130. return {
  131. "version_id": version_id,
  132. "version_status": "draft",
  133. "plan_id": self.plan_id,
  134. "plan_status": "compiled",
  135. "plan_hash": "d" * 64,
  136. }
  137. def test(self, version_id, actor_uid, *, plan_id):
  138. self.repository.calls.append(
  139. (
  140. "test_rule_version",
  141. {
  142. "version_id": version_id,
  143. "actor_uid": actor_uid,
  144. "plan_id": plan_id,
  145. },
  146. )
  147. )
  148. return {
  149. "version_id": version_id,
  150. "version_status": "validated",
  151. "plan_id": plan_id,
  152. "plan_status": "tested",
  153. "plan_hash": "d" * 64,
  154. }
  155. def publish(self, version_id, actor_uid):
  156. self.repository.calls.append(
  157. ("publish_rule_version", {"version_id": version_id, "actor_uid": actor_uid})
  158. )
  159. return {
  160. "id": version_id,
  161. "status": "published",
  162. "plan_id": self.plan_id,
  163. "plan_status": "published",
  164. }
  165. def evidence(self, version_id):
  166. return {"version_id": version_id, "version_status": "validated"}
  167. def catalog(self, *, query, limit):
  168. return []
  169. class FakeGraphSession:
  170. def __init__(self):
  171. self.calls = []
  172. def run(self, query, parameters):
  173. self.calls.append((query, parameters))
  174. return [
  175. {
  176. "domain_id": 9,
  177. "domain_key": "customer_raw",
  178. "revision": "v2",
  179. "field_name": "customer_id",
  180. "data_type": "string",
  181. "nullable": False,
  182. "precision": None,
  183. "scale": None,
  184. "timezone": None,
  185. }
  186. ]
  187. def __enter__(self):
  188. return self
  189. def __exit__(self, *_args):
  190. return False
  191. class FakeGraphDriver:
  192. def __init__(self):
  193. self.session = FakeGraphSession()
  194. def get_session(self):
  195. return self.session
  196. class SnapshotOnlyRepository:
  197. def __init__(self):
  198. self.snapshots = {}
  199. def find_schema_snapshot(self, *, schema_ref, schema_hash):
  200. return self.snapshots.get((schema_ref, schema_hash))
  201. def persist_schema_snapshot(self, *, snapshot):
  202. value = {"id": new_governance_uid(), **snapshot}
  203. self.snapshots[(snapshot["schema_ref"], snapshot["schema_hash"])] = value
  204. return value
  205. def _headers(app, role):
  206. token = issue_access_token(
  207. user_id=new_governance_uid(),
  208. roles=[role],
  209. secret=app.config["SECRET_KEY"],
  210. now=datetime.now(UTC),
  211. lifetime=timedelta(minutes=10),
  212. )
  213. return {"Authorization": f"Bearer {token}"}
  214. def _use_token_identity(monkeypatch):
  215. def load(token, *, secret):
  216. claims = decode_access_token(token, secret=secret)
  217. return {
  218. "id": claims["sub"],
  219. "username": "contract-test",
  220. "display_name": "Contract Test",
  221. "roles": claims["roles"],
  222. }
  223. monkeypatch.setattr(
  224. "app.core.system.auth.load_identity_from_token",
  225. load,
  226. )
  227. def test_rule_capabilities_and_validation_are_registered_and_governed(monkeypatch):
  228. from app import create_app
  229. app = create_app()
  230. _use_token_identity(monkeypatch)
  231. app.config["TESTING"] = True
  232. app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
  233. "dedicated-test-receipt-secret-with-entropy"
  234. )
  235. client = app.test_client()
  236. response = client.get("/api/rules/capabilities", headers=_headers(app, "viewer"))
  237. assert response.status_code == 200
  238. capabilities = response.get_json()["data"]
  239. assert capabilities["natural_language_authoring"] is True
  240. assert capabilities["immutable_asset_versions"] is True
  241. assert capabilities["server_side_publishing"] is True
  242. assert capabilities["production_line_release"] is True
  243. assert capabilities["data_factory_activation"] is False
  244. spec = valid_rule_spec()
  245. response = client.post(
  246. "/api/rules/validate",
  247. json={"asset_type": "rule", "spec": spec},
  248. headers=_headers(app, "editor"),
  249. )
  250. assert response.status_code == 200
  251. result = response.get_json()["data"]
  252. assert result["spec_hash"] == rule_spec_hash(spec)
  253. assert result["normalized"]["rule_uid"] == spec["rule_uid"]
  254. forbidden = client.post(
  255. "/api/rules/validate",
  256. json={"asset_type": "rule", "spec": spec},
  257. headers=_headers(app, "viewer"),
  258. )
  259. assert forbidden.status_code == 403
  260. def test_rule_interpret_uses_configured_agent_and_preserves_surface(monkeypatch):
  261. from app import create_app
  262. app = create_app()
  263. _use_token_identity(monkeypatch)
  264. app.config["TESTING"] = True
  265. app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
  266. "dedicated-test-receipt-secret-with-entropy"
  267. )
  268. agent = FakeAuthoringAgent()
  269. app.extensions["data_rule_authoring_agent"] = agent
  270. repository = FakeRuleRepository()
  271. app.extensions["data_rule_repository"] = repository
  272. client = app.test_client()
  273. response = client.post(
  274. "/api/rules/interpret",
  275. json={
  276. "source_text": "手机号去空格后必须为11位数字",
  277. "authoring_surface": "data_standard",
  278. "context": {
  279. "input_schema_snapshot_id": new_governance_uid(),
  280. "output_schema_snapshot_id": new_governance_uid(),
  281. "input_sample_artifact_ref": (
  282. "minio://trusted/input.parquet"
  283. ),
  284. "golden_output_artifact_ref": None,
  285. },
  286. },
  287. headers=_headers(app, "editor"),
  288. )
  289. assert response.status_code == 200
  290. assert response.get_json()["data"]["status"] == "ready"
  291. assert response.get_json()["data"]["generation_run_id"]
  292. assert response.get_json()["data"]["generation_receipt"]
  293. assert agent.calls[0]["authoring_surface"] == "data_standard"
  294. assert repository.calls[0][0] == "resolve_validation_context"
  295. assert repository.calls[1][0] == "record_generation_run"
  296. def test_rule_interpret_preflights_receipt_signer_before_model_call(monkeypatch):
  297. from app import create_app
  298. app = create_app()
  299. _use_token_identity(monkeypatch)
  300. app.config["TESTING"] = True
  301. app.config["RULE_GENERATION_RECEIPT_SECRET"] = None
  302. agent = FakeAuthoringAgent()
  303. repository = FakeRuleRepository()
  304. app.extensions["data_rule_authoring_agent"] = agent
  305. app.extensions["data_rule_repository"] = repository
  306. client = app.test_client()
  307. response = client.post(
  308. "/api/rules/interpret",
  309. json={
  310. "source_text": "手机号去空格后必须为11位数字",
  311. "authoring_surface": "data_standard",
  312. "context": {
  313. "input_schema_snapshot_id": new_governance_uid(),
  314. "output_schema_snapshot_id": new_governance_uid(),
  315. "input_sample_artifact_ref": (
  316. "minio://trusted/input.parquet"
  317. ),
  318. "golden_output_artifact_ref": None,
  319. },
  320. },
  321. headers=_headers(app, "editor"),
  322. )
  323. assert response.status_code == 503
  324. assert agent.calls == []
  325. assert repository.calls == []
  326. def test_rule_interpret_and_validate_reject_unknown_fields(monkeypatch):
  327. from app import create_app
  328. app = create_app()
  329. _use_token_identity(monkeypatch)
  330. app.config["TESTING"] = True
  331. app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
  332. "dedicated-test-receipt-secret-with-entropy"
  333. )
  334. repository = FakeRuleRepository()
  335. app.extensions["data_rule_repository"] = repository
  336. app.extensions["data_rule_authoring_agent"] = FakeAuthoringAgent()
  337. client = app.test_client()
  338. headers = _headers(app, "editor")
  339. interpreted = client.post(
  340. "/api/rules/interpret",
  341. json={
  342. "source_text": "手机号必须为11位数字",
  343. "authoring_surface": "data_standard",
  344. "context": {},
  345. "status": "published",
  346. },
  347. headers=headers,
  348. )
  349. validated = client.post(
  350. "/api/rules/validate",
  351. json={
  352. "asset_type": "rule",
  353. "spec": valid_rule_spec(),
  354. "evidence": {"status": "success"},
  355. },
  356. headers=headers,
  357. )
  358. assert interpreted.status_code == 400
  359. assert validated.status_code == 400
  360. assert repository.calls == []
  361. def test_published_rule_catalog_uses_canonical_closed_contract(monkeypatch):
  362. from app import create_app
  363. app = create_app()
  364. _use_token_identity(monkeypatch)
  365. app.config["TESTING"] = True
  366. repository = FakeRuleRepository()
  367. app.extensions["rule_publication_service"] = FakePublicationService(
  368. repository
  369. )
  370. client = app.test_client()
  371. headers = _headers(app, "viewer")
  372. response = client.get(
  373. "/api/rules/catalog?query=mobile&limit=10",
  374. headers=headers,
  375. )
  376. rejected = client.get(
  377. "/api/rules/catalog?query=mobile&status=published",
  378. headers=headers,
  379. )
  380. assert response.status_code == 200
  381. assert response.get_json()["data"] == {"items": []}
  382. assert rejected.status_code == 400
  383. def test_production_line_resolve_preview_expands_standard_without_writing(monkeypatch):
  384. from app import create_app
  385. app = create_app()
  386. _use_token_identity(monkeypatch)
  387. app.config["TESTING"] = True
  388. client = app.test_client()
  389. standard_id = new_governance_uid()
  390. standard_rule_id = new_governance_uid()
  391. direct_rule_id = new_governance_uid()
  392. standard_rule = published_rule(standard_rule_id, assertion_only_rule())
  393. direct_rule = published_rule(direct_rule_id)
  394. response = client.post(
  395. "/api/rules/production-lines/resolve",
  396. json={
  397. "dataflow_spec": valid_dataflow_spec(standard_id, direct_rule_id),
  398. "standard_versions": {
  399. standard_id: published_standard(standard_id, standard_rule_id)
  400. },
  401. "rule_versions": {
  402. standard_rule_id: standard_rule,
  403. direct_rule_id: direct_rule,
  404. },
  405. "component_binding_ids": {
  406. "normalize_customer": new_governance_uid(),
  407. "customer_standard:mobile_format": new_governance_uid(),
  408. },
  409. },
  410. headers=_headers(app, "editor"),
  411. )
  412. assert response.status_code == 200
  413. result = response.get_json()["data"]
  414. assert result["preview"] is True
  415. assert result["release_ready"] is False
  416. assert result["package"]["package_hash"]
  417. assert result["package"]["standard_version_ids"] == [standard_id]
  418. def test_rule_api_rejects_invalid_or_unauthenticated_requests(monkeypatch):
  419. from app import create_app
  420. app = create_app()
  421. _use_token_identity(monkeypatch)
  422. app.config["TESTING"] = True
  423. client = app.test_client()
  424. assert client.get("/api/rules/capabilities").status_code == 401
  425. response = client.post(
  426. "/api/rules/validate",
  427. json={"asset_type": "rule", "spec": {"schema_version": "1.0"}},
  428. headers=_headers(app, "editor"),
  429. )
  430. assert response.status_code == 400
  431. assert "missing" not in str(response.get_json()).lower()
  432. def test_rule_and_standard_versions_are_created_then_published_by_separate_roles(
  433. monkeypatch,
  434. ):
  435. from app import create_app
  436. from tests.core.data_rules.test_contracts import valid_standard_spec
  437. app = create_app()
  438. _use_token_identity(monkeypatch)
  439. app.config["TESTING"] = True
  440. repository = FakeRuleRepository()
  441. app.extensions["data_rule_repository"] = repository
  442. publication = FakePublicationService(repository)
  443. app.extensions["rule_publication_service"] = publication
  444. client = app.test_client()
  445. rule_spec = valid_rule_spec()
  446. created = client.post(
  447. "/api/rules/rule-versions",
  448. json={
  449. "source_text": "手机号必须为11位数字",
  450. "rule_spec": rule_spec,
  451. "category": "standard_clause",
  452. "generation_receipt": "signed-test-receipt",
  453. },
  454. headers=_headers(app, "editor"),
  455. )
  456. assert created.status_code == 201
  457. assert created.get_json()["data"]["status"] == "draft"
  458. rule_version_id = created.get_json()["data"]["id"]
  459. compiled = client.post(
  460. f"/api/rules/rule-versions/{rule_version_id}/validate",
  461. headers=_headers(app, "editor"),
  462. )
  463. assert compiled.status_code == 200
  464. plan_id = compiled.get_json()["data"]["plan_id"]
  465. tested = client.post(
  466. f"/api/rules/rule-versions/{rule_version_id}/test",
  467. json={"plan_id": plan_id},
  468. headers=_headers(app, "editor"),
  469. )
  470. assert tested.status_code == 200
  471. assert tested.get_json()["data"]["version_status"] == "validated"
  472. forbidden = client.post(
  473. f"/api/rules/rule-versions/{rule_version_id}/publish",
  474. headers=_headers(app, "editor"),
  475. )
  476. assert forbidden.status_code == 403
  477. published = client.post(
  478. f"/api/rules/rule-versions/{rule_version_id}/publish",
  479. headers=_headers(app, "admin"),
  480. )
  481. assert published.status_code == 200
  482. assert published.get_json()["data"]["status"] == "published"
  483. standard_spec = valid_standard_spec(rule_version_id)
  484. standard = client.post(
  485. "/api/rules/standard-versions",
  486. json={
  487. "source_text": "客户手机号遵循统一格式",
  488. "standard_spec": standard_spec,
  489. },
  490. headers=_headers(app, "editor"),
  491. )
  492. assert standard.status_code == 201
  493. standard_version_id = standard.get_json()["data"]["id"]
  494. standard_published = client.post(
  495. f"/api/rules/standard-versions/{standard_version_id}/publish",
  496. headers=_headers(app, "admin"),
  497. )
  498. assert standard_published.status_code == 200
  499. assert standard_published.get_json()["data"]["status"] == "published"
  500. methods = [method for method, _kwargs in repository.calls]
  501. assert methods == [
  502. "create_rule_version",
  503. "validate_rule_version",
  504. "test_rule_version",
  505. "publish_rule_version",
  506. "create_standard_version",
  507. "publish_standard_version",
  508. ]
  509. def test_create_version_rejects_client_selected_lifecycle_status(monkeypatch):
  510. from app import create_app
  511. app = create_app()
  512. _use_token_identity(monkeypatch)
  513. app.config["TESTING"] = True
  514. repository = FakeRuleRepository()
  515. app.extensions["data_rule_repository"] = repository
  516. app.extensions["rule_publication_service"] = FakePublicationService(repository)
  517. client = app.test_client()
  518. response = client.post(
  519. "/api/rules/rule-versions",
  520. json={
  521. "source_text": "手机号必须为11位数字",
  522. "rule_spec": valid_rule_spec(),
  523. "generation_receipt": "signed-test-receipt",
  524. "status": "published",
  525. },
  526. headers=_headers(app, "editor"),
  527. )
  528. assert response.status_code == 400
  529. def test_generation_receipt_signer_requires_dedicated_secret(monkeypatch):
  530. from app import create_app
  531. from app.api.data_rules.routes import _receipt_signer
  532. monkeypatch.delenv("RULE_GENERATION_RECEIPT_SECRET", raising=False)
  533. app = create_app()
  534. app.config["RULE_GENERATION_RECEIPT_SECRET"] = None
  535. with app.app_context(), pytest.raises(
  536. RuntimeError, match="receipt secret"
  537. ):
  538. _receipt_signer()
  539. app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
  540. "dedicated-test-receipt-secret-with-entropy"
  541. )
  542. with app.app_context():
  543. signer = _receipt_signer()
  544. assert signer is not None
  545. def test_rule_gates_reject_caller_supplied_compile_or_test_evidence(monkeypatch):
  546. from app import create_app
  547. app = create_app()
  548. _use_token_identity(monkeypatch)
  549. app.config["TESTING"] = True
  550. repository = FakeRuleRepository()
  551. app.extensions["rule_publication_service"] = FakePublicationService(repository)
  552. client = app.test_client()
  553. version_id = new_governance_uid()
  554. forged_compile = client.post(
  555. f"/api/rules/rule-versions/{version_id}/validate",
  556. json={"status": "success", "plan_hash": "a" * 64},
  557. headers=_headers(app, "editor"),
  558. )
  559. forged_test = client.post(
  560. f"/api/rules/rule-versions/{version_id}/test",
  561. json={
  562. "plan_id": new_governance_uid(),
  563. "evidence": {"status": "success"},
  564. },
  565. headers=_headers(app, "editor"),
  566. )
  567. assert forged_compile.status_code == 409
  568. assert forged_test.status_code == 409
  569. assert repository.calls == []
  570. def test_create_rule_version_rejects_legacy_v1_payload_before_repository(monkeypatch):
  571. from app import create_app
  572. app = create_app()
  573. _use_token_identity(monkeypatch)
  574. app.config["TESTING"] = True
  575. repository = FakeRuleRepository()
  576. app.extensions["data_rule_repository"] = repository
  577. client = app.test_client()
  578. legacy = valid_rule_spec()
  579. legacy["schema_version"] = "1.0"
  580. response = client.post(
  581. "/api/rules/rule-versions",
  582. json={"source_text": "旧版规则不能再创建", "rule_spec": legacy},
  583. headers=_headers(app, "editor"),
  584. )
  585. assert response.status_code == 400
  586. assert repository.calls == []
  587. def test_dataflow_release_uses_server_assets_and_release_permission(monkeypatch):
  588. from app import create_app
  589. app = create_app()
  590. _use_token_identity(monkeypatch)
  591. app.config["TESTING"] = True
  592. service = FakeReleaseService()
  593. app.extensions["production_line_release_service"] = service
  594. client = app.test_client()
  595. flow = valid_dataflow_spec()
  596. payload = {
  597. "source_text": "客户数据生产线",
  598. "dataflow_spec": flow,
  599. }
  600. forbidden = client.post(
  601. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  602. json=payload,
  603. headers=_headers(app, "editor"),
  604. )
  605. assert forbidden.status_code == 403
  606. response = client.post(
  607. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  608. json=payload,
  609. headers=_headers(app, "admin"),
  610. )
  611. assert response.status_code == 201
  612. assert response.get_json()["data"]["status"] == "released"
  613. assert service.calls[0]["dataflow_uid"] == flow["dataflow_uid"]
  614. assert "standard_versions" not in service.calls[0]
  615. assert "rule_versions" not in service.calls[0]
  616. assert "component_binding_ids" not in service.calls[0]
  617. def test_dataflow_release_rejects_client_authored_schema_hashes(monkeypatch):
  618. from app import create_app
  619. app = create_app()
  620. _use_token_identity(monkeypatch)
  621. app.config["TESTING"] = True
  622. service = FakeReleaseService()
  623. app.extensions["production_line_release_service"] = service
  624. client = app.test_client()
  625. flow = valid_dataflow_spec()
  626. response = client.post(
  627. f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
  628. json={
  629. "source_text": "客户数据生产线",
  630. "dataflow_spec": flow,
  631. "input_schema_hashes": {"bd:customer_raw:v2": "a" * 64},
  632. "output_schema_hash": "b" * 64,
  633. },
  634. headers=_headers(app, "admin"),
  635. )
  636. assert response.status_code == 409
  637. assert service.calls == []
  638. def test_default_release_service_uses_lazy_neo4j_schema_catalog(monkeypatch):
  639. from app import create_app
  640. from app.api.data_rules.routes import _release_service
  641. app = create_app()
  642. repository = SnapshotOnlyRepository()
  643. driver = FakeGraphDriver()
  644. monkeypatch.setattr(
  645. "app.core.data_rules.schema_resolver.neo4j_driver", driver
  646. )
  647. app.extensions["data_rule_repository"] = repository
  648. with app.app_context():
  649. service = _release_service()
  650. snapshot = service.schema_resolver.resolve("bd:customer_raw:v2")
  651. assert snapshot["source_revision"] == "neo4j:9:v2"
  652. assert driver.session.calls[0][1] == {
  653. "domain_key": "customer_raw",
  654. "revision": "v2",
  655. }