atlas-chat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. atlas/__init__.py +40 -0
  2. atlas/application/__init__.py +7 -0
  3. atlas/application/chat/__init__.py +7 -0
  4. atlas/application/chat/agent/__init__.py +10 -0
  5. atlas/application/chat/agent/act_loop.py +179 -0
  6. atlas/application/chat/agent/factory.py +142 -0
  7. atlas/application/chat/agent/protocols.py +46 -0
  8. atlas/application/chat/agent/react_loop.py +338 -0
  9. atlas/application/chat/agent/think_act_loop.py +171 -0
  10. atlas/application/chat/approval_manager.py +151 -0
  11. atlas/application/chat/elicitation_manager.py +191 -0
  12. atlas/application/chat/events/__init__.py +1 -0
  13. atlas/application/chat/events/agent_event_relay.py +112 -0
  14. atlas/application/chat/modes/__init__.py +1 -0
  15. atlas/application/chat/modes/agent.py +125 -0
  16. atlas/application/chat/modes/plain.py +74 -0
  17. atlas/application/chat/modes/rag.py +81 -0
  18. atlas/application/chat/modes/tools.py +179 -0
  19. atlas/application/chat/orchestrator.py +213 -0
  20. atlas/application/chat/policies/__init__.py +1 -0
  21. atlas/application/chat/policies/tool_authorization.py +99 -0
  22. atlas/application/chat/preprocessors/__init__.py +1 -0
  23. atlas/application/chat/preprocessors/message_builder.py +92 -0
  24. atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
  25. atlas/application/chat/service.py +454 -0
  26. atlas/application/chat/utilities/__init__.py +6 -0
  27. atlas/application/chat/utilities/error_handler.py +367 -0
  28. atlas/application/chat/utilities/event_notifier.py +546 -0
  29. atlas/application/chat/utilities/file_processor.py +613 -0
  30. atlas/application/chat/utilities/tool_executor.py +789 -0
  31. atlas/atlas_chat_cli.py +347 -0
  32. atlas/atlas_client.py +238 -0
  33. atlas/core/__init__.py +0 -0
  34. atlas/core/auth.py +205 -0
  35. atlas/core/authorization_manager.py +27 -0
  36. atlas/core/capabilities.py +123 -0
  37. atlas/core/compliance.py +215 -0
  38. atlas/core/domain_whitelist.py +147 -0
  39. atlas/core/domain_whitelist_middleware.py +82 -0
  40. atlas/core/http_client.py +28 -0
  41. atlas/core/log_sanitizer.py +102 -0
  42. atlas/core/metrics_logger.py +59 -0
  43. atlas/core/middleware.py +131 -0
  44. atlas/core/otel_config.py +242 -0
  45. atlas/core/prompt_risk.py +200 -0
  46. atlas/core/rate_limit.py +0 -0
  47. atlas/core/rate_limit_middleware.py +64 -0
  48. atlas/core/security_headers_middleware.py +51 -0
  49. atlas/domain/__init__.py +37 -0
  50. atlas/domain/chat/__init__.py +1 -0
  51. atlas/domain/chat/dtos.py +85 -0
  52. atlas/domain/errors.py +96 -0
  53. atlas/domain/messages/__init__.py +12 -0
  54. atlas/domain/messages/models.py +160 -0
  55. atlas/domain/rag_mcp_service.py +664 -0
  56. atlas/domain/sessions/__init__.py +7 -0
  57. atlas/domain/sessions/models.py +36 -0
  58. atlas/domain/unified_rag_service.py +371 -0
  59. atlas/infrastructure/__init__.py +10 -0
  60. atlas/infrastructure/app_factory.py +135 -0
  61. atlas/infrastructure/events/__init__.py +1 -0
  62. atlas/infrastructure/events/cli_event_publisher.py +140 -0
  63. atlas/infrastructure/events/websocket_publisher.py +140 -0
  64. atlas/infrastructure/sessions/in_memory_repository.py +56 -0
  65. atlas/infrastructure/transport/__init__.py +7 -0
  66. atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
  67. atlas/init_cli.py +226 -0
  68. atlas/interfaces/__init__.py +15 -0
  69. atlas/interfaces/events.py +134 -0
  70. atlas/interfaces/llm.py +54 -0
  71. atlas/interfaces/rag.py +40 -0
  72. atlas/interfaces/sessions.py +75 -0
  73. atlas/interfaces/tools.py +57 -0
  74. atlas/interfaces/transport.py +24 -0
  75. atlas/main.py +564 -0
  76. atlas/mcp/api_key_demo/README.md +76 -0
  77. atlas/mcp/api_key_demo/main.py +172 -0
  78. atlas/mcp/api_key_demo/run.sh +56 -0
  79. atlas/mcp/basictable/main.py +147 -0
  80. atlas/mcp/calculator/main.py +149 -0
  81. atlas/mcp/code-executor/execution_engine.py +98 -0
  82. atlas/mcp/code-executor/execution_environment.py +95 -0
  83. atlas/mcp/code-executor/main.py +528 -0
  84. atlas/mcp/code-executor/result_processing.py +276 -0
  85. atlas/mcp/code-executor/script_generation.py +195 -0
  86. atlas/mcp/code-executor/security_checker.py +140 -0
  87. atlas/mcp/corporate_cars/main.py +437 -0
  88. atlas/mcp/csv_reporter/main.py +545 -0
  89. atlas/mcp/duckduckgo/main.py +182 -0
  90. atlas/mcp/elicitation_demo/README.md +171 -0
  91. atlas/mcp/elicitation_demo/main.py +262 -0
  92. atlas/mcp/env-demo/README.md +158 -0
  93. atlas/mcp/env-demo/main.py +199 -0
  94. atlas/mcp/file_size_test/main.py +284 -0
  95. atlas/mcp/filesystem/main.py +348 -0
  96. atlas/mcp/image_demo/main.py +113 -0
  97. atlas/mcp/image_demo/requirements.txt +4 -0
  98. atlas/mcp/logging_demo/README.md +72 -0
  99. atlas/mcp/logging_demo/main.py +103 -0
  100. atlas/mcp/many_tools_demo/main.py +50 -0
  101. atlas/mcp/order_database/__init__.py +0 -0
  102. atlas/mcp/order_database/main.py +369 -0
  103. atlas/mcp/order_database/signal_data.csv +1001 -0
  104. atlas/mcp/pdfbasic/main.py +394 -0
  105. atlas/mcp/pptx_generator/main.py +760 -0
  106. atlas/mcp/pptx_generator/requirements.txt +13 -0
  107. atlas/mcp/pptx_generator/run_test.sh +1 -0
  108. atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
  109. atlas/mcp/progress_demo/main.py +167 -0
  110. atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
  111. atlas/mcp/progress_updates_demo/README.md +120 -0
  112. atlas/mcp/progress_updates_demo/main.py +497 -0
  113. atlas/mcp/prompts/main.py +222 -0
  114. atlas/mcp/public_demo/main.py +189 -0
  115. atlas/mcp/sampling_demo/README.md +169 -0
  116. atlas/mcp/sampling_demo/main.py +234 -0
  117. atlas/mcp/thinking/main.py +77 -0
  118. atlas/mcp/tool_planner/main.py +240 -0
  119. atlas/mcp/ui-demo/badmesh.png +0 -0
  120. atlas/mcp/ui-demo/main.py +383 -0
  121. atlas/mcp/ui-demo/templates/button_demo.html +32 -0
  122. atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
  123. atlas/mcp/ui-demo/templates/form_demo.html +28 -0
  124. atlas/mcp/username-override-demo/README.md +320 -0
  125. atlas/mcp/username-override-demo/main.py +308 -0
  126. atlas/modules/__init__.py +0 -0
  127. atlas/modules/config/__init__.py +34 -0
  128. atlas/modules/config/cli.py +231 -0
  129. atlas/modules/config/config_manager.py +1096 -0
  130. atlas/modules/file_storage/__init__.py +22 -0
  131. atlas/modules/file_storage/cli.py +330 -0
  132. atlas/modules/file_storage/content_extractor.py +290 -0
  133. atlas/modules/file_storage/manager.py +295 -0
  134. atlas/modules/file_storage/mock_s3_client.py +402 -0
  135. atlas/modules/file_storage/s3_client.py +417 -0
  136. atlas/modules/llm/__init__.py +19 -0
  137. atlas/modules/llm/caller.py +287 -0
  138. atlas/modules/llm/litellm_caller.py +675 -0
  139. atlas/modules/llm/models.py +19 -0
  140. atlas/modules/mcp_tools/__init__.py +17 -0
  141. atlas/modules/mcp_tools/client.py +2123 -0
  142. atlas/modules/mcp_tools/token_storage.py +556 -0
  143. atlas/modules/prompts/prompt_provider.py +130 -0
  144. atlas/modules/rag/__init__.py +24 -0
  145. atlas/modules/rag/atlas_rag_client.py +336 -0
  146. atlas/modules/rag/client.py +129 -0
  147. atlas/routes/admin_routes.py +865 -0
  148. atlas/routes/config_routes.py +484 -0
  149. atlas/routes/feedback_routes.py +361 -0
  150. atlas/routes/files_routes.py +274 -0
  151. atlas/routes/health_routes.py +40 -0
  152. atlas/routes/mcp_auth_routes.py +223 -0
  153. atlas/server_cli.py +164 -0
  154. atlas/tests/conftest.py +20 -0
  155. atlas/tests/integration/test_mcp_auth_integration.py +152 -0
  156. atlas/tests/manual_test_sampling.py +87 -0
  157. atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
  158. atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
  159. atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
  160. atlas/tests/test_agent_roa.py +135 -0
  161. atlas/tests/test_app_factory_smoke.py +47 -0
  162. atlas/tests/test_approval_manager.py +439 -0
  163. atlas/tests/test_atlas_client.py +188 -0
  164. atlas/tests/test_atlas_rag_client.py +447 -0
  165. atlas/tests/test_atlas_rag_integration.py +224 -0
  166. atlas/tests/test_attach_file_flow.py +287 -0
  167. atlas/tests/test_auth_utils.py +165 -0
  168. atlas/tests/test_backend_public_url.py +185 -0
  169. atlas/tests/test_banner_logging.py +287 -0
  170. atlas/tests/test_capability_tokens_and_injection.py +203 -0
  171. atlas/tests/test_compliance_level.py +54 -0
  172. atlas/tests/test_compliance_manager.py +253 -0
  173. atlas/tests/test_config_manager.py +617 -0
  174. atlas/tests/test_config_manager_paths.py +12 -0
  175. atlas/tests/test_core_auth.py +18 -0
  176. atlas/tests/test_core_utils.py +190 -0
  177. atlas/tests/test_docker_env_sync.py +202 -0
  178. atlas/tests/test_domain_errors.py +329 -0
  179. atlas/tests/test_domain_whitelist.py +359 -0
  180. atlas/tests/test_elicitation_manager.py +408 -0
  181. atlas/tests/test_elicitation_routing.py +296 -0
  182. atlas/tests/test_env_demo_server.py +88 -0
  183. atlas/tests/test_error_classification.py +113 -0
  184. atlas/tests/test_error_flow_integration.py +116 -0
  185. atlas/tests/test_feedback_routes.py +333 -0
  186. atlas/tests/test_file_content_extraction.py +1134 -0
  187. atlas/tests/test_file_extraction_routes.py +158 -0
  188. atlas/tests/test_file_library.py +107 -0
  189. atlas/tests/test_file_manager_unit.py +18 -0
  190. atlas/tests/test_health_route.py +49 -0
  191. atlas/tests/test_http_client_stub.py +8 -0
  192. atlas/tests/test_imports_smoke.py +30 -0
  193. atlas/tests/test_interfaces_llm_response.py +9 -0
  194. atlas/tests/test_issue_access_denied_fix.py +136 -0
  195. atlas/tests/test_llm_env_expansion.py +836 -0
  196. atlas/tests/test_log_level_sensitive_data.py +285 -0
  197. atlas/tests/test_mcp_auth_routes.py +341 -0
  198. atlas/tests/test_mcp_client_auth.py +331 -0
  199. atlas/tests/test_mcp_data_injection.py +270 -0
  200. atlas/tests/test_mcp_get_authorized_servers.py +95 -0
  201. atlas/tests/test_mcp_hot_reload.py +512 -0
  202. atlas/tests/test_mcp_image_content.py +424 -0
  203. atlas/tests/test_mcp_logging.py +172 -0
  204. atlas/tests/test_mcp_progress_updates.py +313 -0
  205. atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
  206. atlas/tests/test_mcp_prompts_server.py +39 -0
  207. atlas/tests/test_mcp_tool_result_parsing.py +296 -0
  208. atlas/tests/test_metrics_logger.py +56 -0
  209. atlas/tests/test_middleware_auth.py +379 -0
  210. atlas/tests/test_prompt_risk_and_acl.py +141 -0
  211. atlas/tests/test_rag_mcp_aggregator.py +204 -0
  212. atlas/tests/test_rag_mcp_service.py +224 -0
  213. atlas/tests/test_rate_limit_middleware.py +45 -0
  214. atlas/tests/test_routes_config_smoke.py +60 -0
  215. atlas/tests/test_routes_files_download_token.py +41 -0
  216. atlas/tests/test_routes_files_health.py +18 -0
  217. atlas/tests/test_runtime_imports.py +53 -0
  218. atlas/tests/test_sampling_integration.py +482 -0
  219. atlas/tests/test_security_admin_routes.py +61 -0
  220. atlas/tests/test_security_capability_tokens.py +65 -0
  221. atlas/tests/test_security_file_stats_scope.py +21 -0
  222. atlas/tests/test_security_header_injection.py +191 -0
  223. atlas/tests/test_security_headers_and_filename.py +63 -0
  224. atlas/tests/test_shared_session_repository.py +101 -0
  225. atlas/tests/test_system_prompt_loading.py +181 -0
  226. atlas/tests/test_token_storage.py +505 -0
  227. atlas/tests/test_tool_approval_config.py +93 -0
  228. atlas/tests/test_tool_approval_utils.py +356 -0
  229. atlas/tests/test_tool_authorization_group_filtering.py +223 -0
  230. atlas/tests/test_tool_details_in_config.py +108 -0
  231. atlas/tests/test_tool_planner.py +300 -0
  232. atlas/tests/test_unified_rag_service.py +398 -0
  233. atlas/tests/test_username_override_in_approval.py +258 -0
  234. atlas/tests/test_websocket_auth_header.py +168 -0
  235. atlas/version.py +6 -0
  236. atlas_chat-0.1.0.data/data/.env.example +253 -0
  237. atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
  238. atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
  239. atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
  240. atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
  241. atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
  242. atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
  243. atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
  244. atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
  245. atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
  246. atlas_chat-0.1.0.dist-info/METADATA +236 -0
  247. atlas_chat-0.1.0.dist-info/RECORD +250 -0
  248. atlas_chat-0.1.0.dist-info/WHEEL +5 -0
  249. atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
  250. atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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"]