""" Tests for the Flask application factory. Tests cover: - is_private_ip helper (re-imported from security.network_utils) - DiskSpoolingRequest class - create_app function - CSRF protection - Error handlers - Static file serving """ import pytest from unittest.mock import Mock, patch from flask import Flask class TestIsPrivateIp: """Tests for the is_private_ip helper used by SecureCookieMiddleware.""" def test_localhost_ipv4(self): """127.0.0.1 is private.""" from local_deep_research.security.network_utils import is_private_ip assert is_private_ip("127.0.0.1") is True def test_localhost_ipv6(self): """::1 is private.""" from local_deep_research.security.network_utils import is_private_ip assert is_private_ip("::1") is True def test_private_class_a(self): """10.x.x.x is private.""" from local_deep_research.security.network_utils import is_private_ip assert is_private_ip("10.0.0.1") is True assert is_private_ip("10.255.255.255") is True def test_private_class_b(self): """172.16.x.x - 172.31.x.x is private.""" from local_deep_research.security.network_utils import is_private_ip assert is_private_ip("172.16.0.1") is True assert is_private_ip("172.31.255.255") is True def test_private_class_c(self): """192.168.x.x is private.""" from local_deep_research.security.network_utils import is_private_ip assert is_private_ip("192.168.0.1") is True assert is_private_ip("192.168.255.255") is True def test_public_ip(self): """Public IPs are not private.""" from local_deep_research.security.network_utils import is_private_ip assert is_private_ip("8.8.8.8") is False assert is_private_ip("1.1.1.1") is False assert is_private_ip("142.250.190.78") is False def test_invalid_ip(self): """Invalid IP returns False.""" from local_deep_research.security.network_utils import is_private_ip assert is_private_ip("invalid") is False assert is_private_ip("256.256.256.256") is False assert is_private_ip("") is False class TestDiskSpoolingRequest: """Tests for DiskSpoolingRequest class.""" def test_max_form_memory_size(self): """DiskSpoolingRequest has correct memory threshold.""" from local_deep_research.web.app_factory import DiskSpoolingRequest # 5MB threshold assert DiskSpoolingRequest.max_form_memory_size == 5 * 1024 * 1024 def test_inherits_from_request(self): """DiskSpoolingRequest inherits from Flask Request.""" from local_deep_research.web.app_factory import DiskSpoolingRequest from flask import Request assert issubclass(DiskSpoolingRequest, Request) class TestCreateApp: """Tests for create_app function.""" def test_returns_flask_app_and_socketio(self): """create_app returns Flask app and SocketIO.""" from local_deep_research.web.app_factory import create_app with patch( "local_deep_research.web.app_factory.SocketIOService" ) as mock_socketio: mock_socketio_instance = Mock() mock_socketio.return_value.get_socketio.return_value = ( mock_socketio_instance ) app, socketio = create_app() assert isinstance(app, Flask) assert socketio is not None def test_csrf_protection_enabled(self): """CSRF protection is enabled.""" from local_deep_research.web.app_factory import create_app with patch("local_deep_research.web.app_factory.SocketIOService"): app, _ = create_app() # CSRF extension should be registered assert "csrf" in app.extensions def test_uses_disk_spooling_request(self): """App uses DiskSpoolingRequest class.""" from local_deep_research.web.app_factory import ( create_app, DiskSpoolingRequest, ) with patch("local_deep_research.web.app_factory.SocketIOService"): app, _ = create_app() assert app.request_class == DiskSpoolingRequest def test_proxy_fix_middleware(self): """App has ProxyFix middleware.""" from local_deep_research.web.app_factory import create_app with patch("local_deep_research.web.app_factory.SocketIOService"): app, _ = create_app() # Check that wsgi_app has been wrapped # The actual wsgi_app is wrapped multiple times assert app.wsgi_app is not None def test_has_static_dir_config(self): """App has STATIC_DIR config.""" from local_deep_research.web.app_factory import create_app with patch("local_deep_research.web.app_factory.SocketIOService"): app, _ = create_app() assert "STATIC_DIR" in app.config def test_error_handlers_registered(self): """Error handlers are registered.""" from local_deep_research.web.app_factory import create_app with patch("local_deep_research.web.app_factory.SocketIOService"): app, _ = create_app() # Check that error handlers exist for common codes assert app.error_handler_spec is not None class TestAppRoutes: """Tests for routes registered by create_app.""" @pytest.fixture def app(self): """Create test app.""" from local_deep_research.web.app_factory import create_app with patch("local_deep_research.web.app_factory.SocketIOService"): app, _ = create_app() app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False return app @pytest.fixture def client(self, app): """Create test client.""" return app.test_client() def test_static_route_exists(self, app): """Static route is registered.""" # Check that the static route exists rules = [rule.rule for rule in app.url_map.iter_rules()] static_routes = [r for r in rules if "static" in r] assert len(static_routes) > 0 def test_index_route_exists(self, app): """Index route is registered.""" rules = [rule.rule for rule in app.url_map.iter_rules()] assert "/" in rules def test_api_routes_registered(self, app): """API routes are registered.""" rules = [rule.rule for rule in app.url_map.iter_rules()] api_routes = [r for r in rules if "/api/" in r] assert len(api_routes) > 0 class TestSecurityHeaders: """Tests for security headers.""" @pytest.fixture def app(self): """Create test app.""" from local_deep_research.web.app_factory import create_app with patch("local_deep_research.web.app_factory.SocketIOService"): app, _ = create_app() app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False return app @pytest.fixture def client(self, app): """Create test client.""" return app.test_client() def test_response_has_security_headers(self, client): """Responses have security headers.""" # audit: PUNCHLIST reviewed 2026-05 — KEEP (ASSERT_TRUE). response = client.get("/") # Check for common security headers # Content-Security-Policy or X-Content-Type-Options headers = dict(response.headers) # At least one security header should be present security_headers = [ "X-Content-Type-Options", "X-Frame-Options", "Content-Security-Policy", ] # Note: Security headers may not be set on all routes # Just verify the app runs without errors and we can check headers _ = any(h in headers for h in security_headers) assert response is not None class TestCsrfProtection: """Tests for CSRF protection.""" @pytest.fixture def app(self): """Create test app.""" from local_deep_research.web.app_factory import create_app with patch("local_deep_research.web.app_factory.SocketIOService"): app, _ = create_app() app.config["TESTING"] = True return app def test_csrf_enabled_by_default(self, app): """CSRF is enabled by default.""" assert "csrf" in app.extensions def test_csrf_token_endpoint_exists(self, app): """CSRF token endpoint exists.""" # audit: PUNCHLIST reviewed 2026-05 — KEEP (ASSERT_TRUE). rules = [rule.rule for rule in app.url_map.iter_rules()] csrf_routes = [r for r in rules if "csrf" in r.lower()] # Should have a CSRF token endpoint assert len(csrf_routes) >= 0 # May not have explicit route class TestRateLimiting: """Tests for rate limiting.""" @pytest.fixture def app(self): """Create test app.""" from local_deep_research.web.app_factory import create_app with patch("local_deep_research.web.app_factory.SocketIOService"): app, _ = create_app() app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False return app class TestErrorHandlers: """Tests for error handlers.""" @pytest.fixture def app(self): """Create test app with error test routes.""" from local_deep_research.web.app_factory import create_app with patch("local_deep_research.web.app_factory.SocketIOService"): app, _ = create_app() app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False # Add test routes that trigger errors @app.route("/test-500") def trigger_500(): raise Exception("Test error") return app @pytest.fixture def client(self, app): """Create test client.""" return app.test_client() def test_404_returns_json_for_api(self, client): """404 returns JSON for API routes.""" response = client.get("/api/nonexistent-route") # Should return 404 assert response.status_code == 404 def test_404_returns_html_for_web(self, client): """404 returns HTML for web routes.""" response = client.get("/nonexistent-page") # Should return 404 assert response.status_code == 404 class TestFileUploadSecurity: """Tests for file upload security.""" def test_file_upload_validator_available(self): """FileUploadValidator is available.""" from local_deep_research.security.file_upload_validator import ( FileUploadValidator, ) validator = FileUploadValidator() assert validator is not None def test_max_form_memory_prevents_memory_exhaustion(self): """DiskSpoolingRequest prevents memory exhaustion. Files larger than the threshold are spooled to disk by Werkzeug instead of being held in RAM — keeping memory bounded even though the per-file cap (FileUploadValidator.MAX_FILE_SIZE) is large. """ from local_deep_research.security.file_upload_validator import ( FileUploadValidator, ) from local_deep_research.web.app_factory import DiskSpoolingRequest # 5MB threshold means files larger than this go to disk. threshold = DiskSpoolingRequest.max_form_memory_size assert threshold == 5 * 1024 * 1024 # 5MB # Sanity: threshold must stay well below the per-file cap, otherwise # large uploads would never spool to disk. Reference the configured # cap directly so the bound scales when the cap is changed. assert threshold < FileUploadValidator.MAX_FILE_SIZE class TestSecureCookieMiddleware: """Tests for SecureCookieMiddleware WSGI middleware. The middleware adds the Secure flag iff the request is HTTPS, regardless of source IP. ProxyFix translates X-Forwarded-Proto into wsgi.url_scheme before this middleware runs. """ def _make_middleware(self, testing_mode=False): from local_deep_research.security.web_middleware import ( SecureCookieMiddleware, ) def inner(environ, start_response): start_response( "200 OK", [("Set-Cookie", "session=abc; Path=/; HttpOnly")] ) return [b""] flask_app = Mock() flask_app.config = {"LDR_TESTING_MODE": testing_mode} return SecureCookieMiddleware(inner, flask_app) def _capture_headers(self, mw, environ): captured = {} def start_response(status, headers, exc_info=None): captured["headers"] = headers list(mw(environ, start_response)) return captured["headers"] def test_http_does_not_add_secure_regardless_of_source(self): """HTTP requests never get Secure, even from public IPs (#3849).""" mw = self._make_middleware() for remote_addr in [ "127.0.0.1", "192.168.1.100", "172.17.0.2", "8.8.8.8", # Docker Desktop NAT gateway range that triggered #3849 "172.67.130.145", ]: headers = self._capture_headers( mw, {"REMOTE_ADDR": remote_addr, "wsgi.url_scheme": "http"}, ) cookie = next(v for n, v in headers if n.lower() == "set-cookie") assert "Secure" not in cookie, ( f"HTTP from {remote_addr} should not get Secure flag" ) def test_https_adds_secure(self): """HTTPS requests always get Secure.""" mw = self._make_middleware() headers = self._capture_headers( mw, {"REMOTE_ADDR": "8.8.8.8", "wsgi.url_scheme": "https"} ) cookie = next(v for n, v in headers if n.lower() == "set-cookie") assert "Secure" in cookie def test_testing_mode_skips_secure_even_on_https(self): """LDR_TESTING_MODE disables the Secure flag entirely.""" mw = self._make_middleware(testing_mode=True) headers = self._capture_headers( mw, {"REMOTE_ADDR": "8.8.8.8", "wsgi.url_scheme": "https"} ) cookie = next(v for n, v in headers if n.lower() == "set-cookie") assert "Secure" not in cookie def test_warning_fires_once_per_instance(self): """The HTTP-to-public-IP warning is one-shot per middleware instance.""" mw = self._make_middleware() env = {"REMOTE_ADDR": "8.8.8.8", "wsgi.url_scheme": "http"} with patch( "local_deep_research.security.web_middleware.logger" ) as mock_logger: self._capture_headers(mw, env) self._capture_headers(mw, env) self._capture_headers(mw, env) warning_calls = [ c for c in mock_logger.warning.call_args_list if "Serving HTTP to non-private client" in c.args[0] ] assert len(warning_calls) == 1 def test_warning_does_not_fire_for_private_ip(self): """No warning when end-user IP is in a private range.""" mw = self._make_middleware() with patch( "local_deep_research.security.web_middleware.logger" ) as mock_logger: self._capture_headers( mw, { "REMOTE_ADDR": "192.168.1.100", "wsgi.url_scheme": "http", }, ) warning_calls = [ c for c in mock_logger.warning.call_args_list if "Serving HTTP to non-private client" in c.args[0] ] assert not warning_calls class TestSessionConfiguration: """Tests for session cookie configuration.""" @pytest.fixture def app(self): """Create test app.""" from local_deep_research.web.app_factory import create_app with patch("local_deep_research.web.app_factory.SocketIOService"): app, _ = create_app() return app def test_session_cookie_httponly(self, app): """Session cookie should have HttpOnly flag.""" assert app.config["SESSION_COOKIE_HTTPONLY"] is True def test_session_cookie_samesite(self, app): """Session cookie should have SameSite=Lax.""" assert app.config["SESSION_COOKIE_SAMESITE"] == "Lax" def test_permanent_session_lifetime(self, app): """Session should have 30-day lifetime.""" assert app.config["PERMANENT_SESSION_LIFETIME"] == 30 * 24 * 3600 def test_preferred_url_scheme_https(self, app): """Preferred URL scheme should be https.""" assert app.config["PREFERRED_URL_SCHEME"] == "https" def test_wtf_csrf_enabled(self, app): """WTF CSRF should be enabled.""" assert app.config["WTF_CSRF_ENABLED"] is True class TestBlueprintRegistration: """Tests for blueprint registration.""" @pytest.fixture def app(self): """Create test app.""" from local_deep_research.web.app_factory import create_app with patch("local_deep_research.web.app_factory.SocketIOService"): app, _ = create_app() app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False return app def test_auth_blueprint_registered(self, app): """Auth blueprint should be registered.""" rules = [rule.rule for rule in app.url_map.iter_rules()] auth_routes = [r for r in rules if "/auth/" in r] assert len(auth_routes) > 0 def test_research_blueprint_registered(self, app): """Research blueprint should be registered.""" rules = [rule.rule for rule in app.url_map.iter_rules()] research_routes = [r for r in rules if "/research" in r] assert len(research_routes) > 0 def test_settings_blueprint_registered(self, app): """Settings blueprint should be registered.""" # audit: PUNCHLIST reviewed 2026-05 — KEEP (ASSERT_TRUE). rules = [rule.rule for rule in app.url_map.iter_rules()] settings_routes = [r for r in rules if "/settings" in r] assert len(settings_routes) >= 0 # May or may not exist def test_library_blueprint_registered(self, app): """Library blueprint should be registered.""" # audit: PUNCHLIST reviewed 2026-05 — KEEP (ASSERT_TRUE). rules = [rule.rule for rule in app.url_map.iter_rules()] library_routes = [r for r in rules if "/library" in r] assert len(library_routes) >= 0 # May or may not exist