7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
289 lines
10 KiB
Python
289 lines
10 KiB
Python
"""
|
|
Tests for error handler *behavior* in app_factory.register_error_handlers().
|
|
|
|
Existing tests cover registration and 404 content; these test:
|
|
- 401 handler JSON vs redirect branching
|
|
- 413 handler JSON vs text branching
|
|
- 500 handler JSON vs text branching
|
|
- WebAPIException handler JSON conversion
|
|
- CSRF handler IP-based messaging (private/public/proxied/HTTPS)
|
|
- NewsAPIException handler JSON conversion
|
|
- is_private_ip edge cases (IPv4-mapped IPv6, link-local, multicast)
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import patch
|
|
|
|
from flask import abort
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
"""Create a test app with error handlers registered."""
|
|
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
|
|
# Must disable exception propagation so error handlers are invoked
|
|
app.config["PROPAGATE_EXCEPTIONS"] = False
|
|
app.config["TRAP_HTTP_EXCEPTIONS"] = False
|
|
|
|
# Routes that trigger 401 errors
|
|
@app.route("/api/trigger-401")
|
|
def api_trigger_401():
|
|
abort(401)
|
|
|
|
@app.route("/settings/api/trigger-401")
|
|
def settings_api_trigger_401():
|
|
abort(401)
|
|
|
|
@app.route("/trigger-401")
|
|
def web_trigger_401():
|
|
abort(401)
|
|
|
|
# Routes that trigger 413 errors
|
|
@app.route("/api/trigger-413")
|
|
def api_trigger_413():
|
|
abort(413)
|
|
|
|
@app.route("/trigger-413")
|
|
def web_trigger_413():
|
|
abort(413)
|
|
|
|
# Routes that trigger 500 errors
|
|
@app.route("/api/trigger-500")
|
|
def api_trigger_500():
|
|
raise Exception("boom")
|
|
|
|
@app.route("/trigger-500")
|
|
def web_trigger_500():
|
|
raise Exception("boom")
|
|
|
|
# Route that raises WebAPIException
|
|
@app.route("/api/trigger-web-api-error")
|
|
def trigger_web_api_error():
|
|
from local_deep_research.web.exceptions import (
|
|
WebAPIException,
|
|
)
|
|
|
|
raise WebAPIException(
|
|
message="Something went wrong",
|
|
status_code=400,
|
|
error_code="BAD_REQUEST",
|
|
details={"field": "name"},
|
|
)
|
|
|
|
# Route that raises AuthenticationRequiredError
|
|
@app.route("/api/trigger-auth-error")
|
|
def trigger_auth_error():
|
|
from local_deep_research.web.exceptions import (
|
|
AuthenticationRequiredError,
|
|
)
|
|
|
|
raise AuthenticationRequiredError()
|
|
|
|
# Route that raises NewsAPIException
|
|
@app.route("/api/trigger-news-error")
|
|
def trigger_news_error():
|
|
from local_deep_research.news.exceptions import (
|
|
NewsAPIException,
|
|
)
|
|
|
|
raise NewsAPIException(
|
|
message="Feed unavailable",
|
|
status_code=503,
|
|
error_code="FEED_ERROR",
|
|
)
|
|
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
return app.test_client()
|
|
|
|
|
|
class TestUnauthorizedHandler:
|
|
"""Tests for the 401 error handler behavior."""
|
|
|
|
def test_401_returns_json_for_api_path(self, client):
|
|
"""API paths get JSON error response for 401."""
|
|
response = client.get("/api/trigger-401")
|
|
assert response.status_code == 401
|
|
data = response.get_json()
|
|
assert data is not None
|
|
assert data["error"] == "Authentication required"
|
|
|
|
def test_401_returns_json_for_settings_api_path(self, client):
|
|
"""/settings/api/ paths get JSON error response for 401."""
|
|
response = client.get("/settings/api/trigger-401")
|
|
assert response.status_code == 401
|
|
data = response.get_json()
|
|
assert data is not None
|
|
assert data["error"] == "Authentication required"
|
|
|
|
def test_401_redirects_for_web_path(self, client):
|
|
"""Non-API paths redirect to login page for 401."""
|
|
response = client.get("/trigger-401")
|
|
assert response.status_code == 302
|
|
assert "/auth/login" in response.location
|
|
|
|
|
|
class TestRequestTooLargeHandler:
|
|
"""Tests for the 413 error handler behavior."""
|
|
|
|
def test_413_returns_json_for_api_path(self, client):
|
|
"""API paths get JSON error response for 413."""
|
|
response = client.get("/api/trigger-413")
|
|
assert response.status_code == 413
|
|
data = response.get_json()
|
|
assert data is not None
|
|
assert data["error"] == "Request too large"
|
|
|
|
def test_413_returns_text_for_web_path(self, client):
|
|
"""Non-API paths get plain text error response for 413."""
|
|
response = client.get("/trigger-413")
|
|
assert response.status_code == 413
|
|
assert response.data == b"Request too large"
|
|
|
|
|
|
class TestServerErrorHandler:
|
|
"""Tests for the 500 error handler behavior."""
|
|
|
|
def test_500_returns_json_for_api_path(self, client):
|
|
"""API paths get JSON error response for 500."""
|
|
response = client.get("/api/trigger-500")
|
|
assert response.status_code == 500
|
|
data = response.get_json()
|
|
assert data is not None
|
|
assert data["error"] == "Server error"
|
|
|
|
def test_500_returns_text_for_web_path(self, client):
|
|
"""Non-API paths get plain text error response for 500."""
|
|
response = client.get("/trigger-500")
|
|
assert response.status_code == 500
|
|
assert response.data == b"Server error"
|
|
|
|
|
|
class TestCsrfErrorHandler:
|
|
"""Tests for CSRF error handler IP-based branching."""
|
|
|
|
def _trigger_csrf(
|
|
self, app, remote_addr, is_secure=False, forwarded_for=None
|
|
):
|
|
"""Trigger a CSRF error with controlled request environment."""
|
|
from flask_wtf.csrf import CSRFError
|
|
|
|
# Must set PREFERRED_URL_SCHEME to match is_secure, because Flask
|
|
# uses it to determine request.scheme / request.is_secure
|
|
app.config["PREFERRED_URL_SCHEME"] = "https" if is_secure else "http"
|
|
|
|
ctx_kwargs = {
|
|
"environ_base": {
|
|
"REMOTE_ADDR": remote_addr,
|
|
"wsgi.url_scheme": "https" if is_secure else "http",
|
|
},
|
|
}
|
|
if forwarded_for:
|
|
ctx_kwargs["headers"] = {"X-Forwarded-For": forwarded_for}
|
|
|
|
with app.test_request_context("/api/test", **ctx_kwargs):
|
|
# CSRF handler is registered under status code 400
|
|
handlers_400 = app.error_handler_spec.get(None, {}).get(400, {})
|
|
handler = handlers_400.get(CSRFError)
|
|
if handler is None:
|
|
pytest.skip("CSRFError handler not registered")
|
|
error = CSRFError("CSRF token missing")
|
|
response = handler(error)
|
|
return response
|
|
|
|
def test_csrf_error_returns_description_as_json(self, app):
|
|
"""CSRF errors return the error description as JSON, regardless of
|
|
source IP or scheme. The old IP-based 'use HTTPS' branch is gone."""
|
|
response = self._trigger_csrf(app, "8.8.8.8")
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert "error" in data
|
|
# No IP-specific guidance is added anymore
|
|
assert "Solutions" not in data["error"]
|
|
assert "8.8.8.8" not in data["error"]
|
|
|
|
def test_csrf_returns_400_status(self, app):
|
|
"""CSRF errors return 400 status code."""
|
|
from flask_wtf.csrf import CSRFError
|
|
|
|
with app.test_request_context("/api/test"):
|
|
handlers_400 = app.error_handler_spec.get(None, {}).get(400, {})
|
|
handler = handlers_400.get(CSRFError)
|
|
if handler is None:
|
|
pytest.skip("CSRFError handler not registered")
|
|
response = handler(CSRFError("bad token"))
|
|
assert response.status_code == 400
|
|
|
|
|
|
class TestNewsAPIExceptionHandler:
|
|
"""Tests for NewsAPIException error handler."""
|
|
|
|
def test_news_exception_returns_json(self, client):
|
|
"""NewsAPIException is converted to JSON with error_code and status."""
|
|
response = client.get("/api/trigger-news-error")
|
|
assert response.status_code == 503
|
|
data = response.get_json()
|
|
assert data["error"] == "Feed unavailable"
|
|
assert data["error_code"] == "FEED_ERROR"
|
|
assert data["status_code"] == 503
|
|
|
|
|
|
class TestWebAPIExceptionHandler:
|
|
"""Tests for WebAPIException error handler."""
|
|
|
|
def test_web_api_exception_returns_json(self, client):
|
|
"""WebAPIException is converted to JSON with to_dict() fields."""
|
|
response = client.get("/api/trigger-web-api-error")
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert data is not None
|
|
assert data["status"] == "error"
|
|
assert data["message"] == "Something went wrong"
|
|
assert data["error_code"] == "BAD_REQUEST"
|
|
assert data["details"] == {"field": "name"}
|
|
|
|
def test_web_api_exception_preserves_status_code(self, client):
|
|
"""Response uses the exception's status_code, not a default."""
|
|
response = client.get("/api/trigger-web-api-error")
|
|
assert response.status_code == 400
|
|
|
|
def test_authentication_required_error_returns_401(self, client):
|
|
"""AuthenticationRequiredError returns 401 with structured JSON."""
|
|
response = client.get("/api/trigger-auth-error")
|
|
assert response.status_code == 401
|
|
data = response.get_json()
|
|
assert data is not None
|
|
assert data["status"] == "error"
|
|
assert "Authentication required" in data["message"]
|
|
assert data["error_code"] == "AUTHENTICATION_REQUIRED"
|
|
|
|
|
|
class TestIsPrivateIpEdgeCases:
|
|
"""Edge cases for is_private_ip not covered by existing tests."""
|
|
|
|
def test_ipv4_mapped_ipv6_private(self):
|
|
"""IPv4-mapped IPv6 address with private IPv4 is treated as private."""
|
|
from local_deep_research.security.network_utils import is_private_ip
|
|
|
|
assert is_private_ip("::ffff:192.168.1.1") is True
|
|
|
|
def test_link_local_address(self):
|
|
"""Link-local addresses (169.254.x.x) are private."""
|
|
from local_deep_research.security.network_utils import is_private_ip
|
|
|
|
assert is_private_ip("169.254.1.1") is True
|
|
|
|
def test_multicast_address(self):
|
|
"""Multicast addresses (224.x.x.x) are not private."""
|
|
from local_deep_research.security.network_utils import is_private_ip
|
|
|
|
assert is_private_ip("224.0.0.1") is False
|