test_device_knowledge_postgres.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. from __future__ import annotations
  2. import os
  3. import uuid
  4. from datetime import UTC, datetime
  5. import pytest
  6. from sqlalchemy import text
  7. pytestmark = pytest.mark.integration
  8. def test_device_knowledge_prefilters_source_scope_before_matching(monkeypatch):
  9. platform_url = os.environ.get("TEST_DATABASE_URL")
  10. if not platform_url:
  11. pytest.skip("TEST_DATABASE_URL is required")
  12. monkeypatch.setenv("DATABASE_URL", platform_url)
  13. from app import create_app, db
  14. from app.core.knowledge.retrieval.device import (
  15. SqlDeviceKnowledgeRepository,
  16. )
  17. from app.models.data_research import (
  18. DeviceAsset,
  19. DeviceAssetSourceMapping,
  20. DeviceOperationalEvent,
  21. IngestionSource,
  22. )
  23. app = create_app()
  24. app.config.update(TESTING=True)
  25. suffix = uuid.uuid4().hex[:10]
  26. actor_uid = str(uuid.uuid4())
  27. domain_a = str(uuid.uuid4())
  28. domain_b = str(uuid.uuid4())
  29. source_a = str(uuid.uuid4())
  30. source_b = str(uuid.uuid4())
  31. source_unscoped = str(uuid.uuid4())
  32. asset_a = str(uuid.uuid4())
  33. asset_b = str(uuid.uuid4())
  34. asset_unscoped = str(uuid.uuid4())
  35. source_uids = (source_a, source_b, source_unscoped)
  36. asset_uids = (asset_a, asset_b, asset_unscoped)
  37. now = datetime.now(UTC).replace(microsecond=0)
  38. try:
  39. with app.app_context():
  40. db.session.execute(
  41. text(
  42. """
  43. INSERT INTO public.users (
  44. id, username, display_name, password_hash, status
  45. ) VALUES (
  46. CAST(:id AS uuid), :username, :username,
  47. 'integration-test', 'active'
  48. )
  49. """
  50. ),
  51. {
  52. "id": actor_uid,
  53. "username": f"wp10-actor-{suffix}",
  54. },
  55. )
  56. for uid, label, scope in (
  57. (source_a, "A", {"business_domains": [domain_a]}),
  58. (source_b, "B", {"business_domains": [domain_b]}),
  59. (source_unscoped, "U", {}),
  60. ):
  61. db.session.add(
  62. IngestionSource(
  63. uid=uid,
  64. source_type="database",
  65. name=f"WP10 source {label} {suffix}",
  66. config={"password": f"secret-{label}"},
  67. permission_scope=scope,
  68. status="active",
  69. created_by=actor_uid,
  70. )
  71. )
  72. for uid, label, owner in (
  73. (asset_a, "A", "张工"),
  74. (asset_b, "B", "李工"),
  75. (asset_unscoped, "U", "王工"),
  76. ):
  77. db.session.add(
  78. DeviceAsset(
  79. uid=uid,
  80. asset_type="device",
  81. name=f"WP10循环泵-{suffix}-{label}",
  82. status="active",
  83. current_version=1,
  84. content_hash=label.lower() * 64,
  85. location=f"{label}动力车间",
  86. organization="设备动力部",
  87. responsible_person=owner,
  88. attributes={"password": f"asset-secret-{label}"},
  89. created_by=actor_uid,
  90. updated_by=actor_uid,
  91. updated_at=now,
  92. )
  93. )
  94. db.session.flush()
  95. for uid, asset_uid, source_uid, label in (
  96. (str(uuid.uuid4()), asset_a, source_a, "A"),
  97. (str(uuid.uuid4()), asset_b, source_b, "B"),
  98. (
  99. str(uuid.uuid4()),
  100. asset_unscoped,
  101. source_unscoped,
  102. "U",
  103. ),
  104. ):
  105. db.session.add(
  106. DeviceAssetSourceMapping(
  107. uid=uid,
  108. asset_uid=asset_uid,
  109. source_uid=source_uid,
  110. source_entity="asset.equipment",
  111. asset_type="device",
  112. source_code=f"EQ-WP10-{suffix}-{label}",
  113. source_updated_at=now,
  114. first_seen_at=now,
  115. last_seen_at=now,
  116. )
  117. )
  118. db.session.add(
  119. DeviceOperationalEvent(
  120. uid=str(uuid.uuid4()),
  121. source_uid=source_uid,
  122. source_entity="fault.events",
  123. source_code=f"FT-WP10-{suffix}-{label}",
  124. event_type="fault",
  125. asset_uid=asset_uid,
  126. title=f"{label}域轴承故障-{suffix}",
  127. severity="error",
  128. status="observed",
  129. occurred_at=now,
  130. evidence_refs={
  131. "password": f"event-secret-{label}"
  132. },
  133. content_hash=(label.lower() + "f") * 32,
  134. created_by=actor_uid,
  135. )
  136. )
  137. db.session.commit()
  138. repository = SqlDeviceKnowledgeRepository(db.session)
  139. admin_rows = repository.search(
  140. query=f"WP10循环泵-{suffix}",
  141. global_access=True,
  142. business_domain_uids=(),
  143. limit=20,
  144. )
  145. domain_a_rows = repository.search(
  146. query=f"WP10循环泵-{suffix}",
  147. global_access=False,
  148. business_domain_uids=(domain_a,),
  149. limit=20,
  150. )
  151. forbidden_source = repository.search(
  152. query=f"EQ-WP10-{suffix}-B",
  153. global_access=False,
  154. business_domain_uids=(domain_a,),
  155. limit=20,
  156. )
  157. unscoped_source = repository.search(
  158. query=f"EQ-WP10-{suffix}-U",
  159. global_access=False,
  160. business_domain_uids=(domain_a,),
  161. limit=20,
  162. )
  163. fault_rows = repository.search(
  164. query=f"A域轴承故障-{suffix}",
  165. global_access=False,
  166. business_domain_uids=(domain_a,),
  167. limit=20,
  168. )
  169. natural_question_rows = repository.search(
  170. query=f"WP10循环泵-{suffix}-A最近有哪些故障?",
  171. global_access=False,
  172. business_domain_uids=(domain_a,),
  173. limit=20,
  174. )
  175. detail = repository.get_detail(
  176. asset_a,
  177. global_access=False,
  178. business_domain_uids=(domain_a,),
  179. )
  180. forbidden_detail = repository.get_detail(
  181. asset_b,
  182. global_access=False,
  183. business_domain_uids=(domain_a,),
  184. )
  185. assert {row.asset_uid for row in admin_rows} == set(asset_uids)
  186. assert [row.asset_uid for row in domain_a_rows] == [asset_a]
  187. assert domain_a_rows[0].business_domain_uid == domain_a
  188. assert forbidden_source == ()
  189. assert unscoped_source == ()
  190. assert [row.asset_uid for row in fault_rows] == [asset_a]
  191. assert [row.asset_uid for row in natural_question_rows] == [
  192. asset_a
  193. ]
  194. assert fault_rows[0].related_events == (
  195. ("fault", f"A域轴承故障-{suffix}", f"FT-WP10-{suffix}-A"),
  196. )
  197. assert detail is not None
  198. assert detail.source_codes == (f"EQ-WP10-{suffix}-A",)
  199. assert forbidden_detail is None
  200. serialized = repr((domain_a_rows, fault_rows, detail))
  201. assert "secret-" not in serialized
  202. assert "permission_scope" not in serialized
  203. finally:
  204. with app.app_context():
  205. db.session.execute(
  206. text(
  207. "DELETE FROM public.device_operational_events "
  208. "WHERE created_by = CAST(:actor_uid AS uuid)"
  209. ),
  210. {"actor_uid": actor_uid},
  211. )
  212. db.session.execute(
  213. text(
  214. "DELETE FROM public.device_assets "
  215. "WHERE uid = ANY(CAST(:uids AS uuid[]))"
  216. ),
  217. {"uids": list(asset_uids)},
  218. )
  219. db.session.execute(
  220. text(
  221. "DELETE FROM public.ingestion_sources "
  222. "WHERE uid = ANY(CAST(:uids AS uuid[]))"
  223. ),
  224. {"uids": list(source_uids)},
  225. )
  226. db.session.execute(
  227. text(
  228. "DELETE FROM public.users "
  229. "WHERE id = CAST(:id AS uuid)"
  230. ),
  231. {"id": actor_uid},
  232. )
  233. db.session.commit()