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,185 @@
1
+ """
2
+ Tests for BACKEND_PUBLIC_URL configuration and absolute URL generation.
3
+
4
+ This module tests that the file download URL generation correctly handles:
5
+ 1. Relative URLs when BACKEND_PUBLIC_URL is not configured (backward compatibility)
6
+ 2. Absolute URLs when BACKEND_PUBLIC_URL is configured (remote MCP server support)
7
+ 3. Proper token generation and validation
8
+ """
9
+
10
+ from unittest.mock import MagicMock, patch
11
+
12
+ import pytest
13
+
14
+ from atlas.core.capabilities import create_download_url, generate_file_token
15
+
16
+
17
+ def create_mock_settings(backend_public_url=None):
18
+ """Helper to create a properly mocked settings object."""
19
+ mock_settings = MagicMock()
20
+ mock_settings.backend_public_url = backend_public_url
21
+ mock_settings.capability_token_secret = "test-secret-key-for-testing"
22
+ mock_settings.capability_token_ttl_seconds = 3600
23
+ return mock_settings
24
+
25
+
26
+ class TestBackendPublicUrlConfiguration:
27
+ """Test suite for BACKEND_PUBLIC_URL configuration behavior."""
28
+
29
+ def test_relative_url_without_backend_public_url(self):
30
+ """Test that relative URLs are generated when BACKEND_PUBLIC_URL is not set."""
31
+ mock_settings = create_mock_settings(backend_public_url=None)
32
+
33
+ with patch('atlas.core.capabilities.config_manager') as mock_cm:
34
+ mock_cm.app_settings = mock_settings
35
+
36
+ url = create_download_url("test-key-123", "user@example.com")
37
+
38
+ # Should start with / (relative URL)
39
+ assert url.startswith("/api/files/download/")
40
+ assert "test-key-123" in url
41
+ assert "token=" in url
42
+ assert not url.startswith("http")
43
+
44
+ def test_absolute_url_with_backend_public_url(self):
45
+ """Test that absolute URLs are generated when BACKEND_PUBLIC_URL is configured."""
46
+ mock_settings = create_mock_settings(backend_public_url="https://atlas.example.com")
47
+
48
+ with patch('atlas.core.capabilities.config_manager') as mock_cm:
49
+ mock_cm.app_settings = mock_settings
50
+
51
+ url = create_download_url("test-key-456", "admin@example.com")
52
+
53
+ # Should be absolute URL
54
+ assert url.startswith("https://atlas.example.com/api/files/download/")
55
+ assert "test-key-456" in url
56
+ assert "token=" in url
57
+
58
+ def test_absolute_url_strips_trailing_slash(self):
59
+ """Test that trailing slashes in BACKEND_PUBLIC_URL are handled correctly."""
60
+ mock_settings = create_mock_settings(backend_public_url="https://atlas.example.com/")
61
+
62
+ with patch('atlas.core.capabilities.config_manager') as mock_cm:
63
+ mock_cm.app_settings = mock_settings
64
+
65
+ url = create_download_url("test-key-789", "user@example.com")
66
+
67
+ # Should not have double slash
68
+ assert "https://atlas.example.com/api/files/download/" in url
69
+ assert "https://atlas.example.com//api" not in url
70
+
71
+ def test_url_with_non_standard_port(self):
72
+ """Test absolute URL generation with non-standard port."""
73
+ mock_settings = create_mock_settings(backend_public_url="https://atlas.example.com:8443")
74
+
75
+ with patch('atlas.core.capabilities.config_manager') as mock_cm:
76
+ mock_cm.app_settings = mock_settings
77
+
78
+ url = create_download_url("test-key-abc", "user@example.com")
79
+
80
+ # Should include port in URL
81
+ assert url.startswith("https://atlas.example.com:8443/api/files/download/")
82
+
83
+ def test_url_with_localhost(self):
84
+ """Test absolute URL generation with localhost (development mode)."""
85
+ mock_settings = create_mock_settings(backend_public_url="http://localhost:8000")
86
+
87
+ with patch('atlas.core.capabilities.config_manager') as mock_cm:
88
+ mock_cm.app_settings = mock_settings
89
+
90
+ url = create_download_url("test-key-dev", "dev@example.com")
91
+
92
+ # Should use localhost URL
93
+ assert url.startswith("http://localhost:8000/api/files/download/")
94
+
95
+ def test_fallback_without_user_email(self):
96
+ """Test URL generation without user email (no token)."""
97
+ mock_settings = create_mock_settings(backend_public_url="https://atlas.example.com")
98
+
99
+ with patch('atlas.core.capabilities.config_manager') as mock_cm:
100
+ mock_cm.app_settings = mock_settings
101
+
102
+ url = create_download_url("test-key-nouser", None)
103
+
104
+ # Should be absolute but without token
105
+ assert url.startswith("https://atlas.example.com/api/files/download/")
106
+ assert "test-key-nouser" in url
107
+ assert "token=" not in url
108
+
109
+ def test_config_error_falls_back_to_relative(self):
110
+ """Test that configuration errors fall back to relative URLs gracefully."""
111
+ # Mock config manager app_settings to raise exception when accessed
112
+ mock_cm = MagicMock()
113
+ # When app_settings is accessed, raise exception
114
+ type(mock_cm).app_settings = property(lambda self: (_ for _ in ()).throw(Exception("Config error")))
115
+
116
+ # Also need to mock _get_secret since it also accesses config_manager
117
+ with patch('atlas.core.capabilities.config_manager', mock_cm):
118
+ with patch('atlas.core.capabilities._get_secret', return_value=b'test-secret'):
119
+ url = create_download_url("test-key-error", "user@example.com")
120
+
121
+ # Should fall back to relative URL
122
+ assert url.startswith("/api/files/download/")
123
+ assert not url.startswith("http")
124
+
125
+
126
+ class TestTokenValidation:
127
+ """Test suite for token generation and validation with absolute URLs."""
128
+
129
+ def test_token_works_with_absolute_urls(self):
130
+ """Test that tokens generated for absolute URLs are valid."""
131
+ from atlas.core.capabilities import verify_file_token
132
+
133
+ user_email = "test@example.com"
134
+ file_key = "test-key-123"
135
+
136
+ mock_settings = create_mock_settings()
137
+ with patch('atlas.core.capabilities.config_manager') as mock_cm:
138
+ mock_cm.app_settings = mock_settings
139
+
140
+ # Generate token
141
+ token = generate_file_token(user_email, file_key, ttl_seconds=60)
142
+
143
+ # Verify token
144
+ claims = verify_file_token(token)
145
+
146
+ assert claims is not None
147
+ assert claims["u"] == user_email
148
+ assert claims["k"] == file_key
149
+ assert claims["e"] > 0 # Expiry timestamp exists
150
+
151
+
152
+ class TestBackwardCompatibility:
153
+ """Test suite to ensure backward compatibility with existing behavior."""
154
+
155
+ def test_stdio_servers_still_work_with_relative_urls(self):
156
+ """Test that stdio (local) servers continue to work with relative URLs."""
157
+ mock_settings = create_mock_settings(backend_public_url=None)
158
+
159
+ with patch('atlas.core.capabilities.config_manager') as mock_cm:
160
+ mock_cm.app_settings = mock_settings
161
+
162
+ url = create_download_url("local-file", "local@example.com")
163
+
164
+ # Local servers can resolve relative URLs
165
+ assert url.startswith("/api/files/download/")
166
+
167
+ def test_existing_mcp_servers_unaffected(self):
168
+ """Test that existing MCP server configurations continue to work."""
169
+ # This test verifies that the changes don't break existing deployments
170
+ # that haven't configured BACKEND_PUBLIC_URL
171
+
172
+ mock_settings = create_mock_settings(backend_public_url=None)
173
+
174
+ with patch('atlas.core.capabilities.config_manager') as mock_cm:
175
+ mock_cm.app_settings = mock_settings
176
+
177
+ # Should handle missing attribute gracefully and return relative URL
178
+ url = create_download_url("legacy-key", "legacy@example.com")
179
+
180
+ assert url.startswith("/api/files/download/")
181
+
182
+
183
+ if __name__ == "__main__":
184
+ pytest.main([__file__, "-v"])
185
+
@@ -0,0 +1,287 @@
1
+ """Tests for banner message save logging functionality."""
2
+ import logging
3
+
4
+ from main import app
5
+ from starlette.testclient import TestClient
6
+
7
+ from atlas.modules.config import config_manager
8
+
9
+
10
+ def test_banner_save_success_logging(caplog, tmp_path, monkeypatch):
11
+ """Test that successful banner save produces INFO log with file path."""
12
+ client = TestClient(app)
13
+
14
+ # Setup temp config directory
15
+ config_dir = tmp_path / "config" / "overrides"
16
+ config_dir.mkdir(parents=True, exist_ok=True)
17
+
18
+ # Mock config path to use temp directory
19
+ def mock_get_admin_config_path(filename):
20
+ return config_dir / filename
21
+
22
+ monkeypatch.setattr(
23
+ "atlas.routes.admin_routes.get_admin_config_path",
24
+ mock_get_admin_config_path
25
+ )
26
+
27
+ # Mock setup_config_overrides to avoid side effects
28
+ monkeypatch.setattr("atlas.routes.admin_routes.setup_config_overrides", lambda: None)
29
+
30
+ # Capture logs at INFO level
31
+ with caplog.at_level(logging.INFO):
32
+ # Make request to update banner messages
33
+ response = client.post(
34
+ "/admin/banners",
35
+ json={"messages": ["Test banner message", "Another test message"]},
36
+ headers={"X-User-Email": "admin@example.com"}
37
+ )
38
+
39
+ # Verify request succeeded
40
+ assert response.status_code == 200
41
+
42
+ # Check that INFO log was created with success message
43
+ info_logs = [record for record in caplog.records if record.levelname == "INFO"]
44
+ assert len(info_logs) > 0
45
+
46
+ # Find the banner save log
47
+ banner_logs = [
48
+ log for log in info_logs
49
+ if "Banner messages successfully saved to disk" in log.message
50
+ ]
51
+ assert len(banner_logs) == 1
52
+
53
+ # Verify log contains file path and admin user
54
+ log_message = banner_logs[0].message
55
+ assert "messages.txt" in log_message
56
+ assert "admin@example.com" in log_message
57
+
58
+
59
+ def test_banner_save_failure_logging(caplog, tmp_path, monkeypatch):
60
+ """Test that failed banner save produces ERROR log with details."""
61
+ client = TestClient(app)
62
+
63
+ # Mock get_admin_config_path to return a path
64
+ readonly_file = tmp_path / "readonly.txt"
65
+ readonly_file.write_text("test")
66
+
67
+ def mock_get_admin_config_path(filename):
68
+ return readonly_file
69
+
70
+ monkeypatch.setattr(
71
+ "atlas.routes.admin_routes.get_admin_config_path",
72
+ mock_get_admin_config_path
73
+ )
74
+
75
+ # Mock setup_config_overrides to avoid side effects
76
+ monkeypatch.setattr("atlas.routes.admin_routes.setup_config_overrides", lambda: None)
77
+
78
+ # Mock write_file_content to raise an exception
79
+ def mock_write_file_content(file_path, content, file_type="text"):
80
+ raise PermissionError("Permission denied")
81
+
82
+ monkeypatch.setattr(
83
+ "atlas.routes.admin_routes.write_file_content",
84
+ mock_write_file_content
85
+ )
86
+
87
+ # Capture logs at ERROR level
88
+ with caplog.at_level(logging.ERROR):
89
+ # Make request to update banner messages (should fail)
90
+ response = client.post(
91
+ "/admin/banners",
92
+ json={"messages": ["Test banner message"]},
93
+ headers={"X-User-Email": "admin@example.com"}
94
+ )
95
+
96
+ # Verify request failed
97
+ assert response.status_code == 500
98
+
99
+ # Check that ERROR log was created with failure message
100
+ error_logs = [record for record in caplog.records if record.levelname == "ERROR"]
101
+ assert len(error_logs) > 0
102
+
103
+ # Find the banner save error log
104
+ banner_error_logs = [
105
+ log for log in error_logs
106
+ if "Failed to save banner messages to disk" in log.message
107
+ ]
108
+ assert len(banner_error_logs) == 1
109
+
110
+ # Verify log contains file path and error details
111
+ log_message = banner_error_logs[0].message
112
+ assert "readonly.txt" in log_message
113
+ assert "Permission denied" in log_message or "PermissionError" in log_message
114
+
115
+
116
+ def test_banner_save_logs_sanitized_paths(caplog, tmp_path, monkeypatch):
117
+ """Test that file paths in logs are sanitized to prevent log injection."""
118
+ client = TestClient(app)
119
+
120
+ # Setup temp config directory with a potentially malicious name
121
+ config_dir = tmp_path / "config" / "overrides"
122
+ config_dir.mkdir(parents=True, exist_ok=True)
123
+
124
+ # Mock config path
125
+ def mock_get_admin_config_path(filename):
126
+ return config_dir / filename
127
+
128
+ monkeypatch.setattr(
129
+ "atlas.routes.admin_routes.get_admin_config_path",
130
+ mock_get_admin_config_path
131
+ )
132
+
133
+ # Mock setup_config_overrides to avoid side effects
134
+ monkeypatch.setattr("atlas.routes.admin_routes.setup_config_overrides", lambda: None)
135
+
136
+ # Capture logs at INFO level
137
+ with caplog.at_level(logging.INFO):
138
+ # Make request to update banner messages
139
+ response = client.post(
140
+ "/admin/banners",
141
+ json={"messages": ["Test message"]},
142
+ headers={"X-User-Email": "admin@example.com"}
143
+ )
144
+
145
+ # Verify request succeeded
146
+ assert response.status_code == 200
147
+
148
+ # Check that log messages don't contain raw newlines or control characters
149
+ info_logs = [record for record in caplog.records if record.levelname == "INFO"]
150
+ banner_logs = [
151
+ log for log in info_logs
152
+ if "Banner messages successfully saved to disk" in log.message
153
+ ]
154
+
155
+ assert len(banner_logs) == 1
156
+ log_message = banner_logs[0].message
157
+
158
+ # Verify no newlines in the log message
159
+ assert "\n" not in log_message
160
+ assert "\r" not in log_message
161
+
162
+
163
+ def test_banner_get_includes_enabled_status(tmp_path, monkeypatch):
164
+ """Test that GET /admin/banners includes banner_enabled status."""
165
+ client = TestClient(app)
166
+
167
+ # Setup temp config directory
168
+ config_dir = tmp_path / "config" / "overrides"
169
+ config_dir.mkdir(parents=True, exist_ok=True)
170
+ messages_file = config_dir / "messages.txt"
171
+ messages_file.write_text("Test message\n")
172
+
173
+ # Mock config path to use temp directory
174
+ def mock_get_admin_config_path(filename):
175
+ return config_dir / filename
176
+
177
+ monkeypatch.setattr(
178
+ "atlas.routes.admin_routes.get_admin_config_path",
179
+ mock_get_admin_config_path
180
+ )
181
+
182
+ # Mock setup_config_overrides to avoid side effects
183
+ monkeypatch.setattr("atlas.routes.admin_routes.setup_config_overrides", lambda: None)
184
+
185
+ # Make request to get banner config
186
+ response = client.get(
187
+ "/admin/banners",
188
+ headers={"X-User-Email": "admin@example.com"}
189
+ )
190
+
191
+ # Verify request succeeded
192
+ assert response.status_code == 200
193
+
194
+ # Check response contains banner_enabled field
195
+ data = response.json()
196
+ assert "banner_enabled" in data
197
+ assert isinstance(data["banner_enabled"], bool)
198
+ # The field should match the current config setting
199
+ assert data["banner_enabled"] == config_manager.app_settings.banner_enabled
200
+
201
+
202
+ def test_banner_get_with_feature_disabled(tmp_path, monkeypatch):
203
+ """Test that GET /admin/banners returns banner_enabled: false when feature is disabled."""
204
+ client = TestClient(app)
205
+
206
+ # Setup temp config directory
207
+ config_dir = tmp_path / "config" / "overrides"
208
+ config_dir.mkdir(parents=True, exist_ok=True)
209
+ messages_file = config_dir / "messages.txt"
210
+ messages_file.write_text("Test message\n")
211
+
212
+ # Mock config path
213
+ def mock_get_admin_config_path(filename):
214
+ return config_dir / filename
215
+
216
+ monkeypatch.setattr(
217
+ "atlas.routes.admin_routes.get_admin_config_path",
218
+ mock_get_admin_config_path
219
+ )
220
+
221
+ # Mock setup_config_overrides
222
+ monkeypatch.setattr("atlas.routes.admin_routes.setup_config_overrides", lambda: None)
223
+
224
+ # Mock banner_enabled to be false
225
+ monkeypatch.setattr(
226
+ "atlas.routes.admin_routes.config_manager.app_settings.banner_enabled",
227
+ False
228
+ )
229
+
230
+ # Make request to get banner config
231
+ response = client.get(
232
+ "/admin/banners",
233
+ headers={"X-User-Email": "admin@example.com"}
234
+ )
235
+
236
+ # Verify request succeeded
237
+ assert response.status_code == 200
238
+
239
+ # Check response contains banner_enabled field set to false
240
+ data = response.json()
241
+ assert "banner_enabled" in data
242
+ assert data["banner_enabled"] is False
243
+
244
+
245
+ def test_banner_get_with_feature_enabled(tmp_path, monkeypatch):
246
+ """Test that GET /admin/banners returns banner_enabled: true when feature is enabled."""
247
+ client = TestClient(app)
248
+
249
+ # Setup temp config directory
250
+ config_dir = tmp_path / "config" / "overrides"
251
+ config_dir.mkdir(parents=True, exist_ok=True)
252
+ messages_file = config_dir / "messages.txt"
253
+ messages_file.write_text("Test message\n")
254
+
255
+ # Mock config path
256
+ def mock_get_admin_config_path(filename):
257
+ return config_dir / filename
258
+
259
+ monkeypatch.setattr(
260
+ "atlas.routes.admin_routes.get_admin_config_path",
261
+ mock_get_admin_config_path
262
+ )
263
+
264
+ # Mock setup_config_overrides
265
+ monkeypatch.setattr("atlas.routes.admin_routes.setup_config_overrides", lambda: None)
266
+
267
+ # Mock banner_enabled to be true
268
+ monkeypatch.setattr(
269
+ "atlas.routes.admin_routes.config_manager.app_settings.banner_enabled",
270
+ True
271
+ )
272
+
273
+ # Make request to get banner config
274
+ response = client.get(
275
+ "/admin/banners",
276
+ headers={"X-User-Email": "admin@example.com"}
277
+ )
278
+
279
+ # Verify request succeeded
280
+ assert response.status_code == 200
281
+
282
+ # Check response contains banner_enabled field set to true
283
+ data = response.json()
284
+ assert "banner_enabled" in data
285
+ assert data["banner_enabled"] is True
286
+
287
+
@@ -0,0 +1,203 @@
1
+ import base64
2
+ import os
3
+ import sys
4
+ import types
5
+ from typing import Optional
6
+
7
+ import pytest
8
+ from fastapi.testclient import TestClient
9
+
10
+ # Ensure backend root is on path
11
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
12
+
13
+ # Save original module before patching (so we can restore it later)
14
+ _ORIGINAL_LITELLM_MODULE = sys.modules.get("atlas.modules.llm.litellm_caller")
15
+
16
+ # Stub LiteLLM before importing app to avoid external dependency in tests
17
+ fake_litellm_caller = types.ModuleType("atlas.modules.llm.litellm_caller")
18
+
19
+
20
+ class _FakeLLM:
21
+ def __init__(self, *args, **kwargs):
22
+ pass
23
+
24
+ async def call_plain(self, *args, **kwargs):
25
+ return "ok"
26
+
27
+
28
+ fake_litellm_caller.LiteLLMCaller = _FakeLLM # type: ignore
29
+ sys.modules["atlas.modules.llm.litellm_caller"] = fake_litellm_caller
30
+
31
+ from main import app # noqa: E402 # type: ignore
32
+
33
+
34
+ def _restore_original_module():
35
+ """Restore the original litellm_caller module if it was saved."""
36
+ if _ORIGINAL_LITELLM_MODULE is not None:
37
+ sys.modules["atlas.modules.llm.litellm_caller"] = _ORIGINAL_LITELLM_MODULE
38
+ elif "atlas.modules.llm.litellm_caller" in sys.modules:
39
+ del sys.modules["atlas.modules.llm.litellm_caller"]
40
+
41
+
42
+ @pytest.fixture(scope="module", autouse=True)
43
+ def restore_litellm_module_after_tests():
44
+ """Restore the original LiteLLM module after all tests in this module complete."""
45
+ yield
46
+ _restore_original_module()
47
+
48
+ from atlas.core.capabilities import generate_file_token, verify_file_token # noqa: E402 # type: ignore
49
+
50
+
51
+ class FakeS3:
52
+ def __init__(self):
53
+ self._store = {}
54
+ self.endpoint_url = "mock://s3"
55
+ self.bucket_name = "test-bucket"
56
+
57
+ async def upload_file(self, user_email: str, filename: str, content_base64: str, content_type: str = "application/octet-stream", tags=None, source_type: str = "user"):
58
+ key = f"k_{len(self._store)+1}"
59
+ meta = {
60
+ "key": key,
61
+ "filename": filename,
62
+ "size": len(base64.b64decode(content_base64)),
63
+ "content_type": content_type,
64
+ "last_modified": "now",
65
+ "etag": "test",
66
+ "tags": tags or {"source": source_type},
67
+ "user_email": user_email,
68
+ "content_base64": content_base64,
69
+ }
70
+ self._store[key] = meta
71
+ return meta
72
+
73
+ async def get_file(self, user_email: str, file_key: str):
74
+ return self._store.get(file_key)
75
+
76
+
77
+ @pytest.fixture()
78
+ def client(monkeypatch):
79
+ # Inject fake S3 into app_factory for route handlers
80
+ from atlas.infrastructure import app_factory as af # type: ignore
81
+
82
+ fake = FakeS3()
83
+
84
+ original_get = af.get_file_storage
85
+ af.get_file_storage = lambda: fake # type: ignore
86
+
87
+ try:
88
+ yield TestClient(app)
89
+ finally:
90
+ # Restore
91
+ af.get_file_storage = original_get # type: ignore
92
+
93
+
94
+ def test_capability_token_roundtrip():
95
+ token = generate_file_token("alice@example.com", "k123", ttl_seconds=60)
96
+ claims = verify_file_token(token)
97
+ assert claims is not None
98
+ assert claims["u"] == "alice@example.com"
99
+ assert claims["k"] == "k123"
100
+
101
+
102
+ def _extract_key(resp_json) -> Optional[str]:
103
+ if isinstance(resp_json, dict):
104
+ return resp_json.get("key")
105
+ return None
106
+
107
+
108
+ def test_download_with_and_without_token(client):
109
+ """Test file upload/download with proper authentication in production mode."""
110
+ # Upload a small text file via API with authentication header
111
+ content = base64.b64encode(b"hello world").decode("utf-8")
112
+ upload_resp = client.post(
113
+ "/api/files",
114
+ json={
115
+ "filename": "hello.txt",
116
+ "content_base64": content,
117
+ "content_type": "text/plain",
118
+ "tags": {"source": "test"},
119
+ },
120
+ headers={"X-User-Email": "test@example.com"},
121
+ )
122
+ assert upload_resp.status_code == 200
123
+ key = _extract_key(upload_resp.json())
124
+ assert key
125
+
126
+ # Download with same user authentication
127
+ dl_resp = client.get(
128
+ f"/api/files/download/{key}",
129
+ headers={"X-User-Email": "test@example.com"}
130
+ )
131
+ assert dl_resp.status_code == 200
132
+ assert dl_resp.content == b"hello world"
133
+
134
+ # With capability token for a different user (token grants access)
135
+ token = generate_file_token("alice@example.com", key, ttl_seconds=60)
136
+ dl_resp2 = client.get(
137
+ f"/api/files/download/{key}",
138
+ params={"token": token},
139
+ headers={"X-User-Email": "alice@example.com"}
140
+ )
141
+ assert dl_resp2.status_code == 200
142
+ assert dl_resp2.content == b"hello world"
143
+
144
+
145
+ def test_download_without_auth_fails_in_production_mode(client):
146
+ """Test that requests without authentication fail in production mode (debug_mode=False).
147
+
148
+ This test validates that when debug_mode=False (production mode), requests without
149
+ the X-User-Email header are rejected with 401 Unauthorized.
150
+
151
+ Note: This test will pass in production mode and fail in debug mode, which is expected.
152
+ CI runs tests in both modes to validate both behaviors.
153
+ """
154
+ from atlas.infrastructure.app_factory import app_factory # type: ignore
155
+
156
+ # Skip this test if running in debug mode (it's expected to fail)
157
+ if app_factory.config_manager.app_settings.debug_mode:
158
+ pytest.skip("Skipping production mode test - running in debug mode")
159
+
160
+ content = base64.b64encode(b"test content").decode("utf-8")
161
+
162
+ # Try to upload without authentication - should fail with 401 in production mode
163
+ upload_resp = client.post(
164
+ "/api/files",
165
+ json={
166
+ "filename": "test.txt",
167
+ "content_base64": content,
168
+ "content_type": "text/plain",
169
+ "tags": {"source": "test"},
170
+ },
171
+ )
172
+ assert upload_resp.status_code == 401
173
+
174
+
175
+ def test_injection_produces_tokenized_urls(client, monkeypatch):
176
+ """Verify that tool argument injection replaces filename with tokenized URL."""
177
+ from atlas.application.chat.utilities.tool_executor import inject_context_into_args # type: ignore
178
+
179
+ # Create a fake session context with a file mapping
180
+ session_context = {
181
+ "user_email": "bob@example.com",
182
+ "files": {
183
+ "report.pdf": {"key": "abc123", "content_type": "application/pdf"}
184
+ },
185
+ }
186
+
187
+ args = {"filename": "report.pdf"}
188
+ injected = inject_context_into_args(args, session_context)
189
+
190
+ assert injected["username"] == "bob@example.com"
191
+ assert injected["original_filename"] == "report.pdf"
192
+ # URL should include /api/files/download/abc123 and a token query param
193
+ assert injected["filename"].startswith("/api/files/download/abc123")
194
+ assert "?token=" in injected["filename"]
195
+
196
+ # Multiple files
197
+ args2 = {"file_names": ["report.pdf", "missing.txt"]}
198
+ injected2 = inject_context_into_args(args2, session_context)
199
+ assert injected2["original_file_names"] == ["report.pdf", "missing.txt"]
200
+ assert injected2["file_names"][0].startswith("/api/files/download/abc123")
201
+ assert "?token=" in injected2["file_names"][0]
202
+ # Missing.txt doesn't resolve to key, kept as-is
203
+ assert injected2["file_names"][1] == "missing.txt"