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,505 @@
1
+ """Unit tests for MCP token storage.
2
+
3
+ Tests the secure per-user token storage module including:
4
+ - Token encryption/decryption
5
+ - Token storage and retrieval
6
+ - Token expiration handling
7
+ - Per-user isolation
8
+ - Error handling
9
+
10
+ Updated: 2025-01-21
11
+ """
12
+
13
+ import tempfile
14
+ import time
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ from atlas.modules.mcp_tools.token_storage import (
20
+ AuthenticationRequiredException,
21
+ MCPTokenStorage,
22
+ StoredToken,
23
+ _make_token_key,
24
+ _parse_token_key,
25
+ get_token_storage,
26
+ )
27
+
28
+
29
+ class TestTokenKeyFunctions:
30
+ """Test helper functions for token key management."""
31
+
32
+ def test_make_token_key_basic(self):
33
+ """Should create key from email and server name."""
34
+ key = _make_token_key("user@example.com", "my-server")
35
+ assert key == "user@example.com:my-server"
36
+
37
+ def test_make_token_key_normalizes_case(self):
38
+ """Should normalize email to lowercase."""
39
+ key = _make_token_key("User@Example.COM", "My-Server")
40
+ assert key == "user@example.com:My-Server"
41
+
42
+ def test_parse_token_key_basic(self):
43
+ """Should parse key into email and server name."""
44
+ email, server = _parse_token_key("user@example.com:my-server")
45
+ assert email == "user@example.com"
46
+ assert server == "my-server"
47
+
48
+ def test_parse_token_key_with_colons_in_server(self):
49
+ """Should handle server names with colons (only splits on first colon)."""
50
+ email, server = _parse_token_key("user@example.com:server:with:colons")
51
+ assert email == "user@example.com"
52
+ assert server == "server:with:colons"
53
+
54
+ def test_parse_token_key_invalid_format(self):
55
+ """Should raise ValueError for invalid key format."""
56
+ with pytest.raises(ValueError, match="Invalid token key format"):
57
+ _parse_token_key("no-colon-here")
58
+
59
+
60
+ class TestStoredToken:
61
+ """Test StoredToken dataclass."""
62
+
63
+ def test_stored_token_not_expired_no_expiry(self):
64
+ """Token without expiry should never be expired."""
65
+ token = StoredToken(
66
+ token_type="api_key",
67
+ token_value="test-key",
68
+ user_email="user@example.com",
69
+ server_name="test-server",
70
+ created_at=time.time(),
71
+ expires_at=None,
72
+ )
73
+ assert token.is_expired() is False
74
+
75
+ def test_stored_token_not_expired_future_expiry(self):
76
+ """Token with future expiry should not be expired."""
77
+ token = StoredToken(
78
+ token_type="api_key",
79
+ token_value="test-key",
80
+ user_email="user@example.com",
81
+ server_name="test-server",
82
+ created_at=time.time(),
83
+ expires_at=time.time() + 3600, # 1 hour in future
84
+ )
85
+ assert token.is_expired() is False
86
+
87
+ def test_stored_token_expired_past_expiry(self):
88
+ """Token with past expiry should be expired."""
89
+ token = StoredToken(
90
+ token_type="api_key",
91
+ token_value="test-key",
92
+ user_email="user@example.com",
93
+ server_name="test-server",
94
+ created_at=time.time() - 7200,
95
+ expires_at=time.time() - 3600, # 1 hour in past
96
+ )
97
+ assert token.is_expired() is True
98
+
99
+ def test_stored_token_expired_within_buffer(self):
100
+ """Token expiring within buffer should be considered expired."""
101
+ token = StoredToken(
102
+ token_type="api_key",
103
+ token_value="test-key",
104
+ user_email="user@example.com",
105
+ server_name="test-server",
106
+ created_at=time.time(),
107
+ expires_at=time.time() + 30, # 30 seconds in future
108
+ )
109
+ # With 60-second buffer, should be considered expired
110
+ assert token.is_expired(buffer_seconds=60) is True
111
+ # With 10-second buffer, should not be expired
112
+ assert token.is_expired(buffer_seconds=10) is False
113
+
114
+ def test_stored_token_time_until_expiry(self):
115
+ """Should return correct time until expiry."""
116
+ future_time = time.time() + 3600
117
+ token = StoredToken(
118
+ token_type="api_key",
119
+ token_value="test-key",
120
+ user_email="user@example.com",
121
+ server_name="test-server",
122
+ created_at=time.time(),
123
+ expires_at=future_time,
124
+ )
125
+ # Should be approximately 3600 seconds (allow small margin)
126
+ time_until = token.time_until_expiry()
127
+ assert time_until is not None
128
+ assert 3590 < time_until <= 3600
129
+
130
+ def test_stored_token_time_until_expiry_none(self):
131
+ """Should return None when no expiry set."""
132
+ token = StoredToken(
133
+ token_type="api_key",
134
+ token_value="test-key",
135
+ user_email="user@example.com",
136
+ server_name="test-server",
137
+ created_at=time.time(),
138
+ expires_at=None,
139
+ )
140
+ assert token.time_until_expiry() is None
141
+
142
+ def test_stored_token_time_until_expiry_past(self):
143
+ """Should return 0 when already expired."""
144
+ token = StoredToken(
145
+ token_type="api_key",
146
+ token_value="test-key",
147
+ user_email="user@example.com",
148
+ server_name="test-server",
149
+ created_at=time.time() - 7200,
150
+ expires_at=time.time() - 3600,
151
+ )
152
+ assert token.time_until_expiry() == 0
153
+
154
+
155
+ class TestAuthenticationRequiredException:
156
+ """Test AuthenticationRequiredException."""
157
+
158
+ def test_exception_basic(self):
159
+ """Should create exception with required fields."""
160
+ exc = AuthenticationRequiredException(
161
+ server_name="my-server",
162
+ auth_type="api_key",
163
+ )
164
+ assert exc.server_name == "my-server"
165
+ assert exc.auth_type == "api_key"
166
+ assert exc.message == "Authentication required"
167
+ assert exc.oauth_start_url is None
168
+
169
+ def test_exception_with_message(self):
170
+ """Should accept custom message."""
171
+ exc = AuthenticationRequiredException(
172
+ server_name="my-server",
173
+ auth_type="jwt",
174
+ message="Custom auth message",
175
+ )
176
+ assert exc.message == "Custom auth message"
177
+ assert str(exc) == "Custom auth message"
178
+
179
+ def test_exception_with_oauth_url(self):
180
+ """Should store OAuth start URL for OAuth auth type."""
181
+ exc = AuthenticationRequiredException(
182
+ server_name="oauth-server",
183
+ auth_type="oauth",
184
+ oauth_start_url="/api/mcp/auth/oauth-server/oauth/start",
185
+ )
186
+ assert exc.oauth_start_url == "/api/mcp/auth/oauth-server/oauth/start"
187
+
188
+ def test_exception_to_dict(self):
189
+ """Should convert to dict for frontend consumption."""
190
+ exc = AuthenticationRequiredException(
191
+ server_name="my-server",
192
+ auth_type="api_key",
193
+ message="Please provide API key",
194
+ oauth_start_url=None,
195
+ )
196
+ result = exc.to_dict()
197
+ assert result == {
198
+ "server_name": "my-server",
199
+ "auth_type": "api_key",
200
+ "message": "Please provide API key",
201
+ "oauth_start_url": None,
202
+ }
203
+
204
+
205
+ class TestMCPTokenStorage:
206
+ """Test MCPTokenStorage class."""
207
+
208
+ @pytest.fixture
209
+ def temp_storage_dir(self):
210
+ """Create a temporary directory for token storage."""
211
+ with tempfile.TemporaryDirectory() as tmpdir:
212
+ yield Path(tmpdir)
213
+
214
+ @pytest.fixture
215
+ def storage(self, temp_storage_dir):
216
+ """Create a MCPTokenStorage instance with temp directory."""
217
+ # Pass encryption key directly to avoid app_settings caching issues
218
+ storage = MCPTokenStorage(
219
+ storage_dir=temp_storage_dir,
220
+ encryption_key="test-encryption-key-12345"
221
+ )
222
+ yield storage
223
+
224
+ def test_store_and_retrieve_token(self, storage):
225
+ """Should store and retrieve a token successfully."""
226
+ # Store token
227
+ stored = storage.store_token(
228
+ user_email="user@example.com",
229
+ server_name="test-server",
230
+ token_value="my-api-key-123",
231
+ token_type="api_key",
232
+ )
233
+ assert stored.token_value == "my-api-key-123"
234
+ assert stored.token_type == "api_key"
235
+
236
+ # Retrieve token
237
+ retrieved = storage.get_token("user@example.com", "test-server")
238
+ assert retrieved is not None
239
+ assert retrieved.token_value == "my-api-key-123"
240
+ assert retrieved.token_type == "api_key"
241
+ assert retrieved.user_email == "user@example.com"
242
+ assert retrieved.server_name == "test-server"
243
+
244
+ def test_store_token_with_expiry(self, storage):
245
+ """Should store token with expiration time."""
246
+ expiry = time.time() + 3600
247
+ stored = storage.store_token(
248
+ user_email="user@example.com",
249
+ server_name="test-server",
250
+ token_value="expiring-token",
251
+ token_type="jwt",
252
+ expires_at=expiry,
253
+ )
254
+ assert stored.expires_at == expiry
255
+
256
+ retrieved = storage.get_token("user@example.com", "test-server")
257
+ assert retrieved is not None
258
+ assert retrieved.expires_at == expiry
259
+ assert retrieved.is_expired() is False
260
+
261
+ def test_store_token_with_scopes(self, storage):
262
+ """Should store token with scopes."""
263
+ stored = storage.store_token(
264
+ user_email="user@example.com",
265
+ server_name="test-server",
266
+ token_value="scoped-token",
267
+ token_type="bearer",
268
+ scopes="read write admin",
269
+ )
270
+ assert stored.scopes == "read write admin"
271
+
272
+ retrieved = storage.get_token("user@example.com", "test-server")
273
+ assert retrieved is not None
274
+ assert retrieved.scopes == "read write admin"
275
+
276
+ def test_get_nonexistent_token(self, storage):
277
+ """Should return None for nonexistent token."""
278
+ retrieved = storage.get_token("nobody@example.com", "no-server")
279
+ assert retrieved is None
280
+
281
+ def test_remove_token(self, storage):
282
+ """Should remove token successfully."""
283
+ # Store token
284
+ storage.store_token(
285
+ user_email="user@example.com",
286
+ server_name="test-server",
287
+ token_value="to-be-removed",
288
+ token_type="api_key",
289
+ )
290
+
291
+ # Verify it exists
292
+ assert storage.get_token("user@example.com", "test-server") is not None
293
+
294
+ # Remove it
295
+ removed = storage.remove_token("user@example.com", "test-server")
296
+ assert removed is True
297
+
298
+ # Verify it's gone
299
+ assert storage.get_token("user@example.com", "test-server") is None
300
+
301
+ def test_remove_nonexistent_token(self, storage):
302
+ """Should return False when removing nonexistent token."""
303
+ removed = storage.remove_token("nobody@example.com", "no-server")
304
+ assert removed is False
305
+
306
+ def test_user_isolation(self, storage):
307
+ """Tokens should be isolated per user."""
308
+ # Store token for user1
309
+ storage.store_token(
310
+ user_email="user1@example.com",
311
+ server_name="shared-server",
312
+ token_value="user1-token",
313
+ token_type="api_key",
314
+ )
315
+
316
+ # Store token for user2
317
+ storage.store_token(
318
+ user_email="user2@example.com",
319
+ server_name="shared-server",
320
+ token_value="user2-token",
321
+ token_type="api_key",
322
+ )
323
+
324
+ # Each user should only see their own token
325
+ user1_token = storage.get_token("user1@example.com", "shared-server")
326
+ user2_token = storage.get_token("user2@example.com", "shared-server")
327
+
328
+ assert user1_token is not None
329
+ assert user2_token is not None
330
+ assert user1_token.token_value == "user1-token"
331
+ assert user2_token.token_value == "user2-token"
332
+
333
+ def test_overwrite_existing_token(self, storage):
334
+ """Should overwrite existing token for same user/server."""
335
+ # Store initial token
336
+ storage.store_token(
337
+ user_email="user@example.com",
338
+ server_name="test-server",
339
+ token_value="old-token",
340
+ token_type="api_key",
341
+ )
342
+
343
+ # Store new token
344
+ storage.store_token(
345
+ user_email="user@example.com",
346
+ server_name="test-server",
347
+ token_value="new-token",
348
+ token_type="jwt",
349
+ )
350
+
351
+ # Should get the new token
352
+ retrieved = storage.get_token("user@example.com", "test-server")
353
+ assert retrieved is not None
354
+ assert retrieved.token_value == "new-token"
355
+ assert retrieved.token_type == "jwt"
356
+
357
+ def test_get_user_auth_status(self, storage):
358
+ """Should return auth status for all user's tokens."""
359
+ # Store tokens for user
360
+ storage.store_token(
361
+ user_email="user@example.com",
362
+ server_name="server1",
363
+ token_value="token1",
364
+ token_type="api_key",
365
+ )
366
+ storage.store_token(
367
+ user_email="user@example.com",
368
+ server_name="server2",
369
+ token_value="token2",
370
+ token_type="jwt",
371
+ expires_at=time.time() + 3600,
372
+ )
373
+
374
+ # Store token for different user (should not be included)
375
+ storage.store_token(
376
+ user_email="other@example.com",
377
+ server_name="server3",
378
+ token_value="other-token",
379
+ token_type="bearer",
380
+ )
381
+
382
+ status = storage.get_user_auth_status("user@example.com")
383
+
384
+ assert "server1" in status
385
+ assert "server2" in status
386
+ assert "server3" not in status # Different user
387
+
388
+ assert status["server1"]["token_type"] == "api_key"
389
+ assert status["server2"]["token_type"] == "jwt"
390
+ assert status["server2"]["is_expired"] is False
391
+
392
+ def test_email_case_insensitive(self, storage):
393
+ """Email lookups should be case-insensitive."""
394
+ storage.store_token(
395
+ user_email="User@Example.COM",
396
+ server_name="test-server",
397
+ token_value="test-token",
398
+ token_type="api_key",
399
+ )
400
+
401
+ # Should find with different case
402
+ retrieved = storage.get_token("user@example.com", "test-server")
403
+ assert retrieved is not None
404
+ assert retrieved.token_value == "test-token"
405
+
406
+
407
+ class TestMCPTokenStoragePersistence:
408
+ """Test token storage persistence across instances."""
409
+
410
+ @pytest.fixture
411
+ def temp_storage_dir(self):
412
+ """Create a temporary directory for token storage."""
413
+ with tempfile.TemporaryDirectory() as tmpdir:
414
+ yield Path(tmpdir)
415
+
416
+ def test_persistence_across_instances(self, temp_storage_dir):
417
+ """Tokens should persist across storage instances."""
418
+ # Pass encryption_key directly to avoid app_settings caching issues
419
+ encryption_key = "test-encryption-key-12345"
420
+
421
+ # Create first storage instance and store token
422
+ storage1 = MCPTokenStorage(storage_dir=temp_storage_dir, encryption_key=encryption_key)
423
+ storage1.store_token(
424
+ user_email="user@example.com",
425
+ server_name="test-server",
426
+ token_value="persistent-token",
427
+ token_type="api_key",
428
+ )
429
+
430
+ # Create second storage instance and retrieve token
431
+ storage2 = MCPTokenStorage(storage_dir=temp_storage_dir, encryption_key=encryption_key)
432
+ retrieved = storage2.get_token("user@example.com", "test-server")
433
+
434
+ assert retrieved is not None
435
+ assert retrieved.token_value == "persistent-token"
436
+
437
+
438
+ class TestMCPTokenStorageEncryption:
439
+ """Test token encryption functionality."""
440
+
441
+ @pytest.fixture
442
+ def temp_storage_dir(self):
443
+ """Create a temporary directory for token storage."""
444
+ with tempfile.TemporaryDirectory() as tmpdir:
445
+ yield Path(tmpdir)
446
+
447
+ def test_tokens_encrypted_at_rest(self, temp_storage_dir):
448
+ """Token values should be encrypted in storage file."""
449
+ # Pass encryption key directly to avoid app_settings caching issues
450
+ storage = MCPTokenStorage(
451
+ storage_dir=temp_storage_dir,
452
+ encryption_key="test-encryption-key-12345"
453
+ )
454
+ storage.store_token(
455
+ user_email="user@example.com",
456
+ server_name="test-server",
457
+ token_value="secret-api-key-xyz",
458
+ token_type="api_key",
459
+ )
460
+
461
+ # Read raw storage file
462
+ storage_file = temp_storage_dir / "mcp_tokens.enc"
463
+ raw_content = storage_file.read_text()
464
+
465
+ # Plain token should not appear in raw content
466
+ assert "secret-api-key-xyz" not in raw_content
467
+
468
+ def test_different_keys_cannot_decrypt(self, temp_storage_dir):
469
+ """Tokens encrypted with different keys should not be readable."""
470
+ # Store with one key
471
+ storage1 = MCPTokenStorage(
472
+ storage_dir=temp_storage_dir,
473
+ encryption_key="first-encryption-key"
474
+ )
475
+ storage1.store_token(
476
+ user_email="user@example.com",
477
+ server_name="test-server",
478
+ token_value="secret-token",
479
+ token_type="api_key",
480
+ )
481
+
482
+ # Try to read with different key
483
+ storage2 = MCPTokenStorage(
484
+ storage_dir=temp_storage_dir,
485
+ encryption_key="different-encryption-key"
486
+ )
487
+
488
+ # Should return None (decryption fails gracefully)
489
+ retrieved = storage2.get_token("user@example.com", "test-server")
490
+ assert retrieved is None
491
+
492
+
493
+ class TestGetMCPTokenStorageSingleton:
494
+ """Test the get_token_storage singleton function."""
495
+
496
+ def test_returns_token_storage_instance(self):
497
+ """Should return a MCPTokenStorage instance."""
498
+ storage = get_token_storage()
499
+ assert isinstance(storage, MCPTokenStorage)
500
+
501
+ def test_returns_same_instance(self):
502
+ """Should return the same instance on repeated calls."""
503
+ storage1 = get_token_storage()
504
+ storage2 = get_token_storage()
505
+ assert storage1 is storage2
@@ -0,0 +1,93 @@
1
+ """Tests for tool approval configuration loading and management."""
2
+
3
+ from atlas.modules.config.config_manager import ConfigManager
4
+
5
+
6
+ class TestToolApprovalConfig:
7
+ """Test tool approval configuration loading."""
8
+
9
+ def test_tool_approvals_config_loads(self):
10
+ """Test that tool approvals config loads successfully."""
11
+ cm = ConfigManager()
12
+ approval_config = cm.tool_approvals_config
13
+
14
+ assert approval_config is not None
15
+ assert hasattr(approval_config, "require_approval_by_default")
16
+ assert hasattr(approval_config, "tools")
17
+
18
+ def test_default_approval_config_structure(self):
19
+ """Test the structure of default approval config."""
20
+ cm = ConfigManager()
21
+ approval_config = cm.tool_approvals_config
22
+
23
+ # Default config should have require_approval_by_default (check it's boolean)
24
+ assert isinstance(approval_config.require_approval_by_default, bool)
25
+ # Default config should have tools dict (may or may not be empty)
26
+ assert isinstance(approval_config.tools, dict)
27
+
28
+ def test_tool_specific_config(self):
29
+ """Test that tool-specific configurations can be loaded."""
30
+ cm = ConfigManager()
31
+ approval_config = cm.tool_approvals_config
32
+
33
+ # Test basic structure - config may have tool-specific configs from overrides
34
+ assert hasattr(approval_config, 'tools')
35
+ assert isinstance(approval_config.tools, dict)
36
+
37
+ # If there are any tool configs, verify they have the right structure
38
+ for tool_name, tool_config in approval_config.tools.items():
39
+ assert hasattr(tool_config, 'require_approval')
40
+ assert hasattr(tool_config, 'allow_edit')
41
+ assert isinstance(tool_config.require_approval, bool)
42
+ assert isinstance(tool_config.allow_edit, bool)
43
+
44
+ def test_config_has_boolean_default(self):
45
+ """Test that require_approval_by_default is a boolean."""
46
+ cm = ConfigManager()
47
+ approval_config = cm.tool_approvals_config
48
+
49
+ assert isinstance(approval_config.require_approval_by_default, bool)
50
+
51
+ def test_tools_config_structure(self):
52
+ """Test that tools in config have correct structure."""
53
+ cm = ConfigManager()
54
+ approval_config = cm.tool_approvals_config
55
+
56
+ # Each tool config should have require_approval and allow_edit
57
+ for tool_name, tool_config in approval_config.tools.items():
58
+ assert hasattr(tool_config, 'require_approval')
59
+ assert hasattr(tool_config, 'allow_edit')
60
+ assert isinstance(tool_config.require_approval, bool)
61
+ assert isinstance(tool_config.allow_edit, bool)
62
+
63
+ def test_config_manager_provides_approvals_config(self):
64
+ """Test that ConfigManager provides tool_approvals_config."""
65
+ cm = ConfigManager()
66
+
67
+ assert hasattr(cm, 'tool_approvals_config')
68
+ assert cm.tool_approvals_config is not None
69
+
70
+ def test_multiple_config_manager_instances(self):
71
+ """Test that multiple ConfigManager instances can coexist."""
72
+ cm1 = ConfigManager()
73
+ cm2 = ConfigManager()
74
+
75
+ config1 = cm1.tool_approvals_config
76
+ config2 = cm2.tool_approvals_config
77
+
78
+ # Both should have valid configs
79
+ assert config1 is not None
80
+ assert config2 is not None
81
+
82
+ def test_config_contains_expected_fields(self):
83
+ """Test that approval config has all expected fields."""
84
+ cm = ConfigManager()
85
+ approval_config = cm.tool_approvals_config
86
+
87
+ # Should have these attributes
88
+ assert hasattr(approval_config, 'require_approval_by_default')
89
+ assert hasattr(approval_config, 'tools')
90
+
91
+ # Types should be correct
92
+ assert isinstance(approval_config.require_approval_by_default, bool)
93
+ assert isinstance(approval_config.tools, dict)