7a0da7932b
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
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
250 lines
9.3 KiB
Python
250 lines
9.3 KiB
Python
"""Tests for SocketIOService.__init_singleton and singleton behavior."""
|
|
|
|
from unittest.mock import patch
|
|
import pytest
|
|
from flask import Flask
|
|
|
|
from local_deep_research.web.services.socket_service import SocketIOService
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_singleton():
|
|
"""Reset singleton before each test."""
|
|
SocketIOService._instance = None
|
|
yield
|
|
SocketIOService._instance = None
|
|
|
|
|
|
@pytest.fixture
|
|
def minimal_app():
|
|
"""Create a minimal Flask app (no create_app() which triggers SocketIOService)."""
|
|
app = Flask(__name__)
|
|
app.config["SECRET_KEY"] = "test"
|
|
app.config["TESTING"] = True
|
|
return app
|
|
|
|
|
|
class TestSocketIOServiceSingleton:
|
|
"""Tests for singleton creation and behavior."""
|
|
|
|
def test_requires_app_on_first_creation(self):
|
|
"""Should raise ValueError if no app provided on first init."""
|
|
with pytest.raises(ValueError, match="Flask app must be specified"):
|
|
SocketIOService()
|
|
|
|
@patch("local_deep_research.web.services.socket_service.SocketIO")
|
|
@patch("local_deep_research.settings.env_registry.get_env_setting")
|
|
def test_creates_singleton_with_app(
|
|
self, mock_env, mock_socketio, minimal_app
|
|
):
|
|
mock_env.return_value = None
|
|
|
|
service = SocketIOService(app=minimal_app)
|
|
assert service is not None
|
|
assert SocketIOService._instance is service
|
|
|
|
@patch("local_deep_research.web.services.socket_service.SocketIO")
|
|
@patch("local_deep_research.settings.env_registry.get_env_setting")
|
|
def test_second_call_returns_same_instance(
|
|
self, mock_env, mock_socketio, minimal_app
|
|
):
|
|
mock_env.return_value = None
|
|
|
|
first = SocketIOService(app=minimal_app)
|
|
second = SocketIOService(app=minimal_app)
|
|
assert first is second
|
|
|
|
@patch("local_deep_research.web.services.socket_service.SocketIO")
|
|
@patch("local_deep_research.settings.env_registry.get_env_setting")
|
|
def test_second_call_without_app_returns_existing(
|
|
self, mock_env, mock_socketio, minimal_app
|
|
):
|
|
mock_env.return_value = None
|
|
|
|
first = SocketIOService(app=minimal_app)
|
|
second = SocketIOService() # No app needed on second call
|
|
assert first is second
|
|
|
|
|
|
class TestSocketIOServiceCorsConfig:
|
|
"""Tests for WebSocket CORS configuration in __init_singleton."""
|
|
|
|
@patch("local_deep_research.web.services.socket_service.SocketIO")
|
|
@patch("local_deep_research.settings.env_registry.get_env_setting")
|
|
def test_default_cors_is_same_origin(
|
|
self, mock_env, mock_socketio, minimal_app
|
|
):
|
|
"""No env var set -> secure same-origin-only default (None), per #3091."""
|
|
mock_env.return_value = None
|
|
|
|
SocketIOService(app=minimal_app)
|
|
mock_socketio.assert_called_once()
|
|
call_kwargs = mock_socketio.call_args[1]
|
|
assert call_kwargs["cors_allowed_origins"] is None
|
|
|
|
@patch("local_deep_research.web.services.socket_service.SocketIO")
|
|
@patch("local_deep_research.settings.env_registry.get_env_setting")
|
|
def test_wildcard_env_setting(self, mock_env, mock_socketio, minimal_app):
|
|
mock_env.return_value = "*"
|
|
|
|
SocketIOService(app=minimal_app)
|
|
call_kwargs = mock_socketio.call_args[1]
|
|
assert call_kwargs["cors_allowed_origins"] == "*"
|
|
|
|
@patch("local_deep_research.web.services.socket_service.SocketIO")
|
|
@patch("local_deep_research.settings.env_registry.get_env_setting")
|
|
def test_specific_origins(self, mock_env, mock_socketio, minimal_app):
|
|
mock_env.return_value = "https://a.com, https://b.com"
|
|
|
|
SocketIOService(app=minimal_app)
|
|
call_kwargs = mock_socketio.call_args[1]
|
|
assert call_kwargs["cors_allowed_origins"] == [
|
|
"https://a.com",
|
|
"https://b.com",
|
|
]
|
|
|
|
@patch("local_deep_research.web.services.socket_service.SocketIO")
|
|
@patch("local_deep_research.settings.env_registry.get_env_setting")
|
|
def test_empty_env_disables_cors(
|
|
self, mock_env, mock_socketio, minimal_app
|
|
):
|
|
"""Empty string env var -> same-origin only (None)."""
|
|
mock_env.return_value = ""
|
|
|
|
SocketIOService(app=minimal_app)
|
|
call_kwargs = mock_socketio.call_args[1]
|
|
assert call_kwargs["cors_allowed_origins"] is None
|
|
|
|
@patch("local_deep_research.web.services.socket_service.SocketIO")
|
|
@patch("local_deep_research.settings.env_registry.get_env_setting")
|
|
def test_socketio_async_mode_threading(
|
|
self, mock_env, mock_socketio, minimal_app
|
|
):
|
|
mock_env.return_value = None
|
|
|
|
SocketIOService(app=minimal_app)
|
|
call_kwargs = mock_socketio.call_args[1]
|
|
assert call_kwargs["async_mode"] == "threading"
|
|
|
|
@patch("local_deep_research.web.services.socket_service.SocketIO")
|
|
@patch("local_deep_research.settings.env_registry.get_env_setting")
|
|
def test_socketio_path(self, mock_env, mock_socketio, minimal_app):
|
|
mock_env.return_value = None
|
|
|
|
SocketIOService(app=minimal_app)
|
|
call_kwargs = mock_socketio.call_args[1]
|
|
assert call_kwargs["path"] == "/socket.io"
|
|
|
|
|
|
class TestOriginRejectionLogging:
|
|
"""The diagnostic hook that surfaces engine.io's silenced 'bad-origin'
|
|
WebSocket rejections through loguru (so a misconfigured origin isn't a
|
|
silent frozen UI)."""
|
|
|
|
def test_logs_each_rejected_origin_once_and_ignores_other_errors(self):
|
|
from loguru import logger
|
|
from flask import Flask
|
|
from flask_socketio import SocketIO
|
|
from local_deep_research.web.services.socket_service import (
|
|
_install_origin_rejection_logging,
|
|
)
|
|
|
|
app = Flask(__name__)
|
|
app.config["SECRET_KEY"] = "test"
|
|
socketio = SocketIO(
|
|
app, async_mode="threading", cors_allowed_origins=None
|
|
)
|
|
|
|
assert _install_origin_rejection_logging(socketio) is True
|
|
eio = socketio.server.eio
|
|
|
|
# The package disables loguru by default (__init__.py); app startup
|
|
# re-enables it. Enable it here so the warning emitted from inside the
|
|
# socket_service module reaches our sink.
|
|
logger.enable("local_deep_research")
|
|
warnings: list[str] = []
|
|
sink_id = logger.add(
|
|
lambda m: warnings.append(m.record["message"]), level="WARNING"
|
|
)
|
|
try:
|
|
eio._log_error_once(
|
|
"https://evil.com is not an accepted origin.", "bad-origin"
|
|
)
|
|
# same origin again -> deduped (no second warning)
|
|
eio._log_error_once(
|
|
"https://evil.com is not an accepted origin.", "bad-origin"
|
|
)
|
|
# a different origin -> its own warning
|
|
eio._log_error_once(
|
|
"https://other.com is not an accepted origin.", "bad-origin"
|
|
)
|
|
# an unrelated engine.io error -> must NOT be turned into a warning
|
|
eio._log_error_once("Invalid transport", "bad-transport")
|
|
finally:
|
|
logger.remove(sink_id)
|
|
logger.disable("local_deep_research") # restore import-time default
|
|
|
|
origin_warnings = [
|
|
w for w in warnings if "rejected a WebSocket handshake" in w
|
|
]
|
|
assert len(origin_warnings) == 2
|
|
assert any("evil.com" in w for w in origin_warnings)
|
|
assert any("other.com" in w for w in origin_warnings)
|
|
# the fix hint is present; the unrelated error never warned
|
|
assert all(
|
|
"LDR_SECURITY_WEBSOCKET_ALLOWED_ORIGINS" in w
|
|
for w in origin_warnings
|
|
)
|
|
assert not any("Invalid transport" in w for w in warnings)
|
|
|
|
def test_returns_false_when_engineio_internals_absent(self):
|
|
from unittest.mock import Mock
|
|
from local_deep_research.web.services.socket_service import (
|
|
_install_origin_rejection_logging,
|
|
)
|
|
|
|
broken = Mock()
|
|
# No .server.eio._log_error_once -> AttributeError path -> no-op.
|
|
del broken.server
|
|
assert _install_origin_rejection_logging(broken) is False
|
|
|
|
def test_dedup_set_is_capped(self):
|
|
"""The per-origin dedup set is bounded: Origin is attacker-controlled at
|
|
the pre-auth handshake, so feeding many distinct origins must not warn
|
|
(or grow) without limit."""
|
|
from loguru import logger
|
|
from flask import Flask
|
|
from flask_socketio import SocketIO
|
|
from local_deep_research.web.services.socket_service import (
|
|
_install_origin_rejection_logging,
|
|
)
|
|
|
|
app = Flask(__name__)
|
|
app.config["SECRET_KEY"] = "test"
|
|
socketio = SocketIO(
|
|
app, async_mode="threading", cors_allowed_origins=None
|
|
)
|
|
assert _install_origin_rejection_logging(socketio) is True
|
|
eio = socketio.server.eio
|
|
|
|
logger.enable("local_deep_research")
|
|
warnings: list[str] = []
|
|
sink_id = logger.add(
|
|
lambda m: warnings.append(m.record["message"]), level="WARNING"
|
|
)
|
|
try:
|
|
for i in range(250):
|
|
eio._log_error_once(
|
|
f"https://h{i}.example is not an accepted origin.",
|
|
"bad-origin",
|
|
)
|
|
finally:
|
|
logger.remove(sink_id)
|
|
logger.disable("local_deep_research")
|
|
|
|
origin_warnings = [
|
|
w for w in warnings if "rejected a WebSocket handshake" in w
|
|
]
|
|
# Capped at 100 despite 250 distinct origins fed in.
|
|
assert len(origin_warnings) == 100
|