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.
- atlas/__init__.py +40 -0
- atlas/application/__init__.py +7 -0
- atlas/application/chat/__init__.py +7 -0
- atlas/application/chat/agent/__init__.py +10 -0
- atlas/application/chat/agent/act_loop.py +179 -0
- atlas/application/chat/agent/factory.py +142 -0
- atlas/application/chat/agent/protocols.py +46 -0
- atlas/application/chat/agent/react_loop.py +338 -0
- atlas/application/chat/agent/think_act_loop.py +171 -0
- atlas/application/chat/approval_manager.py +151 -0
- atlas/application/chat/elicitation_manager.py +191 -0
- atlas/application/chat/events/__init__.py +1 -0
- atlas/application/chat/events/agent_event_relay.py +112 -0
- atlas/application/chat/modes/__init__.py +1 -0
- atlas/application/chat/modes/agent.py +125 -0
- atlas/application/chat/modes/plain.py +74 -0
- atlas/application/chat/modes/rag.py +81 -0
- atlas/application/chat/modes/tools.py +179 -0
- atlas/application/chat/orchestrator.py +213 -0
- atlas/application/chat/policies/__init__.py +1 -0
- atlas/application/chat/policies/tool_authorization.py +99 -0
- atlas/application/chat/preprocessors/__init__.py +1 -0
- atlas/application/chat/preprocessors/message_builder.py +92 -0
- atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
- atlas/application/chat/service.py +454 -0
- atlas/application/chat/utilities/__init__.py +6 -0
- atlas/application/chat/utilities/error_handler.py +367 -0
- atlas/application/chat/utilities/event_notifier.py +546 -0
- atlas/application/chat/utilities/file_processor.py +613 -0
- atlas/application/chat/utilities/tool_executor.py +789 -0
- atlas/atlas_chat_cli.py +347 -0
- atlas/atlas_client.py +238 -0
- atlas/core/__init__.py +0 -0
- atlas/core/auth.py +205 -0
- atlas/core/authorization_manager.py +27 -0
- atlas/core/capabilities.py +123 -0
- atlas/core/compliance.py +215 -0
- atlas/core/domain_whitelist.py +147 -0
- atlas/core/domain_whitelist_middleware.py +82 -0
- atlas/core/http_client.py +28 -0
- atlas/core/log_sanitizer.py +102 -0
- atlas/core/metrics_logger.py +59 -0
- atlas/core/middleware.py +131 -0
- atlas/core/otel_config.py +242 -0
- atlas/core/prompt_risk.py +200 -0
- atlas/core/rate_limit.py +0 -0
- atlas/core/rate_limit_middleware.py +64 -0
- atlas/core/security_headers_middleware.py +51 -0
- atlas/domain/__init__.py +37 -0
- atlas/domain/chat/__init__.py +1 -0
- atlas/domain/chat/dtos.py +85 -0
- atlas/domain/errors.py +96 -0
- atlas/domain/messages/__init__.py +12 -0
- atlas/domain/messages/models.py +160 -0
- atlas/domain/rag_mcp_service.py +664 -0
- atlas/domain/sessions/__init__.py +7 -0
- atlas/domain/sessions/models.py +36 -0
- atlas/domain/unified_rag_service.py +371 -0
- atlas/infrastructure/__init__.py +10 -0
- atlas/infrastructure/app_factory.py +135 -0
- atlas/infrastructure/events/__init__.py +1 -0
- atlas/infrastructure/events/cli_event_publisher.py +140 -0
- atlas/infrastructure/events/websocket_publisher.py +140 -0
- atlas/infrastructure/sessions/in_memory_repository.py +56 -0
- atlas/infrastructure/transport/__init__.py +7 -0
- atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
- atlas/init_cli.py +226 -0
- atlas/interfaces/__init__.py +15 -0
- atlas/interfaces/events.py +134 -0
- atlas/interfaces/llm.py +54 -0
- atlas/interfaces/rag.py +40 -0
- atlas/interfaces/sessions.py +75 -0
- atlas/interfaces/tools.py +57 -0
- atlas/interfaces/transport.py +24 -0
- atlas/main.py +564 -0
- atlas/mcp/api_key_demo/README.md +76 -0
- atlas/mcp/api_key_demo/main.py +172 -0
- atlas/mcp/api_key_demo/run.sh +56 -0
- atlas/mcp/basictable/main.py +147 -0
- atlas/mcp/calculator/main.py +149 -0
- atlas/mcp/code-executor/execution_engine.py +98 -0
- atlas/mcp/code-executor/execution_environment.py +95 -0
- atlas/mcp/code-executor/main.py +528 -0
- atlas/mcp/code-executor/result_processing.py +276 -0
- atlas/mcp/code-executor/script_generation.py +195 -0
- atlas/mcp/code-executor/security_checker.py +140 -0
- atlas/mcp/corporate_cars/main.py +437 -0
- atlas/mcp/csv_reporter/main.py +545 -0
- atlas/mcp/duckduckgo/main.py +182 -0
- atlas/mcp/elicitation_demo/README.md +171 -0
- atlas/mcp/elicitation_demo/main.py +262 -0
- atlas/mcp/env-demo/README.md +158 -0
- atlas/mcp/env-demo/main.py +199 -0
- atlas/mcp/file_size_test/main.py +284 -0
- atlas/mcp/filesystem/main.py +348 -0
- atlas/mcp/image_demo/main.py +113 -0
- atlas/mcp/image_demo/requirements.txt +4 -0
- atlas/mcp/logging_demo/README.md +72 -0
- atlas/mcp/logging_demo/main.py +103 -0
- atlas/mcp/many_tools_demo/main.py +50 -0
- atlas/mcp/order_database/__init__.py +0 -0
- atlas/mcp/order_database/main.py +369 -0
- atlas/mcp/order_database/signal_data.csv +1001 -0
- atlas/mcp/pdfbasic/main.py +394 -0
- atlas/mcp/pptx_generator/main.py +760 -0
- atlas/mcp/pptx_generator/requirements.txt +13 -0
- atlas/mcp/pptx_generator/run_test.sh +1 -0
- atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
- atlas/mcp/progress_demo/main.py +167 -0
- atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
- atlas/mcp/progress_updates_demo/README.md +120 -0
- atlas/mcp/progress_updates_demo/main.py +497 -0
- atlas/mcp/prompts/main.py +222 -0
- atlas/mcp/public_demo/main.py +189 -0
- atlas/mcp/sampling_demo/README.md +169 -0
- atlas/mcp/sampling_demo/main.py +234 -0
- atlas/mcp/thinking/main.py +77 -0
- atlas/mcp/tool_planner/main.py +240 -0
- atlas/mcp/ui-demo/badmesh.png +0 -0
- atlas/mcp/ui-demo/main.py +383 -0
- atlas/mcp/ui-demo/templates/button_demo.html +32 -0
- atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
- atlas/mcp/ui-demo/templates/form_demo.html +28 -0
- atlas/mcp/username-override-demo/README.md +320 -0
- atlas/mcp/username-override-demo/main.py +308 -0
- atlas/modules/__init__.py +0 -0
- atlas/modules/config/__init__.py +34 -0
- atlas/modules/config/cli.py +231 -0
- atlas/modules/config/config_manager.py +1096 -0
- atlas/modules/file_storage/__init__.py +22 -0
- atlas/modules/file_storage/cli.py +330 -0
- atlas/modules/file_storage/content_extractor.py +290 -0
- atlas/modules/file_storage/manager.py +295 -0
- atlas/modules/file_storage/mock_s3_client.py +402 -0
- atlas/modules/file_storage/s3_client.py +417 -0
- atlas/modules/llm/__init__.py +19 -0
- atlas/modules/llm/caller.py +287 -0
- atlas/modules/llm/litellm_caller.py +675 -0
- atlas/modules/llm/models.py +19 -0
- atlas/modules/mcp_tools/__init__.py +17 -0
- atlas/modules/mcp_tools/client.py +2123 -0
- atlas/modules/mcp_tools/token_storage.py +556 -0
- atlas/modules/prompts/prompt_provider.py +130 -0
- atlas/modules/rag/__init__.py +24 -0
- atlas/modules/rag/atlas_rag_client.py +336 -0
- atlas/modules/rag/client.py +129 -0
- atlas/routes/admin_routes.py +865 -0
- atlas/routes/config_routes.py +484 -0
- atlas/routes/feedback_routes.py +361 -0
- atlas/routes/files_routes.py +274 -0
- atlas/routes/health_routes.py +40 -0
- atlas/routes/mcp_auth_routes.py +223 -0
- atlas/server_cli.py +164 -0
- atlas/tests/conftest.py +20 -0
- atlas/tests/integration/test_mcp_auth_integration.py +152 -0
- atlas/tests/manual_test_sampling.py +87 -0
- atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
- atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
- atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
- atlas/tests/test_agent_roa.py +135 -0
- atlas/tests/test_app_factory_smoke.py +47 -0
- atlas/tests/test_approval_manager.py +439 -0
- atlas/tests/test_atlas_client.py +188 -0
- atlas/tests/test_atlas_rag_client.py +447 -0
- atlas/tests/test_atlas_rag_integration.py +224 -0
- atlas/tests/test_attach_file_flow.py +287 -0
- atlas/tests/test_auth_utils.py +165 -0
- atlas/tests/test_backend_public_url.py +185 -0
- atlas/tests/test_banner_logging.py +287 -0
- atlas/tests/test_capability_tokens_and_injection.py +203 -0
- atlas/tests/test_compliance_level.py +54 -0
- atlas/tests/test_compliance_manager.py +253 -0
- atlas/tests/test_config_manager.py +617 -0
- atlas/tests/test_config_manager_paths.py +12 -0
- atlas/tests/test_core_auth.py +18 -0
- atlas/tests/test_core_utils.py +190 -0
- atlas/tests/test_docker_env_sync.py +202 -0
- atlas/tests/test_domain_errors.py +329 -0
- atlas/tests/test_domain_whitelist.py +359 -0
- atlas/tests/test_elicitation_manager.py +408 -0
- atlas/tests/test_elicitation_routing.py +296 -0
- atlas/tests/test_env_demo_server.py +88 -0
- atlas/tests/test_error_classification.py +113 -0
- atlas/tests/test_error_flow_integration.py +116 -0
- atlas/tests/test_feedback_routes.py +333 -0
- atlas/tests/test_file_content_extraction.py +1134 -0
- atlas/tests/test_file_extraction_routes.py +158 -0
- atlas/tests/test_file_library.py +107 -0
- atlas/tests/test_file_manager_unit.py +18 -0
- atlas/tests/test_health_route.py +49 -0
- atlas/tests/test_http_client_stub.py +8 -0
- atlas/tests/test_imports_smoke.py +30 -0
- atlas/tests/test_interfaces_llm_response.py +9 -0
- atlas/tests/test_issue_access_denied_fix.py +136 -0
- atlas/tests/test_llm_env_expansion.py +836 -0
- atlas/tests/test_log_level_sensitive_data.py +285 -0
- atlas/tests/test_mcp_auth_routes.py +341 -0
- atlas/tests/test_mcp_client_auth.py +331 -0
- atlas/tests/test_mcp_data_injection.py +270 -0
- atlas/tests/test_mcp_get_authorized_servers.py +95 -0
- atlas/tests/test_mcp_hot_reload.py +512 -0
- atlas/tests/test_mcp_image_content.py +424 -0
- atlas/tests/test_mcp_logging.py +172 -0
- atlas/tests/test_mcp_progress_updates.py +313 -0
- atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
- atlas/tests/test_mcp_prompts_server.py +39 -0
- atlas/tests/test_mcp_tool_result_parsing.py +296 -0
- atlas/tests/test_metrics_logger.py +56 -0
- atlas/tests/test_middleware_auth.py +379 -0
- atlas/tests/test_prompt_risk_and_acl.py +141 -0
- atlas/tests/test_rag_mcp_aggregator.py +204 -0
- atlas/tests/test_rag_mcp_service.py +224 -0
- atlas/tests/test_rate_limit_middleware.py +45 -0
- atlas/tests/test_routes_config_smoke.py +60 -0
- atlas/tests/test_routes_files_download_token.py +41 -0
- atlas/tests/test_routes_files_health.py +18 -0
- atlas/tests/test_runtime_imports.py +53 -0
- atlas/tests/test_sampling_integration.py +482 -0
- atlas/tests/test_security_admin_routes.py +61 -0
- atlas/tests/test_security_capability_tokens.py +65 -0
- atlas/tests/test_security_file_stats_scope.py +21 -0
- atlas/tests/test_security_header_injection.py +191 -0
- atlas/tests/test_security_headers_and_filename.py +63 -0
- atlas/tests/test_shared_session_repository.py +101 -0
- atlas/tests/test_system_prompt_loading.py +181 -0
- atlas/tests/test_token_storage.py +505 -0
- atlas/tests/test_tool_approval_config.py +93 -0
- atlas/tests/test_tool_approval_utils.py +356 -0
- atlas/tests/test_tool_authorization_group_filtering.py +223 -0
- atlas/tests/test_tool_details_in_config.py +108 -0
- atlas/tests/test_tool_planner.py +300 -0
- atlas/tests/test_unified_rag_service.py +398 -0
- atlas/tests/test_username_override_in_approval.py +258 -0
- atlas/tests/test_websocket_auth_header.py +168 -0
- atlas/version.py +6 -0
- atlas_chat-0.1.0.data/data/.env.example +253 -0
- atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
- atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
- atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
- atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
- atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
- atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
- atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
- atlas_chat-0.1.0.dist-info/METADATA +236 -0
- atlas_chat-0.1.0.dist-info/RECORD +250 -0
- atlas_chat-0.1.0.dist-info/WHEEL +5 -0
- atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
- 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)
|