| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502 |
- from __future__ import annotations
- from datetime import datetime, timedelta, timezone
- from app.core.common.identifiers import new_governance_uid
- from app.core.data_rules.contracts import rule_spec_hash
- from app.core.system.tokens import decode_access_token, issue_access_token
- from tests.core.data_rules.test_contracts import (
- valid_dataflow_spec,
- valid_rule_spec,
- )
- from tests.core.data_rules.test_production_line import (
- assertion_only_rule,
- published_rule,
- published_standard,
- )
- class FakeAuthoringAgent:
- def __init__(self):
- self.calls = []
- def interpret(self, **kwargs):
- self.calls.append(kwargs)
- return {
- "status": "ready",
- "source_text": kwargs["source_text"],
- "candidate_hash": "a" * 64,
- "context_hash": "b" * 64,
- "candidate": {
- "candidate_type": "rule",
- "rule_spec": valid_rule_spec(),
- },
- }
- class FakeRuleRepository:
- def __init__(self):
- self.calls = []
- def create_rule_version(self, **kwargs):
- self.calls.append(("create_rule_version", kwargs))
- return {
- "id": new_governance_uid(),
- "rule_uid": kwargs["rule_spec"]["rule_uid"],
- "version_no": 1,
- "status": "validated",
- "spec_hash": rule_spec_hash(kwargs["rule_spec"]),
- "created": True,
- }
- def record_generation_run(self, **kwargs):
- self.calls.append(("record_generation_run", kwargs))
- return {
- "id": new_governance_uid(),
- "correlation_id": new_governance_uid(),
- "decision": kwargs["evidence"]["status"],
- }
- def publish_rule_version(self, **kwargs):
- self.calls.append(("publish_rule_version", kwargs))
- return {
- "id": kwargs["version_id"],
- "rule_uid": new_governance_uid(),
- "version_no": 1,
- "status": "published",
- "spec_hash": "a" * 64,
- }
- def create_standard_version(self, **kwargs):
- self.calls.append(("create_standard_version", kwargs))
- return {
- "id": new_governance_uid(),
- "standard_uid": kwargs["standard_spec"]["standard_uid"],
- "version_no": 1,
- "status": "validated",
- "spec_hash": "b" * 64,
- "created": True,
- }
- def publish_standard_version(self, **kwargs):
- self.calls.append(("publish_standard_version", kwargs))
- return {
- "id": kwargs["version_id"],
- "standard_uid": new_governance_uid(),
- "version_no": 1,
- "status": "published",
- "spec_hash": "b" * 64,
- }
- class FakeReleaseService:
- def __init__(self):
- self.calls = []
- def release(self, **kwargs):
- self.calls.append(kwargs)
- return {
- "id": new_governance_uid(),
- "version_no": 1,
- "status": "released",
- "package_hash": "c" * 64,
- "package": {
- "package_hash": "c" * 64,
- "standard_version_ids": [],
- "rule_version_ids": [],
- },
- }
- class FakeGraphSession:
- def __init__(self):
- self.calls = []
- def run(self, query, parameters):
- self.calls.append((query, parameters))
- return [
- {
- "domain_id": 9,
- "domain_key": "customer_raw",
- "revision": "v2",
- "field_name": "customer_id",
- "data_type": "string",
- "nullable": False,
- "precision": None,
- "scale": None,
- "timezone": None,
- }
- ]
- def __enter__(self):
- return self
- def __exit__(self, *_args):
- return False
- class FakeGraphDriver:
- def __init__(self):
- self.session = FakeGraphSession()
- def get_session(self):
- return self.session
- class SnapshotOnlyRepository:
- def __init__(self):
- self.snapshots = {}
- def find_schema_snapshot(self, *, schema_ref, schema_hash):
- return self.snapshots.get((schema_ref, schema_hash))
- def persist_schema_snapshot(self, *, snapshot):
- value = {"id": new_governance_uid(), **snapshot}
- self.snapshots[(snapshot["schema_ref"], snapshot["schema_hash"])] = value
- return value
- def _headers(app, role):
- token = issue_access_token(
- user_id=new_governance_uid(),
- roles=[role],
- secret=app.config["SECRET_KEY"],
- now=datetime.now(timezone.utc),
- lifetime=timedelta(minutes=10),
- )
- return {"Authorization": f"Bearer {token}"}
- def _use_token_identity(monkeypatch):
- def load(token, *, secret):
- claims = decode_access_token(token, secret=secret)
- return {
- "id": claims["sub"],
- "username": "contract-test",
- "display_name": "Contract Test",
- "roles": claims["roles"],
- }
- monkeypatch.setattr(
- "app.core.system.auth.load_identity_from_token",
- load,
- )
- def test_rule_capabilities_and_validation_are_registered_and_governed(monkeypatch):
- from app import create_app
- app = create_app()
- _use_token_identity(monkeypatch)
- app.config["TESTING"] = True
- client = app.test_client()
- response = client.get("/api/rules/capabilities", headers=_headers(app, "viewer"))
- assert response.status_code == 200
- capabilities = response.get_json()["data"]
- assert capabilities["natural_language_authoring"] is True
- assert capabilities["immutable_asset_versions"] is True
- assert capabilities["server_side_publishing"] is True
- assert capabilities["production_line_release"] is True
- assert capabilities["data_factory_activation"] is False
- spec = valid_rule_spec()
- response = client.post(
- "/api/rules/validate",
- json={"asset_type": "rule", "spec": spec},
- headers=_headers(app, "editor"),
- )
- assert response.status_code == 200
- result = response.get_json()["data"]
- assert result["spec_hash"] == rule_spec_hash(spec)
- assert result["normalized"]["rule_uid"] == spec["rule_uid"]
- forbidden = client.post(
- "/api/rules/validate",
- json={"asset_type": "rule", "spec": spec},
- headers=_headers(app, "viewer"),
- )
- assert forbidden.status_code == 403
- def test_rule_interpret_uses_configured_agent_and_preserves_surface(monkeypatch):
- from app import create_app
- app = create_app()
- _use_token_identity(monkeypatch)
- app.config["TESTING"] = True
- agent = FakeAuthoringAgent()
- app.extensions["data_rule_authoring_agent"] = agent
- repository = FakeRuleRepository()
- app.extensions["data_rule_repository"] = repository
- client = app.test_client()
- response = client.post(
- "/api/rules/interpret",
- json={
- "source_text": "手机号去空格后必须为11位数字",
- "authoring_surface": "data_standard",
- "context": {"input_schema_ref": "bd:customer:v7"},
- },
- headers=_headers(app, "editor"),
- )
- assert response.status_code == 200
- assert response.get_json()["data"]["status"] == "ready"
- assert response.get_json()["data"]["generation_run_id"]
- assert agent.calls[0]["authoring_surface"] == "data_standard"
- assert repository.calls[0][0] == "record_generation_run"
- def test_production_line_resolve_preview_expands_standard_without_writing(monkeypatch):
- from app import create_app
- app = create_app()
- _use_token_identity(monkeypatch)
- app.config["TESTING"] = True
- client = app.test_client()
- standard_id = new_governance_uid()
- standard_rule_id = new_governance_uid()
- direct_rule_id = new_governance_uid()
- standard_rule = published_rule(standard_rule_id, assertion_only_rule())
- direct_rule = published_rule(direct_rule_id)
- response = client.post(
- "/api/rules/production-lines/resolve",
- json={
- "dataflow_spec": valid_dataflow_spec(standard_id, direct_rule_id),
- "standard_versions": {
- standard_id: published_standard(standard_id, standard_rule_id)
- },
- "rule_versions": {
- standard_rule_id: standard_rule,
- direct_rule_id: direct_rule,
- },
- "component_binding_ids": {
- "normalize_customer": new_governance_uid(),
- "customer_standard:mobile_format": new_governance_uid(),
- },
- },
- headers=_headers(app, "editor"),
- )
- assert response.status_code == 200
- result = response.get_json()["data"]
- assert result["preview"] is True
- assert result["release_ready"] is False
- assert result["package"]["package_hash"]
- assert result["package"]["standard_version_ids"] == [standard_id]
- def test_rule_api_rejects_invalid_or_unauthenticated_requests(monkeypatch):
- from app import create_app
- app = create_app()
- _use_token_identity(monkeypatch)
- app.config["TESTING"] = True
- client = app.test_client()
- assert client.get("/api/rules/capabilities").status_code == 401
- response = client.post(
- "/api/rules/validate",
- json={"asset_type": "rule", "spec": {"schema_version": "1.0"}},
- headers=_headers(app, "editor"),
- )
- assert response.status_code == 400
- assert "missing" not in str(response.get_json()).lower()
- def test_rule_and_standard_versions_are_created_then_published_by_separate_roles(
- monkeypatch,
- ):
- from app import create_app
- from tests.core.data_rules.test_contracts import valid_standard_spec
- app = create_app()
- _use_token_identity(monkeypatch)
- app.config["TESTING"] = True
- repository = FakeRuleRepository()
- app.extensions["data_rule_repository"] = repository
- client = app.test_client()
- rule_spec = valid_rule_spec()
- created = client.post(
- "/api/rules/rule-versions",
- json={
- "source_text": "手机号必须为11位数字",
- "rule_spec": rule_spec,
- "category": "standard_clause",
- },
- headers=_headers(app, "editor"),
- )
- assert created.status_code == 201
- assert created.get_json()["data"]["status"] == "validated"
- rule_version_id = created.get_json()["data"]["id"]
- forbidden = client.post(
- f"/api/rules/rule-versions/{rule_version_id}/publish",
- headers=_headers(app, "editor"),
- )
- assert forbidden.status_code == 403
- published = client.post(
- f"/api/rules/rule-versions/{rule_version_id}/publish",
- headers=_headers(app, "admin"),
- )
- assert published.status_code == 200
- assert published.get_json()["data"]["status"] == "published"
- standard_spec = valid_standard_spec(rule_version_id)
- standard = client.post(
- "/api/rules/standard-versions",
- json={
- "source_text": "客户手机号遵循统一格式",
- "standard_spec": standard_spec,
- },
- headers=_headers(app, "editor"),
- )
- assert standard.status_code == 201
- standard_version_id = standard.get_json()["data"]["id"]
- standard_published = client.post(
- f"/api/rules/standard-versions/{standard_version_id}/publish",
- headers=_headers(app, "admin"),
- )
- assert standard_published.status_code == 200
- assert standard_published.get_json()["data"]["status"] == "published"
- methods = [method for method, _kwargs in repository.calls]
- assert methods == [
- "create_rule_version",
- "publish_rule_version",
- "create_standard_version",
- "publish_standard_version",
- ]
- def test_create_version_rejects_client_selected_lifecycle_status(monkeypatch):
- from app import create_app
- app = create_app()
- _use_token_identity(monkeypatch)
- app.config["TESTING"] = True
- app.extensions["data_rule_repository"] = FakeRuleRepository()
- client = app.test_client()
- response = client.post(
- "/api/rules/rule-versions",
- json={
- "source_text": "手机号必须为11位数字",
- "rule_spec": valid_rule_spec(),
- "status": "published",
- },
- headers=_headers(app, "editor"),
- )
- assert response.status_code == 400
- def test_create_rule_version_rejects_legacy_v1_payload_before_repository(monkeypatch):
- from app import create_app
- app = create_app()
- _use_token_identity(monkeypatch)
- app.config["TESTING"] = True
- repository = FakeRuleRepository()
- app.extensions["data_rule_repository"] = repository
- client = app.test_client()
- legacy = valid_rule_spec()
- legacy["schema_version"] = "1.0"
- response = client.post(
- "/api/rules/rule-versions",
- json={"source_text": "旧版规则不能再创建", "rule_spec": legacy},
- headers=_headers(app, "editor"),
- )
- assert response.status_code == 400
- assert repository.calls == []
- def test_dataflow_release_uses_server_assets_and_release_permission(monkeypatch):
- from app import create_app
- app = create_app()
- _use_token_identity(monkeypatch)
- app.config["TESTING"] = True
- service = FakeReleaseService()
- app.extensions["production_line_release_service"] = service
- client = app.test_client()
- flow = valid_dataflow_spec()
- payload = {
- "source_text": "客户数据生产线",
- "dataflow_spec": flow,
- }
- forbidden = client.post(
- f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
- json=payload,
- headers=_headers(app, "editor"),
- )
- assert forbidden.status_code == 403
- response = client.post(
- f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
- json=payload,
- headers=_headers(app, "admin"),
- )
- assert response.status_code == 201
- assert response.get_json()["data"]["status"] == "released"
- assert service.calls[0]["dataflow_uid"] == flow["dataflow_uid"]
- assert "standard_versions" not in service.calls[0]
- assert "rule_versions" not in service.calls[0]
- assert "component_binding_ids" not in service.calls[0]
- def test_dataflow_release_rejects_client_authored_schema_hashes(monkeypatch):
- from app import create_app
- app = create_app()
- _use_token_identity(monkeypatch)
- app.config["TESTING"] = True
- service = FakeReleaseService()
- app.extensions["production_line_release_service"] = service
- client = app.test_client()
- flow = valid_dataflow_spec()
- response = client.post(
- f"/api/rules/production-lines/{flow['dataflow_uid']}/release",
- json={
- "source_text": "客户数据生产线",
- "dataflow_spec": flow,
- "input_schema_hashes": {"bd:customer_raw:v2": "a" * 64},
- "output_schema_hash": "b" * 64,
- },
- headers=_headers(app, "admin"),
- )
- assert response.status_code == 409
- assert service.calls == []
- def test_default_release_service_uses_lazy_neo4j_schema_catalog(monkeypatch):
- from app import create_app
- from app.api.data_rules.routes import _release_service
- app = create_app()
- repository = SnapshotOnlyRepository()
- driver = FakeGraphDriver()
- monkeypatch.setattr(
- "app.core.data_rules.schema_resolver.neo4j_driver", driver
- )
- app.extensions["data_rule_repository"] = repository
- with app.app_context():
- service = _release_service()
- snapshot = service.schema_resolver.resolve("bd:customer_raw:v2")
- assert snapshot["source_revision"] == "neo4j:9:v2"
- assert driver.session.calls[0][1] == {
- "domain_key": "customer_raw",
- "revision": "v2",
- }
|