from __future__ import annotations import json from types import SimpleNamespace from unittest.mock import ANY, AsyncMock, MagicMock from urllib.parse import parse_qs, urlsplit import pytest from websockets.datastructures import Headers from nanobot.config.loader import get_config_path from nanobot.webui.http_utils import http_json_response from nanobot.webui.settings_routes import WebUISettingsRouter from nanobot.webui.settings_services import WebUISettingsServices def _router(*, authorized: bool = True) -> WebUISettingsRouter: return WebUISettingsRouter( settings=WebUISettingsServices.create(get_config_path()), bus=SimpleNamespace(), logger=SimpleNamespace(exception=lambda *_args: None), check_api_token=lambda _request: authorized, parse_query=lambda path: parse_qs(urlsplit(path).query), json_response=http_json_response, error_response=lambda status, message: http_json_response( {"error": message}, status=status, ), runtime_surface="browser", runtime_capabilities={}, mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback", ) def _mutation_request(path: str, payload: dict[str, object]) -> SimpleNamespace: request = SimpleNamespace(path=path, headers=Headers()) request._nanobot_webui_mutation_request = True request._nanobot_webui_mutation_payload = payload request._nanobot_trusted_proxy_authenticated = True return request @pytest.mark.asyncio async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None: config = SimpleNamespace( type="streamableHttp", auth="oauth", url="https://app.xmind.com/api/mcp", ) monkeypatch.setattr( "nanobot.webui.settings_routes.ensure_mcp_oauth_server", lambda _query, *, config_path=None: ("xmind", config), ) router = _router() start = AsyncMock(return_value={ "status": "authorization_required", "flow_id": "flow-123", "name": "xmind", "authorization_url": "https://xmind.example/authorize?state=state-123", }) router._mcp_oauth = SimpleNamespace(start=start) request = _mutation_request( "/api/settings/mcp-oauth/start", {"name": "xmind"}, ) response = await router.dispatch(None, request, "/api/settings/mcp-oauth/start") assert response is not None assert response.status_code == 200 assert json.loads(response.body)["flow_id"] == "flow-123" start.assert_awaited_once_with( "xmind", config, "https://gateway.example/auth/mcp/callback", reload_mcp=ANY, reset_credentials=False, ) denied = _router(authorized=False) denied_response = await denied.dispatch(None, request, "/api/settings/mcp-oauth/start") assert denied_response is not None assert denied_response.status_code == 401 failed = _router() failed._mcp_oauth = SimpleNamespace( start=AsyncMock(side_effect=RuntimeError("upstream secret response")) ) failed_response = await failed.dispatch(None, request, "/api/settings/mcp-oauth/start") assert failed_response is not None assert failed_response.status_code == 500 assert json.loads(failed_response.body) == {"error": "MCP OAuth start failed"} assert b"upstream secret response" not in failed_response.body @pytest.mark.asyncio async def test_mcp_oauth_callback_is_state_authenticated_and_returns_close_page() -> None: router = _router(authorized=False) submit = MagicMock(return_value="xmind") router._mcp_oauth = SimpleNamespace(submit_callback=submit) request = SimpleNamespace( path="/auth/mcp/callback?code=oauth-code&state=state-123", headers=Headers(), ) response = await router.dispatch(None, request, "/auth/mcp/callback") assert response is not None assert response.status_code == 200 assert response.headers["Content-Type"] == "text/html; charset=utf-8" assert response.headers["Cache-Control"] == "no-store" assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"] assert b"window.close" in response.body assert b"Authorization received" in response.body assert b"oauth-code" not in response.body submit.assert_called_once_with(state="state-123", code="oauth-code", error=None) @pytest.mark.asyncio async def test_mcp_oauth_manual_completion_reads_websocket_payload() -> None: callback_url = ( "http://127.0.0.1:8765/auth/mcp/callback?code=oauth-code&state=state-123" ) router = _router() submit = MagicMock( return_value={ "flow_id": "flow-123", "name": "linear", "status": "connecting", "expires_in": 299, "completion_input": "callback_url", } ) router._mcp_oauth = SimpleNamespace(submit_callback_url=submit) request = _mutation_request( "/api/settings/mcp-oauth/complete", {"flow_id": "flow-123", "callback_url": callback_url}, ) response = await router.dispatch(None, request, "/api/settings/mcp-oauth/complete") assert response is not None assert response.status_code == 200 assert json.loads(response.body)["status"] == "connecting" assert b"oauth-code" not in response.body submit.assert_called_once_with(flow_id="flow-123", callback_url=callback_url) denied = _router(authorized=False) denied_response = await denied.dispatch( None, request, "/api/settings/mcp-oauth/complete", ) assert denied_response is not None assert denied_response.status_code == 401 @pytest.mark.parametrize( ("provider", "authorization_response"), [ ("xai_grok", "secret"), ( "openai_codex", "http://localhost:1455/auth/callback?code=secret&state=test", ), ], ) @pytest.mark.asyncio async def test_oauth_completion_reads_websocket_payload( monkeypatch, provider: str, authorization_response: str, ) -> None: captured: dict[str, object] = {} def complete( query, authorization_response=None, *, oauth_flows=None, config_path=None, ): captured.update(query=query, authorization_response=authorization_response) return { "status": "pending", "provider": provider, "flow_id": "flow-123", } monkeypatch.setattr("nanobot.webui.settings_routes.complete_oauth_provider", complete) router = _router() request = _mutation_request( "/api/settings/provider/oauth-login/complete", { "provider": provider, "flow_id": "flow-123", "authorization_response": authorization_response, }, ) response = await router.dispatch( None, request, "/api/settings/provider/oauth-login/complete", ) assert response is not None assert response.status_code == 200 assert json.loads(response.body) == { "status": "pending", "provider": provider, "flow_id": "flow-123", } assert captured == { "query": {"provider": [provider], "flow_id": ["flow-123"]}, "authorization_response": authorization_response, } assert request.path == "/api/settings/provider/oauth-login/complete" assert not request.headers @pytest.mark.parametrize( ("route_path", "function_name", "payload", "expected_query"), [ ( "/api/settings/model-configurations/delete", "delete_model_configuration", {"name": "spare"}, {"name": ["spare"]}, ), ( "/api/settings/model-configurations/migrate", "migrate_model_configurations", {}, {}, ), ( "/api/settings/model-call-order/update", "update_model_call_order", {"order": ["backup"]}, {"order": ['["backup"]']}, ), ], ) @pytest.mark.asyncio async def test_model_preset_mutation_routes( monkeypatch, route_path: str, function_name: str, payload: dict[str, object], expected_query: dict[str, list[str]], ) -> None: captured: dict[str, object] = {} def mutate(query, *, config_path=None): captured["query"] = query return {"routed": function_name} monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate) request = _mutation_request(route_path, payload) response = await _router().dispatch(None, request, route_path) assert response is not None assert response.status_code == 200 assert json.loads(response.body)["routed"] == function_name assert captured["query"] == expected_query @pytest.mark.asyncio async def test_settings_get_mutation_route_is_method_not_allowed() -> None: path = "/api/settings/provider/update" request = SimpleNamespace( path=f"{path}?provider=openrouter&api_key=must-not-run", headers=Headers(), ) response = await _router().dispatch(None, request, path) assert response is not None assert response.status_code == 405 assert json.loads(response.body) == { "error": "WebUI mutations require an authenticated WebSocket" } @pytest.mark.parametrize( ("update_info", "expected"), [ (None, {"updateAvailable": None}), ( { "currentVersion": "1.2.0", "latestVersion": "1.3.0", "pypiUrl": "https://pypi.org/project/nanobot-ai/", }, { "updateAvailable": { "currentVersion": "1.2.0", "latestVersion": "1.3.0", "pypiUrl": "https://pypi.org/project/nanobot-ai/", } }, ), ], ) @pytest.mark.asyncio async def test_version_check_route_returns_stable_payload( monkeypatch: pytest.MonkeyPatch, update_info: dict[str, str] | None, expected: dict[str, object], ) -> None: monkeypatch.setattr( "nanobot.webui.settings_routes.check_for_update", lambda: update_info, ) request = SimpleNamespace(path="/api/settings/version-check", headers=Headers()) response = await _router().dispatch(None, request, request.path) assert response is not None assert response.status_code == 200 assert json.loads(response.body) == expected @pytest.mark.asyncio async def test_version_check_route_enforces_auth_and_bounds_failures( monkeypatch: pytest.MonkeyPatch, ) -> None: check = MagicMock(side_effect=RuntimeError("upstream secret body")) monkeypatch.setattr("nanobot.webui.settings_routes.check_for_update", check) request = SimpleNamespace(path="/api/settings/version-check", headers=Headers()) unauthorized = await _router(authorized=False).dispatch(None, request, request.path) assert unauthorized is not None assert unauthorized.status_code == 401 check.assert_not_called() failed = await _router().dispatch(None, request, request.path) assert failed is not None assert failed.status_code == 500 assert json.loads(failed.body) == {"error": "version check failed"} assert "upstream secret body" not in failed.body.decode()