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,88 @@
1
+ """
2
+ Unit tests for the env-demo MCP server.
3
+ Tests the environment variable demonstration functionality.
4
+ """
5
+ import os
6
+
7
+ import pytest
8
+
9
+
10
+ # Test that the server can be imported
11
+ def test_server_imports():
12
+ """Test that the env-demo server module can be imported."""
13
+ try:
14
+ import sys
15
+ sys.path.insert(0, '/home/runner/work/atlas-ui-3/atlas-ui-3/backend')
16
+ # Import the module by file path since directory has hyphen
17
+ import importlib.util
18
+ spec = importlib.util.spec_from_file_location(
19
+ "env_demo_main",
20
+ "/home/runner/work/atlas-ui-3/atlas-ui-3/backend/mcp/env-demo/main.py"
21
+ )
22
+ module = importlib.util.module_from_spec(spec)
23
+ # Don't execute - just verify it can be loaded
24
+ assert spec is not None
25
+ assert module is not None
26
+ except Exception as e:
27
+ pytest.fail(f"Failed to import env-demo server: {e}")
28
+
29
+
30
+ def test_env_var_configuration():
31
+ """Test that environment variables are accessible."""
32
+ # Set test environment variables
33
+ os.environ["TEST_CLOUD_PROFILE"] = "test-profile"
34
+ os.environ["TEST_CLOUD_REGION"] = "test-region"
35
+
36
+ # Verify they can be read
37
+ assert os.environ.get("TEST_CLOUD_PROFILE") == "test-profile"
38
+ assert os.environ.get("TEST_CLOUD_REGION") == "test-region"
39
+
40
+ # Clean up
41
+ del os.environ["TEST_CLOUD_PROFILE"]
42
+ del os.environ["TEST_CLOUD_REGION"]
43
+
44
+
45
+ def test_env_var_substitution_pattern():
46
+ """Test the ${VAR} pattern that should be resolved by config_manager."""
47
+ # This tests the pattern that config_manager.resolve_env_var handles
48
+ # We test the pattern matching logic directly
49
+ import re
50
+
51
+ # Set a test variable
52
+ os.environ["TEST_API_KEY"] = "secret-123"
53
+
54
+ # Test the ${VAR} pattern matching (same as config_manager.resolve_env_var)
55
+ pattern = r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}'
56
+
57
+ # Test resolution
58
+ test_value = "${TEST_API_KEY}"
59
+ match = re.match(pattern, test_value)
60
+ assert match is not None
61
+ var_name = match.group(1)
62
+ assert var_name == "TEST_API_KEY"
63
+ resolved = os.environ.get(var_name)
64
+ assert resolved == "secret-123"
65
+
66
+ # Test literal value (no substitution)
67
+ test_value = "literal-value"
68
+ match = re.match(pattern, test_value)
69
+ assert match is None # Should not match
70
+
71
+ # Test missing variable
72
+ test_value = "${MISSING_VAR}"
73
+ match = re.match(pattern, test_value)
74
+ assert match is not None
75
+ var_name = match.group(1)
76
+ assert var_name == "MISSING_VAR"
77
+ missing_var = os.environ.get(var_name)
78
+ assert missing_var is None # Variable doesn't exist
79
+
80
+ # Clean up
81
+ del os.environ["TEST_API_KEY"]
82
+
83
+
84
+
85
+
86
+
87
+ if __name__ == "__main__":
88
+ pytest.main([__file__, "-v"])
@@ -0,0 +1,113 @@
1
+ """Tests for error classification and user-friendly error messages."""
2
+
3
+ from atlas.application.chat.utilities.error_handler import classify_llm_error
4
+ from atlas.domain.errors import LLMAuthenticationError, LLMServiceError, LLMTimeoutError, RateLimitError
5
+
6
+
7
+ class TestErrorClassification:
8
+ """Test error classification for LLM errors."""
9
+
10
+ def test_classify_rate_limit_error_by_type_name(self):
11
+ """Test classification of rate limit errors by exception type name."""
12
+ # Create a custom exception class to test type name detection
13
+ class RateLimitError(Exception):
14
+ pass
15
+
16
+ error = RateLimitError("Some error message")
17
+
18
+ from atlas.domain.errors import RateLimitError as DomainRateLimitError
19
+ error_class, user_msg, log_msg = classify_llm_error(error)
20
+
21
+ assert error_class == DomainRateLimitError
22
+ assert "high traffic" in user_msg.lower()
23
+ assert "try again" in user_msg.lower()
24
+ assert "rate limit" in log_msg.lower()
25
+
26
+ def test_classify_rate_limit_error_by_message_content(self):
27
+ """Test classification of rate limit errors by message content."""
28
+ error = Exception("We're experiencing high traffic right now! Please try again soon.")
29
+
30
+ error_class, user_msg, log_msg = classify_llm_error(error)
31
+
32
+ assert error_class == RateLimitError
33
+ assert "high traffic" in user_msg.lower()
34
+ assert "try again" in user_msg.lower()
35
+
36
+ def test_classify_rate_limit_error_alternative_message(self):
37
+ """Test classification of rate limit errors with alternative wording."""
38
+ error = Exception("Rate limit exceeded for this API key")
39
+
40
+ error_class, user_msg, log_msg = classify_llm_error(error)
41
+
42
+ assert error_class == RateLimitError
43
+ assert "try again" in user_msg.lower()
44
+
45
+ def test_classify_timeout_error(self):
46
+ """Test classification of timeout errors."""
47
+ error = Exception("Request timed out after 30 seconds")
48
+
49
+ error_class, user_msg, log_msg = classify_llm_error(error)
50
+
51
+ assert error_class == LLMTimeoutError
52
+ assert "timeout" in user_msg.lower() or "timed out" in user_msg.lower()
53
+ assert "try again" in user_msg.lower()
54
+
55
+ def test_classify_authentication_error(self):
56
+ """Test classification of authentication errors."""
57
+ error = Exception("Invalid API key provided")
58
+
59
+ error_class, user_msg, log_msg = classify_llm_error(error)
60
+
61
+ assert error_class == LLMAuthenticationError
62
+ assert "authentication" in user_msg.lower()
63
+ assert "administrator" in user_msg.lower()
64
+
65
+ def test_classify_unauthorized_error(self):
66
+ """Test classification of unauthorized errors."""
67
+ error = Exception("Unauthorized access")
68
+
69
+ error_class, user_msg, log_msg = classify_llm_error(error)
70
+
71
+ assert error_class == LLMAuthenticationError
72
+ assert "authentication" in user_msg.lower()
73
+
74
+ def test_classify_generic_llm_error(self):
75
+ """Test classification of generic LLM errors."""
76
+ error = Exception("Something went wrong with the model")
77
+
78
+ error_class, user_msg, log_msg = classify_llm_error(error)
79
+
80
+ assert error_class == LLMServiceError
81
+ assert "error" in user_msg.lower()
82
+ assert "try again" in user_msg.lower() or "contact support" in user_msg.lower()
83
+
84
+ def test_error_messages_are_user_friendly(self):
85
+ """Test that all error messages are user-friendly (no technical details)."""
86
+ test_errors = [
87
+ Exception("RateLimitError: Rate limit exceeded"),
88
+ Exception("Request timeout after 60s"),
89
+ Exception("Invalid API key: abc123"),
90
+ Exception("Unknown model error"),
91
+ ]
92
+
93
+ for error in test_errors:
94
+ _, user_msg, _ = classify_llm_error(error)
95
+
96
+ # User messages should be helpful and not expose technical details
97
+ assert len(user_msg) > 20 # Should be a complete sentence
98
+ # Technical details should not appear in user message
99
+ technical_substrings = ["RateLimitError:", "abc123", "stack trace"]
100
+ for technical in technical_substrings:
101
+ assert technical not in user_msg, f"User message should not contain technical detail: {technical}"
102
+ assert user_msg[0].isupper() # Starts with capital letter
103
+ assert user_msg.endswith(".") # Ends with period
104
+
105
+ def test_log_messages_contain_error_details(self):
106
+ """Test that log messages contain error details for debugging."""
107
+ error = Exception("RateLimitError: We're experiencing high traffic")
108
+
109
+ _, _, log_msg = classify_llm_error(error)
110
+
111
+ # Log message should contain the actual error for debugging
112
+ assert "high traffic" in log_msg.lower()
113
+ assert len(log_msg) > 10
@@ -0,0 +1,116 @@
1
+ """Integration test for error flow from LLM to WebSocket."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock
4
+
5
+ import pytest
6
+
7
+ from atlas.domain.errors import LLMAuthenticationError, LLMTimeoutError, RateLimitError
8
+
9
+
10
+ class TestErrorFlowIntegration:
11
+ """Test that errors flow correctly from LLM through to error responses."""
12
+
13
+ @pytest.mark.asyncio
14
+ async def test_rate_limit_error_flow(self):
15
+ """Test that rate limit errors result in proper user-friendly messages."""
16
+ from atlas.application.chat.utilities.error_handler import safe_call_llm_with_tools
17
+
18
+ # Mock LLM caller that raises a rate limit error
19
+ mock_llm = MagicMock()
20
+ mock_llm.call_with_tools = AsyncMock(
21
+ side_effect=Exception("RateLimitError: We're experiencing high traffic right now! Please try again soon.")
22
+ )
23
+
24
+ # Call should raise our custom RateLimitError
25
+ with pytest.raises(RateLimitError) as exc_info:
26
+ await safe_call_llm_with_tools(
27
+ llm_caller=mock_llm,
28
+ model="test-model",
29
+ messages=[{"role": "user", "content": "test"}],
30
+ tools_schema=[],
31
+ )
32
+
33
+ # Verify the error message is user-friendly
34
+ error_msg = str(exc_info.value.message if hasattr(exc_info.value, 'message') else exc_info.value)
35
+ assert "high traffic" in error_msg.lower()
36
+ assert "try again" in error_msg.lower()
37
+ # Should NOT contain technical details
38
+ assert "RateLimitError:" not in error_msg
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_timeout_error_flow(self):
42
+ """Test that timeout errors result in proper user-friendly messages."""
43
+ from atlas.application.chat.utilities.error_handler import safe_call_llm_with_tools
44
+
45
+ # Mock LLM caller that raises a timeout error
46
+ mock_llm = MagicMock()
47
+ mock_llm.call_with_tools = AsyncMock(
48
+ side_effect=Exception("Request timed out after 60 seconds")
49
+ )
50
+
51
+ # Call should raise our custom LLMTimeoutError
52
+ with pytest.raises(LLMTimeoutError) as exc_info:
53
+ await safe_call_llm_with_tools(
54
+ llm_caller=mock_llm,
55
+ model="test-model",
56
+ messages=[{"role": "user", "content": "test"}],
57
+ tools_schema=[],
58
+ )
59
+
60
+ # Verify the error message is user-friendly
61
+ error_msg = str(exc_info.value.message if hasattr(exc_info.value, 'message') else exc_info.value)
62
+ assert "timeout" in error_msg.lower() or "timed out" in error_msg.lower()
63
+ assert "try again" in error_msg.lower()
64
+
65
+ @pytest.mark.asyncio
66
+ async def test_authentication_error_flow(self):
67
+ """Test that authentication errors result in proper user-friendly messages."""
68
+ from atlas.application.chat.utilities.error_handler import safe_call_llm_with_tools
69
+
70
+ # Mock LLM caller that raises an auth error
71
+ mock_llm = MagicMock()
72
+ mock_llm.call_with_tools = AsyncMock(
73
+ side_effect=Exception("Invalid API key provided")
74
+ )
75
+
76
+ # Call should raise our custom LLMAuthenticationError
77
+ with pytest.raises(LLMAuthenticationError) as exc_info:
78
+ await safe_call_llm_with_tools(
79
+ llm_caller=mock_llm,
80
+ model="test-model",
81
+ messages=[{"role": "user", "content": "test"}],
82
+ tools_schema=[],
83
+ )
84
+
85
+ # Verify the error message is user-friendly
86
+ error_msg = str(exc_info.value.message if hasattr(exc_info.value, 'message') else exc_info.value)
87
+ assert "authentication" in error_msg.lower()
88
+ assert "administrator" in error_msg.lower()
89
+ # Should NOT contain the actual API key reference
90
+ assert "API key" not in error_msg and "api key" not in error_msg.lower()
91
+
92
+ @pytest.mark.asyncio
93
+ async def test_successful_llm_call(self):
94
+ """Test that successful LLM calls work normally."""
95
+ from atlas.application.chat.utilities.error_handler import safe_call_llm_with_tools
96
+ from atlas.interfaces.llm import LLMResponse
97
+
98
+ # Mock successful LLM response
99
+ mock_response = LLMResponse(
100
+ content="Test response",
101
+ model_used="test-model"
102
+ )
103
+
104
+ mock_llm = MagicMock()
105
+ mock_llm.call_with_tools = AsyncMock(return_value=mock_response)
106
+
107
+ # Call should succeed
108
+ result = await safe_call_llm_with_tools(
109
+ llm_caller=mock_llm,
110
+ model="test-model",
111
+ messages=[{"role": "user", "content": "test"}],
112
+ tools_schema=[],
113
+ )
114
+
115
+ assert result == mock_response
116
+ assert result.content == "Test response"
@@ -0,0 +1,333 @@
1
+ """Tests for feedback routes to prevent regression of issue #200.
2
+
3
+ Verifies that:
4
+ - POST /api/feedback is accessible to authenticated users
5
+ - GET /api/feedback requires admin group membership
6
+ - GET /api/feedback/stats requires admin group membership
7
+ - DELETE /api/feedback/{id} requires admin group membership
8
+ """
9
+
10
+ import json
11
+ import tempfile
12
+ from pathlib import Path
13
+ from unittest.mock import patch
14
+
15
+ import pytest
16
+ from main import app
17
+ from starlette.testclient import TestClient
18
+
19
+ AUTH_HEADERS = {"X-User-Email": "test@test.com"}
20
+ ADMIN_HEADERS = {"X-User-Email": "admin@test.com"}
21
+
22
+
23
+ @pytest.fixture
24
+ def temp_feedback_dir():
25
+ """Create a temporary directory for feedback files."""
26
+ with tempfile.TemporaryDirectory() as tmpdir:
27
+ yield Path(tmpdir)
28
+
29
+
30
+ @pytest.fixture
31
+ def mock_feedback_dir(temp_feedback_dir, monkeypatch):
32
+ """Mock the feedback directory to use temp directory."""
33
+ def mock_get_feedback_directory():
34
+ return temp_feedback_dir
35
+
36
+ monkeypatch.setattr(
37
+ "atlas.routes.feedback_routes.get_feedback_directory",
38
+ mock_get_feedback_directory
39
+ )
40
+ return temp_feedback_dir
41
+
42
+
43
+ @pytest.fixture
44
+ def mock_admin_check():
45
+ """Mock admin group check to allow admin@test.com."""
46
+ async def mock_is_user_in_group(user: str, group: str) -> bool:
47
+ return user == "admin@test.com"
48
+
49
+ with patch("atlas.routes.feedback_routes.is_user_in_group", mock_is_user_in_group):
50
+ yield
51
+
52
+
53
+ class TestFeedbackRouteRegistration:
54
+ """Test that feedback routes are properly registered (issue #200)."""
55
+
56
+ def test_post_feedback_route_exists(self):
57
+ """POST /api/feedback should not return 404."""
58
+ client = TestClient(app)
59
+ resp = client.post(
60
+ "/api/feedback",
61
+ json={"rating": 1, "comment": "test", "session": {}},
62
+ headers=AUTH_HEADERS
63
+ )
64
+ assert resp.status_code != 404, "Feedback route not registered (issue #200)"
65
+
66
+ def test_get_feedback_route_exists(self):
67
+ """GET /api/feedback should not return 404."""
68
+ client = TestClient(app)
69
+ resp = client.get("/api/feedback", headers=AUTH_HEADERS)
70
+ assert resp.status_code != 404, "Feedback route not registered (issue #200)"
71
+
72
+ def test_get_feedback_stats_route_exists(self):
73
+ """GET /api/feedback/stats should not return 404."""
74
+ client = TestClient(app)
75
+ resp = client.get("/api/feedback/stats", headers=AUTH_HEADERS)
76
+ assert resp.status_code != 404, "Feedback stats route not registered (issue #200)"
77
+
78
+
79
+ class TestFeedbackSubmission:
80
+ """Test feedback submission by regular users."""
81
+
82
+ def test_submit_feedback_success(self, mock_feedback_dir, mock_admin_check):
83
+ """Regular users can submit feedback."""
84
+ client = TestClient(app)
85
+ resp = client.post(
86
+ "/api/feedback",
87
+ json={"rating": 1, "comment": "Great service!", "session": {"model": "gpt-4"}},
88
+ headers=AUTH_HEADERS
89
+ )
90
+ assert resp.status_code == 200
91
+ data = resp.json()
92
+ assert data["message"] == "Feedback submitted successfully"
93
+ assert "feedback_id" in data
94
+ assert "timestamp" in data
95
+
96
+ def test_submit_feedback_validates_rating(self, mock_feedback_dir, mock_admin_check):
97
+ """Feedback submission validates rating values."""
98
+ client = TestClient(app)
99
+ resp = client.post(
100
+ "/api/feedback",
101
+ json={"rating": 5, "comment": "Invalid rating"},
102
+ headers=AUTH_HEADERS
103
+ )
104
+ assert resp.status_code == 400
105
+ assert "Rating must be -1, 0, or 1" in resp.json()["detail"]
106
+
107
+ def test_submit_feedback_creates_file(self, mock_feedback_dir, mock_admin_check):
108
+ """Feedback submission creates a JSON file."""
109
+ client = TestClient(app)
110
+ resp = client.post(
111
+ "/api/feedback",
112
+ json={"rating": 0, "comment": "Neutral feedback"},
113
+ headers=AUTH_HEADERS
114
+ )
115
+ assert resp.status_code == 200
116
+
117
+ feedback_files = list(mock_feedback_dir.glob("feedback_*.json"))
118
+ assert len(feedback_files) == 1
119
+
120
+ with open(feedback_files[0]) as f:
121
+ saved_data = json.load(f)
122
+ assert saved_data["rating"] == 0
123
+ assert saved_data["comment"] == "Neutral feedback"
124
+ assert saved_data["user"] == "test@test.com"
125
+
126
+
127
+ class TestFeedbackAdminAccess:
128
+ """Test that viewing feedback requires admin access."""
129
+
130
+ def test_get_feedback_requires_admin(self, mock_feedback_dir, mock_admin_check):
131
+ """GET /api/feedback returns 403 for non-admin users."""
132
+ client = TestClient(app)
133
+ resp = client.get("/api/feedback", headers=AUTH_HEADERS)
134
+ assert resp.status_code == 403
135
+ assert "Admin access required" in resp.json()["detail"]
136
+
137
+ def test_get_feedback_stats_requires_admin(self, mock_feedback_dir, mock_admin_check):
138
+ """GET /api/feedback/stats returns 403 for non-admin users."""
139
+ client = TestClient(app)
140
+ resp = client.get("/api/feedback/stats", headers=AUTH_HEADERS)
141
+ assert resp.status_code == 403
142
+ assert "Admin access required" in resp.json()["detail"]
143
+
144
+ def test_admin_can_view_feedback(self, mock_feedback_dir, mock_admin_check):
145
+ """Admin users can view feedback list."""
146
+ client = TestClient(app)
147
+
148
+ client.post(
149
+ "/api/feedback",
150
+ json={"rating": 1, "comment": "Test"},
151
+ headers=AUTH_HEADERS
152
+ )
153
+
154
+ resp = client.get("/api/feedback", headers=ADMIN_HEADERS)
155
+ assert resp.status_code == 200
156
+ data = resp.json()
157
+ assert "feedback" in data
158
+ assert "pagination" in data
159
+ assert "statistics" in data
160
+
161
+ def test_admin_can_view_stats(self, mock_feedback_dir, mock_admin_check):
162
+ """Admin users can view feedback statistics."""
163
+ client = TestClient(app)
164
+ resp = client.get("/api/feedback/stats", headers=ADMIN_HEADERS)
165
+ assert resp.status_code == 200
166
+ data = resp.json()
167
+ assert "total_feedback" in data
168
+ assert "rating_distribution" in data
169
+
170
+
171
+ class TestFeedbackDeletion:
172
+ """Test feedback deletion by admin."""
173
+
174
+ def test_delete_feedback_requires_admin(self, mock_feedback_dir, mock_admin_check):
175
+ """DELETE /api/feedback/{id} returns 403 for non-admin users."""
176
+ client = TestClient(app)
177
+ resp = client.delete("/api/feedback/fake-id", headers=AUTH_HEADERS)
178
+ assert resp.status_code == 403
179
+
180
+ def test_admin_can_delete_feedback(self, mock_feedback_dir, mock_admin_check):
181
+ """Admin users can delete feedback."""
182
+ client = TestClient(app)
183
+
184
+ resp = client.post(
185
+ "/api/feedback",
186
+ json={"rating": -1, "comment": "To be deleted"},
187
+ headers=AUTH_HEADERS
188
+ )
189
+ feedback_id = resp.json()["feedback_id"]
190
+
191
+ resp = client.delete(f"/api/feedback/{feedback_id}", headers=ADMIN_HEADERS)
192
+ assert resp.status_code == 200
193
+ assert resp.json()["message"] == "Feedback deleted successfully"
194
+
195
+ def test_delete_nonexistent_feedback_returns_404(self, mock_feedback_dir, mock_admin_check):
196
+ """Deleting non-existent feedback returns 404."""
197
+ client = TestClient(app)
198
+ resp = client.delete("/api/feedback/nonexistent", headers=ADMIN_HEADERS)
199
+ assert resp.status_code == 404
200
+
201
+
202
+ class TestFeedbackDownload:
203
+ """Test feedback download functionality."""
204
+
205
+ def test_download_feedback_requires_admin(self, mock_feedback_dir, mock_admin_check):
206
+ """GET /api/feedback/download returns 403 for non-admin users."""
207
+ client = TestClient(app)
208
+ resp = client.get("/api/feedback/download", headers=AUTH_HEADERS)
209
+ assert resp.status_code == 403
210
+
211
+ def test_download_feedback_csv_format(self, mock_feedback_dir, mock_admin_check):
212
+ """Admin users can download feedback as CSV."""
213
+ client = TestClient(app)
214
+
215
+ # Create some test feedback
216
+ client.post(
217
+ "/api/feedback",
218
+ json={"rating": 1, "comment": "Great service!", "session": {"model": "gpt-4"}},
219
+ headers=AUTH_HEADERS
220
+ )
221
+ client.post(
222
+ "/api/feedback",
223
+ json={"rating": -1, "comment": "Poor experience", "session": {"model": "gpt-3"}},
224
+ headers=AUTH_HEADERS
225
+ )
226
+
227
+ # Download as CSV
228
+ resp = client.get("/api/feedback/download?format=csv", headers=ADMIN_HEADERS)
229
+ assert resp.status_code == 200
230
+ assert resp.headers["content-type"] == "text/csv; charset=utf-8"
231
+ assert "attachment; filename=" in resp.headers["content-disposition"]
232
+ assert "feedback_export_" in resp.headers["content-disposition"]
233
+
234
+ # Verify CSV content
235
+ csv_content = resp.text
236
+ lines = [line.strip() for line in csv_content.strip().split('\n') if line.strip()]
237
+ assert len(lines) >= 3 # Header + 2 data rows
238
+
239
+ # Check header
240
+ assert lines[0] == "id,timestamp,user,rating,comment"
241
+
242
+ # Check that feedback appears in CSV (order may vary)
243
+ found_positive = any("1" in line and "Great service!" in line for line in lines[1:])
244
+ found_negative = any("-1" in line and "Poor experience" in line for line in lines[1:])
245
+ assert found_positive, "Positive feedback not found in CSV"
246
+ assert found_negative, "Negative feedback not found in CSV"
247
+
248
+ def test_download_feedback_json_format(self, mock_feedback_dir, mock_admin_check):
249
+ """Admin users can download feedback as JSON."""
250
+ client = TestClient(app)
251
+
252
+ # Create test feedback
253
+ resp1 = client.post(
254
+ "/api/feedback",
255
+ json={"rating": 1, "comment": "JSON test", "session": {"model": "gpt-4"}},
256
+ headers=AUTH_HEADERS
257
+ )
258
+ feedback_id = resp1.json()["feedback_id"]
259
+
260
+ # Download as JSON
261
+ resp = client.get("/api/feedback/download?format=json", headers=ADMIN_HEADERS)
262
+ assert resp.status_code == 200
263
+ assert resp.headers["content-type"] == "application/json"
264
+ assert "attachment; filename=" in resp.headers["content-disposition"]
265
+ assert "feedback_export_" in resp.headers["content-disposition"]
266
+
267
+ # Verify JSON content
268
+ json_data = resp.json()
269
+ assert isinstance(json_data, list)
270
+ assert len(json_data) == 1
271
+
272
+ feedback = json_data[0]
273
+ assert feedback["id"] == feedback_id
274
+ assert feedback["rating"] == 1
275
+ assert feedback["comment"] == "JSON test"
276
+ assert feedback["user"] == "test@test.com"
277
+ assert "timestamp" in feedback
278
+ assert "session_info" in feedback
279
+ assert "server_context" in feedback
280
+
281
+ def test_download_feedback_empty_csv(self, mock_feedback_dir, mock_admin_check):
282
+ """Downloading empty feedback as CSV returns header-only file."""
283
+ client = TestClient(app)
284
+
285
+ resp = client.get("/api/feedback/download?format=csv", headers=ADMIN_HEADERS)
286
+ assert resp.status_code == 200
287
+
288
+ csv_content = resp.text
289
+ lines = csv_content.strip().split('\n')
290
+ assert len(lines) == 1 # Only header row
291
+ assert lines[0] == "id,timestamp,user,rating,comment"
292
+
293
+ def test_download_feedback_empty_json(self, mock_feedback_dir, mock_admin_check):
294
+ """Downloading empty feedback as JSON returns empty array."""
295
+ client = TestClient(app)
296
+
297
+ resp = client.get("/api/feedback/download?format=json", headers=ADMIN_HEADERS)
298
+ assert resp.status_code == 200
299
+
300
+ json_data = resp.json()
301
+ assert json_data == []
302
+
303
+ def test_download_feedback_csv_sanitizes_fields(self, mock_feedback_dir, mock_admin_check):
304
+ """CSV download properly handles missing fields with defaults."""
305
+ client = TestClient(app)
306
+
307
+ # Clear any existing feedback files
308
+ for f in mock_feedback_dir.glob("feedback_*.json"):
309
+ f.unlink()
310
+
311
+ # Manually create feedback file with missing fields (using correct naming pattern)
312
+ import json
313
+ feedback_file = mock_feedback_dir / "feedback_manual_test123.json"
314
+ manual_feedback = {
315
+ "id": "test123",
316
+ "timestamp": "2026-01-10T12:00:00",
317
+ "user": "test@example.com"
318
+ # Missing rating, comment, session_info, server_context
319
+ }
320
+ with open(feedback_file, 'w') as f:
321
+ json.dump(manual_feedback, f)
322
+
323
+ resp = client.get("/api/feedback/download?format=csv", headers=ADMIN_HEADERS)
324
+ assert resp.status_code == 200
325
+
326
+ csv_content = resp.text
327
+ lines = csv_content.strip().split('\n')
328
+ assert len(lines) == 2 # Header + 1 data row
329
+
330
+ data_line = lines[1]
331
+ assert "test123" in data_line
332
+ assert "test@example.com" in data_line
333
+ # Missing fields should be empty strings