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,285 @@
1
+ """Tests for log level control of sensitive data logging.
2
+
3
+ These tests verify that:
4
+ 1. User message content is only logged at DEBUG level, not INFO
5
+ 2. LLM response content is only logged at DEBUG level, not INFO
6
+ 3. Non-sensitive metadata is always logged at INFO level
7
+ 4. The LOG_LEVEL environment variable controls this behavior
8
+ """
9
+
10
+ import asyncio
11
+ import importlib
12
+ import logging
13
+ import sys
14
+ from unittest.mock import AsyncMock, MagicMock, patch
15
+
16
+ import pytest
17
+
18
+ from atlas.application.chat.service import ChatService
19
+
20
+
21
+ def _ensure_real_litellm_module():
22
+ """
23
+ Ensure the real litellm_caller module is in sys.modules.
24
+
25
+ Some test files (e.g., test_capability_tokens_and_injection.py) patch the
26
+ litellm_caller module at import time. This function forces a reimport of
27
+ the real module to ensure patches work correctly.
28
+ """
29
+ module_name = "atlas.modules.llm.litellm_caller"
30
+
31
+ # Check if current module is fake
32
+ if module_name in sys.modules:
33
+ current_module = sys.modules[module_name]
34
+ if hasattr(current_module, "LiteLLMCaller"):
35
+ caller_class = current_module.LiteLLMCaller
36
+ if not hasattr(caller_class, "_get_model_kwargs"):
37
+ # It's a fake, remove it and reimport
38
+ del sys.modules[module_name]
39
+ importlib.import_module(module_name)
40
+ else:
41
+ # Not in sys.modules, import fresh
42
+ importlib.import_module(module_name)
43
+
44
+
45
+ # Module-level LiteLLMCaller variable, set by fixture
46
+ LiteLLMCaller = None
47
+
48
+
49
+ @pytest.fixture(autouse=True)
50
+ def ensure_real_litellm_for_tests():
51
+ """Fixture to ensure real LiteLLM module is loaded before each test."""
52
+ _ensure_real_litellm_module()
53
+ # Re-import the class from the now-correct module
54
+ from atlas.modules.llm.litellm_caller import LiteLLMCaller as RealCaller
55
+ # Make it available globally for this module
56
+ global LiteLLMCaller
57
+ LiteLLMCaller = RealCaller
58
+ yield
59
+
60
+
61
+ # Initially try to get the real class
62
+ _ensure_real_litellm_module()
63
+ _initial_module = importlib.import_module("atlas.modules.llm.litellm_caller")
64
+ LiteLLMCaller = _initial_module.LiteLLMCaller
65
+
66
+
67
+ class TestLogLevelSensitiveData:
68
+ """Tests for log level control of sensitive data."""
69
+
70
+ def test_tool_approval_response_summary_excludes_argument_values(self):
71
+ """Tool approval payloads must never log raw tool arguments at INFO."""
72
+ from atlas.core.log_sanitizer import summarize_tool_approval_response_for_logging
73
+
74
+ payload = {
75
+ "type": "tool_approval_response",
76
+ "tool_call_id": "fc_07785773-7583-4e97-bb7d-a24f1f8a0c4b",
77
+ "approved": True,
78
+ "arguments": {
79
+ "file_name": "New_Mexico_Snakes",
80
+ "markdown_content": "# Snakes of New Mexico\n- secret stuff"
81
+ },
82
+ "reason": "User approved"
83
+ }
84
+
85
+ summary = summarize_tool_approval_response_for_logging(payload)
86
+
87
+ # Must include safe metadata
88
+ assert "type=tool_approval_response" in summary
89
+ assert "tool_call_id=fc_07785773-7583-4e97-bb7d-a24f1f8a0c4b" in summary
90
+ assert "approved=True" in summary
91
+
92
+ # Must not include argument values or reason contents
93
+ assert "New_Mexico_Snakes" not in summary
94
+ assert "Snakes of New Mexico" not in summary
95
+ assert "User approved" not in summary
96
+
97
+ def test_chat_service_info_level_excludes_content(self, caplog):
98
+ """Test that INFO level logging excludes user message content."""
99
+ # Create mock dependencies
100
+ mock_llm = MagicMock()
101
+ mock_tool_manager = MagicMock()
102
+ mock_connection = MagicMock()
103
+ mock_config = MagicMock()
104
+ mock_session_repo = MagicMock()
105
+ mock_session_repo.get = AsyncMock(return_value=None)
106
+ mock_session_repo.create = AsyncMock(return_value=None)
107
+
108
+ service = ChatService(
109
+ llm=mock_llm,
110
+ tool_manager=mock_tool_manager,
111
+ connection=mock_connection,
112
+ config_manager=mock_config,
113
+ session_repository=mock_session_repo
114
+ )
115
+
116
+ # Set log level to INFO
117
+ with caplog.at_level(logging.INFO):
118
+ # Create test session first
119
+ asyncio.run(service.create_session("test-session", "test@test.com"))
120
+
121
+ # Clear logs from session creation
122
+ caplog.clear()
123
+
124
+ # Try to call handle_chat_message (it will fail but we only care about logs)
125
+ try:
126
+ asyncio.run(service.handle_chat_message(
127
+ session_id="test-session",
128
+ content="This is sensitive user input that should not be logged at INFO level",
129
+ model="test-model",
130
+ user_email="test@test.com"
131
+ ))
132
+ except Exception:
133
+ pass # We expect this to fail, we're just checking logs
134
+
135
+ # Check that logs exist but don't contain the sensitive content
136
+ log_messages = [record.message for record in caplog.records if record.levelno == logging.INFO]
137
+
138
+ # Should have INFO log about the call
139
+ assert any("handle_chat_message called" in msg for msg in log_messages), \
140
+ "Should have INFO log about handle_chat_message call"
141
+
142
+ # Should NOT contain the sensitive content at INFO level
143
+ assert not any("sensitive user input" in msg for msg in log_messages), \
144
+ "Should NOT log sensitive content at INFO level"
145
+
146
+ # Should log metadata like content length
147
+ assert any("content_length" in msg for msg in log_messages), \
148
+ "Should log content_length metadata at INFO level"
149
+
150
+ def test_chat_service_debug_level_includes_content(self, caplog):
151
+ """Test that DEBUG level logging includes user message content."""
152
+ # Create mock dependencies
153
+ mock_llm = MagicMock()
154
+ mock_tool_manager = MagicMock()
155
+ mock_connection = MagicMock()
156
+ mock_config = MagicMock()
157
+ mock_session_repo = MagicMock()
158
+ mock_session_repo.get = AsyncMock(return_value=None)
159
+ mock_session_repo.create = AsyncMock(return_value=None)
160
+
161
+ service = ChatService(
162
+ llm=mock_llm,
163
+ tool_manager=mock_tool_manager,
164
+ connection=mock_connection,
165
+ config_manager=mock_config,
166
+ session_repository=mock_session_repo
167
+ )
168
+
169
+ # Set log level to DEBUG
170
+ with caplog.at_level(logging.DEBUG):
171
+ # Create test session first
172
+ asyncio.run(service.create_session("test-session", "test@test.com"))
173
+
174
+ # Clear logs from session creation
175
+ caplog.clear()
176
+
177
+ # Try to call handle_chat_message
178
+ try:
179
+ asyncio.run(service.handle_chat_message(
180
+ session_id="test-session",
181
+ content="This is sensitive user input",
182
+ model="test-model",
183
+ user_email="test@test.com"
184
+ ))
185
+ except Exception:
186
+ pass # We expect this to fail, we're just checking logs
187
+
188
+ # Check that DEBUG logs include the content
189
+ log_messages = [record.message for record in caplog.records if record.levelno == logging.DEBUG]
190
+
191
+ # Should contain the sensitive content at DEBUG level
192
+ assert any("sensitive user input" in msg for msg in log_messages), \
193
+ "Should log sensitive content at DEBUG level"
194
+
195
+ @pytest.mark.asyncio
196
+ async def test_llm_caller_info_level_excludes_response_preview(self, caplog):
197
+ """Test that INFO level logging excludes LLM response previews."""
198
+ # Mock the acompletion call
199
+ mock_response = MagicMock()
200
+ mock_response.choices = [MagicMock()]
201
+ mock_response.choices[0].message = MagicMock()
202
+ mock_response.choices[0].message.content = "This is a sensitive LLM response with user data"
203
+
204
+ with patch('atlas.modules.llm.litellm_caller.acompletion', return_value=mock_response):
205
+ caller = LiteLLMCaller()
206
+
207
+ # Mock the config to return test model
208
+ with patch.object(caller, '_get_litellm_model_name', return_value='gpt-4'):
209
+ with patch.object(caller, '_get_model_kwargs', return_value={}):
210
+ # Set log level to INFO
211
+ with caplog.at_level(logging.INFO):
212
+ await caller.call_plain(
213
+ model_name="test-model",
214
+ messages=[{"role": "user", "content": "test"}],
215
+ temperature=0.7
216
+ )
217
+
218
+ # Check logs
219
+ log_messages = [record.message for record in caplog.records if record.levelno == logging.INFO]
220
+
221
+ # Should have INFO log about the call
222
+ assert any("Plain LLM call" in msg for msg in log_messages), \
223
+ "Should have INFO log about LLM call"
224
+
225
+ # Should NOT contain response preview at INFO level
226
+ assert not any("sensitive LLM response" in msg for msg in log_messages), \
227
+ "Should NOT log response preview at INFO level"
228
+
229
+ # Should log response length instead
230
+ assert any("response length" in msg for msg in log_messages), \
231
+ "Should log response length at INFO level"
232
+
233
+ @pytest.mark.asyncio
234
+ async def test_llm_caller_debug_level_includes_response_preview(self, caplog):
235
+ """Test that DEBUG level logging includes LLM response previews."""
236
+ # Mock the acompletion call
237
+ mock_response = MagicMock()
238
+ mock_response.choices = [MagicMock()]
239
+ mock_response.choices[0].message = MagicMock()
240
+ mock_response.choices[0].message.content = "This is a sensitive LLM response"
241
+
242
+ with patch('atlas.modules.llm.litellm_caller.acompletion', return_value=mock_response):
243
+ caller = LiteLLMCaller()
244
+
245
+ # Mock the config to return test model
246
+ with patch.object(caller, '_get_litellm_model_name', return_value='gpt-4'):
247
+ with patch.object(caller, '_get_model_kwargs', return_value={}):
248
+ # Set log level to DEBUG
249
+ with caplog.at_level(logging.DEBUG):
250
+ await caller.call_plain(
251
+ model_name="test-model",
252
+ messages=[{"role": "user", "content": "test"}],
253
+ temperature=0.7
254
+ )
255
+
256
+ # Check logs
257
+ log_messages = [record.message for record in caplog.records if record.levelno == logging.DEBUG]
258
+
259
+ # Should contain response preview at DEBUG level
260
+ assert any("sensitive LLM response" in msg for msg in log_messages), \
261
+ "Should log response preview at DEBUG level"
262
+
263
+ def test_log_level_from_config_manager(self):
264
+ """Test that LOG_LEVEL configuration mechanism exists and is functional."""
265
+ # This test verifies the log level configuration mechanism exists
266
+ # The actual value will be whatever was set during module initialization
267
+ from atlas.core.otel_config import OpenTelemetryConfig
268
+ from atlas.modules.config.config_manager import AppSettings
269
+
270
+ # Verify AppSettings has log_level field
271
+ app_settings = AppSettings()
272
+ assert hasattr(app_settings, 'log_level'), \
273
+ "AppSettings should have log_level field"
274
+
275
+ # Verify otel_config reads log level
276
+ config = OpenTelemetryConfig()
277
+ assert hasattr(config, 'log_level'), \
278
+ "OpenTelemetryConfig should have log_level attribute"
279
+ assert isinstance(config.log_level, int), \
280
+ "log_level should be an integer (logging level)"
281
+
282
+ # Verify log level is one of the valid logging levels
283
+ valid_levels = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL]
284
+ assert config.log_level in valid_levels, \
285
+ f"log_level should be a valid logging level, got {config.log_level}"
@@ -0,0 +1,341 @@
1
+ """Unit tests for MCP authentication routes.
2
+
3
+ Tests the API endpoints for per-user token management:
4
+ - GET /api/mcp/auth/status - Get auth status for all servers
5
+ - POST /api/mcp/auth/{server}/token - Upload token for server
6
+ - DELETE /api/mcp/auth/{server}/token - Remove token for server
7
+
8
+ Updated: 2025-01-21
9
+ """
10
+
11
+ import time
12
+ from unittest.mock import AsyncMock, MagicMock, patch
13
+
14
+ import pytest
15
+ from fastapi import FastAPI
16
+ from fastapi.testclient import TestClient
17
+
18
+ from atlas.core.log_sanitizer import get_current_user
19
+ from atlas.routes.mcp_auth_routes import TokenUpload, router
20
+
21
+
22
+ # Create a test app with the auth routes
23
+ def create_test_app(user_override: str = "test@example.com"):
24
+ """Create a FastAPI test app with auth routes."""
25
+ app = FastAPI()
26
+ app.include_router(router)
27
+
28
+ # Override the get_current_user dependency
29
+ async def override_get_current_user():
30
+ return user_override
31
+
32
+ app.dependency_overrides[get_current_user] = override_get_current_user
33
+ return app
34
+
35
+
36
+ class TestGetAuthStatus:
37
+ """Test GET /api/mcp/auth/status endpoint."""
38
+
39
+ @pytest.fixture
40
+ def client(self):
41
+ """Create test client."""
42
+ app = create_test_app()
43
+ return TestClient(app)
44
+
45
+ @pytest.fixture
46
+ def mock_dependencies(self):
47
+ """Mock the dependencies for auth routes."""
48
+ with patch("atlas.routes.mcp_auth_routes.app_factory") as mock_factory, \
49
+ patch("atlas.routes.mcp_auth_routes.get_token_storage") as mock_storage:
50
+
51
+ # Mock MCP manager
52
+ mock_mcp_manager = AsyncMock()
53
+ mock_mcp_manager.get_authorized_servers = AsyncMock(return_value=["server1", "server2"])
54
+ mock_mcp_manager.servers_config = {
55
+ "server1": {"auth_type": "api_key", "description": "API Key Server"},
56
+ "server2": {"auth_type": "jwt", "description": "JWT Server"},
57
+ }
58
+ mock_factory.get_mcp_manager.return_value = mock_mcp_manager
59
+
60
+ # Mock token storage
61
+ mock_token_storage = MagicMock()
62
+ mock_token_storage.get_user_auth_status.return_value = {
63
+ "server1": {
64
+ "token_type": "api_key",
65
+ "is_expired": False,
66
+ "expires_at": None,
67
+ "time_until_expiry": None,
68
+ "has_refresh_token": False,
69
+ "scopes": None,
70
+ }
71
+ }
72
+ mock_storage.return_value = mock_token_storage
73
+
74
+ yield {
75
+ "factory": mock_factory,
76
+ "storage": mock_storage,
77
+ "mcp_manager": mock_mcp_manager,
78
+ "token_storage": mock_token_storage,
79
+ }
80
+
81
+ def test_get_auth_status_success(self, client, mock_dependencies):
82
+ """Should return auth status for all servers."""
83
+ response = client.get("/api/mcp/auth/status")
84
+
85
+ assert response.status_code == 200
86
+ data = response.json()
87
+
88
+ assert "servers" in data
89
+ assert "user" in data
90
+ assert data["user"] == "test@example.com"
91
+ assert len(data["servers"]) == 2
92
+
93
+ def test_get_auth_status_shows_authenticated_servers(self, client, mock_dependencies):
94
+ """Should indicate which servers user is authenticated with."""
95
+ response = client.get("/api/mcp/auth/status")
96
+
97
+ data = response.json()
98
+ servers = {s["server_name"]: s for s in data["servers"]}
99
+
100
+ # server1 has token stored
101
+ assert servers["server1"]["authenticated"] is True
102
+ assert servers["server1"]["auth_type"] == "api_key"
103
+
104
+ # server2 has no token
105
+ assert servers["server2"]["authenticated"] is False
106
+ assert servers["server2"]["auth_type"] == "jwt"
107
+
108
+ def test_get_auth_status_includes_token_details(self, client, mock_dependencies):
109
+ """Should include token details for authenticated servers."""
110
+ response = client.get("/api/mcp/auth/status")
111
+
112
+ data = response.json()
113
+ server1 = next(s for s in data["servers"] if s["server_name"] == "server1")
114
+
115
+ assert server1["token_type"] == "api_key"
116
+ assert server1["is_expired"] is False
117
+
118
+
119
+ class TestUploadToken:
120
+ """Test POST /api/mcp/auth/{server_name}/token endpoint."""
121
+
122
+ @pytest.fixture
123
+ def client(self):
124
+ """Create test client."""
125
+ app = create_test_app()
126
+ return TestClient(app)
127
+
128
+ @pytest.fixture
129
+ def mock_dependencies(self):
130
+ """Mock the dependencies for auth routes."""
131
+ with patch("atlas.routes.mcp_auth_routes.app_factory") as mock_factory, \
132
+ patch("atlas.routes.mcp_auth_routes.get_token_storage") as mock_storage:
133
+
134
+ mock_mcp_manager = AsyncMock()
135
+ mock_mcp_manager.get_authorized_servers = AsyncMock(return_value=["test-server"])
136
+ mock_mcp_manager.servers_config = {
137
+ "test-server": {"auth_type": "api_key", "description": "Test Server"},
138
+ }
139
+ mock_factory.get_mcp_manager.return_value = mock_mcp_manager
140
+
141
+ mock_token_storage = MagicMock()
142
+ mock_stored_token = MagicMock()
143
+ mock_stored_token.token_type = "api_key"
144
+ mock_stored_token.expires_at = None
145
+ mock_stored_token.scopes = None
146
+ mock_token_storage.store_token.return_value = mock_stored_token
147
+ mock_storage.return_value = mock_token_storage
148
+
149
+ yield {
150
+ "factory": mock_factory,
151
+ "storage": mock_storage,
152
+ "mcp_manager": mock_mcp_manager,
153
+ "token_storage": mock_token_storage,
154
+ }
155
+
156
+ def test_upload_token_success(self, client, mock_dependencies):
157
+ """Should store token successfully."""
158
+ response = client.post(
159
+ "/api/mcp/auth/test-server/token",
160
+ json={"token": "my-api-key-123"}
161
+ )
162
+
163
+ assert response.status_code == 200
164
+ data = response.json()
165
+
166
+ assert data["message"] == "Token stored for server 'test-server'"
167
+ assert data["server_name"] == "test-server"
168
+ assert data["token_type"] == "api_key"
169
+
170
+ def test_upload_token_with_expiry(self, client, mock_dependencies):
171
+ """Should store token with expiration time."""
172
+ expiry = time.time() + 3600
173
+ response = client.post(
174
+ "/api/mcp/auth/test-server/token",
175
+ json={"token": "my-api-key", "expires_at": expiry}
176
+ )
177
+
178
+ assert response.status_code == 200
179
+ mock_dependencies["token_storage"].store_token.assert_called_once()
180
+ call_args = mock_dependencies["token_storage"].store_token.call_args
181
+ assert call_args.kwargs["expires_at"] == expiry
182
+
183
+ def test_upload_token_with_scopes(self, client, mock_dependencies):
184
+ """Should store token with scopes."""
185
+ response = client.post(
186
+ "/api/mcp/auth/test-server/token",
187
+ json={"token": "my-api-key", "scopes": "read write"}
188
+ )
189
+
190
+ assert response.status_code == 200
191
+ mock_dependencies["token_storage"].store_token.assert_called_once()
192
+ call_args = mock_dependencies["token_storage"].store_token.call_args
193
+ assert call_args.kwargs["scopes"] == "read write"
194
+
195
+ def test_upload_token_empty_rejected(self, client, mock_dependencies):
196
+ """Should reject empty token."""
197
+ response = client.post(
198
+ "/api/mcp/auth/test-server/token",
199
+ json={"token": ""}
200
+ )
201
+
202
+ assert response.status_code == 400
203
+ assert "empty" in response.json()["detail"].lower()
204
+
205
+ def test_upload_token_whitespace_only_rejected(self, client, mock_dependencies):
206
+ """Should reject whitespace-only token."""
207
+ response = client.post(
208
+ "/api/mcp/auth/test-server/token",
209
+ json={"token": " "}
210
+ )
211
+
212
+ assert response.status_code == 400
213
+
214
+ def test_upload_token_unauthorized_server(self, client, mock_dependencies):
215
+ """Should reject token for unauthorized server."""
216
+ response = client.post(
217
+ "/api/mcp/auth/unauthorized-server/token",
218
+ json={"token": "my-api-key"}
219
+ )
220
+
221
+ assert response.status_code == 403
222
+ assert "Not authorized" in response.json()["detail"]
223
+
224
+ def test_upload_token_wrong_auth_type(self, client, mock_dependencies):
225
+ """Should reject token for server with auth_type=none."""
226
+ mock_dependencies["mcp_manager"].servers_config["test-server"]["auth_type"] = "none"
227
+
228
+ response = client.post(
229
+ "/api/mcp/auth/test-server/token",
230
+ json={"token": "my-api-key"}
231
+ )
232
+
233
+ assert response.status_code == 400
234
+ assert "does not accept token authentication" in response.json()["detail"]
235
+
236
+ def test_upload_token_strips_whitespace(self, client, mock_dependencies):
237
+ """Should strip whitespace from token."""
238
+ response = client.post(
239
+ "/api/mcp/auth/test-server/token",
240
+ json={"token": " my-api-key "}
241
+ )
242
+
243
+ assert response.status_code == 200
244
+ call_args = mock_dependencies["token_storage"].store_token.call_args
245
+ assert call_args.kwargs["token_value"] == "my-api-key"
246
+
247
+
248
+ class TestRemoveToken:
249
+ """Test DELETE /api/mcp/auth/{server_name}/token endpoint."""
250
+
251
+ @pytest.fixture
252
+ def client(self):
253
+ """Create test client."""
254
+ app = create_test_app()
255
+ return TestClient(app)
256
+
257
+ @pytest.fixture
258
+ def mock_dependencies(self):
259
+ """Mock the dependencies for auth routes."""
260
+ with patch("atlas.routes.mcp_auth_routes.get_token_storage") as mock_storage, \
261
+ patch("atlas.routes.mcp_auth_routes.app_factory") as mock_factory:
262
+
263
+ mock_token_storage = MagicMock()
264
+ mock_token_storage.remove_token.return_value = True
265
+ mock_storage.return_value = mock_token_storage
266
+
267
+ # Mock tool manager for cache invalidation
268
+ mock_tool_manager = AsyncMock()
269
+ mock_tool_manager._invalidate_user_client = AsyncMock()
270
+ mock_factory.get_mcp_manager.return_value = mock_tool_manager
271
+
272
+ yield {
273
+ "storage": mock_storage,
274
+ "token_storage": mock_token_storage,
275
+ "factory": mock_factory,
276
+ "tool_manager": mock_tool_manager,
277
+ }
278
+
279
+ def test_remove_token_success(self, client, mock_dependencies):
280
+ """Should remove token successfully."""
281
+ response = client.delete("/api/mcp/auth/test-server/token")
282
+
283
+ assert response.status_code == 200
284
+ data = response.json()
285
+
286
+ assert data["message"] == "Token removed for server 'test-server'"
287
+ assert data["server_name"] == "test-server"
288
+
289
+ def test_remove_token_invalidates_cache(self, client, mock_dependencies):
290
+ """Should invalidate cached client when token is removed."""
291
+ response = client.delete("/api/mcp/auth/test-server/token")
292
+
293
+ assert response.status_code == 200
294
+ # Verify cache invalidation was called
295
+ mock_dependencies["tool_manager"]._invalidate_user_client.assert_called_once_with(
296
+ "test@example.com", "test-server"
297
+ )
298
+
299
+ def test_remove_token_not_found(self, client, mock_dependencies):
300
+ """Should return 404 when token doesn't exist."""
301
+ mock_dependencies["token_storage"].remove_token.return_value = False
302
+
303
+ response = client.delete("/api/mcp/auth/nonexistent-server/token")
304
+
305
+ assert response.status_code == 404
306
+ assert "No token found" in response.json()["detail"]
307
+
308
+
309
+ class TestTokenUploadModel:
310
+ """Test TokenUpload Pydantic model."""
311
+
312
+ def test_token_required(self):
313
+ """Token field should be required."""
314
+ with pytest.raises(Exception):
315
+ TokenUpload()
316
+
317
+ def test_token_accepts_string(self):
318
+ """Token field should accept string."""
319
+ model = TokenUpload(token="my-api-key")
320
+ assert model.token == "my-api-key"
321
+
322
+ def test_expires_at_optional(self):
323
+ """expires_at should be optional."""
324
+ model = TokenUpload(token="my-api-key")
325
+ assert model.expires_at is None
326
+
327
+ def test_expires_at_accepts_float(self):
328
+ """expires_at should accept float timestamp."""
329
+ expiry = time.time() + 3600
330
+ model = TokenUpload(token="my-api-key", expires_at=expiry)
331
+ assert model.expires_at == expiry
332
+
333
+ def test_scopes_optional(self):
334
+ """scopes should be optional."""
335
+ model = TokenUpload(token="my-api-key")
336
+ assert model.scopes is None
337
+
338
+ def test_scopes_accepts_string(self):
339
+ """scopes should accept space-separated string."""
340
+ model = TokenUpload(token="my-api-key", scopes="read write admin")
341
+ assert model.scopes == "read write admin"