atlas-chat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. atlas/__init__.py +40 -0
  2. atlas/application/__init__.py +7 -0
  3. atlas/application/chat/__init__.py +7 -0
  4. atlas/application/chat/agent/__init__.py +10 -0
  5. atlas/application/chat/agent/act_loop.py +179 -0
  6. atlas/application/chat/agent/factory.py +142 -0
  7. atlas/application/chat/agent/protocols.py +46 -0
  8. atlas/application/chat/agent/react_loop.py +338 -0
  9. atlas/application/chat/agent/think_act_loop.py +171 -0
  10. atlas/application/chat/approval_manager.py +151 -0
  11. atlas/application/chat/elicitation_manager.py +191 -0
  12. atlas/application/chat/events/__init__.py +1 -0
  13. atlas/application/chat/events/agent_event_relay.py +112 -0
  14. atlas/application/chat/modes/__init__.py +1 -0
  15. atlas/application/chat/modes/agent.py +125 -0
  16. atlas/application/chat/modes/plain.py +74 -0
  17. atlas/application/chat/modes/rag.py +81 -0
  18. atlas/application/chat/modes/tools.py +179 -0
  19. atlas/application/chat/orchestrator.py +213 -0
  20. atlas/application/chat/policies/__init__.py +1 -0
  21. atlas/application/chat/policies/tool_authorization.py +99 -0
  22. atlas/application/chat/preprocessors/__init__.py +1 -0
  23. atlas/application/chat/preprocessors/message_builder.py +92 -0
  24. atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
  25. atlas/application/chat/service.py +454 -0
  26. atlas/application/chat/utilities/__init__.py +6 -0
  27. atlas/application/chat/utilities/error_handler.py +367 -0
  28. atlas/application/chat/utilities/event_notifier.py +546 -0
  29. atlas/application/chat/utilities/file_processor.py +613 -0
  30. atlas/application/chat/utilities/tool_executor.py +789 -0
  31. atlas/atlas_chat_cli.py +347 -0
  32. atlas/atlas_client.py +238 -0
  33. atlas/core/__init__.py +0 -0
  34. atlas/core/auth.py +205 -0
  35. atlas/core/authorization_manager.py +27 -0
  36. atlas/core/capabilities.py +123 -0
  37. atlas/core/compliance.py +215 -0
  38. atlas/core/domain_whitelist.py +147 -0
  39. atlas/core/domain_whitelist_middleware.py +82 -0
  40. atlas/core/http_client.py +28 -0
  41. atlas/core/log_sanitizer.py +102 -0
  42. atlas/core/metrics_logger.py +59 -0
  43. atlas/core/middleware.py +131 -0
  44. atlas/core/otel_config.py +242 -0
  45. atlas/core/prompt_risk.py +200 -0
  46. atlas/core/rate_limit.py +0 -0
  47. atlas/core/rate_limit_middleware.py +64 -0
  48. atlas/core/security_headers_middleware.py +51 -0
  49. atlas/domain/__init__.py +37 -0
  50. atlas/domain/chat/__init__.py +1 -0
  51. atlas/domain/chat/dtos.py +85 -0
  52. atlas/domain/errors.py +96 -0
  53. atlas/domain/messages/__init__.py +12 -0
  54. atlas/domain/messages/models.py +160 -0
  55. atlas/domain/rag_mcp_service.py +664 -0
  56. atlas/domain/sessions/__init__.py +7 -0
  57. atlas/domain/sessions/models.py +36 -0
  58. atlas/domain/unified_rag_service.py +371 -0
  59. atlas/infrastructure/__init__.py +10 -0
  60. atlas/infrastructure/app_factory.py +135 -0
  61. atlas/infrastructure/events/__init__.py +1 -0
  62. atlas/infrastructure/events/cli_event_publisher.py +140 -0
  63. atlas/infrastructure/events/websocket_publisher.py +140 -0
  64. atlas/infrastructure/sessions/in_memory_repository.py +56 -0
  65. atlas/infrastructure/transport/__init__.py +7 -0
  66. atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
  67. atlas/init_cli.py +226 -0
  68. atlas/interfaces/__init__.py +15 -0
  69. atlas/interfaces/events.py +134 -0
  70. atlas/interfaces/llm.py +54 -0
  71. atlas/interfaces/rag.py +40 -0
  72. atlas/interfaces/sessions.py +75 -0
  73. atlas/interfaces/tools.py +57 -0
  74. atlas/interfaces/transport.py +24 -0
  75. atlas/main.py +564 -0
  76. atlas/mcp/api_key_demo/README.md +76 -0
  77. atlas/mcp/api_key_demo/main.py +172 -0
  78. atlas/mcp/api_key_demo/run.sh +56 -0
  79. atlas/mcp/basictable/main.py +147 -0
  80. atlas/mcp/calculator/main.py +149 -0
  81. atlas/mcp/code-executor/execution_engine.py +98 -0
  82. atlas/mcp/code-executor/execution_environment.py +95 -0
  83. atlas/mcp/code-executor/main.py +528 -0
  84. atlas/mcp/code-executor/result_processing.py +276 -0
  85. atlas/mcp/code-executor/script_generation.py +195 -0
  86. atlas/mcp/code-executor/security_checker.py +140 -0
  87. atlas/mcp/corporate_cars/main.py +437 -0
  88. atlas/mcp/csv_reporter/main.py +545 -0
  89. atlas/mcp/duckduckgo/main.py +182 -0
  90. atlas/mcp/elicitation_demo/README.md +171 -0
  91. atlas/mcp/elicitation_demo/main.py +262 -0
  92. atlas/mcp/env-demo/README.md +158 -0
  93. atlas/mcp/env-demo/main.py +199 -0
  94. atlas/mcp/file_size_test/main.py +284 -0
  95. atlas/mcp/filesystem/main.py +348 -0
  96. atlas/mcp/image_demo/main.py +113 -0
  97. atlas/mcp/image_demo/requirements.txt +4 -0
  98. atlas/mcp/logging_demo/README.md +72 -0
  99. atlas/mcp/logging_demo/main.py +103 -0
  100. atlas/mcp/many_tools_demo/main.py +50 -0
  101. atlas/mcp/order_database/__init__.py +0 -0
  102. atlas/mcp/order_database/main.py +369 -0
  103. atlas/mcp/order_database/signal_data.csv +1001 -0
  104. atlas/mcp/pdfbasic/main.py +394 -0
  105. atlas/mcp/pptx_generator/main.py +760 -0
  106. atlas/mcp/pptx_generator/requirements.txt +13 -0
  107. atlas/mcp/pptx_generator/run_test.sh +1 -0
  108. atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
  109. atlas/mcp/progress_demo/main.py +167 -0
  110. atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
  111. atlas/mcp/progress_updates_demo/README.md +120 -0
  112. atlas/mcp/progress_updates_demo/main.py +497 -0
  113. atlas/mcp/prompts/main.py +222 -0
  114. atlas/mcp/public_demo/main.py +189 -0
  115. atlas/mcp/sampling_demo/README.md +169 -0
  116. atlas/mcp/sampling_demo/main.py +234 -0
  117. atlas/mcp/thinking/main.py +77 -0
  118. atlas/mcp/tool_planner/main.py +240 -0
  119. atlas/mcp/ui-demo/badmesh.png +0 -0
  120. atlas/mcp/ui-demo/main.py +383 -0
  121. atlas/mcp/ui-demo/templates/button_demo.html +32 -0
  122. atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
  123. atlas/mcp/ui-demo/templates/form_demo.html +28 -0
  124. atlas/mcp/username-override-demo/README.md +320 -0
  125. atlas/mcp/username-override-demo/main.py +308 -0
  126. atlas/modules/__init__.py +0 -0
  127. atlas/modules/config/__init__.py +34 -0
  128. atlas/modules/config/cli.py +231 -0
  129. atlas/modules/config/config_manager.py +1096 -0
  130. atlas/modules/file_storage/__init__.py +22 -0
  131. atlas/modules/file_storage/cli.py +330 -0
  132. atlas/modules/file_storage/content_extractor.py +290 -0
  133. atlas/modules/file_storage/manager.py +295 -0
  134. atlas/modules/file_storage/mock_s3_client.py +402 -0
  135. atlas/modules/file_storage/s3_client.py +417 -0
  136. atlas/modules/llm/__init__.py +19 -0
  137. atlas/modules/llm/caller.py +287 -0
  138. atlas/modules/llm/litellm_caller.py +675 -0
  139. atlas/modules/llm/models.py +19 -0
  140. atlas/modules/mcp_tools/__init__.py +17 -0
  141. atlas/modules/mcp_tools/client.py +2123 -0
  142. atlas/modules/mcp_tools/token_storage.py +556 -0
  143. atlas/modules/prompts/prompt_provider.py +130 -0
  144. atlas/modules/rag/__init__.py +24 -0
  145. atlas/modules/rag/atlas_rag_client.py +336 -0
  146. atlas/modules/rag/client.py +129 -0
  147. atlas/routes/admin_routes.py +865 -0
  148. atlas/routes/config_routes.py +484 -0
  149. atlas/routes/feedback_routes.py +361 -0
  150. atlas/routes/files_routes.py +274 -0
  151. atlas/routes/health_routes.py +40 -0
  152. atlas/routes/mcp_auth_routes.py +223 -0
  153. atlas/server_cli.py +164 -0
  154. atlas/tests/conftest.py +20 -0
  155. atlas/tests/integration/test_mcp_auth_integration.py +152 -0
  156. atlas/tests/manual_test_sampling.py +87 -0
  157. atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
  158. atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
  159. atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
  160. atlas/tests/test_agent_roa.py +135 -0
  161. atlas/tests/test_app_factory_smoke.py +47 -0
  162. atlas/tests/test_approval_manager.py +439 -0
  163. atlas/tests/test_atlas_client.py +188 -0
  164. atlas/tests/test_atlas_rag_client.py +447 -0
  165. atlas/tests/test_atlas_rag_integration.py +224 -0
  166. atlas/tests/test_attach_file_flow.py +287 -0
  167. atlas/tests/test_auth_utils.py +165 -0
  168. atlas/tests/test_backend_public_url.py +185 -0
  169. atlas/tests/test_banner_logging.py +287 -0
  170. atlas/tests/test_capability_tokens_and_injection.py +203 -0
  171. atlas/tests/test_compliance_level.py +54 -0
  172. atlas/tests/test_compliance_manager.py +253 -0
  173. atlas/tests/test_config_manager.py +617 -0
  174. atlas/tests/test_config_manager_paths.py +12 -0
  175. atlas/tests/test_core_auth.py +18 -0
  176. atlas/tests/test_core_utils.py +190 -0
  177. atlas/tests/test_docker_env_sync.py +202 -0
  178. atlas/tests/test_domain_errors.py +329 -0
  179. atlas/tests/test_domain_whitelist.py +359 -0
  180. atlas/tests/test_elicitation_manager.py +408 -0
  181. atlas/tests/test_elicitation_routing.py +296 -0
  182. atlas/tests/test_env_demo_server.py +88 -0
  183. atlas/tests/test_error_classification.py +113 -0
  184. atlas/tests/test_error_flow_integration.py +116 -0
  185. atlas/tests/test_feedback_routes.py +333 -0
  186. atlas/tests/test_file_content_extraction.py +1134 -0
  187. atlas/tests/test_file_extraction_routes.py +158 -0
  188. atlas/tests/test_file_library.py +107 -0
  189. atlas/tests/test_file_manager_unit.py +18 -0
  190. atlas/tests/test_health_route.py +49 -0
  191. atlas/tests/test_http_client_stub.py +8 -0
  192. atlas/tests/test_imports_smoke.py +30 -0
  193. atlas/tests/test_interfaces_llm_response.py +9 -0
  194. atlas/tests/test_issue_access_denied_fix.py +136 -0
  195. atlas/tests/test_llm_env_expansion.py +836 -0
  196. atlas/tests/test_log_level_sensitive_data.py +285 -0
  197. atlas/tests/test_mcp_auth_routes.py +341 -0
  198. atlas/tests/test_mcp_client_auth.py +331 -0
  199. atlas/tests/test_mcp_data_injection.py +270 -0
  200. atlas/tests/test_mcp_get_authorized_servers.py +95 -0
  201. atlas/tests/test_mcp_hot_reload.py +512 -0
  202. atlas/tests/test_mcp_image_content.py +424 -0
  203. atlas/tests/test_mcp_logging.py +172 -0
  204. atlas/tests/test_mcp_progress_updates.py +313 -0
  205. atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
  206. atlas/tests/test_mcp_prompts_server.py +39 -0
  207. atlas/tests/test_mcp_tool_result_parsing.py +296 -0
  208. atlas/tests/test_metrics_logger.py +56 -0
  209. atlas/tests/test_middleware_auth.py +379 -0
  210. atlas/tests/test_prompt_risk_and_acl.py +141 -0
  211. atlas/tests/test_rag_mcp_aggregator.py +204 -0
  212. atlas/tests/test_rag_mcp_service.py +224 -0
  213. atlas/tests/test_rate_limit_middleware.py +45 -0
  214. atlas/tests/test_routes_config_smoke.py +60 -0
  215. atlas/tests/test_routes_files_download_token.py +41 -0
  216. atlas/tests/test_routes_files_health.py +18 -0
  217. atlas/tests/test_runtime_imports.py +53 -0
  218. atlas/tests/test_sampling_integration.py +482 -0
  219. atlas/tests/test_security_admin_routes.py +61 -0
  220. atlas/tests/test_security_capability_tokens.py +65 -0
  221. atlas/tests/test_security_file_stats_scope.py +21 -0
  222. atlas/tests/test_security_header_injection.py +191 -0
  223. atlas/tests/test_security_headers_and_filename.py +63 -0
  224. atlas/tests/test_shared_session_repository.py +101 -0
  225. atlas/tests/test_system_prompt_loading.py +181 -0
  226. atlas/tests/test_token_storage.py +505 -0
  227. atlas/tests/test_tool_approval_config.py +93 -0
  228. atlas/tests/test_tool_approval_utils.py +356 -0
  229. atlas/tests/test_tool_authorization_group_filtering.py +223 -0
  230. atlas/tests/test_tool_details_in_config.py +108 -0
  231. atlas/tests/test_tool_planner.py +300 -0
  232. atlas/tests/test_unified_rag_service.py +398 -0
  233. atlas/tests/test_username_override_in_approval.py +258 -0
  234. atlas/tests/test_websocket_auth_header.py +168 -0
  235. atlas/version.py +6 -0
  236. atlas_chat-0.1.0.data/data/.env.example +253 -0
  237. atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
  238. atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
  239. atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
  240. atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
  241. atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
  242. atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
  243. atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
  244. atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
  245. atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
  246. atlas_chat-0.1.0.dist-info/METADATA +236 -0
  247. atlas_chat-0.1.0.dist-info/RECORD +250 -0
  248. atlas_chat-0.1.0.dist-info/WHEEL +5 -0
  249. atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
  250. atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,224 @@
1
+ """Integration tests for AtlasRAGClient with the mock service.
2
+
3
+ These tests require the atlas-rag-api-mock service to be running.
4
+ They can be skipped if the mock service is not available.
5
+ """
6
+
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ import time
11
+
12
+ import httpx
13
+ import pytest
14
+
15
+ # Add paths for imports
16
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
17
+
18
+ from atlas.modules.rag.atlas_rag_client import AtlasRAGClient
19
+
20
+ MOCK_URL = "http://localhost:8002"
21
+ MOCK_TOKEN = "test-atlas-rag-token"
22
+ MOCK_STARTUP_TIMEOUT = 10
23
+
24
+
25
+ def is_mock_running() -> bool:
26
+ """Check if the mock service is running."""
27
+ try:
28
+ response = httpx.get(f"{MOCK_URL}/health", timeout=2.0)
29
+ return response.status_code == 200
30
+ except Exception:
31
+ return False
32
+
33
+
34
+ @pytest.fixture(scope="module")
35
+ def mock_service():
36
+ """Start the mock service if not already running."""
37
+ if is_mock_running():
38
+ yield MOCK_URL
39
+ return
40
+
41
+ # Try to start the mock service
42
+ mock_path = os.path.join(
43
+ os.path.dirname(__file__), "..", "..", "mocks", "atlas-rag-api-mock", "main.py"
44
+ )
45
+ mock_path = os.path.abspath(mock_path)
46
+
47
+ if not os.path.exists(mock_path):
48
+ pytest.skip(f"Mock service not found at {mock_path}")
49
+
50
+ process = subprocess.Popen(
51
+ [sys.executable, mock_path],
52
+ stdout=subprocess.PIPE,
53
+ stderr=subprocess.PIPE,
54
+ )
55
+
56
+ # Wait for service to start
57
+ start_time = time.time()
58
+ while time.time() - start_time < MOCK_STARTUP_TIMEOUT:
59
+ if is_mock_running():
60
+ break
61
+ time.sleep(0.5)
62
+ else:
63
+ process.terminate()
64
+ pytest.skip("Could not start mock service")
65
+
66
+ yield MOCK_URL
67
+
68
+ # Cleanup
69
+ process.terminate()
70
+ process.wait(timeout=5)
71
+
72
+
73
+ @pytest.fixture
74
+ def client():
75
+ """Create an AtlasRAGClient configured for the mock service."""
76
+ return AtlasRAGClient(
77
+ base_url=MOCK_URL,
78
+ bearer_token=MOCK_TOKEN,
79
+ default_model="test-model",
80
+ top_k=4,
81
+ )
82
+
83
+
84
+ class TestAtlasRAGIntegration:
85
+ """Integration tests for AtlasRAGClient with the mock service."""
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_discover_data_sources_success(self, mock_service, client):
89
+ """Test discovering data sources for a known user."""
90
+ sources = await client.discover_data_sources("test@test.com")
91
+
92
+ assert len(sources) > 0
93
+ # test@test.com has employee, engineering, devops, admin groups - sees all sources
94
+ source_names = [s.name for s in sources]
95
+ assert "company-policies" in source_names
96
+ assert "technical-docs" in source_names
97
+ assert "product-knowledge" in source_names # Public
98
+
99
+ # Check compliance levels are returned
100
+ for source in sources:
101
+ assert source.compliance_level in ["Internal", "Public"]
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_discover_data_sources_unknown_user(self, mock_service, client):
105
+ """Test discovering data sources for an unknown user returns only public sources."""
106
+ sources = await client.discover_data_sources("unknown@example.com")
107
+ # Unknown users get public sources only
108
+ source_names = [s.name for s in sources]
109
+ assert "product-knowledge" in source_names
110
+ assert "company-policies" not in source_names
111
+ assert "technical-docs" not in source_names
112
+
113
+ @pytest.mark.asyncio
114
+ async def test_discover_data_sources_limited_access(self, mock_service, client):
115
+ """Test that users only see corpora they have access to."""
116
+ # bob@example.com has employee and sales groups
117
+ sources = await client.discover_data_sources("bob@example.com")
118
+
119
+ source_names = [s.name for s in sources]
120
+ assert "company-policies" in source_names # requires employee
121
+ assert "product-knowledge" in source_names # Public
122
+ # Should NOT have access to technical-docs (requires engineering or devops)
123
+ assert "technical-docs" not in source_names
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_query_rag_success(self, mock_service, client):
127
+ """Test successful RAG query."""
128
+ messages = [{"role": "user", "content": "What is the API authentication?"}]
129
+
130
+ response = await client.query_rag(
131
+ user_name="test@test.com",
132
+ data_source="technical-docs",
133
+ messages=messages,
134
+ )
135
+
136
+ assert response.content is not None
137
+ assert len(response.content) > 0
138
+ # Should contain information about API or authentication
139
+ assert "API" in response.content or "authentication" in response.content.lower()
140
+
141
+ # Check metadata
142
+ assert response.metadata is not None
143
+ assert response.metadata.query_processing_time_ms >= 0
144
+ assert response.metadata.data_source_name == "technical-docs"
145
+ assert response.metadata.retrieval_method == "keyword-search"
146
+ assert len(response.metadata.documents_found) > 0
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_query_rag_with_metadata(self, mock_service, client):
150
+ """Test that RAG query returns document metadata."""
151
+ messages = [{"role": "user", "content": "Tell me about deployment pipeline"}]
152
+
153
+ response = await client.query_rag(
154
+ user_name="charlie@example.com", # Has employee, engineering, devops groups
155
+ data_source="technical-docs",
156
+ messages=messages,
157
+ )
158
+
159
+ assert response.metadata is not None
160
+ assert len(response.metadata.documents_found) > 0
161
+
162
+ # Check document metadata structure
163
+ doc = response.metadata.documents_found[0]
164
+ assert doc.source is not None
165
+ assert doc.confidence_score > 0
166
+ assert doc.content_type is not None
167
+
168
+ @pytest.mark.asyncio
169
+ async def test_query_rag_access_denied(self, mock_service, client):
170
+ """Test that RAG query returns 403 for unauthorized access."""
171
+ messages = [{"role": "user", "content": "Show me technical docs"}]
172
+
173
+ with pytest.raises(Exception) as exc_info:
174
+ await client.query_rag(
175
+ user_name="bob@example.com", # Has employee, sales - no engineering/devops
176
+ data_source="technical-docs", # Requires engineering or devops
177
+ messages=messages,
178
+ )
179
+
180
+ # Should raise HTTPException with 403
181
+ assert "403" in str(exc_info.value) or "access" in str(exc_info.value).lower()
182
+
183
+ @pytest.mark.asyncio
184
+ async def test_query_rag_corpus_not_found(self, mock_service, client):
185
+ """Test that RAG query returns 404 for non-existent corpus."""
186
+ messages = [{"role": "user", "content": "Search something"}]
187
+
188
+ with pytest.raises(Exception) as exc_info:
189
+ await client.query_rag(
190
+ user_name="test@test.com",
191
+ data_source="non-existent-corpus",
192
+ messages=messages,
193
+ )
194
+
195
+ # Should raise HTTPException with 404
196
+ assert "404" in str(exc_info.value) or "not found" in str(exc_info.value).lower()
197
+
198
+
199
+ class TestAtlasRAGAuthFailures:
200
+ """Test authentication failure scenarios."""
201
+
202
+ @pytest.mark.asyncio
203
+ async def test_missing_token(self, mock_service):
204
+ """Test that requests without token fail with 401."""
205
+ client = AtlasRAGClient(
206
+ base_url=MOCK_URL,
207
+ bearer_token=None, # No token
208
+ )
209
+
210
+ # Discovery should return empty list on auth failure (graceful degradation)
211
+ sources = await client.discover_data_sources("test@test.com")
212
+ assert sources == []
213
+
214
+ @pytest.mark.asyncio
215
+ async def test_invalid_token(self, mock_service):
216
+ """Test that requests with invalid token fail with 401."""
217
+ client = AtlasRAGClient(
218
+ base_url=MOCK_URL,
219
+ bearer_token="invalid-token",
220
+ )
221
+
222
+ # Discovery should return empty list on auth failure (graceful degradation)
223
+ sources = await client.discover_data_sources("test@test.com")
224
+ assert sources == []
@@ -0,0 +1,287 @@
1
+ import base64
2
+ import uuid
3
+
4
+ import pytest
5
+
6
+ from atlas.application.chat.service import ChatService
7
+ from atlas.modules.file_storage.manager import FileManager
8
+ from atlas.modules.file_storage.mock_s3_client import MockS3StorageClient
9
+
10
+
11
+ class FakeLLM:
12
+ async def call_plain(self, model_name, messages, temperature=0.7):
13
+ return "ok"
14
+
15
+ async def call_with_tools(self, model_name, messages, tools_schema, tool_choice="auto", temperature=0.7):
16
+ from atlas.interfaces.llm import LLMResponse
17
+ return LLMResponse(content="ok", tool_calls=None, model_used=model_name)
18
+
19
+ async def call_with_rag(self, model_name, messages, data_sources, user_email, temperature=0.7):
20
+ return "ok"
21
+
22
+ async def call_with_rag_and_tools(self, model_name, messages, data_sources, tools_schema, user_email, tool_choice="auto", temperature=0.7):
23
+ from atlas.interfaces.llm import LLMResponse
24
+ return LLMResponse(content="ok", tool_calls=None, model_used=model_name)
25
+
26
+
27
+ @pytest.fixture
28
+ def file_manager():
29
+ # Use in-process mock S3 for deterministic tests
30
+ return FileManager(s3_client=MockS3StorageClient())
31
+
32
+
33
+ @pytest.fixture
34
+ def chat_service(file_manager):
35
+ # Minimal ChatService wiring for file/session operations
36
+ return ChatService(llm=FakeLLM(), file_manager=file_manager)
37
+
38
+
39
+ @pytest.mark.asyncio
40
+ async def test_handle_attach_file_success_creates_session_and_emits_update(chat_service, file_manager):
41
+ user_email = "user1@example.com"
42
+ session_id = uuid.uuid4()
43
+
44
+ # Seed a file into the mock storage for this user
45
+ filename = "report.txt"
46
+ content_b64 = base64.b64encode(b"hello world").decode()
47
+ upload_meta = await file_manager.s3_client.upload_file(
48
+ user_email=user_email,
49
+ filename=filename,
50
+ content_base64=content_b64,
51
+ content_type="text/plain",
52
+ tags={"source": "user"},
53
+ source_type="user",
54
+ )
55
+ s3_key = upload_meta["key"]
56
+
57
+ updates = []
58
+
59
+ async def capture_update(msg):
60
+ updates.append(msg)
61
+
62
+ # Act: attach the file to a brand new session (auto-creates session)
63
+ resp = await chat_service.handle_attach_file(
64
+ session_id=session_id,
65
+ s3_key=s3_key,
66
+ user_email=user_email,
67
+ update_callback=capture_update,
68
+ )
69
+
70
+ # Assert: success response and files_update emitted
71
+ assert resp.get("type") == "file_attach"
72
+ assert resp.get("success") is True
73
+ assert resp.get("filename") == filename
74
+
75
+ assert any(
76
+ u.get("type") == "intermediate_update" and u.get("update_type") == "files_update"
77
+ for u in updates
78
+ ), "Expected a files_update intermediate update to be emitted"
79
+
80
+ # Session context should include the file by filename
81
+ session = chat_service.sessions.get(session_id)
82
+ assert session is not None
83
+ assert filename in session.context.get("files", {})
84
+ assert session.context["files"][filename]["key"] == s3_key
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_handle_attach_file_not_found_returns_error(chat_service):
89
+ user_email = "user1@example.com"
90
+ session_id = uuid.uuid4()
91
+
92
+ # Non-existent S3 key for the same user
93
+ bad_key = f"users/{user_email}/uploads/does_not_exist_12345.txt"
94
+ resp = await chat_service.handle_attach_file(
95
+ session_id=session_id,
96
+ s3_key=bad_key,
97
+ user_email=user_email,
98
+ update_callback=None,
99
+ )
100
+
101
+ assert resp.get("type") == "file_attach"
102
+ assert resp.get("success") is False
103
+ assert "File not found" in resp.get("error", "")
104
+
105
+
106
+ @pytest.mark.asyncio
107
+ async def test_handle_attach_file_unauthorized_other_user_key(chat_service, file_manager):
108
+ # Upload under user1
109
+ owner_email = "owner@example.com"
110
+ other_email = "other@example.com"
111
+ session_id = uuid.uuid4()
112
+
113
+ filename = "secret.pdf"
114
+ content_b64 = base64.b64encode(b"top-secret").decode()
115
+ upload_meta = await file_manager.s3_client.upload_file(
116
+ user_email=owner_email,
117
+ filename=filename,
118
+ content_base64=content_b64,
119
+ content_type="application/pdf",
120
+ tags={"source": "user"},
121
+ source_type="user",
122
+ )
123
+ s3_key = upload_meta["key"]
124
+
125
+ # Attempt to attach with a different user should fail
126
+ resp = await chat_service.handle_attach_file(
127
+ session_id=session_id,
128
+ s3_key=s3_key,
129
+ user_email=other_email,
130
+ update_callback=None,
131
+ )
132
+
133
+ assert resp.get("type") == "file_attach"
134
+ assert resp.get("success") is False
135
+ assert "Access denied" in resp.get("error", "")
136
+
137
+
138
+ @pytest.mark.asyncio
139
+ async def test_handle_reset_session_reinitializes(chat_service):
140
+ user_email = "user1@example.com"
141
+ session_id = uuid.uuid4()
142
+
143
+ # Create a session first
144
+ await chat_service.create_session(session_id, user_email)
145
+ assert chat_service.sessions.get(session_id) is not None
146
+
147
+ # Reset the session
148
+ resp = await chat_service.handle_reset_session(session_id=session_id, user_email=user_email)
149
+
150
+ assert resp.get("type") == "session_reset"
151
+ # After reset, a fresh active session should exist for the same id
152
+ new_session = chat_service.sessions.get(session_id)
153
+ assert new_session is not None
154
+ assert new_session.active is True
155
+
156
+
157
+ @pytest.mark.asyncio
158
+ async def test_handle_download_file_success_after_attach(chat_service, file_manager):
159
+ user_email = "user1@example.com"
160
+ session_id = uuid.uuid4()
161
+
162
+ # Upload and then attach to session
163
+ filename = "notes.md"
164
+ content_bytes = b"### Title\nSome content."
165
+ content_b64 = base64.b64encode(content_bytes).decode()
166
+ upload_meta = await file_manager.s3_client.upload_file(
167
+ user_email=user_email,
168
+ filename=filename,
169
+ content_base64=content_b64,
170
+ content_type="text/markdown",
171
+ tags={"source": "user"},
172
+ source_type="user",
173
+ )
174
+ s3_key = upload_meta["key"]
175
+
176
+ await chat_service.handle_attach_file(
177
+ session_id=session_id,
178
+ s3_key=s3_key,
179
+ user_email=user_email,
180
+ update_callback=None,
181
+ )
182
+
183
+ # Act: download by filename (from session context)
184
+ resp = await chat_service.handle_download_file(
185
+ session_id=session_id,
186
+ filename=filename,
187
+ user_email=user_email,
188
+ )
189
+
190
+ assert resp.get("type") is not None
191
+ # content_base64 should match uploaded content
192
+ returned_b64 = resp.get("content_base64")
193
+ assert isinstance(returned_b64, str) and len(returned_b64) > 0
194
+ assert base64.b64decode(returned_b64) == content_bytes
195
+
196
+
197
+ @pytest.mark.asyncio
198
+ async def test_handle_download_file_not_in_session_returns_error(chat_service):
199
+ user_email = "user1@example.com"
200
+ session_id = uuid.uuid4()
201
+ filename = "missing.txt"
202
+
203
+ # No attach performed; should error that file isn't in session
204
+ resp = await chat_service.handle_download_file(
205
+ session_id=session_id,
206
+ filename=filename,
207
+ user_email=user_email,
208
+ )
209
+
210
+ assert resp.get("error") == "Session or file manager not available" or resp.get("error") == "File not found in session"
211
+
212
+
213
+ @pytest.mark.asyncio
214
+ async def test_upload_file_with_spaces_in_filename(file_manager):
215
+ """Files with spaces in their names should upload successfully after sanitization."""
216
+ user_email = "user1@example.com"
217
+ filename_with_spaces = "my report file.txt"
218
+ content_b64 = base64.b64encode(b"some content").decode()
219
+
220
+ result = await file_manager.upload_file(
221
+ user_email=user_email,
222
+ filename=filename_with_spaces,
223
+ content_base64=content_b64,
224
+ source_type="user",
225
+ )
226
+
227
+ # Filename should be sanitized (spaces replaced with underscores)
228
+ assert result["filename"] == "my_report_file.txt"
229
+ assert "my_report_file.txt" in result["key"]
230
+ assert " " not in result["key"]
231
+
232
+
233
+ @pytest.mark.asyncio
234
+ async def test_upload_multiple_files_with_spaces(file_manager):
235
+ """upload_multiple_files should sanitize filenames containing spaces."""
236
+ user_email = "user1@example.com"
237
+ files = {
238
+ "my document.pdf": base64.b64encode(b"pdf bytes").decode(),
239
+ "another file.txt": base64.b64encode(b"text bytes").decode(),
240
+ }
241
+
242
+ uploaded = await file_manager.upload_multiple_files(
243
+ user_email=user_email,
244
+ files=files,
245
+ source_type="user",
246
+ )
247
+
248
+ assert "my_document.pdf" in uploaded
249
+ assert "another_file.txt" in uploaded
250
+ for key in uploaded.values():
251
+ assert " " not in key
252
+
253
+
254
+ @pytest.mark.asyncio
255
+ async def test_attach_file_with_spaces_end_to_end(chat_service, file_manager):
256
+ """Full flow: upload a file with spaces, attach it, verify sanitized name in session."""
257
+ user_email = "user1@example.com"
258
+ session_id = uuid.uuid4()
259
+ filename_with_spaces = "test report.txt"
260
+ content_b64 = base64.b64encode(b"hello spaces").decode()
261
+
262
+ upload_meta = await file_manager.upload_file(
263
+ user_email=user_email,
264
+ filename=filename_with_spaces,
265
+ content_base64=content_b64,
266
+ source_type="user",
267
+ )
268
+ s3_key = upload_meta["key"]
269
+
270
+ resp = await chat_service.handle_attach_file(
271
+ session_id=session_id,
272
+ s3_key=s3_key,
273
+ user_email=user_email,
274
+ update_callback=None,
275
+ )
276
+
277
+ assert resp.get("success") is True
278
+ assert " " not in s3_key
279
+
280
+ # Verify the session stores the sanitized filename (no spaces)
281
+ session = chat_service.sessions.get(session_id)
282
+ assert session is not None
283
+ session_files = session.context.get("files", {})
284
+ # The filename key in the session should have underscores, not spaces
285
+ stored_names = list(session_files.keys())
286
+ for name in stored_names:
287
+ assert " " not in name, f"Session stored filename with spaces: {name}"
@@ -0,0 +1,165 @@
1
+ """Tests for auth_utils module."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from atlas.core.authorization_manager import AuthorizationManager, create_authorization_manager
8
+
9
+
10
+ class TestAuthorizationManager:
11
+ """Test suite for AuthorizationManager class."""
12
+
13
+ @pytest.fixture
14
+ def mock_auth_check_func(self):
15
+ """Create a mock auth check function."""
16
+ return AsyncMock()
17
+
18
+ @pytest.fixture
19
+ def mock_app_settings(self):
20
+ """Create mock app settings."""
21
+ settings = MagicMock()
22
+ settings.admin_group = "admin"
23
+ return settings
24
+
25
+ @pytest.fixture
26
+ def auth_manager(self, mock_auth_check_func, mock_app_settings):
27
+ """Create an AuthorizationManager instance with mocked dependencies."""
28
+ with patch('atlas.core.authorization_manager.get_app_settings', return_value=mock_app_settings):
29
+ return AuthorizationManager(mock_auth_check_func)
30
+
31
+ @pytest.mark.asyncio
32
+ async def test_is_admin_returns_true_for_admin_user(self, auth_manager, mock_auth_check_func):
33
+ """Test that is_admin returns True when auth check function returns True."""
34
+ # Arrange
35
+ user_email = "admin@example.com"
36
+ mock_auth_check_func.return_value = True
37
+
38
+ # Act
39
+ result = await auth_manager.is_admin(user_email)
40
+
41
+ # Assert
42
+ assert result is True
43
+ mock_auth_check_func.assert_called_once_with(user_email, "admin")
44
+
45
+ @pytest.mark.asyncio
46
+ async def test_is_admin_returns_false_for_non_admin_user(self, auth_manager, mock_auth_check_func):
47
+ """Test that is_admin returns False when auth check function returns False."""
48
+ # Arrange
49
+ user_email = "user@example.com"
50
+ mock_auth_check_func.return_value = False
51
+
52
+ # Act
53
+ result = await auth_manager.is_admin(user_email)
54
+
55
+ # Assert
56
+ assert result is False
57
+ mock_auth_check_func.assert_called_once_with(user_email, "admin")
58
+
59
+ @pytest.mark.asyncio
60
+ async def test_is_admin_uses_correct_admin_group(self, mock_auth_check_func):
61
+ """Test that is_admin uses the admin group from app settings."""
62
+ # Arrange
63
+ custom_admin_group = "super_admin"
64
+ mock_settings = MagicMock()
65
+ mock_settings.admin_group = custom_admin_group
66
+
67
+ with patch('atlas.core.authorization_manager.get_app_settings', return_value=mock_settings):
68
+ auth_manager = AuthorizationManager(mock_auth_check_func)
69
+
70
+ user_email = "admin@example.com"
71
+ mock_auth_check_func.return_value = True
72
+
73
+ # Act
74
+ await auth_manager.is_admin(user_email)
75
+
76
+ # Assert
77
+ mock_auth_check_func.assert_called_once_with(user_email, custom_admin_group)
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_is_admin_handles_auth_check_exception(self, auth_manager, mock_auth_check_func):
81
+ """Test that is_admin properly propagates exceptions from auth check function."""
82
+ # Arrange
83
+ user_email = "user@example.com"
84
+ mock_auth_check_func.side_effect = Exception("Auth service unavailable")
85
+
86
+ # Act & Assert
87
+ with pytest.raises(Exception, match="Auth service unavailable"):
88
+ await auth_manager.is_admin(user_email)
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_is_admin_with_empty_email(self, auth_manager, mock_auth_check_func):
92
+ """Test that is_admin handles empty email addresses."""
93
+ # Arrange
94
+ user_email = ""
95
+ mock_auth_check_func.return_value = False
96
+
97
+ # Act
98
+ result = await auth_manager.is_admin(user_email)
99
+
100
+ # Assert
101
+ assert result is False
102
+ mock_auth_check_func.assert_called_once_with("", "admin")
103
+
104
+ @pytest.mark.asyncio
105
+ async def test_is_admin_with_none_email(self, auth_manager, mock_auth_check_func):
106
+ """Test that is_admin handles None email addresses."""
107
+ # Arrange
108
+ user_email = None
109
+ mock_auth_check_func.return_value = False
110
+
111
+ # Act
112
+ result = await auth_manager.is_admin(user_email)
113
+
114
+ # Assert
115
+ assert result is False
116
+ mock_auth_check_func.assert_called_once_with(None, "admin")
117
+
118
+
119
+ class TestCreateAuthorizationManager:
120
+ """Test suite for create_authorization_manager factory function."""
121
+
122
+ def test_create_authorization_manager_returns_instance(self):
123
+ """Test that factory function returns an AuthorizationManager instance."""
124
+ # Arrange
125
+ mock_auth_check_func = AsyncMock()
126
+
127
+ # Act
128
+ with patch('atlas.core.authorization_manager.get_app_settings'):
129
+ result = create_authorization_manager(mock_auth_check_func)
130
+
131
+ # Assert
132
+ assert isinstance(result, AuthorizationManager)
133
+ assert result.auth_check_func is mock_auth_check_func
134
+
135
+ def test_create_authorization_manager_initializes_app_settings(self):
136
+ """Test that factory function properly initializes app settings."""
137
+ # Arrange
138
+ mock_auth_check_func = AsyncMock()
139
+ mock_settings = MagicMock()
140
+ mock_settings.admin_group = "test_admin"
141
+
142
+ # Act
143
+ with patch('atlas.core.authorization_manager.get_app_settings', return_value=mock_settings) as mock_get_settings:
144
+ result = create_authorization_manager(mock_auth_check_func)
145
+
146
+ # Assert
147
+ mock_get_settings.assert_called_once()
148
+ assert result.app_settings is mock_settings
149
+
150
+ @pytest.mark.asyncio
151
+ async def test_created_manager_works_correctly(self):
152
+ """Test that the manager created by factory function works correctly."""
153
+ # Arrange
154
+ mock_auth_check_func = AsyncMock(return_value=True)
155
+ mock_settings = MagicMock()
156
+ mock_settings.admin_group = "admin"
157
+
158
+ # Act
159
+ with patch('atlas.core.authorization_manager.get_app_settings', return_value=mock_settings):
160
+ manager = create_authorization_manager(mock_auth_check_func)
161
+ result = await manager.is_admin("test@example.com")
162
+
163
+ # Assert
164
+ assert result is True
165
+ mock_auth_check_func.assert_called_once_with("test@example.com", "admin")