mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
344 lines
11 KiB
Python
344 lines
11 KiB
Python
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()
|