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