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,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test header injection vulnerabilities.
|
|
3
|
+
|
|
4
|
+
This test suite demonstrates the header injection attack vector and
|
|
5
|
+
documents why reverse proxy configuration is critical.
|
|
6
|
+
"""
|
|
7
|
+
import pytest
|
|
8
|
+
from fastapi.testclient import TestClient
|
|
9
|
+
from main import app
|
|
10
|
+
|
|
11
|
+
client = TestClient(app)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_direct_access_header_injection_vulnerability():
|
|
15
|
+
"""
|
|
16
|
+
SECURITY WARNING: This test demonstrates a CRITICAL vulnerability.
|
|
17
|
+
|
|
18
|
+
When the app is accessed DIRECTLY (bypassing reverse proxy),
|
|
19
|
+
attackers can inject X-User-Email headers to impersonate any user.
|
|
20
|
+
|
|
21
|
+
This test documents the vulnerability. In production:
|
|
22
|
+
- Main app MUST be network-isolated (not publicly accessible)
|
|
23
|
+
- ALL traffic MUST go through reverse proxy
|
|
24
|
+
- Reverse proxy MUST strip client X-User-Email headers
|
|
25
|
+
|
|
26
|
+
This test will PASS because the app is designed to trust headers
|
|
27
|
+
when behind a properly configured reverse proxy. The test serves
|
|
28
|
+
as documentation of the security requirement.
|
|
29
|
+
"""
|
|
30
|
+
# Attacker tries to impersonate admin by injecting header
|
|
31
|
+
response = client.get(
|
|
32
|
+
"/api/config",
|
|
33
|
+
headers={"X-User-Email": "attacker-pretending-to-be-admin@evil.com"}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# In direct access mode (no proxy), the app trusts this header
|
|
37
|
+
# This is why network isolation is CRITICAL
|
|
38
|
+
assert response.status_code == 200
|
|
39
|
+
|
|
40
|
+
# The app will treat this as a legitimate request from the attacker's email
|
|
41
|
+
# In production, this request should NEVER reach the app (network isolation)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_websocket_header_injection_vulnerability():
|
|
45
|
+
"""
|
|
46
|
+
Demonstrates WebSocket header injection vulnerability with direct access.
|
|
47
|
+
|
|
48
|
+
This shows why the reverse proxy MUST strip X-User-Email headers
|
|
49
|
+
before adding the authenticated user's header.
|
|
50
|
+
"""
|
|
51
|
+
# Attacker connects with injected header
|
|
52
|
+
with client.websocket_connect(
|
|
53
|
+
"/ws",
|
|
54
|
+
headers={"X-User-Email": "attacker@evil.com"}
|
|
55
|
+
) as websocket:
|
|
56
|
+
# Connection succeeds because app trusts the header
|
|
57
|
+
# This is the EXPECTED behavior when behind a proxy that strips headers
|
|
58
|
+
# This is VULNERABLE behavior when directly accessible
|
|
59
|
+
|
|
60
|
+
# Send a test message
|
|
61
|
+
websocket.send_json({
|
|
62
|
+
"type": "chat",
|
|
63
|
+
"content": "test message"
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
# The WebSocket will use "attacker@evil.com" as the user
|
|
67
|
+
# This demonstrates why network isolation is critical
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_multiple_headers_first_wins():
|
|
71
|
+
"""
|
|
72
|
+
Demonstrates the danger of improperly configured reverse proxies.
|
|
73
|
+
|
|
74
|
+
If the reverse proxy adds X-User-Email without stripping the client's
|
|
75
|
+
version first, both headers arrive. Most frameworks (including FastAPI)
|
|
76
|
+
return the FIRST header, allowing the attacker to win.
|
|
77
|
+
|
|
78
|
+
Proper nginx config:
|
|
79
|
+
proxy_set_header X-User-Email ""; # Strip first!
|
|
80
|
+
proxy_set_header X-User-Email $authenticated_user; # Then add
|
|
81
|
+
|
|
82
|
+
Vulnerable nginx config:
|
|
83
|
+
proxy_set_header X-User-Email $authenticated_user; # Only adds, doesn't strip!
|
|
84
|
+
"""
|
|
85
|
+
# Simulate what happens when proxy doesn't strip headers
|
|
86
|
+
# We can't easily test multiple headers with TestClient,
|
|
87
|
+
# but we document the expected behavior
|
|
88
|
+
|
|
89
|
+
# When client sends: X-User-Email: attacker@evil.com
|
|
90
|
+
# And proxy adds: X-User-Email: realuser@example.com
|
|
91
|
+
# The app receives BOTH headers
|
|
92
|
+
|
|
93
|
+
# FastAPI's request.headers.get() returns the FIRST occurrence
|
|
94
|
+
# So the attacker's header would win!
|
|
95
|
+
|
|
96
|
+
# This test documents the requirement for header stripping
|
|
97
|
+
assert True, "Documented: Proxy must strip headers first"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.skip(reason="Requires production environment with reverse proxy")
|
|
101
|
+
def test_production_header_stripping():
|
|
102
|
+
"""
|
|
103
|
+
Test to run in production/staging to verify header stripping works.
|
|
104
|
+
|
|
105
|
+
This test should be run manually against the actual deployment to verify
|
|
106
|
+
that the reverse proxy properly strips client-provided headers.
|
|
107
|
+
|
|
108
|
+
Usage:
|
|
109
|
+
1. Deploy to staging/production with reverse proxy
|
|
110
|
+
2. Get a valid authentication token/cookie
|
|
111
|
+
3. Run this test against the deployed URL
|
|
112
|
+
4. Verify logs show the REAL user, not the injected one
|
|
113
|
+
|
|
114
|
+
Expected behavior:
|
|
115
|
+
- Request includes malicious X-User-Email header
|
|
116
|
+
- Reverse proxy strips it
|
|
117
|
+
- Reverse proxy adds real authenticated user header
|
|
118
|
+
- Backend receives only the real user header
|
|
119
|
+
- Logs confirm backend saw the real user
|
|
120
|
+
"""
|
|
121
|
+
import os
|
|
122
|
+
|
|
123
|
+
import requests
|
|
124
|
+
|
|
125
|
+
deployment_url = os.getenv("PRODUCTION_URL")
|
|
126
|
+
auth_cookie = os.getenv("VALID_AUTH_COOKIE")
|
|
127
|
+
|
|
128
|
+
if not deployment_url or not auth_cookie:
|
|
129
|
+
pytest.skip("Set PRODUCTION_URL and VALID_AUTH_COOKIE env vars")
|
|
130
|
+
|
|
131
|
+
# Try to inject a malicious header
|
|
132
|
+
response = requests.get(
|
|
133
|
+
f"{deployment_url}/api/config",
|
|
134
|
+
headers={"X-User-Email": "attacker@evil.com"},
|
|
135
|
+
cookies={"session": auth_cookie}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
assert response.status_code == 200
|
|
139
|
+
|
|
140
|
+
# Manual verification required:
|
|
141
|
+
# Check backend logs to confirm it received the REAL user from auth,
|
|
142
|
+
# not the injected "attacker@evil.com"
|
|
143
|
+
print("✓ Request succeeded")
|
|
144
|
+
print("⚠ MANUAL VERIFICATION REQUIRED:")
|
|
145
|
+
print(" Check backend logs to confirm user was NOT 'attacker@evil.com'")
|
|
146
|
+
print(" The backend should have received the real authenticated user")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_header_injection_documentation():
|
|
150
|
+
"""
|
|
151
|
+
Documentation test: Lists all security requirements for production deployment.
|
|
152
|
+
|
|
153
|
+
This test always passes but serves as executable documentation of the
|
|
154
|
+
security requirements needed to prevent header injection attacks.
|
|
155
|
+
"""
|
|
156
|
+
security_requirements = [
|
|
157
|
+
"Main app MUST be network-isolated (not publicly accessible)",
|
|
158
|
+
"ALL traffic MUST flow through reverse proxy",
|
|
159
|
+
"Reverse proxy MUST strip client-provided X-User-Email headers",
|
|
160
|
+
"Reverse proxy MUST add X-User-Email header AFTER stripping client headers",
|
|
161
|
+
"Direct access to main app ports MUST be blocked by firewall/VPC",
|
|
162
|
+
"Nginx config MUST include: proxy_set_header X-User-Email '' before setting it",
|
|
163
|
+
"Apache config MUST include: RequestHeader unset X-User-Email before setting it",
|
|
164
|
+
"Network isolation MUST be tested (attempt direct access should fail)",
|
|
165
|
+
"Header injection test MUST be run in production (test_production_header_stripping)",
|
|
166
|
+
"Deployment checklist in docs/reverse-proxy-examples.md MUST be completed",
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
for i, requirement in enumerate(security_requirements, 1):
|
|
170
|
+
print(f"{i}. {requirement}")
|
|
171
|
+
|
|
172
|
+
assert True, "Security requirements documented"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Additional test to verify the current behavior
|
|
176
|
+
def test_x_user_email_header_is_used():
|
|
177
|
+
"""
|
|
178
|
+
Verifies that X-User-Email header is properly extracted.
|
|
179
|
+
|
|
180
|
+
This is the expected behavior when behind a properly configured proxy.
|
|
181
|
+
"""
|
|
182
|
+
test_user = "alice@example.com"
|
|
183
|
+
|
|
184
|
+
response = client.get(
|
|
185
|
+
"/api/config",
|
|
186
|
+
headers={"X-User-Email": test_user}
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
assert response.status_code == 200
|
|
190
|
+
# The middleware should have processed this header
|
|
191
|
+
# In production, this header comes from the reverse proxy, not the client
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from main import app
|
|
2
|
+
from starlette.testclient import TestClient
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_security_headers_present_by_default():
|
|
6
|
+
client = TestClient(app)
|
|
7
|
+
r = client.get("/api/files/healthz", headers={"X-User-Email": "test@test.com"})
|
|
8
|
+
assert r.status_code == 200
|
|
9
|
+
# HSTS intentionally omitted
|
|
10
|
+
assert r.headers.get("X-Content-Type-Options") == "nosniff"
|
|
11
|
+
assert r.headers.get("X-Frame-Options") in ("SAMEORIGIN", "DENY")
|
|
12
|
+
assert r.headers.get("Referrer-Policy") is not None
|
|
13
|
+
# CSP may be present per default value
|
|
14
|
+
assert "Content-Security-Policy" in r.headers
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_download_filename_sanitized(monkeypatch):
|
|
18
|
+
# Insert a file into mock S3 listing by calling upload
|
|
19
|
+
from atlas.infrastructure.app_factory import app_factory
|
|
20
|
+
app_factory.get_file_manager()
|
|
21
|
+
|
|
22
|
+
# Prepare malicious filename
|
|
23
|
+
bad_name = 'evil\r\nInjected.txt'
|
|
24
|
+
content = "SGVsbG8=" # base64(Hello)
|
|
25
|
+
|
|
26
|
+
async def upload_stub(user_email, filename, content_base64, content_type, tags, source_type):
|
|
27
|
+
return {
|
|
28
|
+
"key": "k_mal",
|
|
29
|
+
"filename": filename,
|
|
30
|
+
"size": 5,
|
|
31
|
+
"content_type": "text/plain",
|
|
32
|
+
"last_modified": "now",
|
|
33
|
+
"etag": "etag",
|
|
34
|
+
"tags": tags or {},
|
|
35
|
+
"user_email": user_email,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async def get_stub(user_email, key):
|
|
39
|
+
return {
|
|
40
|
+
"key": key,
|
|
41
|
+
"filename": bad_name,
|
|
42
|
+
"size": 5,
|
|
43
|
+
"content_base64": content,
|
|
44
|
+
"content_type": "text/plain",
|
|
45
|
+
"last_modified": "now",
|
|
46
|
+
"etag": "etag",
|
|
47
|
+
"tags": {},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Patch storage client
|
|
51
|
+
s3 = app_factory.get_file_storage()
|
|
52
|
+
monkeypatch.setattr(s3, "upload_file", upload_stub)
|
|
53
|
+
monkeypatch.setattr(s3, "get_file", get_stub)
|
|
54
|
+
|
|
55
|
+
client = TestClient(app)
|
|
56
|
+
|
|
57
|
+
# Trigger download endpoint directly (no need to actually upload first)
|
|
58
|
+
r = client.get("/api/files/download/k_mal", headers={"X-User-Email": "test@test.com"})
|
|
59
|
+
assert r.status_code == 200
|
|
60
|
+
cd = r.headers.get("Content-Disposition", "")
|
|
61
|
+
assert "\r" not in cd and "\n" not in cd
|
|
62
|
+
assert cd.startswith("attachment;") or cd.startswith("inline;")
|
|
63
|
+
assert r.headers.get("X-Content-Type-Options") == "nosniff"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test that sessions are shared across ChatService instances.
|
|
3
|
+
|
|
4
|
+
This test verifies the fix for the file upload registration issue where
|
|
5
|
+
files attached in one WebSocket connection were not visible in chat messages
|
|
6
|
+
because each ChatService instance had its own session repository.
|
|
7
|
+
"""
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from atlas.domain.messages.models import Message, MessageRole
|
|
13
|
+
from atlas.infrastructure.app_factory import app_factory
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.mark.asyncio
|
|
17
|
+
async def test_sessions_shared_across_chat_service_instances():
|
|
18
|
+
"""
|
|
19
|
+
Test that sessions are shared across different ChatService instances.
|
|
20
|
+
|
|
21
|
+
This simulates the scenario where:
|
|
22
|
+
1. A file is attached via one WebSocket connection (ChatService instance 1)
|
|
23
|
+
2. A chat message is sent via the same or different connection (ChatService instance 2)
|
|
24
|
+
3. The file should be visible in the session retrieved by instance 2
|
|
25
|
+
"""
|
|
26
|
+
# Create two ChatService instances (simulating two WebSocket connections)
|
|
27
|
+
chat_service_1 = app_factory.create_chat_service(connection=None)
|
|
28
|
+
chat_service_2 = app_factory.create_chat_service(connection=None)
|
|
29
|
+
|
|
30
|
+
# Verify they share the same session repository
|
|
31
|
+
assert chat_service_1.session_repository is chat_service_2.session_repository, \
|
|
32
|
+
"ChatService instances should share the same session repository"
|
|
33
|
+
|
|
34
|
+
user_email = "test@example.com"
|
|
35
|
+
session_id = uuid.uuid4()
|
|
36
|
+
|
|
37
|
+
# Step 1: Create a session and attach a file using ChatService 1
|
|
38
|
+
session = await chat_service_1.create_session(session_id, user_email)
|
|
39
|
+
filename = "test-document.pdf"
|
|
40
|
+
session.context["files"] = {
|
|
41
|
+
filename: {
|
|
42
|
+
"key": "s3://bucket/test-document.pdf",
|
|
43
|
+
"content_type": "application/pdf",
|
|
44
|
+
"size": 1024,
|
|
45
|
+
"source": "user",
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Add a message to the session history
|
|
50
|
+
session.history.add_message(Message(
|
|
51
|
+
role=MessageRole.USER,
|
|
52
|
+
content="what files can you see?"
|
|
53
|
+
))
|
|
54
|
+
|
|
55
|
+
# Step 2: Retrieve the session using ChatService 2 (simulating a different connection)
|
|
56
|
+
session_from_cs2 = await chat_service_2.session_repository.get(session_id)
|
|
57
|
+
|
|
58
|
+
# Verify the session exists and contains the file
|
|
59
|
+
assert session_from_cs2 is not None, "Session should be accessible from ChatService 2"
|
|
60
|
+
assert session_from_cs2 is session, "Should be the same session object"
|
|
61
|
+
assert filename in session_from_cs2.context.get("files", {}), \
|
|
62
|
+
"File attached in ChatService 1 should be visible in ChatService 2"
|
|
63
|
+
|
|
64
|
+
# Verify the session history is also shared
|
|
65
|
+
messages = session_from_cs2.history.get_messages_for_llm()
|
|
66
|
+
assert len(messages) == 1, "Session history should be shared"
|
|
67
|
+
assert messages[0]["content"] == "what files can you see?"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.asyncio
|
|
71
|
+
async def test_session_repository_shared_across_app_factory_calls():
|
|
72
|
+
"""
|
|
73
|
+
Test that the session repository is truly shared at the AppFactory level.
|
|
74
|
+
|
|
75
|
+
This verifies that multiple calls to create_chat_service return
|
|
76
|
+
ChatService instances that all share the same underlying session storage.
|
|
77
|
+
"""
|
|
78
|
+
# Create three ChatService instances
|
|
79
|
+
cs1 = app_factory.create_chat_service()
|
|
80
|
+
cs2 = app_factory.create_chat_service()
|
|
81
|
+
cs3 = app_factory.create_chat_service()
|
|
82
|
+
|
|
83
|
+
# All should share the same session repository instance
|
|
84
|
+
assert cs1.session_repository is cs2.session_repository
|
|
85
|
+
assert cs2.session_repository is cs3.session_repository
|
|
86
|
+
assert cs1.session_repository is app_factory.session_repository
|
|
87
|
+
|
|
88
|
+
# Create a session via cs1
|
|
89
|
+
session_id = uuid.uuid4()
|
|
90
|
+
await cs1.create_session(session_id, "user@example.com")
|
|
91
|
+
|
|
92
|
+
# Verify it's accessible from all instances
|
|
93
|
+
assert await cs1.session_repository.get(session_id) is not None
|
|
94
|
+
assert await cs2.session_repository.get(session_id) is not None
|
|
95
|
+
assert await cs3.session_repository.get(session_id) is not None
|
|
96
|
+
|
|
97
|
+
# Verify they all return the same session object
|
|
98
|
+
s1 = await cs1.session_repository.get(session_id)
|
|
99
|
+
s2 = await cs2.session_repository.get(session_id)
|
|
100
|
+
s3 = await cs3.session_repository.get(session_id)
|
|
101
|
+
assert s1 is s2 is s3
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import uuid
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from atlas.application.chat.preprocessors.message_builder import MessageBuilder
|
|
8
|
+
from atlas.domain.messages.models import Message, MessageRole
|
|
9
|
+
from atlas.domain.sessions.models import Session
|
|
10
|
+
from atlas.modules.config import ConfigManager
|
|
11
|
+
from atlas.modules.prompts.prompt_provider import PromptProvider
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.mark.asyncio
|
|
15
|
+
async def test_prompt_provider_loads_system_prompt(tmp_path):
|
|
16
|
+
"""Test that PromptProvider correctly loads and formats system_prompt.md"""
|
|
17
|
+
# Create a temporary system prompt file
|
|
18
|
+
prompts_dir = tmp_path / "prompts"
|
|
19
|
+
prompts_dir.mkdir()
|
|
20
|
+
system_prompt_file = prompts_dir / "system_prompt.md"
|
|
21
|
+
system_prompt_content = "You are a helpful assistant for user {user_email}."
|
|
22
|
+
system_prompt_file.write_text(system_prompt_content)
|
|
23
|
+
|
|
24
|
+
# Create a config manager with custom prompt base path
|
|
25
|
+
config_manager = ConfigManager()
|
|
26
|
+
config_manager.app_settings.prompt_base_path = str(prompts_dir)
|
|
27
|
+
config_manager.app_settings.system_prompt_filename = "system_prompt.md"
|
|
28
|
+
|
|
29
|
+
# Create prompt provider
|
|
30
|
+
prompt_provider = PromptProvider(config_manager)
|
|
31
|
+
|
|
32
|
+
# Test loading system prompt
|
|
33
|
+
result = prompt_provider.get_system_prompt(user_email="test@example.com")
|
|
34
|
+
|
|
35
|
+
assert result is not None
|
|
36
|
+
assert "test@example.com" in result
|
|
37
|
+
assert "helpful assistant" in result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.asyncio
|
|
41
|
+
async def test_prompt_provider_handles_missing_system_prompt():
|
|
42
|
+
"""Test that PromptProvider returns None when system_prompt.md is missing"""
|
|
43
|
+
# Create a config manager pointing to non-existent directory
|
|
44
|
+
config_manager = ConfigManager()
|
|
45
|
+
config_manager.app_settings.prompt_base_path = "/nonexistent/path"
|
|
46
|
+
config_manager.app_settings.system_prompt_filename = "system_prompt.md"
|
|
47
|
+
|
|
48
|
+
# Create prompt provider
|
|
49
|
+
prompt_provider = PromptProvider(config_manager)
|
|
50
|
+
|
|
51
|
+
# Test loading system prompt
|
|
52
|
+
result = prompt_provider.get_system_prompt(user_email="test@example.com")
|
|
53
|
+
|
|
54
|
+
assert result is None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.asyncio
|
|
58
|
+
async def test_message_builder_includes_system_prompt(tmp_path):
|
|
59
|
+
"""Test that MessageBuilder includes system prompt in messages"""
|
|
60
|
+
# Create a temporary system prompt file
|
|
61
|
+
prompts_dir = tmp_path / "prompts"
|
|
62
|
+
prompts_dir.mkdir()
|
|
63
|
+
system_prompt_file = prompts_dir / "system_prompt.md"
|
|
64
|
+
system_prompt_content = "You are a helpful assistant for user {user_email}."
|
|
65
|
+
system_prompt_file.write_text(system_prompt_content)
|
|
66
|
+
|
|
67
|
+
# Create a config manager with custom prompt base path
|
|
68
|
+
config_manager = ConfigManager()
|
|
69
|
+
config_manager.app_settings.prompt_base_path = str(prompts_dir)
|
|
70
|
+
config_manager.app_settings.system_prompt_filename = "system_prompt.md"
|
|
71
|
+
|
|
72
|
+
# Create prompt provider and message builder
|
|
73
|
+
prompt_provider = PromptProvider(config_manager)
|
|
74
|
+
message_builder = MessageBuilder(prompt_provider=prompt_provider)
|
|
75
|
+
|
|
76
|
+
# Create a session with some history
|
|
77
|
+
session = Session(user_email="test@example.com")
|
|
78
|
+
session.history.add_message(Message(role=MessageRole.USER, content="Hello"))
|
|
79
|
+
|
|
80
|
+
# Build messages
|
|
81
|
+
messages = await message_builder.build_messages(
|
|
82
|
+
session=session,
|
|
83
|
+
include_files_manifest=False,
|
|
84
|
+
include_system_prompt=True,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Verify system prompt is first message
|
|
88
|
+
assert len(messages) >= 2 # system prompt + user message
|
|
89
|
+
assert messages[0]["role"] == "system"
|
|
90
|
+
assert "helpful assistant" in messages[0]["content"]
|
|
91
|
+
assert "test@example.com" in messages[0]["content"]
|
|
92
|
+
|
|
93
|
+
# Verify user message is second
|
|
94
|
+
assert messages[1]["role"] == "user"
|
|
95
|
+
assert messages[1]["content"] == "Hello"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.asyncio
|
|
99
|
+
async def test_message_builder_without_system_prompt(tmp_path):
|
|
100
|
+
"""Test that MessageBuilder works without system prompt when disabled"""
|
|
101
|
+
# Create prompt provider without system prompt file
|
|
102
|
+
config_manager = ConfigManager()
|
|
103
|
+
config_manager.app_settings.prompt_base_path = "/nonexistent"
|
|
104
|
+
prompt_provider = PromptProvider(config_manager)
|
|
105
|
+
message_builder = MessageBuilder(prompt_provider=prompt_provider)
|
|
106
|
+
|
|
107
|
+
# Create a session with some history
|
|
108
|
+
session = Session(user_email="test@example.com")
|
|
109
|
+
session.history.add_message(Message(role=MessageRole.USER, content="Hello"))
|
|
110
|
+
|
|
111
|
+
# Build messages with system prompt disabled
|
|
112
|
+
messages = await message_builder.build_messages(
|
|
113
|
+
session=session,
|
|
114
|
+
include_files_manifest=False,
|
|
115
|
+
include_system_prompt=False,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Verify no system prompt
|
|
119
|
+
assert len(messages) == 1
|
|
120
|
+
assert messages[0]["role"] == "user"
|
|
121
|
+
assert messages[0]["content"] == "Hello"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.mark.asyncio
|
|
125
|
+
async def test_system_prompt_sent_to_llm():
|
|
126
|
+
"""Test that system prompt is sent to LLM in chat flow"""
|
|
127
|
+
# Create a temporary directory for prompts
|
|
128
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
129
|
+
prompts_dir = Path(tmp_dir) / "prompts"
|
|
130
|
+
prompts_dir.mkdir()
|
|
131
|
+
system_prompt_file = prompts_dir / "system_prompt.md"
|
|
132
|
+
system_prompt_content = "You are a helpful AI assistant for user {user_email}."
|
|
133
|
+
system_prompt_file.write_text(system_prompt_content)
|
|
134
|
+
|
|
135
|
+
# Create config manager
|
|
136
|
+
config_manager = ConfigManager()
|
|
137
|
+
config_manager.app_settings.prompt_base_path = str(prompts_dir)
|
|
138
|
+
config_manager.app_settings.system_prompt_filename = "system_prompt.md"
|
|
139
|
+
|
|
140
|
+
# Capture messages sent to LLM
|
|
141
|
+
captured = {}
|
|
142
|
+
|
|
143
|
+
class DummyLLM:
|
|
144
|
+
async def call_plain(self, model_name, messages, temperature=0.7):
|
|
145
|
+
captured["messages"] = messages
|
|
146
|
+
return "Hello! I'm here to help."
|
|
147
|
+
|
|
148
|
+
# Create chat service
|
|
149
|
+
from atlas.application.chat.service import ChatService
|
|
150
|
+
|
|
151
|
+
chat_service = ChatService(
|
|
152
|
+
llm=DummyLLM(),
|
|
153
|
+
tool_manager=None,
|
|
154
|
+
connection=None,
|
|
155
|
+
config_manager=config_manager,
|
|
156
|
+
file_manager=None,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Create session and send message
|
|
160
|
+
session_id = uuid.uuid4()
|
|
161
|
+
await chat_service.handle_chat_message(
|
|
162
|
+
session_id=session_id,
|
|
163
|
+
content="Hello",
|
|
164
|
+
model="test-model",
|
|
165
|
+
user_email="tester@example.com",
|
|
166
|
+
selected_tools=None,
|
|
167
|
+
selected_prompts=None,
|
|
168
|
+
selected_data_sources=None,
|
|
169
|
+
only_rag=False,
|
|
170
|
+
tool_choice_required=False,
|
|
171
|
+
agent_mode=False,
|
|
172
|
+
temperature=0.7,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Verify system prompt was sent to LLM
|
|
176
|
+
msgs = captured.get("messages")
|
|
177
|
+
assert msgs, "LLM was not called or messages not captured"
|
|
178
|
+
assert len(msgs) >= 2 # system prompt + user message
|
|
179
|
+
assert msgs[0]["role"] == "system", f"Expected first message to be system, got: {msgs[0]}"
|
|
180
|
+
assert "helpful AI assistant" in msgs[0]["content"]
|
|
181
|
+
assert "tester@example.com" in msgs[0]["content"]
|