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
548 lines
19 KiB
Python
548 lines
19 KiB
Python
"""
|
|
Validation and save-path coverage tests for settings_routes.py.
|
|
|
|
Targeted functions / branches:
|
|
- validate_setting: checkbox non-boolean, number below/above min/max, select invalid,
|
|
select dynamic setting bypass
|
|
- save_settings (POST fallback): empty form, commit failure rollback, blocked setting
|
|
- api_get_all_settings: category query param filtering
|
|
- api_get_db_setting: 404 for missing key
|
|
- coerce_setting_for_write: various ui_element types
|
|
- api_update_setting: string-to-int and string-to-bool coercion via PUT
|
|
"""
|
|
|
|
from contextlib import contextmanager
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from ._settings_route_helpers import _create_test_app, _make_setting
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
MODULE = "local_deep_research.web.routes.settings_routes"
|
|
DECORATOR_MODULE = "local_deep_research.web.utils.route_decorators"
|
|
SETTINGS_PREFIX = "/settings"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. validate_setting: checkbox with non-boolean value returns False
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateCheckboxNonBoolean:
|
|
"""validate_setting returns False when a checkbox value cannot be converted
|
|
to bool by get_typed_setting_value (i.e. the type converter itself returns
|
|
a non-bool because the ui_element mapping is bypassed).
|
|
|
|
parse_boolean always returns bool for any input, so the only way to reach
|
|
the ``not isinstance(value, bool)`` branch is to have get_typed_setting_value
|
|
return a non-bool — which happens when we mock it to do so.
|
|
"""
|
|
|
|
def test_validate_checkbox_non_boolean(self):
|
|
from local_deep_research.web.routes.settings_routes import (
|
|
validate_setting,
|
|
)
|
|
|
|
setting = _make_setting(key="app.flag", ui_element="checkbox")
|
|
|
|
# Patch get_typed_setting_value so that it returns a non-bool value,
|
|
# exercising the ``not isinstance(value, bool)`` guard in validate_setting.
|
|
with patch(
|
|
f"{MODULE}.get_typed_setting_value", return_value="still-a-string"
|
|
):
|
|
is_valid, message = validate_setting(setting, "not_a_bool")
|
|
|
|
assert is_valid is False
|
|
assert message is not None
|
|
assert "boolean" in message.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. validate_setting: number below min_value
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateNumberBelowMin:
|
|
"""validate_setting returns False when a numeric value is below min_value."""
|
|
|
|
def test_validate_number_below_min(self):
|
|
from local_deep_research.web.routes.settings_routes import (
|
|
validate_setting,
|
|
)
|
|
|
|
setting = _make_setting(
|
|
key="search.iterations",
|
|
ui_element="number",
|
|
min_value=1,
|
|
max_value=100,
|
|
)
|
|
|
|
is_valid, message = validate_setting(setting, 0)
|
|
|
|
assert is_valid is False
|
|
assert message is not None
|
|
assert "1" in message # min_value is 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. validate_setting: number above max_value
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateNumberAboveMax:
|
|
"""validate_setting returns False when a numeric value exceeds max_value."""
|
|
|
|
def test_validate_number_above_max(self):
|
|
from local_deep_research.web.routes.settings_routes import (
|
|
validate_setting,
|
|
)
|
|
|
|
setting = _make_setting(
|
|
key="search.iterations",
|
|
ui_element="number",
|
|
min_value=1,
|
|
max_value=10,
|
|
)
|
|
|
|
is_valid, message = validate_setting(setting, 99)
|
|
|
|
assert is_valid is False
|
|
assert message is not None
|
|
assert "10" in message # max_value is 10
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. validate_setting: select with invalid option value
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateSelectInvalidOption:
|
|
"""validate_setting returns False for a select value not in allowed options."""
|
|
|
|
def test_validate_select_invalid_option(self):
|
|
from local_deep_research.web.routes.settings_routes import (
|
|
validate_setting,
|
|
)
|
|
|
|
setting = _make_setting(
|
|
key="app.theme",
|
|
ui_element="select",
|
|
options=[
|
|
{"value": "dark"},
|
|
{"value": "light"},
|
|
],
|
|
)
|
|
|
|
is_valid, message = validate_setting(setting, "neon")
|
|
|
|
assert is_valid is False
|
|
assert message is not None
|
|
assert "dark" in message or "light" in message
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. validate_setting: select on DYNAMIC_SETTINGS key skips option validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateSelectDynamicSettingSkips:
|
|
"""DYNAMIC_SETTINGS keys bypass option-list validation for select elements."""
|
|
|
|
def test_validate_select_dynamic_setting_skips(self):
|
|
from local_deep_research.web.routes.settings_routes import (
|
|
DYNAMIC_SETTINGS,
|
|
validate_setting,
|
|
)
|
|
|
|
# Use a real DYNAMIC_SETTINGS key
|
|
assert "llm.provider" in DYNAMIC_SETTINGS
|
|
|
|
setting = _make_setting(
|
|
key="llm.provider",
|
|
ui_element="select",
|
|
# options present but should be ignored for this key
|
|
options=[{"value": "openai"}],
|
|
)
|
|
|
|
# "any_arbitrary_provider" is not in options, but because the key is
|
|
# in DYNAMIC_SETTINGS the validation must pass.
|
|
is_valid, message = validate_setting(setting, "any_arbitrary_provider")
|
|
|
|
assert is_valid is True
|
|
assert message is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. save_settings POST: empty form data → redirect with flash success (0 updates)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSaveSettingsPostNoFormData:
|
|
"""save_settings POST with no form fields still commits and redirects."""
|
|
|
|
def test_save_settings_post_no_form_data(self):
|
|
app = _create_test_app()
|
|
|
|
mock_settings_manager = MagicMock()
|
|
mock_settings_manager.set_setting.return_value = True
|
|
mock_db_session = MagicMock()
|
|
mock_db_session.query.return_value.all.return_value = []
|
|
|
|
@contextmanager
|
|
def _fake_session(*args, **kwargs):
|
|
yield mock_db_session
|
|
|
|
with (
|
|
patch(
|
|
"local_deep_research.web.auth.decorators.db_manager"
|
|
) as mock_db,
|
|
patch(
|
|
f"{DECORATOR_MODULE}.get_user_db_session",
|
|
side_effect=_fake_session,
|
|
),
|
|
patch(f"{MODULE}.settings_limit", lambda f: f),
|
|
patch(
|
|
f"{DECORATOR_MODULE}.SettingsManager",
|
|
return_value=mock_settings_manager,
|
|
),
|
|
):
|
|
mock_db.connections = {"testuser": True}
|
|
mock_db.has_encryption = False
|
|
|
|
with app.test_client() as client:
|
|
with client.session_transaction() as sess:
|
|
sess["username"] = "testuser"
|
|
sess["session_id"] = "test-session-id"
|
|
|
|
# POST with empty form (only csrf_token which gets stripped)
|
|
response = client.post(
|
|
f"{SETTINGS_PREFIX}/save_settings",
|
|
data={"csrf_token": "dummy"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
# Should redirect back to settings page
|
|
assert response.status_code in (302, 200)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. save_settings POST: db commit raises → rollback called
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSaveSettingsPostCommitFailureRollback:
|
|
"""save_settings POST calls rollback when db_session.commit() raises."""
|
|
|
|
def test_save_settings_post_commit_failure_rollback(self):
|
|
app = _create_test_app()
|
|
|
|
mock_settings_manager = MagicMock()
|
|
mock_settings_manager.set_setting.return_value = True
|
|
mock_db_session = MagicMock()
|
|
mock_db_session.query.return_value.all.return_value = []
|
|
mock_db_session.commit.side_effect = RuntimeError("disk full")
|
|
|
|
@contextmanager
|
|
def _fake_session(*args, **kwargs):
|
|
yield mock_db_session
|
|
|
|
with (
|
|
patch(
|
|
"local_deep_research.web.auth.decorators.db_manager"
|
|
) as mock_db,
|
|
patch(
|
|
f"{DECORATOR_MODULE}.get_user_db_session",
|
|
side_effect=_fake_session,
|
|
),
|
|
patch(f"{MODULE}.settings_limit", lambda f: f),
|
|
patch(
|
|
f"{DECORATOR_MODULE}.SettingsManager",
|
|
return_value=mock_settings_manager,
|
|
),
|
|
):
|
|
mock_db.connections = {"testuser": True}
|
|
mock_db.has_encryption = False
|
|
|
|
with app.test_client() as client:
|
|
with client.session_transaction() as sess:
|
|
sess["username"] = "testuser"
|
|
sess["session_id"] = "test-session-id"
|
|
|
|
response = client.post(
|
|
f"{SETTINGS_PREFIX}/save_settings",
|
|
data={"app.theme": "dark"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
# rollback must have been called
|
|
mock_db_session.rollback.assert_called_once()
|
|
# Still redirects (error flash)
|
|
assert response.status_code in (302, 200)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. api_get_all_settings: category query param filters results
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestApiGetSettingsFilteredByCategory:
|
|
"""GET /settings/api?category=<cat> returns only settings in that category."""
|
|
|
|
def test_api_get_settings_filtered_by_category(self):
|
|
app = _create_test_app()
|
|
|
|
llm_setting = _make_setting(
|
|
key="llm.model",
|
|
value="gpt-4",
|
|
category="llm_general",
|
|
)
|
|
search_setting = _make_setting(
|
|
key="search.tool",
|
|
value="searxng",
|
|
category="search_general",
|
|
)
|
|
|
|
mock_settings_manager = MagicMock()
|
|
# get_all_settings returns a flat dict of key→value
|
|
mock_settings_manager.get_all_settings.return_value = {
|
|
"llm.model": "gpt-4",
|
|
"search.tool": "searxng",
|
|
}
|
|
|
|
mock_db_session = MagicMock()
|
|
# query(Setting).all() used to build category_keys
|
|
mock_db_session.query.return_value.all.return_value = [
|
|
llm_setting,
|
|
search_setting,
|
|
]
|
|
|
|
@contextmanager
|
|
def _fake_session(*args, **kwargs):
|
|
yield mock_db_session
|
|
|
|
with (
|
|
patch(
|
|
"local_deep_research.web.auth.decorators.db_manager"
|
|
) as mock_db,
|
|
patch(
|
|
f"{DECORATOR_MODULE}.get_user_db_session",
|
|
side_effect=_fake_session,
|
|
),
|
|
patch(f"{MODULE}.settings_limit", lambda f: f),
|
|
patch(
|
|
f"{DECORATOR_MODULE}.SettingsManager",
|
|
return_value=mock_settings_manager,
|
|
),
|
|
):
|
|
mock_db.connections = {"testuser": True}
|
|
mock_db.has_encryption = False
|
|
|
|
with app.test_client() as client:
|
|
with client.session_transaction() as sess:
|
|
sess["username"] = "testuser"
|
|
sess["session_id"] = "test-session-id"
|
|
|
|
response = client.get(
|
|
f"{SETTINGS_PREFIX}/api?category=llm_general"
|
|
)
|
|
|
|
# The endpoint should succeed
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert data["status"] == "success"
|
|
# Only llm_general keys should be present
|
|
settings = data["settings"]
|
|
assert "llm.model" in settings
|
|
assert "search.tool" not in settings
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 10. api_get_db_setting: returns 404 for unknown key
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestApiGetDbSettingNotFound:
|
|
"""GET /settings/api/<key> returns 404 when the key is absent from the DB."""
|
|
|
|
def test_api_get_db_setting_not_found(self):
|
|
app = _create_test_app()
|
|
|
|
mock_db_session = MagicMock()
|
|
# Simulate no setting found: query(...).filter(...).first() → None
|
|
mock_db_session.query.return_value.filter.return_value.first.return_value = None
|
|
|
|
@contextmanager
|
|
def _fake_session(*args, **kwargs):
|
|
yield mock_db_session
|
|
|
|
with (
|
|
patch(
|
|
"local_deep_research.web.auth.decorators.db_manager"
|
|
) as mock_db,
|
|
patch(
|
|
f"{DECORATOR_MODULE}.get_user_db_session",
|
|
side_effect=_fake_session,
|
|
),
|
|
patch(f"{MODULE}.settings_limit", lambda f: f),
|
|
):
|
|
mock_db.connections = {"testuser": True}
|
|
mock_db.has_encryption = False
|
|
|
|
with app.test_client() as client:
|
|
with client.session_transaction() as sess:
|
|
sess["username"] = "testuser"
|
|
sess["session_id"] = "test-session-id"
|
|
|
|
response = client.get(
|
|
f"{SETTINGS_PREFIX}/api/nonexistent.setting.key"
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
data = response.get_json()
|
|
assert "error" in data
|
|
assert "nonexistent.setting.key" in data["error"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 11. coerce_setting_for_write: various ui_element type coercions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCoerceSettingForWriteVariousTypes:
|
|
"""coerce_setting_for_write delegates to get_typed_setting_value correctly."""
|
|
|
|
def test_text_returns_string(self):
|
|
from local_deep_research.web.routes.settings_routes import (
|
|
coerce_setting_for_write,
|
|
)
|
|
|
|
result = coerce_setting_for_write("app.name", 42, "text")
|
|
assert result == "42"
|
|
|
|
def test_number_converts_string_to_int_or_float(self):
|
|
from local_deep_research.web.routes.settings_routes import (
|
|
coerce_setting_for_write,
|
|
)
|
|
|
|
result = coerce_setting_for_write("search.iterations", "5", "number")
|
|
assert result == 5
|
|
assert isinstance(result, (int, float))
|
|
|
|
def test_checkbox_converts_string_true_to_bool(self):
|
|
from local_deep_research.web.routes.settings_routes import (
|
|
coerce_setting_for_write,
|
|
)
|
|
|
|
result = coerce_setting_for_write("app.flag", "true", "checkbox")
|
|
assert result is True
|
|
|
|
def test_checkbox_converts_string_false_to_bool(self):
|
|
from local_deep_research.web.routes.settings_routes import (
|
|
coerce_setting_for_write,
|
|
)
|
|
|
|
result = coerce_setting_for_write("app.flag", "false", "checkbox")
|
|
assert result is False
|
|
|
|
def test_select_returns_string(self):
|
|
from local_deep_research.web.routes.settings_routes import (
|
|
coerce_setting_for_write,
|
|
)
|
|
|
|
result = coerce_setting_for_write("app.theme", "dark", "select")
|
|
assert result == "dark"
|
|
|
|
def test_unknown_ui_element_returns_none(self):
|
|
from local_deep_research.web.routes.settings_routes import (
|
|
coerce_setting_for_write,
|
|
)
|
|
|
|
# get_typed_setting_value returns default (None) for unknown ui_element
|
|
result = coerce_setting_for_write("foo.bar", "value", "unknown_widget")
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 12. api_update_setting: string-to-int and string-to-bool coercion via PUT
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestApiUpdateSettingTypeCoercion:
|
|
"""PUT /settings/api/<key> coerces incoming strings to the correct type."""
|
|
|
|
def _put_setting(self, key, payload_value, ui_element):
|
|
"""Helper that performs a PUT and returns (response, mock_set_setting)."""
|
|
app = _create_test_app()
|
|
|
|
db_setting = _make_setting(
|
|
key=key,
|
|
value="old",
|
|
ui_element=ui_element,
|
|
editable=True,
|
|
)
|
|
mock_db_session = MagicMock()
|
|
mock_db_session.query.return_value.filter.return_value.first.return_value = db_setting
|
|
|
|
captured = {}
|
|
|
|
@contextmanager
|
|
def _fake_session(*args, **kwargs):
|
|
yield mock_db_session
|
|
|
|
def _fake_set_setting(k, v, db_session=None):
|
|
captured["key"] = k
|
|
captured["value"] = v
|
|
return True
|
|
|
|
with (
|
|
patch(
|
|
"local_deep_research.web.auth.decorators.db_manager"
|
|
) as mock_db,
|
|
patch(
|
|
f"{DECORATOR_MODULE}.get_user_db_session",
|
|
side_effect=_fake_session,
|
|
),
|
|
patch(f"{MODULE}.settings_limit", lambda f: f),
|
|
patch(f"{MODULE}.set_setting", side_effect=_fake_set_setting),
|
|
patch(f"{MODULE}.validate_setting", return_value=(True, None)),
|
|
):
|
|
mock_db.connections = {"testuser": True}
|
|
mock_db.has_encryption = False
|
|
|
|
with app.test_client() as client:
|
|
with client.session_transaction() as sess:
|
|
sess["username"] = "testuser"
|
|
sess["session_id"] = "test-session-id"
|
|
|
|
response = client.put(
|
|
f"{SETTINGS_PREFIX}/api/{key}",
|
|
json={"value": payload_value},
|
|
content_type="application/json",
|
|
)
|
|
|
|
return response, captured
|
|
|
|
def test_string_to_int_coercion(self):
|
|
"""PUT with string "7" on a number setting stores integer 7."""
|
|
response, captured = self._put_setting(
|
|
"search.iterations", "7", "number"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# set_setting must have been called with the coerced int, not the raw string
|
|
assert "value" in captured
|
|
stored_value = captured["value"]
|
|
assert stored_value == 7
|
|
assert isinstance(stored_value, (int, float))
|
|
|
|
def test_string_to_bool_coercion(self):
|
|
"""PUT with string "true" on a checkbox setting stores boolean True."""
|
|
response, captured = self._put_setting("app.flag", "true", "checkbox")
|
|
|
|
assert response.status_code == 200
|
|
assert "value" in captured
|
|
stored_value = captured["value"]
|
|
assert stored_value is True
|