feat(webui): add guided setup flows
* feat(channels): add guided setup flows * test(channels): preserve setup config values * fix(channels): reflect saved setup state * refactor(channels): simplify setup state metadata * fix(channels): harden setup lifecycle * refactor(channels): centralize setup contracts * fix(channels): route setup actions through webui shim * fix(channels): adapt settings for compact screens * fix(models): preserve default preset display * feat(models): add curated Codex catalog * fix(webui): stop attached gateway on interrupt * fix(webui): simplify apps catalog * docs(webui): clarify apps and runtime features * feat(settings): add guided capability setup * fix(webui): harden setup and managed services * test: keep managed runtime checks portable * test: scope POSIX runtime coverage * fix(webui): simplify file settings * feat(files): bundle document reading * fix(webui): harden setup request boundaries * fix(webui): prevent channel setup status squeeze * fix(settings): group provider compatibility aliases * refactor(settings): remove redundant setup surfaces * fix(webui): harden guided setup lifecycle * fix(webui): preserve channel setup compatibility
This commit is contained in:
@@ -11,7 +11,6 @@ from typing import Any, cast
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from nanobot.cli import onboard as onboard_wizard
|
||||
from nanobot.cli.commands import _merge_missing_defaults
|
||||
from nanobot.cli.onboard import (
|
||||
_BACK_PRESSED,
|
||||
_configure_pydantic_model,
|
||||
@@ -22,18 +21,19 @@ from nanobot.cli.onboard import (
|
||||
_input_text,
|
||||
run_onboard,
|
||||
)
|
||||
from nanobot.config.loader import merge_missing_defaults
|
||||
from nanobot.config.schema import Config, ModelPresetConfig
|
||||
from nanobot.utils.helpers import sync_workspace_templates
|
||||
|
||||
|
||||
class TestMergeMissingDefaults:
|
||||
"""Tests for _merge_missing_defaults recursive config merging."""
|
||||
"""Tests for recursive config default merging."""
|
||||
|
||||
def test_adds_missing_top_level_keys(self):
|
||||
existing = {"a": 1}
|
||||
defaults = {"a": 1, "b": 2, "c": 3}
|
||||
|
||||
result = _merge_missing_defaults(existing, defaults)
|
||||
result = merge_missing_defaults(existing, defaults)
|
||||
|
||||
assert result == {"a": 1, "b": 2, "c": 3}
|
||||
|
||||
@@ -41,7 +41,7 @@ class TestMergeMissingDefaults:
|
||||
existing = {"a": "custom_value"}
|
||||
defaults = {"a": "default_value"}
|
||||
|
||||
result = _merge_missing_defaults(existing, defaults)
|
||||
result = merge_missing_defaults(existing, defaults)
|
||||
|
||||
assert result == {"a": "custom_value"}
|
||||
|
||||
@@ -63,7 +63,7 @@ class TestMergeMissingDefaults:
|
||||
}
|
||||
}
|
||||
|
||||
result = _merge_missing_defaults(existing, defaults)
|
||||
result = merge_missing_defaults(existing, defaults)
|
||||
|
||||
assert result == {
|
||||
"level1": {
|
||||
@@ -76,19 +76,19 @@ class TestMergeMissingDefaults:
|
||||
}
|
||||
|
||||
def test_returns_existing_if_not_dict(self):
|
||||
assert _merge_missing_defaults("string", {"a": 1}) == "string"
|
||||
assert _merge_missing_defaults([1, 2, 3], {"a": 1}) == [1, 2, 3]
|
||||
assert _merge_missing_defaults(None, {"a": 1}) is None
|
||||
assert _merge_missing_defaults(42, {"a": 1}) == 42
|
||||
assert merge_missing_defaults("string", {"a": 1}) == "string"
|
||||
assert merge_missing_defaults([1, 2, 3], {"a": 1}) == [1, 2, 3]
|
||||
assert merge_missing_defaults(None, {"a": 1}) is None
|
||||
assert merge_missing_defaults(42, {"a": 1}) == 42
|
||||
|
||||
def test_returns_existing_if_defaults_not_dict(self):
|
||||
assert _merge_missing_defaults({"a": 1}, "string") == {"a": 1}
|
||||
assert _merge_missing_defaults({"a": 1}, None) == {"a": 1}
|
||||
assert merge_missing_defaults({"a": 1}, "string") == {"a": 1}
|
||||
assert merge_missing_defaults({"a": 1}, None) == {"a": 1}
|
||||
|
||||
def test_handles_empty_dicts(self):
|
||||
assert _merge_missing_defaults({}, {"a": 1}) == {"a": 1}
|
||||
assert _merge_missing_defaults({"a": 1}, {}) == {"a": 1}
|
||||
assert _merge_missing_defaults({}, {}) == {}
|
||||
assert merge_missing_defaults({}, {"a": 1}) == {"a": 1}
|
||||
assert merge_missing_defaults({"a": 1}, {}) == {"a": 1}
|
||||
assert merge_missing_defaults({}, {}) == {}
|
||||
|
||||
def test_backfills_channel_config(self):
|
||||
"""Real-world scenario: backfill missing channel fields."""
|
||||
@@ -105,7 +105,7 @@ class TestMergeMissingDefaults:
|
||||
"allowFrom": [],
|
||||
}
|
||||
|
||||
result = _merge_missing_defaults(existing_channel, default_channel)
|
||||
result = merge_missing_defaults(existing_channel, default_channel)
|
||||
|
||||
assert result["msgFormat"] == "plain"
|
||||
assert result["allowFrom"] == []
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
class _HotChannel(BaseChannel):
|
||||
name = "hot"
|
||||
display_name = "Hot"
|
||||
|
||||
def __init__(self, config, bus):
|
||||
super().__init__(config, bus)
|
||||
self.started = asyncio.Event()
|
||||
self.stopped = asyncio.Event()
|
||||
|
||||
async def start(self):
|
||||
self._running = True
|
||||
self.started.set()
|
||||
await self.stopped.wait()
|
||||
|
||||
async def stop(self):
|
||||
self._running = False
|
||||
self.stopped.set()
|
||||
|
||||
async def send(self, msg): # pragma: no cover - not used by this test
|
||||
raise AssertionError("send should not be called")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_channel_feature_action_starts_and_stops_channel(monkeypatch):
|
||||
disabled = Config.model_validate({
|
||||
"channels": {
|
||||
"websocket": {"enabled": False},
|
||||
"hot": {"enabled": False},
|
||||
}
|
||||
})
|
||||
enabled = Config.model_validate({
|
||||
"channels": {
|
||||
"websocket": {"enabled": False},
|
||||
"hot": {"enabled": True},
|
||||
}
|
||||
})
|
||||
|
||||
import nanobot.channels.registry as registry
|
||||
|
||||
def discover_enabled(enabled_names, **_kwargs):
|
||||
return {"hot": _HotChannel} if "hot" in enabled_names else {}
|
||||
|
||||
configs = iter([enabled, disabled])
|
||||
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
|
||||
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
|
||||
monkeypatch.setattr(registry, "discover_enabled", discover_enabled)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs))
|
||||
|
||||
manager = ChannelManager(disabled, MessageBus())
|
||||
manager._started = True
|
||||
|
||||
enabled_result = await manager.apply_channel_feature_action("enable", "hot")
|
||||
|
||||
assert enabled_result["handled"] is True
|
||||
assert enabled_result["requires_restart"] is False
|
||||
channel = manager.channels["hot"]
|
||||
await asyncio.wait_for(channel.started.wait(), timeout=1)
|
||||
assert channel.is_running is True
|
||||
|
||||
disabled_result = await manager.apply_channel_feature_action("disable", "hot")
|
||||
|
||||
assert disabled_result["handled"] is True
|
||||
assert disabled_result["requires_restart"] is False
|
||||
assert "hot" not in manager.channels
|
||||
assert channel.is_running is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_fails(monkeypatch):
|
||||
enabled = Config.model_validate({
|
||||
"channels": {
|
||||
"websocket": {"enabled": False},
|
||||
"hot": {"enabled": True},
|
||||
}
|
||||
})
|
||||
|
||||
import nanobot.channels.registry as registry
|
||||
|
||||
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
|
||||
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
|
||||
monkeypatch.setattr(
|
||||
registry,
|
||||
"discover_enabled",
|
||||
lambda enabled_names, **_kwargs: {"hot": _HotChannel},
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: enabled)
|
||||
|
||||
manager = ChannelManager(enabled, MessageBus())
|
||||
old_channel = manager.channels["hot"]
|
||||
old_channel._running = True
|
||||
|
||||
def fail_build(*_args, **_kwargs):
|
||||
raise RuntimeError("invalid replacement config")
|
||||
|
||||
monkeypatch.setattr(manager, "_build_channel", fail_build)
|
||||
|
||||
result = await manager.apply_channel_feature_action("enable", "hot")
|
||||
|
||||
assert result["requires_restart"] is True
|
||||
assert manager.channels["hot"] is old_channel
|
||||
assert old_channel.is_running is True
|
||||
assert not old_channel.stopped.is_set()
|
||||
@@ -72,6 +72,34 @@ class _FakeTelegram(BaseChannel):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeFeishu(BaseChannel):
|
||||
name = "feishu"
|
||||
display_name = "Feishu"
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict:
|
||||
return {
|
||||
"instanceId": "default",
|
||||
"name": "nanobot",
|
||||
"enabled": False,
|
||||
"appId": "",
|
||||
"appSecret": "",
|
||||
"domain": "feishu",
|
||||
"groupPolicy": "mention",
|
||||
"topicIsolation": True,
|
||||
"allowFrom": [],
|
||||
}
|
||||
|
||||
async def start(self) -> None:
|
||||
pass
|
||||
|
||||
async def stop(self) -> None:
|
||||
pass
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _make_entry_point(name: str, cls: type):
|
||||
"""Create a mock entry point that returns *cls* on load()."""
|
||||
ep = SimpleNamespace(name=name, load=lambda _cls=cls: _cls)
|
||||
@@ -136,6 +164,49 @@ def test_channels_config_extract_document_text_accepts_camel_alias():
|
||||
assert cfg.extract_document_text is False
|
||||
|
||||
|
||||
def test_channel_manager_expands_feishu_instances(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.registry.discover_enabled",
|
||||
lambda enabled, _names=None, warn_import_errors=True: {"feishu": _FakeFeishu}
|
||||
if "feishu" in enabled
|
||||
else {},
|
||||
)
|
||||
|
||||
cfg = Config.model_validate({
|
||||
"channels": {
|
||||
"feishu": {
|
||||
"instances": [
|
||||
{
|
||||
"id": "default",
|
||||
"enabled": True,
|
||||
"appId": "cli_default",
|
||||
"appSecret": "secret",
|
||||
},
|
||||
{
|
||||
"id": "product",
|
||||
"enabled": True,
|
||||
"appId": "cli_product",
|
||||
"appSecret": "secret",
|
||||
},
|
||||
{
|
||||
"id": "off",
|
||||
"enabled": False,
|
||||
"appId": "cli_off",
|
||||
"appSecret": "secret",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
manager = ChannelManager(cfg, MessageBus())
|
||||
|
||||
assert set(manager.channels) == {"feishu", "feishu.product"}
|
||||
assert manager.channels["feishu"].name == "feishu"
|
||||
assert manager.channels["feishu.product"].name == "feishu.product"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# discover_plugins
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -201,6 +272,17 @@ def test_discover_all_includes_builtins():
|
||||
assert name in discover_channel_names()
|
||||
|
||||
|
||||
def test_discover_channel_names_excludes_internal_helpers():
|
||||
from nanobot.channels.registry import discover_channel_names
|
||||
|
||||
names = discover_channel_names()
|
||||
|
||||
assert "_feishu_ws" not in names
|
||||
assert "_setup" not in names
|
||||
assert "setup" not in names
|
||||
assert "_feishu_instances" not in names
|
||||
|
||||
|
||||
def test_discover_all_includes_external_plugin():
|
||||
from nanobot.channels.registry import discover_all
|
||||
|
||||
@@ -891,6 +973,25 @@ def test_enable_optional_feature_skips_install_when_dependency_present(
|
||||
assert not config_path.exists()
|
||||
|
||||
|
||||
def test_enable_optional_feature_lazy_reader_does_not_require_restart(monkeypatch, tmp_path):
|
||||
from nanobot.optional_features import enable_optional_feature
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: [])
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
"nanobot.optional_features.optional_dependency_groups",
|
||||
lambda: {"documents": ["pypdf>=5.0.0,<6.0.0"]},
|
||||
)
|
||||
monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: True)
|
||||
|
||||
payload = enable_optional_feature("documents", config_path=config_path)
|
||||
|
||||
assert payload["requires_restart"] is False
|
||||
assert payload["last_action"]["message"] == "Feature 'documents' is included with nanobot"
|
||||
|
||||
|
||||
def test_enable_optional_feature_reports_install_failure(monkeypatch, tmp_path):
|
||||
from nanobot.optional_features import (
|
||||
InstallResult,
|
||||
@@ -1009,6 +1110,370 @@ def test_optional_features_payload_counts_enabled_channel_with_missing_dependenc
|
||||
assert payload["enabled_count"] == 1
|
||||
|
||||
|
||||
def test_optional_features_payload_reflects_saved_channel_config(monkeypatch):
|
||||
from nanobot.optional_features import optional_features_payload
|
||||
|
||||
config = Config.model_validate({
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": False,
|
||||
"token": "discord-secret-token",
|
||||
"allowChannels": ["123", "456"],
|
||||
"groupPolicy": "open",
|
||||
}
|
||||
}
|
||||
})
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["discord"])
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
|
||||
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
|
||||
|
||||
payload = optional_features_payload(config=config)
|
||||
|
||||
discord = payload["features"][0]
|
||||
assert discord["name"] == "discord"
|
||||
assert discord["enabled"] is False
|
||||
assert discord["configured"] is True
|
||||
assert discord["config_values"] == {
|
||||
"channels.discord.allowChannels": "123, 456",
|
||||
"channels.discord.groupPolicy": "open",
|
||||
}
|
||||
assert discord["configured_fields"] == [
|
||||
"channels.discord.token",
|
||||
"channels.discord.allowChannels",
|
||||
"channels.discord.groupPolicy",
|
||||
]
|
||||
assert "discord-secret-token" not in json.dumps(payload)
|
||||
|
||||
|
||||
def test_optional_features_payload_marks_enabled_channel_missing_credentials(monkeypatch):
|
||||
from nanobot.optional_features import optional_features_payload
|
||||
|
||||
config = Config.model_validate({"channels": {"discord": {"enabled": True}}})
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["discord"])
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
|
||||
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
|
||||
|
||||
payload = optional_features_payload(config=config)
|
||||
|
||||
discord = payload["features"][0]
|
||||
assert discord["enabled"] is True
|
||||
assert discord["configured"] is False
|
||||
assert "config_values" not in discord
|
||||
assert "configured_fields" not in discord
|
||||
|
||||
|
||||
def test_optional_features_payload_detects_saved_weixin_login_state(tmp_path, monkeypatch):
|
||||
from nanobot.optional_features import optional_features_payload
|
||||
|
||||
state_dir = tmp_path / "weixin-state"
|
||||
state_dir.mkdir()
|
||||
(state_dir / "account.json").write_text(
|
||||
json.dumps({"token": "saved-weixin-token"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = Config.model_validate({
|
||||
"channels": {
|
||||
"weixin": {
|
||||
"enabled": True,
|
||||
"stateDir": str(state_dir),
|
||||
}
|
||||
}
|
||||
})
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"])
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
|
||||
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
|
||||
|
||||
payload = optional_features_payload(config=config)
|
||||
|
||||
weixin = payload["features"][0]
|
||||
assert weixin["enabled"] is True
|
||||
assert weixin["configured"] is True
|
||||
|
||||
|
||||
def test_optional_features_payload_detects_legacy_default_weixin_state(tmp_path, monkeypatch):
|
||||
from nanobot.config import loader
|
||||
from nanobot.optional_features import optional_features_payload
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
loader.save_config(Config(), config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
state_dir = tmp_path / "weixin"
|
||||
state_dir.mkdir()
|
||||
(state_dir / "account.json").write_text(
|
||||
json.dumps({"token": "legacy-weixin-token"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"])
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
|
||||
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
|
||||
|
||||
payload = optional_features_payload(config=Config())
|
||||
|
||||
weixin = payload["features"][0]
|
||||
assert weixin["enabled"] is False
|
||||
assert weixin["configured"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device_id", ["", "DEVICE-ID"])
|
||||
def test_optional_features_payload_requires_matrix_device_id_for_token_login(
|
||||
monkeypatch,
|
||||
device_id,
|
||||
):
|
||||
from nanobot.optional_features import optional_features_payload
|
||||
|
||||
config = Config.model_validate({
|
||||
"channels": {
|
||||
"matrix": {
|
||||
"enabled": False,
|
||||
"homeserver": "https://matrix.example",
|
||||
"userId": "@nanobot:matrix.example",
|
||||
"accessToken": "saved-token",
|
||||
"deviceId": device_id,
|
||||
}
|
||||
}
|
||||
})
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["matrix"])
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
|
||||
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
|
||||
|
||||
payload = optional_features_payload(config=config)
|
||||
|
||||
assert payload["features"][0]["configured"] is bool(device_id)
|
||||
|
||||
|
||||
def test_optional_features_payload_marks_disabled_feishu_as_configured(monkeypatch):
|
||||
from nanobot.optional_features import optional_features_payload
|
||||
|
||||
config = Config.model_validate({
|
||||
"channels": {
|
||||
"feishu": {
|
||||
"enabled": False,
|
||||
"appId": "cli_test",
|
||||
"appSecret": "secret",
|
||||
}
|
||||
}
|
||||
})
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
|
||||
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
|
||||
|
||||
payload = optional_features_payload(config=config)
|
||||
|
||||
feishu = payload["features"][0]
|
||||
assert feishu["name"] == "feishu"
|
||||
assert feishu["enabled"] is False
|
||||
assert feishu["configured"] is True
|
||||
assert feishu["ready"] is False
|
||||
assert payload["enabled_count"] == 0
|
||||
|
||||
|
||||
def test_optional_features_payload_lists_feishu_instances(monkeypatch):
|
||||
from nanobot.optional_features import optional_features_payload
|
||||
|
||||
config = Config.model_validate({
|
||||
"channels": {
|
||||
"feishu": {
|
||||
"instances": [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "nanobot",
|
||||
"displayName": "Voraflare Bot",
|
||||
"avatarUrl": "https://example.com/bot.png",
|
||||
"enabled": True,
|
||||
"appId": "cli_default",
|
||||
"appSecret": "secret",
|
||||
},
|
||||
{
|
||||
"id": "product",
|
||||
"name": "Product bot",
|
||||
"enabled": False,
|
||||
"appId": "cli_product",
|
||||
"appSecret": "secret",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
|
||||
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
|
||||
|
||||
payload = optional_features_payload(config=config)
|
||||
|
||||
feishu = payload["features"][0]
|
||||
assert feishu["name"] == "feishu"
|
||||
assert feishu["enabled"] is True
|
||||
assert feishu["configured"] is True
|
||||
assert payload["enabled_count"] == 1
|
||||
assert feishu["instances"] == [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "nanobot",
|
||||
"display_name": "Voraflare Bot",
|
||||
"avatar_url": "https://example.com/bot.png",
|
||||
"domain": "feishu",
|
||||
"enabled": True,
|
||||
"configured": True,
|
||||
"app_id": "cli_default",
|
||||
"group_policy": "mention",
|
||||
"allow_from": [],
|
||||
},
|
||||
{
|
||||
"id": "product",
|
||||
"name": "Product bot",
|
||||
"display_name": "Product bot",
|
||||
"avatar_url": "",
|
||||
"domain": "feishu",
|
||||
"enabled": False,
|
||||
"configured": True,
|
||||
"app_id": "cli_product",
|
||||
"group_policy": "mention",
|
||||
"allow_from": [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_optional_features_payload_backfills_saved_feishu_identity(monkeypatch, tmp_path):
|
||||
from nanobot.channels import feishu as feishu_module
|
||||
from nanobot.config import loader
|
||||
from nanobot.optional_features import optional_features_payload
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({
|
||||
"channels": {
|
||||
"feishu": {
|
||||
"instances": [{
|
||||
"id": "default",
|
||||
"name": "nanobot",
|
||||
"enabled": True,
|
||||
"appId": "cli_default",
|
||||
"appSecret": "secret",
|
||||
}]
|
||||
}
|
||||
}
|
||||
}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
|
||||
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
|
||||
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"fetch_feishu_app_identity",
|
||||
lambda app_id, app_secret, domain: {
|
||||
"displayName": "Xubin Ren的智能助手",
|
||||
"avatarUrl": "https://example.com/assistant.png",
|
||||
"identityFetchedAt": "2026-07-06T00:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
payload = optional_features_payload()
|
||||
|
||||
instance = payload["features"][0]["instances"][0]
|
||||
assert instance["display_name"] == "Xubin Ren的智能助手"
|
||||
assert instance["avatar_url"] == "https://example.com/assistant.png"
|
||||
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
saved = data["channels"]["feishu"]["instances"][0]
|
||||
assert saved["displayName"] == "Xubin Ren的智能助手"
|
||||
assert saved["avatarUrl"] == "https://example.com/assistant.png"
|
||||
assert saved["identityFetchedAt"] == "2026-07-06T00:00:00Z"
|
||||
|
||||
|
||||
def test_optional_features_payload_records_feishu_identity_attempt_on_empty_result(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
from nanobot.channels import feishu as feishu_module
|
||||
from nanobot.config import loader
|
||||
from nanobot.optional_features import optional_features_payload
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({
|
||||
"channels": {
|
||||
"feishu": {
|
||||
"instances": [{
|
||||
"id": "default",
|
||||
"name": "nanobot",
|
||||
"enabled": True,
|
||||
"appId": "cli_default",
|
||||
"appSecret": "secret",
|
||||
}]
|
||||
}
|
||||
}
|
||||
}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
|
||||
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
|
||||
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
|
||||
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *args: {})
|
||||
monkeypatch.setattr(feishu_module, "_identity_timestamp", lambda: "2026-07-06T00:00:00Z")
|
||||
|
||||
payload = optional_features_payload()
|
||||
|
||||
instance = payload["features"][0]["instances"][0]
|
||||
assert instance["display_name"] == "nanobot"
|
||||
assert instance["avatar_url"] == ""
|
||||
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
saved = data["channels"]["feishu"]["instances"][0]
|
||||
assert saved["identityFetchedAt"] == "2026-07-06T00:00:00Z"
|
||||
assert "displayName" not in saved
|
||||
assert "avatarUrl" not in saved
|
||||
|
||||
|
||||
def test_optional_features_payload_preserves_legacy_flat_feishu_config(monkeypatch, tmp_path):
|
||||
from nanobot.channels import feishu as feishu_module
|
||||
from nanobot.config import loader
|
||||
from nanobot.optional_features import optional_features_payload
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({
|
||||
"channels": {
|
||||
"feishu": {
|
||||
"enabled": True,
|
||||
"appId": "cli_legacy",
|
||||
"appSecret": "legacy-secret",
|
||||
"groupPolicy": "mention",
|
||||
}
|
||||
}
|
||||
}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
|
||||
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
|
||||
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"fetch_feishu_app_identity",
|
||||
lambda *_args: {
|
||||
"displayName": "Legacy assistant",
|
||||
"avatarUrl": "https://example.com/legacy.png",
|
||||
"identityFetchedAt": "2026-07-06T00:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
payload = optional_features_payload()
|
||||
|
||||
assert payload["features"][0]["instances"][0]["display_name"] == "Legacy assistant"
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["feishu"]
|
||||
assert saved["appId"] == "cli_legacy"
|
||||
assert saved["appSecret"] == "legacy-secret"
|
||||
assert saved["displayName"] == "Legacy assistant"
|
||||
assert saved["avatarUrl"] == "https://example.com/legacy.png"
|
||||
assert "instances" not in saved
|
||||
|
||||
|
||||
def test_enable_bootstraps_pip_with_ensurepip(monkeypatch):
|
||||
from nanobot import optional_features
|
||||
|
||||
@@ -1066,6 +1531,8 @@ def test_run_install_command_returns_failure_on_timeout(monkeypatch):
|
||||
|
||||
|
||||
def test_optional_dependency_metadata_for_enable():
|
||||
from nanobot import optional_features
|
||||
|
||||
data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
|
||||
deps = data["project"]["optional-dependencies"]
|
||||
required = data["project"]["dependencies"]
|
||||
@@ -1077,25 +1544,32 @@ def test_optional_dependency_metadata_for_enable():
|
||||
"dingtalk-stream",
|
||||
"lark-oapi",
|
||||
"msgpack",
|
||||
"openpyxl",
|
||||
"pypdf",
|
||||
"python-telegram-bot",
|
||||
"python-docx",
|
||||
"python-pptx",
|
||||
"python-socketio",
|
||||
"qq-botpy",
|
||||
"slack-sdk",
|
||||
"slackify-markdown",
|
||||
):
|
||||
assert not any(dep.startswith(dep_name) for dep in required)
|
||||
for dependency in (
|
||||
"defusedxml>=0.7.1,<1.0.0",
|
||||
"pypdf>=5.0.0,<6.0.0",
|
||||
"python-docx>=1.1.0,<2.0.0",
|
||||
"openpyxl>=3.1.0,<4.0.0",
|
||||
"python-pptx>=1.0.0,<2.0.0",
|
||||
):
|
||||
assert dependency in required
|
||||
assert deps["dingtalk"] == ["dingtalk-stream>=0.24.0,<1.0.0"]
|
||||
assert deps["documents"] == [
|
||||
"defusedxml>=0.7.1,<1.0.0",
|
||||
"pypdf>=5.0.0,<6.0.0",
|
||||
"python-docx>=1.1.0,<2.0.0",
|
||||
"openpyxl>=3.1.0,<4.0.0",
|
||||
"python-pptx>=1.0.0,<2.0.0",
|
||||
]
|
||||
assert deps["pdf"] == ["pypdf>=5.0.0,<6.0.0"]
|
||||
assert deps["feishu"] == ["lark-oapi>=1.5.0,<2.0.0"]
|
||||
assert deps["langfuse"] == ["langfuse>=3.0.0,<4.0.0"]
|
||||
assert deps["mochat"] == [
|
||||
"python-socketio>=5.16.0,<6.0.0",
|
||||
"msgpack>=1.1.0,<2.0.0",
|
||||
@@ -1107,6 +1581,10 @@ def test_optional_dependency_metadata_for_enable():
|
||||
"slack-sdk>=3.39.0,<4.0.0",
|
||||
"slackify-markdown>=0.2.0,<1.0.0",
|
||||
]
|
||||
|
||||
visible = optional_features.optional_dependency_groups()
|
||||
assert "documents" not in visible
|
||||
assert "pdf" not in visible
|
||||
assert any(dep.startswith("python-telegram-bot") for dep in deps["telegram"])
|
||||
assert any(
|
||||
dep.startswith("matrix-nio>=0.25.2") and "sys_platform == 'win32'" in dep
|
||||
@@ -1986,6 +2464,7 @@ async def test_stop_all_cancels_dispatcher_and_stops_channels():
|
||||
|
||||
ch = _StartableChannel(fake_config, mgr.bus)
|
||||
mgr.channels = {"startable": ch}
|
||||
mgr._channel_tasks = {}
|
||||
|
||||
# Create a real cancelled task
|
||||
async def dummy_task():
|
||||
@@ -2063,6 +2542,7 @@ async def test_stop_all_handles_channel_exception():
|
||||
mgr.config = fake_config
|
||||
mgr.bus = MessageBus()
|
||||
mgr.channels = {"stopfailing": _StopFailingChannel(fake_config, mgr.bus)}
|
||||
mgr._channel_tasks = {}
|
||||
mgr._dispatch_task = None
|
||||
|
||||
# Should not raise even if channel.stop() raises
|
||||
@@ -2101,6 +2581,7 @@ async def test_stop_all_handles_channel_stop_cancelled_task():
|
||||
"stopcancelled": _StopCancelledChannel(fake_config, mgr.bus),
|
||||
"next": next_channel,
|
||||
}
|
||||
mgr._channel_tasks = {}
|
||||
mgr._dispatch_task = None
|
||||
|
||||
await mgr.stop_all()
|
||||
@@ -2142,6 +2623,7 @@ async def test_start_all_creates_dispatch_task():
|
||||
|
||||
ch = _StartableChannel(fake_config, mgr.bus)
|
||||
mgr.channels = {"startable": ch}
|
||||
mgr._channel_tasks = {}
|
||||
mgr._dispatch_task = None
|
||||
|
||||
# Cancel immediately after start to avoid running forever
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
from nanobot.channels._setup import channel_setup_spec
|
||||
|
||||
|
||||
def test_channel_setup_spec_derives_route_and_secret_metadata() -> None:
|
||||
slack = channel_setup_spec("slack")
|
||||
|
||||
assert slack is not None
|
||||
assert slack.secrets == {"appToken", "botToken"}
|
||||
assert slack.route_field_types == {
|
||||
"appToken": "secret",
|
||||
"botToken": "secret",
|
||||
"groupPolicy": ("enum", {"mention", "open", "allowlist"}),
|
||||
}
|
||||
assert slack.simple_required_fields == ("appToken", "botToken")
|
||||
|
||||
|
||||
def test_matrix_setup_requires_one_complete_login_method() -> None:
|
||||
matrix = channel_setup_spec("matrix")
|
||||
|
||||
assert matrix is not None
|
||||
base = {
|
||||
"homeserver": "https://matrix.example",
|
||||
"userId": "@nanobot:matrix.example",
|
||||
}
|
||||
assert matrix.is_configured(base | {"password": "secret"})
|
||||
assert matrix.is_configured(base | {"accessToken": "token", "deviceId": "DEVICE"})
|
||||
assert not matrix.is_configured(base | {"accessToken": "token"})
|
||||
|
||||
|
||||
def test_channel_setup_spec_separates_writable_and_snapshot_fields() -> None:
|
||||
matrix = channel_setup_spec("matrix")
|
||||
discord = channel_setup_spec("discord")
|
||||
|
||||
assert matrix is not None
|
||||
assert discord is not None
|
||||
assert "allowFrom" not in matrix.route_field_types
|
||||
assert "allowFrom" in matrix.snapshot_fields
|
||||
assert "allowFrom" in discord.route_field_types
|
||||
assert "allowFrom" not in discord.snapshot_fields
|
||||
|
||||
|
||||
def test_webui_forms_have_writable_mattermost_and_whatsapp_contracts() -> None:
|
||||
mattermost = channel_setup_spec("mattermost")
|
||||
whatsapp = channel_setup_spec("whatsapp")
|
||||
|
||||
assert mattermost is not None
|
||||
assert whatsapp is not None
|
||||
assert mattermost.route_field_types["serverUrl"] == "string"
|
||||
assert mattermost.route_field_types["token"] == "secret"
|
||||
assert whatsapp.route_field_types["allowFrom"] == "list"
|
||||
assert whatsapp.route_field_types["groupPolicy"] == (
|
||||
"enum",
|
||||
{"mention", "open"},
|
||||
)
|
||||
@@ -7,6 +7,11 @@ from nanobot.channels import feishu as feishu_module
|
||||
from nanobot.channels.feishu import FeishuChannel
|
||||
from nanobot.config import loader
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.pairing import store as pairing_store
|
||||
|
||||
|
||||
def _default_feishu_instance(data: dict) -> dict:
|
||||
return data["channels"]["feishu"]["instances"][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -25,15 +30,30 @@ async def test_feishu_login_writes_credentials_to_active_config(monkeypatch, tmp
|
||||
"domain": "lark",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"fetch_feishu_app_identity",
|
||||
lambda app_id, app_secret, domain: {
|
||||
"displayName": "Voraflare Bot",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"identityFetchedAt": "2026-07-06T00:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
channel = FeishuChannel({"enabled": False, "domain": "feishu"}, None)
|
||||
|
||||
assert await channel.login() is True
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert data["channels"]["feishu"]["appId"] == "cli_app"
|
||||
assert data["channels"]["feishu"]["appSecret"] == "secret"
|
||||
assert data["channels"]["feishu"]["domain"] == "lark"
|
||||
assert data["channels"]["feishu"]["enabled"] is True
|
||||
instance = _default_feishu_instance(data)
|
||||
assert instance["id"] == "default"
|
||||
assert instance["appId"] == "cli_app"
|
||||
assert instance["appSecret"] == "secret"
|
||||
assert instance["domain"] == "lark"
|
||||
assert instance["identityKey"] == "lark:cli_app"
|
||||
assert instance["enabled"] is True
|
||||
assert instance["displayName"] == "Voraflare Bot"
|
||||
assert instance["avatarUrl"] == "https://example.com/avatar.png"
|
||||
assert instance["identityFetchedAt"] == "2026-07-06T00:00:00Z"
|
||||
|
||||
|
||||
def test_begin_registration_requires_login_url(monkeypatch):
|
||||
@@ -70,6 +90,298 @@ def test_qr_register_returns_none_on_network_error(monkeypatch):
|
||||
assert feishu_module.qr_register() is None
|
||||
|
||||
|
||||
def test_save_registration_result_keeps_credentials_when_identity_fetch_fails(monkeypatch, tmp_path):
|
||||
config_path = tmp_path / "config.json"
|
||||
loader.save_config(Config(), config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
|
||||
def fail_identity(_app_id, _app_secret, _domain):
|
||||
raise RuntimeError("metadata unavailable")
|
||||
|
||||
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", fail_identity)
|
||||
|
||||
feishu_module.save_registration_result({
|
||||
"app_id": "cli_app",
|
||||
"app_secret": "secret",
|
||||
"domain": "feishu",
|
||||
})
|
||||
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
instance = _default_feishu_instance(data)
|
||||
assert instance["appId"] == "cli_app"
|
||||
assert instance["appSecret"] == "secret"
|
||||
assert instance["identityKey"] == "feishu:cli_app"
|
||||
assert "displayName" not in instance
|
||||
assert "avatarUrl" not in instance
|
||||
|
||||
|
||||
def test_save_registration_result_resets_access_when_instance_app_changes(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config_path = tmp_path / "config.json"
|
||||
pairing_path = tmp_path / "pairing.json"
|
||||
config = Config()
|
||||
config.channels.feishu = {
|
||||
"instances": [
|
||||
{
|
||||
"id": "assistant-test",
|
||||
"instanceId": "assistant-test",
|
||||
"name": "old assistant",
|
||||
"enabled": True,
|
||||
"appId": "cli_old",
|
||||
"appSecret": "old-secret",
|
||||
"identityKey": "feishu:cli_old",
|
||||
"allowFrom": ["old-open-id"],
|
||||
"allow_from": ["old-snake-open-id"],
|
||||
}
|
||||
]
|
||||
}
|
||||
loader.save_config(config, config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
|
||||
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
|
||||
|
||||
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
|
||||
pairing_store.approve_code(approved_code)
|
||||
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
|
||||
|
||||
feishu_module.save_registration_result(
|
||||
{
|
||||
"app_id": "cli_new",
|
||||
"app_secret": "new-secret",
|
||||
"domain": "feishu",
|
||||
},
|
||||
instance_id="assistant-test",
|
||||
name="new assistant",
|
||||
)
|
||||
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
instance = data["channels"]["feishu"]["instances"][0]
|
||||
assert instance["appId"] == "cli_new"
|
||||
assert instance["appSecret"] == "new-secret"
|
||||
assert instance["identityKey"] == "feishu:cli_new"
|
||||
assert instance["allowFrom"] == []
|
||||
assert instance["allow_from"] == []
|
||||
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
|
||||
assert pairing_store.approve_code(pending_code) is None
|
||||
|
||||
|
||||
def test_save_registration_result_keeps_access_when_only_secret_rotates(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config_path = tmp_path / "config.json"
|
||||
pairing_path = tmp_path / "pairing.json"
|
||||
config = Config()
|
||||
config.channels.feishu = {
|
||||
"instances": [
|
||||
{
|
||||
"id": "assistant-test",
|
||||
"instanceId": "assistant-test",
|
||||
"name": "same assistant",
|
||||
"enabled": True,
|
||||
"appId": "cli_same",
|
||||
"appSecret": "old-secret",
|
||||
"domain": "feishu",
|
||||
"identityKey": "feishu:cli_same",
|
||||
"allowFrom": ["old-open-id"],
|
||||
}
|
||||
]
|
||||
}
|
||||
loader.save_config(config, config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
|
||||
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
|
||||
|
||||
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
|
||||
pairing_store.approve_code(approved_code)
|
||||
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
|
||||
|
||||
feishu_module.save_registration_result(
|
||||
{
|
||||
"app_id": "cli_same",
|
||||
"app_secret": "new-secret",
|
||||
"domain": "feishu",
|
||||
},
|
||||
instance_id="assistant-test",
|
||||
name="same assistant",
|
||||
)
|
||||
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
instance = data["channels"]["feishu"]["instances"][0]
|
||||
assert instance["appSecret"] == "new-secret"
|
||||
assert instance["identityKey"] == "feishu:cli_same"
|
||||
assert instance["allowFrom"] == ["old-open-id"]
|
||||
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is True
|
||||
assert pairing_store.approve_code(pending_code) == (
|
||||
"feishu.assistant-test",
|
||||
"pending-user",
|
||||
)
|
||||
|
||||
|
||||
def test_save_registration_result_resets_access_when_domain_changes(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config_path = tmp_path / "config.json"
|
||||
pairing_path = tmp_path / "pairing.json"
|
||||
config = Config()
|
||||
config.channels.feishu = {
|
||||
"instances": [
|
||||
{
|
||||
"id": "assistant-test",
|
||||
"instanceId": "assistant-test",
|
||||
"name": "lark assistant",
|
||||
"enabled": True,
|
||||
"appId": "cli_same",
|
||||
"appSecret": "old-secret",
|
||||
"domain": "lark",
|
||||
"identityKey": "lark:cli_same",
|
||||
"allowFrom": ["old-open-id"],
|
||||
}
|
||||
]
|
||||
}
|
||||
loader.save_config(config, config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
|
||||
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
|
||||
|
||||
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
|
||||
pairing_store.approve_code(approved_code)
|
||||
|
||||
feishu_module.save_registration_result(
|
||||
{
|
||||
"app_id": "cli_same",
|
||||
"app_secret": "new-secret",
|
||||
"domain": "feishu",
|
||||
},
|
||||
instance_id="assistant-test",
|
||||
name="feishu assistant",
|
||||
)
|
||||
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
instance = data["channels"]["feishu"]["instances"][0]
|
||||
assert instance["domain"] == "feishu"
|
||||
assert instance["identityKey"] == "feishu:cli_same"
|
||||
assert instance["allowFrom"] == []
|
||||
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
|
||||
|
||||
|
||||
def test_sync_saved_identity_boundary_resets_access_after_manual_app_change(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config_path = tmp_path / "config.json"
|
||||
pairing_path = tmp_path / "pairing.json"
|
||||
config = Config()
|
||||
config.channels.feishu = {
|
||||
"instances": [
|
||||
{
|
||||
"id": "assistant-test",
|
||||
"instanceId": "assistant-test",
|
||||
"name": "manual assistant",
|
||||
"enabled": True,
|
||||
"appId": "cli_new",
|
||||
"appSecret": "secret",
|
||||
"domain": "feishu",
|
||||
"identityKey": "feishu:cli_old",
|
||||
"allowFrom": ["old-open-id"],
|
||||
"allow_from": ["old-snake-open-id"],
|
||||
}
|
||||
]
|
||||
}
|
||||
loader.save_config(config, config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
|
||||
|
||||
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
|
||||
pairing_store.approve_code(approved_code)
|
||||
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
|
||||
|
||||
assert feishu_module.sync_saved_feishu_identity_boundary(
|
||||
instance_id="assistant-test",
|
||||
app_id="cli_new",
|
||||
domain="feishu",
|
||||
) is True
|
||||
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
instance = data["channels"]["feishu"]["instances"][0]
|
||||
assert instance["identityKey"] == "feishu:cli_new"
|
||||
assert instance["allowFrom"] == []
|
||||
assert instance["allow_from"] == []
|
||||
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
|
||||
assert pairing_store.approve_code(pending_code) is None
|
||||
|
||||
|
||||
def test_sync_saved_identity_boundary_backfills_marker_without_resetting_access(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config_path = tmp_path / "config.json"
|
||||
pairing_path = tmp_path / "pairing.json"
|
||||
config = Config()
|
||||
config.channels.feishu = {
|
||||
"instances": [
|
||||
{
|
||||
"id": "assistant-test",
|
||||
"instanceId": "assistant-test",
|
||||
"name": "existing assistant",
|
||||
"enabled": True,
|
||||
"appId": "cli_existing",
|
||||
"appSecret": "secret",
|
||||
"domain": "feishu",
|
||||
"allowFrom": ["old-open-id"],
|
||||
}
|
||||
]
|
||||
}
|
||||
loader.save_config(config, config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
|
||||
|
||||
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
|
||||
pairing_store.approve_code(approved_code)
|
||||
|
||||
assert feishu_module.sync_saved_feishu_identity_boundary(
|
||||
instance_id="assistant-test",
|
||||
app_id="cli_existing",
|
||||
domain="feishu",
|
||||
) is False
|
||||
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
instance = data["channels"]["feishu"]["instances"][0]
|
||||
assert instance["identityKey"] == "feishu:cli_existing"
|
||||
assert instance["allowFrom"] == ["old-open-id"]
|
||||
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is True
|
||||
|
||||
|
||||
def test_sync_saved_identity_boundary_preserves_legacy_flat_config(monkeypatch, tmp_path):
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.channels.feishu = {
|
||||
"enabled": True,
|
||||
"appId": "cli_existing",
|
||||
"appSecret": "secret",
|
||||
"domain": "feishu",
|
||||
"allowFrom": ["old-open-id"],
|
||||
}
|
||||
loader.save_config(config, config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
|
||||
assert feishu_module.sync_saved_feishu_identity_boundary(
|
||||
instance_id="default",
|
||||
app_id="cli_existing",
|
||||
domain="feishu",
|
||||
) is False
|
||||
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["feishu"]
|
||||
assert saved["appId"] == "cli_existing"
|
||||
assert saved["appSecret"] == "secret"
|
||||
assert saved["identityKey"] == "feishu:cli_existing"
|
||||
assert saved["allowFrom"] == ["old-open-id"]
|
||||
assert "instances" not in saved
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feishu_login_creates_missing_active_config(monkeypatch, tmp_path):
|
||||
missing_config = tmp_path / "missing.json"
|
||||
@@ -89,4 +401,6 @@ async def test_feishu_login_creates_missing_active_config(monkeypatch, tmp_path)
|
||||
assert await channel.login() is True
|
||||
assert missing_config.exists()
|
||||
data = json.loads(missing_config.read_text(encoding="utf-8"))
|
||||
assert data["channels"]["feishu"]["appId"] == "cli_app"
|
||||
instance = _default_feishu_instance(data)
|
||||
assert instance["id"] == "default"
|
||||
assert instance["appId"] == "cli_app"
|
||||
|
||||
@@ -44,6 +44,7 @@ def _make_feishu_channel(
|
||||
channel._client = MagicMock()
|
||||
# _loop is only used by the WebSocket thread bridge; not needed for unit tests
|
||||
channel._loop = None
|
||||
channel._running = True
|
||||
return channel
|
||||
|
||||
|
||||
@@ -209,6 +210,35 @@ def test_reply_message_sync_returns_false_on_api_error() -> None:
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_reply_message_sync_falls_back_to_text_for_interactive_error() -> None:
|
||||
channel = _make_feishu_channel()
|
||||
|
||||
interactive_resp = MagicMock()
|
||||
interactive_resp.success.return_value = False
|
||||
interactive_resp.code = 230099
|
||||
interactive_resp.msg = "cardid is invalid"
|
||||
interactive_resp.get_log_id.return_value = "log_x"
|
||||
|
||||
text_resp = MagicMock()
|
||||
text_resp.success.return_value = True
|
||||
channel._client.im.v1.message.reply.side_effect = [interactive_resp, text_resp]
|
||||
|
||||
ok = channel._reply_message_sync(
|
||||
"om_parent",
|
||||
"interactive",
|
||||
json.dumps(
|
||||
{
|
||||
"config": {"wide_screen_mode": True},
|
||||
"elements": [{"tag": "markdown", "content": "fallback body"}],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert channel._client.im.v1.message.reply.call_count == 2
|
||||
|
||||
|
||||
def test_reply_message_sync_returns_false_on_exception() -> None:
|
||||
channel = _make_feishu_channel()
|
||||
channel._client.im.v1.message.reply.side_effect = RuntimeError("network error")
|
||||
@@ -368,6 +398,37 @@ async def test_send_fallback_to_create_when_reply_fails() -> None:
|
||||
channel._client.im.v1.message.create.assert_called_once()
|
||||
|
||||
|
||||
def test_send_message_sync_falls_back_to_text_for_interactive_error() -> None:
|
||||
channel = _make_feishu_channel()
|
||||
|
||||
interactive_resp = MagicMock()
|
||||
interactive_resp.success.return_value = False
|
||||
interactive_resp.code = 230099
|
||||
interactive_resp.msg = "cardid is invalid"
|
||||
interactive_resp.get_log_id.return_value = "log_x"
|
||||
|
||||
text_resp = MagicMock()
|
||||
text_resp.success.return_value = True
|
||||
text_resp.data = SimpleNamespace(message_id="om_fallback")
|
||||
channel._client.im.v1.message.create.side_effect = [interactive_resp, text_resp]
|
||||
|
||||
message_id = channel._send_message_sync(
|
||||
"chat_id",
|
||||
"oc_abc",
|
||||
"interactive",
|
||||
json.dumps(
|
||||
{
|
||||
"config": {"wide_screen_mode": True},
|
||||
"elements": [{"tag": "markdown", "content": "fallback body"}],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
assert message_id == "om_fallback"
|
||||
assert channel._client.im.v1.message.create.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_multiple_messages_all_use_reply_when_in_topic(tmp_path: Path) -> None:
|
||||
"""When in a topic (has thread_id), all messages use reply API to stay in topic."""
|
||||
@@ -786,6 +847,36 @@ async def test_session_key_group_no_root_id_uses_message_id() -> None:
|
||||
assert bus_spy[0].session_key == "feishu:oc_abc:om_001"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_named_instance_uses_runtime_channel_namespace() -> None:
|
||||
"""Named Feishu assistant instances keep group sessions separate."""
|
||||
channel = _make_feishu_channel(group_policy="open")
|
||||
channel.name = "feishu.product"
|
||||
bus_spy = []
|
||||
original_publish = channel.bus.publish_inbound
|
||||
|
||||
async def capture(msg):
|
||||
bus_spy.append(msg)
|
||||
await original_publish(msg)
|
||||
|
||||
channel.bus.publish_inbound = capture
|
||||
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
|
||||
channel.transcribe_audio = AsyncMock(return_value="")
|
||||
channel._add_reaction = AsyncMock(return_value=None)
|
||||
|
||||
event = _make_feishu_event(
|
||||
chat_type="group",
|
||||
content='{"text": "hello"}',
|
||||
root_id="om_root123",
|
||||
message_id="om_child456",
|
||||
)
|
||||
await channel._on_message(event)
|
||||
|
||||
assert len(bus_spy) == 1
|
||||
assert bus_spy[0].channel == "feishu.product"
|
||||
assert bus_spy[0].session_key == "feishu.product:oc_abc:om_root123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_private_chat_no_override() -> None:
|
||||
"""Private chat never overrides session key (consistent with Telegram/Slack)."""
|
||||
@@ -1036,6 +1127,33 @@ def test_on_background_task_done_removes_from_set() -> None:
|
||||
assert task not in channel._background_tasks
|
||||
|
||||
|
||||
def test_on_message_sync_ignores_events_after_channel_stops() -> None:
|
||||
"""Late WebSocket callbacks should not schedule work after the assistant is off."""
|
||||
channel = _make_feishu_channel()
|
||||
channel._running = False
|
||||
channel._loop = MagicMock()
|
||||
channel._loop.is_running.return_value = True
|
||||
|
||||
with patch("asyncio.run_coroutine_threadsafe") as schedule:
|
||||
channel._on_message_sync(_make_feishu_event())
|
||||
|
||||
schedule.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_ignores_events_after_channel_stops() -> None:
|
||||
"""Stopped assistants must not react, pair, or publish stale Feishu events."""
|
||||
channel = _make_feishu_channel(group_policy="open")
|
||||
channel._running = False
|
||||
channel._add_reaction = AsyncMock()
|
||||
channel._handle_message = AsyncMock()
|
||||
|
||||
await channel._on_message(_make_feishu_event())
|
||||
|
||||
channel._add_reaction.assert_not_awaited()
|
||||
channel._handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_unauthorized_dm_sends_pairing_code_without_side_effects() -> None:
|
||||
"""Unauthorized DM sender gets a pairing code but no media side effects."""
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
from nanobot.channels._feishu_ws import FeishuWsRunner
|
||||
|
||||
|
||||
def test_concurrent_loop_initialization_starts_one_thread(monkeypatch) -> None:
|
||||
runner = FeishuWsRunner()
|
||||
created_loops: list[asyncio.AbstractEventLoop] = []
|
||||
release_start = threading.Event()
|
||||
|
||||
def fake_run_loop() -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
created_loops.append(loop)
|
||||
assert release_start.wait(timeout=2)
|
||||
runner._loop = loop
|
||||
runner._ready.set()
|
||||
|
||||
monkeypatch.setattr(runner, "_run_loop", fake_run_loop)
|
||||
loops: list[asyncio.AbstractEventLoop] = []
|
||||
threads = [threading.Thread(target=lambda: loops.append(runner._ensure_loop())) for _ in range(2)]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
release_start.set()
|
||||
for thread in threads:
|
||||
thread.join(timeout=2)
|
||||
|
||||
assert len(created_loops) == 1
|
||||
assert loops == [created_loops[0], created_loops[0]]
|
||||
created_loops[0].close()
|
||||
@@ -76,6 +76,7 @@ def _make_handler(
|
||||
local_trigger_store: LocalTriggerStore | None = None,
|
||||
cron_pending_job_ids: Any | None = None,
|
||||
local_trigger_pending_ids: Any | None = None,
|
||||
channel_feature_action: Any | None = None,
|
||||
) -> GatewayServices:
|
||||
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
|
||||
workspace = workspace_path or Path.cwd()
|
||||
@@ -93,6 +94,7 @@ def _make_handler(
|
||||
local_trigger_store=local_trigger_store,
|
||||
cron_pending_job_ids=cron_pending_job_ids,
|
||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||
channel_feature_action=channel_feature_action,
|
||||
)
|
||||
|
||||
|
||||
@@ -108,6 +110,7 @@ def _ch(
|
||||
local_trigger_store: LocalTriggerStore | None = None,
|
||||
cron_pending_job_ids: Any | None = None,
|
||||
local_trigger_pending_ids: Any | None = None,
|
||||
channel_feature_action: Any | None = None,
|
||||
**extra: Any,
|
||||
) -> WebSocketChannel:
|
||||
cfg: dict[str, Any] = {
|
||||
@@ -129,6 +132,7 @@ def _ch(
|
||||
local_trigger_store=local_trigger_store,
|
||||
cron_pending_job_ids=cron_pending_job_ids,
|
||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||
channel_feature_action=channel_feature_action,
|
||||
)
|
||||
return WebSocketChannel(cfg, bus, gateway=gateway)
|
||||
|
||||
@@ -650,6 +654,128 @@ async def test_nanobot_feature_routes_require_token_and_enable(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pairing_routes_require_token_and_approve_or_deny(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pending = [
|
||||
{
|
||||
"code": "ABCD-EFGH",
|
||||
"channel": "feishu",
|
||||
"sender_id": "ou_123",
|
||||
"created_at": 1_000.0,
|
||||
"expires_at": 1_600.0,
|
||||
}
|
||||
]
|
||||
approved: list[str] = []
|
||||
denied: list[str] = []
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.settings_routes.list_pending", lambda: list(pending))
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_routes.approve_code",
|
||||
lambda code: approved.append(code) or ("feishu", "ou_123") if code == "ABCD-EFGH" else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_routes.deny_code",
|
||||
lambda code: denied.append(code) or code == "ABCD-EFGH",
|
||||
)
|
||||
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
|
||||
denied_response = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(path="/api/settings/pairing"),
|
||||
"/api/settings/pairing",
|
||||
)
|
||||
assert denied_response is not None
|
||||
assert denied_response.status_code == 401
|
||||
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
listed = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(auth, path="/api/settings/pairing"),
|
||||
"/api/settings/pairing",
|
||||
)
|
||||
assert listed is not None
|
||||
assert listed.status_code == 200
|
||||
body = json.loads(listed.body.decode())
|
||||
assert body["requests"][0]["code"] == "ABCD-EFGH"
|
||||
assert body["requests"][0]["channel"] == "feishu"
|
||||
assert body["requests"][0]["sender_id"] == "ou_123"
|
||||
assert body["requests"][0]["created_at_ms"] == 1_000_000
|
||||
assert body["requests"][0]["expires_at_ms"] == 1_600_000
|
||||
|
||||
approved_response = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(auth, path="/api/settings/pairing/approve?code=ABCD-EFGH"),
|
||||
"/api/settings/pairing/approve",
|
||||
)
|
||||
assert approved_response is not None
|
||||
assert approved_response.status_code == 200
|
||||
body = json.loads(approved_response.body.decode())
|
||||
assert body["last_action"]["action"] == "approve"
|
||||
assert body["last_action"]["sender_id"] == "ou_123"
|
||||
assert approved == ["ABCD-EFGH"]
|
||||
|
||||
denied_action = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(auth, path="/api/settings/pairing/deny?code=ABCD-EFGH"),
|
||||
"/api/settings/pairing/deny",
|
||||
)
|
||||
assert denied_action is not None
|
||||
assert denied_action.status_code == 200
|
||||
assert json.loads(denied_action.body.decode())["last_action"]["action"] == "deny"
|
||||
assert denied == ["ABCD-EFGH"]
|
||||
|
||||
missing_code = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(auth, path="/api/settings/pairing/approve"),
|
||||
"/api/settings/pairing/approve",
|
||||
)
|
||||
assert missing_code is not None
|
||||
assert missing_code.status_code == 400
|
||||
assert "Missing pairing code" in missing_code.body.decode()
|
||||
|
||||
|
||||
def test_api_service_settings_read_api_key_from_private_header(bus: MagicMock) -> None:
|
||||
channel = _ch(bus)
|
||||
request = _FakeReq(
|
||||
{"X-Nanobot-API-Service-Values": json.dumps({"api_key": "secret-token"})},
|
||||
path="/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
|
||||
)
|
||||
|
||||
query = channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
|
||||
|
||||
assert query == {
|
||||
"host": ["0.0.0.0"],
|
||||
"port": ["8900"],
|
||||
"timeout": ["120"],
|
||||
"api_key": ["secret-token"],
|
||||
}
|
||||
|
||||
|
||||
def test_api_service_settings_reject_invalid_private_header(bus: MagicMock) -> None:
|
||||
from nanobot.webui.settings_api import WebUISettingsError
|
||||
|
||||
channel = _ch(bus)
|
||||
request = _FakeReq(
|
||||
{"X-Nanobot-API-Service-Values": json.dumps({"api_key": 123})},
|
||||
path="/api/settings/api-service/start?host=127.0.0.1",
|
||||
)
|
||||
|
||||
with pytest.raises(WebUISettingsError, match="API key must be a string"):
|
||||
channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
|
||||
|
||||
query_secret = _FakeReq(
|
||||
path="/api/settings/api-service/start?host=127.0.0.1&api_key=secret-token",
|
||||
)
|
||||
with pytest.raises(WebUISettingsError, match="private header"):
|
||||
channel.gateway.http.settings_routes._parse_api_service_settings_query(query_secret)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nanobot_feature_remote_install_requires_opt_in(
|
||||
bus: MagicMock,
|
||||
@@ -733,6 +859,506 @@ async def test_nanobot_feature_local_install_allowed_by_default(
|
||||
] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nanobot_feature_channel_action_can_apply_without_restart(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
_stub_matrix_feature(monkeypatch, config_path, deps=["matrix-nio>=0.25.2"])
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
|
||||
calls.append((action, name))
|
||||
return {
|
||||
"handled": True,
|
||||
"ok": True,
|
||||
"requires_restart": False,
|
||||
"message": "Matrix channel applied without restart.",
|
||||
}
|
||||
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path),
|
||||
port=_free_port(),
|
||||
channel_feature_action=channel_feature_action,
|
||||
)
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
request = _FakeReq(
|
||||
{"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"},
|
||||
path="/api/settings/nanobot-features/enable?name=matrix",
|
||||
)
|
||||
|
||||
response = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
request,
|
||||
"/api/settings/nanobot-features/enable",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body.decode())
|
||||
assert calls == [("enable", "matrix")]
|
||||
assert body["requires_restart"] is False
|
||||
assert body["restart_required_sections"] == []
|
||||
assert body["last_action"]["hot_reload"] is True
|
||||
assert body["last_action"]["message"].endswith("Matrix channel applied without restart.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feishu_connect_routes_write_config_and_hot_reload(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from nanobot.channels import feishu as feishu_module
|
||||
from nanobot.config import loader
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
loader.save_config(Config(), config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
monkeypatch.setattr(feishu_module, "_init_registration", lambda _domain: None)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"_begin_registration",
|
||||
lambda _domain: {
|
||||
"device_code": "device",
|
||||
"qr_url": "https://accounts.feishu.cn/login?device_code=device",
|
||||
"interval": 2,
|
||||
"expire_in": 600,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"poll_registration_once",
|
||||
lambda *, device_code, domain: {
|
||||
"status": "succeeded",
|
||||
"app_id": "cli_app",
|
||||
"app_secret": "secret",
|
||||
"domain": "feishu",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"fetch_feishu_app_identity",
|
||||
lambda app_id, app_secret, domain: {
|
||||
"displayName": "Voraflare Bot",
|
||||
"avatarUrl": "https://example.com/feishu.png",
|
||||
"identityFetchedAt": "2026-07-06T00:00:00Z",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_routes.nanobot_features_action",
|
||||
lambda _action, _query, *, allow_install=True: {
|
||||
"features": [{
|
||||
"name": "feishu",
|
||||
"display_name": "Feishu",
|
||||
"type": "channel",
|
||||
"enabled": True,
|
||||
"installed": True,
|
||||
"ready": True,
|
||||
"status": "enabled",
|
||||
"install_supported": True,
|
||||
"requires_restart": True,
|
||||
}],
|
||||
"enabled_count": 1,
|
||||
"requires_restart": True,
|
||||
"last_action": {"ok": True, "message": "Enabled channel 'feishu'", "enabled": True},
|
||||
},
|
||||
)
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
|
||||
calls.append((action, name))
|
||||
return {
|
||||
"handled": True,
|
||||
"ok": True,
|
||||
"requires_restart": False,
|
||||
"message": "Feishu channel applied without restart.",
|
||||
}
|
||||
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path),
|
||||
port=_free_port(),
|
||||
channel_feature_action=channel_feature_action,
|
||||
)
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"}
|
||||
|
||||
started = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
auth,
|
||||
path="/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default",
|
||||
),
|
||||
"/api/settings/channels/feishu/connect/start",
|
||||
)
|
||||
|
||||
assert started is not None
|
||||
assert started.status_code == 200
|
||||
start_body = json.loads(started.body.decode())
|
||||
assert start_body["status"] == "pending"
|
||||
assert start_body["instance_id"] == "default"
|
||||
assert start_body["qr_url"].startswith("https://accounts.feishu.cn/")
|
||||
|
||||
polled = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
auth,
|
||||
path=f"/api/settings/channels/feishu/connect/poll?session_id={start_body['session_id']}",
|
||||
),
|
||||
"/api/settings/channels/feishu/connect/poll",
|
||||
)
|
||||
|
||||
assert polled is not None
|
||||
assert polled.status_code == 200
|
||||
body = json.loads(polled.body.decode())
|
||||
assert body["status"] == "succeeded"
|
||||
assert body["instance_id"] == "default"
|
||||
assert "app_secret" not in body
|
||||
assert calls == [("enable", "feishu")]
|
||||
assert body["nanobot_features"]["requires_restart"] is False
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert data["channels"]["feishu"]["instances"][0]["id"] == "default"
|
||||
assert data["channels"]["feishu"]["instances"][0]["appId"] == "cli_app"
|
||||
assert data["channels"]["feishu"]["instances"][0]["appSecret"] == "secret"
|
||||
assert data["channels"]["feishu"]["instances"][0]["enabled"] is True
|
||||
assert data["channels"]["feishu"]["instances"][0]["displayName"] == "Voraflare Bot"
|
||||
assert data["channels"]["feishu"]["instances"][0]["avatarUrl"] == "https://example.com/feishu.png"
|
||||
|
||||
|
||||
def test_feishu_connect_create_appends_instance(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from nanobot.channels import feishu as feishu_module
|
||||
from nanobot.config import loader
|
||||
from nanobot.webui.channel_connect import FeishuConnectStore
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({
|
||||
"channels": {
|
||||
"feishu": {
|
||||
"instances": [{
|
||||
"id": "default",
|
||||
"name": "nanobot",
|
||||
"enabled": True,
|
||||
"appId": "cli_default",
|
||||
"appSecret": "default-secret",
|
||||
}]
|
||||
}
|
||||
}
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
monkeypatch.setattr(feishu_module, "_init_registration", lambda _domain: None)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"_begin_registration",
|
||||
lambda _domain: {
|
||||
"device_code": "device",
|
||||
"qr_url": "https://accounts.feishu.cn/login?device_code=device",
|
||||
"interval": 2,
|
||||
"expire_in": 600,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"poll_registration_once",
|
||||
lambda *, device_code, domain: {
|
||||
"status": "succeeded",
|
||||
"app_id": "cli_new",
|
||||
"app_secret": "new-secret",
|
||||
"domain": "feishu",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"fetch_feishu_app_identity",
|
||||
lambda app_id, app_secret, domain: {
|
||||
"displayName": f"Assistant {app_id}",
|
||||
"avatarUrl": f"https://example.com/{app_id}.png",
|
||||
"identityFetchedAt": "2026-07-06T00:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
store = FeishuConnectStore()
|
||||
started = store.start(mode="create")
|
||||
polled = store.poll(started["session_id"])
|
||||
|
||||
assert polled["status"] == "succeeded"
|
||||
assert polled["instance_id"] != "default"
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
instances = data["channels"]["feishu"]["instances"]
|
||||
assert [item["id"] for item in instances] == ["default", polled["instance_id"]]
|
||||
assert instances[0]["appId"] == "cli_default"
|
||||
assert instances[1]["appId"] == "cli_new"
|
||||
assert instances[0].get("displayName") is None
|
||||
assert instances[1]["displayName"] == "Assistant cli_new"
|
||||
assert instances[1]["avatarUrl"] == "https://example.com/cli_new.png"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_configure_route_saves_discord_config_and_hot_reloads(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from nanobot.config import loader
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
loader.save_config(Config(), config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
|
||||
def fake_feature_action(
|
||||
action: str,
|
||||
query: dict[str, list[str]],
|
||||
*,
|
||||
allow_install: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
assert action == "enable"
|
||||
assert query == {"name": ["discord"]}
|
||||
cfg = loader.load_config()
|
||||
section = dict(getattr(cfg.channels, "discord", {}) or {})
|
||||
section["enabled"] = True
|
||||
setattr(cfg.channels, "discord", section)
|
||||
loader.save_config(cfg)
|
||||
return {
|
||||
"features": [{
|
||||
"name": "discord",
|
||||
"display_name": "Discord",
|
||||
"type": "channel",
|
||||
"enabled": True,
|
||||
"installed": True,
|
||||
"ready": True,
|
||||
"status": "enabled",
|
||||
"install_supported": True,
|
||||
"requires_restart": True,
|
||||
}],
|
||||
"enabled_count": 1,
|
||||
"requires_restart": True,
|
||||
"last_action": {"ok": True, "message": "Enabled channel 'discord'", "enabled": True},
|
||||
}
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.settings_routes.nanobot_features_action", fake_feature_action)
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
|
||||
calls.append((action, name))
|
||||
cfg = loader.load_config()
|
||||
assert getattr(cfg.channels, "discord")["token"] == "discord-token"
|
||||
return {
|
||||
"handled": True,
|
||||
"ok": True,
|
||||
"requires_restart": False,
|
||||
"message": "Discord channel applied without restart.",
|
||||
}
|
||||
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path),
|
||||
port=_free_port(),
|
||||
channel_feature_action=channel_feature_action,
|
||||
)
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
response = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
{
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Host": "127.0.0.1:8765",
|
||||
"X-Nanobot-Channel-Values": json.dumps(
|
||||
{
|
||||
"channels.discord.token": "discord-token",
|
||||
"channels.discord.allowChannels": "123, 456",
|
||||
"channels.discord.groupPolicy": "open",
|
||||
}
|
||||
),
|
||||
},
|
||||
path="/api/settings/channels/configure?name=discord&enable=true",
|
||||
),
|
||||
"/api/settings/channels/configure",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body.decode())
|
||||
assert body["saved"] is True
|
||||
assert body["name"] == "discord"
|
||||
assert "discord-token" not in response.body.decode()
|
||||
assert calls == [("enable", "discord")]
|
||||
assert body["nanobot_features"]["requires_restart"] is False
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert data["channels"]["discord"] == {
|
||||
"token": "discord-token",
|
||||
"allowChannels": ["123", "456"],
|
||||
"groupPolicy": "open",
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_configure_route_preserves_existing_channel_values(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from nanobot.config import loader
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
setattr(
|
||||
config.channels,
|
||||
"discord",
|
||||
{
|
||||
"enabled": True,
|
||||
"token": "old-discord-token",
|
||||
"allowChannels": ["old-channel"],
|
||||
"groupPolicy": "mention",
|
||||
"customExtra": "keep-me",
|
||||
"nested": {"value": 42},
|
||||
},
|
||||
)
|
||||
loader.save_config(config, config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
response = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
{
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Host": "127.0.0.1:8765",
|
||||
"X-Nanobot-Channel-Values": json.dumps(
|
||||
{
|
||||
"channels.discord.token": "",
|
||||
"channels.discord.allowChannels": "new-channel",
|
||||
}
|
||||
),
|
||||
},
|
||||
path="/api/settings/channels/configure?name=discord",
|
||||
),
|
||||
"/api/settings/channels/configure",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body.decode())
|
||||
assert body["saved_keys"] == ["channels.discord.allowChannels"]
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert data["channels"]["discord"] == {
|
||||
"enabled": True,
|
||||
"token": "old-discord-token",
|
||||
"allowChannels": ["new-channel"],
|
||||
"groupPolicy": "mention",
|
||||
"customExtra": "keep-me",
|
||||
"nested": {"value": 42},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_configure_route_saves_matrix_device_id_without_replacing_token(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from nanobot.config import loader
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
setattr(
|
||||
config.channels,
|
||||
"matrix",
|
||||
{
|
||||
"enabled": False,
|
||||
"homeserver": "https://matrix.example",
|
||||
"userId": "@nanobot:matrix.example",
|
||||
"accessToken": "saved-token",
|
||||
},
|
||||
)
|
||||
loader.save_config(config, config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
response = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
{
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Host": "127.0.0.1:8765",
|
||||
"X-Nanobot-Channel-Values": json.dumps(
|
||||
{
|
||||
"channels.matrix.accessToken": "",
|
||||
"channels.matrix.deviceId": "DEVICE-ID",
|
||||
}
|
||||
),
|
||||
},
|
||||
path="/api/settings/channels/configure?name=matrix",
|
||||
),
|
||||
"/api/settings/channels/configure",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert data["channels"]["matrix"]["accessToken"] == "saved-token"
|
||||
assert data["channels"]["matrix"]["deviceId"] == "DEVICE-ID"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_configure_route_saves_mattermost_setup(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from nanobot.config import loader
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
loader.save_config(Config(), config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
response = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
{
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Host": "127.0.0.1:8765",
|
||||
"X-Nanobot-Channel-Values": json.dumps(
|
||||
{
|
||||
"channels.mattermost.serverUrl": "https://chat.example.com",
|
||||
"channels.mattermost.token": "mattermost-token",
|
||||
"channels.mattermost.teamId": "platform",
|
||||
}
|
||||
),
|
||||
},
|
||||
path="/api/settings/channels/configure?name=mattermost",
|
||||
),
|
||||
"/api/settings/channels/configure",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert data["channels"]["mattermost"] == {
|
||||
"serverUrl": "https://chat.example.com",
|
||||
"token": "mattermost-token",
|
||||
"teamId": "platform",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nanobot_feature_loopback_reverse_proxy_install_requires_opt_in(
|
||||
bus: MagicMock,
|
||||
|
||||
+180
-1
@@ -7,6 +7,7 @@ from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
@@ -1607,6 +1608,12 @@ def _patch_webui_provider_ready(monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.providers.factory.build_provider_snapshot", _snapshot)
|
||||
|
||||
|
||||
def _patch_gateway_ports_free(monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_a, **_kw: False)
|
||||
monkeypatch.setattr("nanobot.cli.commands._tcp_endpoint_reachable", lambda *_a, **_kw: False)
|
||||
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_a, **_kw: False)
|
||||
|
||||
|
||||
def _patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
config: Config,
|
||||
@@ -1643,6 +1650,7 @@ def _patch_cli_command_runtime(
|
||||
"nanobot.providers.factory.load_provider_snapshot",
|
||||
lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config),
|
||||
)
|
||||
_patch_gateway_ports_free(monkeypatch)
|
||||
|
||||
if message_bus is not None:
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", message_bus)
|
||||
@@ -1701,11 +1709,17 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
|
||||
assert len(websocket["tokenIssueSecret"]) >= 32
|
||||
assert data["agents"]["defaults"]["workspace"] == str(workspace)
|
||||
assert seen["templates"] == workspace
|
||||
assert seen["gateway_kwargs"] == {"port": 18888, "open_browser_url": None}
|
||||
assert seen["gateway_kwargs"] == {
|
||||
"port": 18888,
|
||||
"open_browser_url": None,
|
||||
"webui_bundle_mode": "auto",
|
||||
}
|
||||
compact_output = re.sub(r"\s+", " ", _strip_ansi(result.stdout))
|
||||
assert "bootstrap secret was generated" in compact_output
|
||||
assert "channels.websocket.tokenIssueSecret" in compact_output
|
||||
assert "rerun without --no-open" in compact_output
|
||||
assert "nanobot is running in this terminal" in compact_output
|
||||
assert "Press Ctrl+C here to stop nanobot" in compact_output
|
||||
|
||||
|
||||
def test_webui_yes_refuses_missing_provider_setup(monkeypatch, tmp_path: Path) -> None:
|
||||
@@ -1732,6 +1746,10 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
|
||||
seen: dict[str, object] = {}
|
||||
_patch_webui_provider_ready(monkeypatch)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._prepare_webui_bundle_for_gateway",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
class _FakeRuntime:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
@@ -1785,6 +1803,21 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
|
||||
assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=")
|
||||
assert "bootstrapSecret=<redacted>" in compact_output
|
||||
assert "bootstrapSecret=" in opened_url
|
||||
assert "Closing the browser does not stop channels or automations" in compact_output
|
||||
assert "nanobot gateway stop --config" in compact_output
|
||||
|
||||
|
||||
def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> None:
|
||||
opened: list[str] = []
|
||||
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret"
|
||||
monkeypatch.setattr("webbrowser.open", lambda value: opened.append(value))
|
||||
|
||||
cli_commands._open_webui_browser(url, wait=False)
|
||||
|
||||
assert opened == [url]
|
||||
output = _strip_ansi(capsys.readouterr().out)
|
||||
assert "bootstrapSecret=<redacted>" in output
|
||||
assert "super-secret" not in output
|
||||
|
||||
|
||||
def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
|
||||
@@ -1799,6 +1832,10 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
|
||||
seen: dict[str, object] = {}
|
||||
_patch_webui_provider_ready(monkeypatch)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._prepare_webui_bundle_for_gateway",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
def _status(options: GatewayStartOptions) -> GatewayStatus:
|
||||
return GatewayStatus(
|
||||
@@ -1861,6 +1898,125 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
|
||||
assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=")
|
||||
|
||||
|
||||
def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text("{}")
|
||||
seen: dict[str, object] = {}
|
||||
_patch_webui_provider_ready(monkeypatch)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._open_webui_browser",
|
||||
lambda url, **kwargs: seen.update({"opened_url": url, "open_kwargs": kwargs}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._run_gateway",
|
||||
lambda *_args, **_kwargs: pytest.fail("existing gateway should be reused"),
|
||||
)
|
||||
|
||||
class _FakeRuntime:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
seen["runtime_kwargs"] = kwargs
|
||||
|
||||
def status(self):
|
||||
return SimpleNamespace(running=True)
|
||||
|
||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._attach_to_background_gateway",
|
||||
lambda runtime: seen.__setitem__("attached_runtime", runtime),
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Gateway is already running; attaching to the existing WebUI" in result.stdout
|
||||
assert isinstance(seen["attached_runtime"], _FakeRuntime)
|
||||
opened_url = seen["opened_url"]
|
||||
assert isinstance(opened_url, str)
|
||||
parsed = urlparse(opened_url)
|
||||
assert f"{parsed.scheme}://{parsed.netloc}" == "http://127.0.0.1:8765"
|
||||
fragment = parsed.fragment.removeprefix("/?")
|
||||
assert parse_qs(fragment).get("bootstrapSecret")
|
||||
assert seen["open_kwargs"] == {"wait": False}
|
||||
|
||||
|
||||
def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> None:
|
||||
stopped = False
|
||||
|
||||
class _FakeRuntime:
|
||||
def status(self):
|
||||
return SimpleNamespace(running=True)
|
||||
|
||||
def stop(self):
|
||||
nonlocal stopped
|
||||
stopped = True
|
||||
return SimpleNamespace(ok=True, message="gateway_stopped")
|
||||
|
||||
def _interrupt(_seconds: float) -> None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.commands.time.sleep", _interrupt)
|
||||
|
||||
cli_commands._attach_to_background_gateway(_FakeRuntime())
|
||||
|
||||
assert stopped is True
|
||||
output = capsys.readouterr().out
|
||||
assert "Closing the browser does not stop channels or automations" in output
|
||||
assert "Press Ctrl+C here to stop nanobot" in output
|
||||
assert "Gateway stopped" in output
|
||||
|
||||
|
||||
def test_webui_foreground_does_not_claim_unmanaged_gateway(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text("{}")
|
||||
_patch_webui_provider_ready(monkeypatch)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args: True)
|
||||
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args: True)
|
||||
monkeypatch.setattr("nanobot.cli.commands._open_webui_browser", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._attach_to_background_gateway",
|
||||
lambda _runtime: pytest.fail("unmanaged gateway must not be attached"),
|
||||
)
|
||||
|
||||
class _FakeRuntime:
|
||||
def __init__(self, **_kwargs) -> None:
|
||||
pass
|
||||
|
||||
def status(self):
|
||||
return SimpleNamespace(running=False)
|
||||
|
||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
||||
|
||||
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "controlled by another foreground command" in result.stdout
|
||||
|
||||
|
||||
def test_webui_foreground_refuses_occupied_webui_port(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text("{}")
|
||||
_patch_webui_provider_ready(monkeypatch)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr("nanobot.cli.commands._tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._run_gateway",
|
||||
lambda *_args, **_kwargs: pytest.fail("gateway should not start on occupied ports"),
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "nanobot cannot start because one of its local ports is already in use" in result.stdout
|
||||
assert "--port" in result.stdout
|
||||
assert "--gateway-port" in result.stdout
|
||||
|
||||
|
||||
def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None:
|
||||
pytest.importorskip("aiohttp")
|
||||
|
||||
@@ -1998,6 +2154,7 @@ def test_gateway_unbound_agent_cron_is_skipped(
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
||||
_patch_gateway_ports_free(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.build_provider_snapshot",
|
||||
lambda _config: _test_provider_snapshot(provider, _config),
|
||||
@@ -2124,6 +2281,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
||||
_patch_gateway_ports_free(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.build_provider_snapshot",
|
||||
lambda _config: _test_provider_snapshot(provider, _config),
|
||||
@@ -3062,6 +3220,27 @@ def test_serve_rejects_wildcard_host_without_api_key(monkeypatch, tmp_path: Path
|
||||
assert "api_app" not in seen
|
||||
|
||||
|
||||
def test_serve_rejects_specific_network_interface_without_api_key(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config_file = _write_instance_config(tmp_path)
|
||||
config = Config()
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
_patch_serve_runtime(monkeypatch, config, seen)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["serve", "--config", str(config_file), "--host", "192.168.1.10"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "api_key" in result.stdout
|
||||
assert "prevent unauthenticated access" in result.stdout
|
||||
assert "api_app" not in seen
|
||||
|
||||
|
||||
def test_channels_login_requires_channel_name() -> None:
|
||||
result = runner.invoke(app, ["channels", "login"])
|
||||
|
||||
|
||||
@@ -88,13 +88,22 @@ def _test_app(tmp_path: Path, config: Config | None = None):
|
||||
app = typer.Typer()
|
||||
fake_runtime = FakeRuntime(tmp_path)
|
||||
fake_service = FakeServiceInstaller(tmp_path)
|
||||
run_calls: list[tuple[Config, int | None]] = []
|
||||
run_calls: list[tuple[Config, int | None, str | None]] = []
|
||||
prepare_calls: list[tuple[Config, str]] = []
|
||||
|
||||
def load_runtime_config(_config_path: str | None, _workspace: str | None) -> Config:
|
||||
return config or Config()
|
||||
|
||||
def run_gateway(config: Config, *, port: int | None = None) -> None:
|
||||
run_calls.append((config, port))
|
||||
def run_gateway(
|
||||
config: Config,
|
||||
*,
|
||||
port: int | None = None,
|
||||
webui_bundle_mode: str | None = None,
|
||||
) -> None:
|
||||
run_calls.append((config, port, webui_bundle_mode))
|
||||
|
||||
def prepare_webui_bundle(config: Config, mode: str) -> None:
|
||||
prepare_calls.append((config, mode))
|
||||
|
||||
app.add_typer(
|
||||
create_gateway_app(
|
||||
@@ -104,36 +113,39 @@ def _test_app(tmp_path: Path, config: Config | None = None):
|
||||
run_gateway=run_gateway,
|
||||
runtime_factory=lambda **_kwargs: fake_runtime,
|
||||
service_factory=lambda: fake_service,
|
||||
prepare_webui_bundle=prepare_webui_bundle,
|
||||
),
|
||||
name="gateway",
|
||||
)
|
||||
return app, fake_runtime, fake_service, run_calls
|
||||
return app, fake_runtime, fake_service, run_calls, prepare_calls
|
||||
|
||||
|
||||
def test_gateway_default_still_runs_foreground(tmp_path):
|
||||
app, _runtime, _service, calls = _test_app(tmp_path)
|
||||
app, _runtime, _service, calls, _prepare_calls = _test_app(tmp_path)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--port", "18791"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1] == 18791
|
||||
assert calls[0][2] == "warn"
|
||||
|
||||
|
||||
def test_gateway_background_starts_detached_runtime(tmp_path):
|
||||
config = Config()
|
||||
config.gateway.port = 18792
|
||||
app, fake_runtime, _service, _calls = _test_app(tmp_path, config=config)
|
||||
app, fake_runtime, _service, _calls, prepare_calls = _test_app(tmp_path, config=config)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--background"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Gateway started in the background" in result.stdout
|
||||
assert fake_runtime.started_options == GatewayStartOptions(port=18792)
|
||||
assert prepare_calls == [(config, "warn")]
|
||||
|
||||
|
||||
def test_gateway_rejects_conflicting_modes(tmp_path):
|
||||
app, _runtime, _service, _calls = _test_app(tmp_path)
|
||||
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--foreground", "--background"])
|
||||
|
||||
@@ -142,7 +154,7 @@ def test_gateway_rejects_conflicting_modes(tmp_path):
|
||||
|
||||
|
||||
def test_gateway_status_uses_runtime(tmp_path):
|
||||
app, _runtime, _service, _calls = _test_app(tmp_path)
|
||||
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "status"])
|
||||
|
||||
@@ -152,7 +164,7 @@ def test_gateway_status_uses_runtime(tmp_path):
|
||||
|
||||
|
||||
def test_gateway_logs_can_read_without_following(tmp_path):
|
||||
app, _runtime, _service, _calls = _test_app(tmp_path)
|
||||
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "logs", "--tail", "12", "--no-follow"])
|
||||
|
||||
@@ -161,7 +173,7 @@ def test_gateway_logs_can_read_without_following(tmp_path):
|
||||
|
||||
|
||||
def test_gateway_stop_treats_not_running_as_clean(tmp_path):
|
||||
app, fake_runtime, _service, _calls = _test_app(tmp_path)
|
||||
app, fake_runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
|
||||
|
||||
def fake_stop(*, timeout_s: int) -> RuntimeResult:
|
||||
fake_runtime.stop_timeout = timeout_s
|
||||
@@ -179,7 +191,7 @@ def test_gateway_stop_treats_not_running_as_clean(tmp_path):
|
||||
def test_gateway_restart_starts_background_runtime(tmp_path):
|
||||
config = Config()
|
||||
config.gateway.port = 18793
|
||||
app, fake_runtime, _service, _calls = _test_app(tmp_path, config=config)
|
||||
app, fake_runtime, _service, _calls, prepare_calls = _test_app(tmp_path, config=config)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "restart", "--timeout", "9", "--verbose"])
|
||||
|
||||
@@ -187,12 +199,13 @@ def test_gateway_restart_starts_background_runtime(tmp_path):
|
||||
assert "Gateway restarted in the background" in result.stdout
|
||||
assert fake_runtime.stop_timeout == 9
|
||||
assert fake_runtime.restarted_options == GatewayStartOptions(port=18793, verbose=True)
|
||||
assert prepare_calls == [(config, "warn")]
|
||||
|
||||
|
||||
def test_gateway_install_service_uses_service_installer(tmp_path):
|
||||
config = Config()
|
||||
config.gateway.port = 18794
|
||||
app, _runtime, service, _calls = _test_app(tmp_path, config=config)
|
||||
app, _runtime, service, _calls, _prepare_calls = _test_app(tmp_path, config=config)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "install-service", "--dry-run", "--manager", "systemd"])
|
||||
|
||||
@@ -205,7 +218,7 @@ def test_gateway_install_service_uses_service_installer(tmp_path):
|
||||
|
||||
|
||||
def test_gateway_uninstall_service_uses_service_installer(tmp_path):
|
||||
app, _runtime, service, _calls = _test_app(tmp_path)
|
||||
app, _runtime, service, _calls, _prepare_calls = _test_app(tmp_path)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
pid = 23456
|
||||
|
||||
|
||||
def test_api_runtime_uses_isolated_paths(tmp_path: Path) -> None:
|
||||
paths = api_runtime_paths(tmp_path / "config.json")
|
||||
|
||||
assert paths.state_path.parent == tmp_path / "run"
|
||||
assert paths.state_path.name.startswith("api.")
|
||||
assert paths.log_path.parent == tmp_path / "logs"
|
||||
|
||||
|
||||
def test_api_runtime_builds_detached_serve_command(tmp_path: Path, monkeypatch) -> None:
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake_popen(command, **_kwargs):
|
||||
calls.append(command)
|
||||
return FakeProcess()
|
||||
|
||||
runtime = ApiRuntime(
|
||||
paths=api_runtime_paths(tmp_path / "config.json"),
|
||||
platform_name="Linux",
|
||||
python_executable="/python",
|
||||
popen=fake_popen,
|
||||
sleep=lambda _seconds: None,
|
||||
)
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 23456)
|
||||
|
||||
result = runtime.start_background(ApiStartOptions(
|
||||
host="0.0.0.0",
|
||||
port=9900,
|
||||
workspace="/tmp/workspace",
|
||||
config_path="/tmp/config.json",
|
||||
))
|
||||
|
||||
assert result.ok is True
|
||||
assert result.message == "api_started_background"
|
||||
assert calls == [[
|
||||
"/python",
|
||||
"-m",
|
||||
"nanobot",
|
||||
"serve",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"9900",
|
||||
"--workspace",
|
||||
"/tmp/workspace",
|
||||
"--config",
|
||||
"/tmp/config.json",
|
||||
]]
|
||||
@@ -1,9 +1,13 @@
|
||||
import json
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
|
||||
import pytest
|
||||
|
||||
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions, GatewayStatus
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
@@ -83,6 +87,57 @@ def test_start_background_writes_state_and_child_command(tmp_path, monkeypatch):
|
||||
assert state["port"] == 18790
|
||||
|
||||
|
||||
def test_concurrent_background_starts_create_only_one_process(tmp_path, monkeypatch):
|
||||
first_spawned = threading.Event()
|
||||
release_first = threading.Event()
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake_popen(command, **_kwargs):
|
||||
calls.append(command)
|
||||
first_spawned.set()
|
||||
return FakeProcess()
|
||||
|
||||
def first_sleep(_seconds):
|
||||
assert release_first.wait(timeout=2)
|
||||
|
||||
first = GatewayRuntime(
|
||||
paths=_paths(tmp_path),
|
||||
platform_name="Linux",
|
||||
popen=fake_popen,
|
||||
sleep=first_sleep,
|
||||
)
|
||||
second = GatewayRuntime(
|
||||
paths=_paths(tmp_path),
|
||||
platform_name="Linux",
|
||||
popen=fake_popen,
|
||||
sleep=lambda _seconds: None,
|
||||
)
|
||||
for runtime in (first, second):
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 12345)
|
||||
|
||||
results = []
|
||||
first_thread = threading.Thread(
|
||||
target=lambda: results.append(first.start_background(GatewayStartOptions(port=18790)))
|
||||
)
|
||||
second_thread = threading.Thread(
|
||||
target=lambda: results.append(second.start_background(GatewayStartOptions(port=18790)))
|
||||
)
|
||||
|
||||
first_thread.start()
|
||||
assert first_spawned.wait(timeout=2)
|
||||
second_thread.start()
|
||||
release_first.set()
|
||||
first_thread.join(timeout=2)
|
||||
second_thread.join(timeout=2)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert sorted((result.ok, result.message) for result in results) == [
|
||||
(False, "gateway_already_running"),
|
||||
(True, "gateway_started_background"),
|
||||
]
|
||||
|
||||
|
||||
def test_start_background_uses_windows_process_group_flags(tmp_path, monkeypatch):
|
||||
calls: list[dict] = []
|
||||
|
||||
@@ -172,6 +227,34 @@ def test_stop_keeps_state_when_process_survives_timeout(tmp_path, monkeypatch):
|
||||
assert runtime.paths.state_path.exists()
|
||||
|
||||
|
||||
def test_stop_succeeds_when_process_exits_at_timeout_boundary(tmp_path, monkeypatch):
|
||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||
running = GatewayStatus(
|
||||
running=True,
|
||||
pid=12345,
|
||||
state_path=runtime.paths.state_path,
|
||||
log_path=runtime.paths.log_path,
|
||||
)
|
||||
stopped = GatewayStatus(
|
||||
running=False,
|
||||
pid=None,
|
||||
state_path=runtime.paths.state_path,
|
||||
log_path=runtime.paths.log_path,
|
||||
reason="stop_timeout",
|
||||
)
|
||||
statuses = iter([running, stopped])
|
||||
monkeypatch.setattr(runtime, "status", lambda **_kwargs: next(statuses))
|
||||
monkeypatch.setattr(runtime, "_read_state", lambda: {"pid": 12345, "identity": 12345})
|
||||
monkeypatch.setattr(runtime, "_record_matches_process", lambda *_args: True)
|
||||
monkeypatch.setattr(runtime, "_terminate", lambda *_args, **_kwargs: False)
|
||||
|
||||
result = runtime.stop(timeout_s=0)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.message == "gateway_stopped"
|
||||
assert result.status.running is False
|
||||
|
||||
|
||||
def test_terminate_windows_falls_back_when_ctrl_break_is_rejected(tmp_path, monkeypatch):
|
||||
taskkill_calls: list[dict] = []
|
||||
wait_timeouts: list[int | float] = []
|
||||
@@ -191,7 +274,7 @@ def test_terminate_windows_falls_back_when_ctrl_break_is_rejected(tmp_path, monk
|
||||
def fake_kill(_pid, _signal):
|
||||
raise OSError(87, "The parameter is incorrect")
|
||||
|
||||
monkeypatch.setattr("nanobot.gateway.runtime.os.kill", fake_kill)
|
||||
monkeypatch.setattr("nanobot.process_runtime.os.kill", fake_kill)
|
||||
|
||||
def fake_wait_for_exit(_pid, _timeout_s):
|
||||
wait_timeouts.append(_timeout_s)
|
||||
@@ -212,3 +295,30 @@ def test_terminate_windows_falls_back_when_ctrl_break_is_rejected(tmp_path, monk
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups are unavailable")
|
||||
def test_terminate_posix_tolerates_process_group_disappearing_before_sigkill(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
runtime = GatewayRuntime(
|
||||
paths=_paths(tmp_path),
|
||||
platform_name="Darwin",
|
||||
sleep=lambda _seconds: None,
|
||||
)
|
||||
waits = iter([False, True])
|
||||
monkeypatch.setattr(
|
||||
"nanobot.process_runtime.os.getpgid",
|
||||
lambda _pid: 1234,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
def fake_killpg(_pgid, sent_signal):
|
||||
if sent_signal == signal.SIGKILL:
|
||||
raise PermissionError(1, "Operation not permitted")
|
||||
|
||||
monkeypatch.setattr("nanobot.process_runtime.os.killpg", fake_killpg, raising=False)
|
||||
monkeypatch.setattr(runtime, "_wait_for_exit", lambda *_args: next(waits))
|
||||
|
||||
assert runtime._terminate_posix(1234, timeout_s=1) is True
|
||||
|
||||
@@ -42,6 +42,15 @@ class TestGenerateCode:
|
||||
assert store.approve_code(code2) is None
|
||||
|
||||
|
||||
class TestFormatPairingReply:
|
||||
def test_points_owner_to_webui_with_command_fallback(self) -> None:
|
||||
reply = store.format_pairing_reply("ABCD-EFGH")
|
||||
|
||||
assert "nanobot WebUI" in reply
|
||||
assert "ABCD-EFGH" in reply
|
||||
assert "/pairing approve ABCD-EFGH" in reply
|
||||
|
||||
|
||||
class TestApproveDeny:
|
||||
def test_approve_moves_to_approved(self) -> None:
|
||||
code = store.generate_code("telegram", "123")
|
||||
@@ -82,6 +91,21 @@ class TestRevoke:
|
||||
def test_revoke_unknown_returns_false(self) -> None:
|
||||
assert store.revoke("telegram", "999") is False
|
||||
|
||||
def test_clear_channel_removes_approved_and_pending(self) -> None:
|
||||
code = store.generate_code("telegram", "123")
|
||||
store.approve_code(code)
|
||||
store.generate_code("telegram", "456")
|
||||
store.generate_code("discord", "789")
|
||||
|
||||
assert store.clear_channel("telegram") == {"approved": 1, "pending": 1}
|
||||
|
||||
assert store.is_approved("telegram", "123") is False
|
||||
pending = store.list_pending()
|
||||
assert [item["channel"] for item in pending] == ["discord"]
|
||||
|
||||
def test_clear_channel_unknown_returns_zero_counts(self) -> None:
|
||||
assert store.clear_channel("telegram") == {"approved": 0, "pending": 0}
|
||||
|
||||
|
||||
class TestListPending:
|
||||
def test_empty(self) -> None:
|
||||
|
||||
@@ -13,6 +13,7 @@ from nanobot.security.network import (
|
||||
contains_internal_url,
|
||||
env_proxy_applies_to_url,
|
||||
httpx_env_proxy_mounts,
|
||||
is_loopback_host,
|
||||
pin_resolved_url_dns,
|
||||
resolve_url_target,
|
||||
validate_url_target,
|
||||
@@ -21,6 +22,22 @@ from nanobot.security.network import (
|
||||
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host",
|
||||
["localhost", "LOCALHOST.", "127.0.0.1", "127.0.0.2", "::1", "[::1]"],
|
||||
)
|
||||
def test_is_loopback_host_accepts_explicit_loopback(host: str) -> None:
|
||||
assert is_loopback_host(host)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host",
|
||||
["0.0.0.0", "::", "192.168.1.10", "api.internal", "example.com"],
|
||||
)
|
||||
def test_is_loopback_host_rejects_network_targets(host: str) -> None:
|
||||
assert not is_loopback_host(host)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name in (*_PROXY_ENV_VARS, "NO_PROXY", "no_proxy"):
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""Tests for document text extraction utilities."""
|
||||
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.utils.document import (
|
||||
SUPPORTED_EXTENSIONS,
|
||||
PdfSafetyError,
|
||||
_is_text_extension,
|
||||
extract_pdf_pages,
|
||||
extract_text,
|
||||
)
|
||||
|
||||
@@ -252,6 +257,76 @@ class TestExtractText:
|
||||
assert result is not None
|
||||
assert "Inside group" in result
|
||||
|
||||
def test_extract_text_rejects_oversized_office_archive(self, tmp_path, monkeypatch):
|
||||
office_file = tmp_path / "oversized.docx"
|
||||
with ZipFile(office_file, "w") as archive:
|
||||
archive.writestr("word/document.xml", "x" * 32)
|
||||
|
||||
monkeypatch.setattr("nanobot.utils.document._MAX_OFFICE_UNCOMPRESSED_SIZE", 16)
|
||||
|
||||
assert "Office document expands beyond" in (extract_text(office_file) or "")
|
||||
|
||||
def test_extract_text_stops_streaming_xlsx_at_text_limit(self, tmp_path, monkeypatch):
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
xlsx_file = tmp_path / "large.xlsx"
|
||||
wb = Workbook(write_only=True)
|
||||
ws = wb.create_sheet()
|
||||
for index in range(100):
|
||||
ws.append([f"row-{index}-" + "x" * 20])
|
||||
wb.save(xlsx_file)
|
||||
|
||||
visited = 0
|
||||
real_load_workbook = load_workbook
|
||||
|
||||
def tracked_load_workbook(*args, **kwargs):
|
||||
workbook = real_load_workbook(*args, **kwargs)
|
||||
worksheet = workbook[workbook.sheetnames[0]]
|
||||
original_iter_rows = worksheet.iter_rows
|
||||
|
||||
def tracked_rows(*row_args, **row_kwargs):
|
||||
nonlocal visited
|
||||
for row in original_iter_rows(*row_args, **row_kwargs):
|
||||
visited += 1
|
||||
yield row
|
||||
|
||||
worksheet.iter_rows = tracked_rows
|
||||
return workbook
|
||||
|
||||
monkeypatch.setattr("openpyxl.load_workbook", tracked_load_workbook)
|
||||
monkeypatch.setattr("nanobot.utils.document._MAX_TEXT_LENGTH", 80)
|
||||
|
||||
result = extract_text(xlsx_file)
|
||||
|
||||
assert result is not None
|
||||
assert "truncated at 80 chars" in result
|
||||
assert visited < 100
|
||||
|
||||
def test_extract_pdf_pages_rejects_large_content_stream(self, tmp_path, monkeypatch):
|
||||
class _Contents:
|
||||
@staticmethod
|
||||
def get_data():
|
||||
return b"x" * 17
|
||||
|
||||
class _Page:
|
||||
@staticmethod
|
||||
def get_contents():
|
||||
return _Contents()
|
||||
|
||||
@staticmethod
|
||||
def extract_text():
|
||||
return "should not be reached"
|
||||
|
||||
class _Reader:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
self.pages = [_Page()]
|
||||
|
||||
monkeypatch.setattr("pypdf.PdfReader", _Reader)
|
||||
monkeypatch.setattr("nanobot.utils.document._MAX_PDF_CONTENT_STREAM_SIZE", 16)
|
||||
|
||||
with pytest.raises(PdfSafetyError, match="content stream exceeds"):
|
||||
extract_pdf_pages(tmp_path / "large.pdf")
|
||||
|
||||
def test_extract_text_pdf_not_found(self, tmp_path: Path):
|
||||
"""Test that missing PDF files return error string."""
|
||||
missing_pdf = tmp_path / "nonexistent.pdf"
|
||||
|
||||
@@ -6,8 +6,8 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
|
||||
from nanobot.agent.tools import file_state
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -197,6 +197,19 @@ class TestReadPdf:
|
||||
assert "Page 3 content" in result
|
||||
assert "Page 1 content" not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pdf_rejects_invalid_page_range(self, tool, tmp_path):
|
||||
fitz = pytest.importorskip("fitz")
|
||||
pdf_path = tmp_path / "test.pdf"
|
||||
doc = fitz.open()
|
||||
doc.new_page()
|
||||
doc.save(str(pdf_path))
|
||||
doc.close()
|
||||
|
||||
result = await tool.execute(path=str(pdf_path), pages="bad")
|
||||
|
||||
assert "Invalid page range" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pdf_file_not_found_error(self, tool, tmp_path):
|
||||
result = await tool.execute(path=str(tmp_path / "nope.pdf"))
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.webui.build import ensure_webui_bundle, inspect_webui_bundle
|
||||
|
||||
_MTIME_BASE_NS = 1_700_000_000_000_000_000
|
||||
_MTIME_STEP_NS = 5_000_000_000
|
||||
|
||||
|
||||
def _touch(path: Path, *, mtime_ns: int) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(path.name, encoding="utf-8")
|
||||
if mtime_ns < 1_000_000_000_000_000:
|
||||
mtime_ns = _MTIME_BASE_NS + mtime_ns * _MTIME_STEP_NS
|
||||
os.utime(path, ns=(mtime_ns, mtime_ns))
|
||||
|
||||
|
||||
def test_inspect_webui_bundle_ignores_packaged_install_without_source(tmp_path: Path) -> None:
|
||||
source = tmp_path / "site-packages" / "webui"
|
||||
dist = tmp_path / "site-packages" / "nanobot" / "web" / "dist"
|
||||
_touch(dist / "index.html", mtime_ns=20)
|
||||
|
||||
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
|
||||
|
||||
assert status.source_available is False
|
||||
assert status.stale is False
|
||||
assert status.reason == "no_source"
|
||||
|
||||
|
||||
def test_inspect_webui_bundle_marks_missing_dist_stale(tmp_path: Path) -> None:
|
||||
source = tmp_path / "webui"
|
||||
dist = tmp_path / "nanobot" / "web" / "dist"
|
||||
_touch(source / "package.json", mtime_ns=10)
|
||||
|
||||
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
|
||||
|
||||
assert status.source_available is True
|
||||
assert status.dist_available is False
|
||||
assert status.stale is True
|
||||
assert status.reason == "missing_dist"
|
||||
|
||||
|
||||
def test_inspect_webui_bundle_detects_source_newer_than_dist(tmp_path: Path) -> None:
|
||||
source = tmp_path / "webui"
|
||||
dist = tmp_path / "nanobot" / "web" / "dist"
|
||||
_touch(source / "package.json", mtime_ns=10)
|
||||
_touch(source / "src" / "App.tsx", mtime_ns=30)
|
||||
_touch(dist / "index.html", mtime_ns=20)
|
||||
|
||||
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
|
||||
|
||||
assert status.stale is True
|
||||
assert status.reason == "source_newer"
|
||||
assert status.newest_source == source / "src" / "App.tsx"
|
||||
|
||||
|
||||
def test_inspect_webui_bundle_accepts_fresh_dist(tmp_path: Path) -> None:
|
||||
source = tmp_path / "webui"
|
||||
dist = tmp_path / "nanobot" / "web" / "dist"
|
||||
_touch(source / "package.json", mtime_ns=10)
|
||||
_touch(source / "src" / "App.tsx", mtime_ns=20)
|
||||
_touch(dist / "index.html", mtime_ns=30)
|
||||
|
||||
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
|
||||
|
||||
assert status.stale is False
|
||||
assert status.reason == "fresh"
|
||||
|
||||
|
||||
def test_ensure_webui_bundle_auto_builds_stale_dist(tmp_path: Path) -> None:
|
||||
source = tmp_path / "webui"
|
||||
dist = tmp_path / "nanobot" / "web" / "dist"
|
||||
_touch(source / "package.json", mtime_ns=10)
|
||||
_touch(source / "src" / "App.tsx", mtime_ns=30)
|
||||
_touch(dist / "index.html", mtime_ns=20)
|
||||
commands: list[tuple[str, ...]] = []
|
||||
|
||||
def fake_run(command, *, cwd: Path, check: bool) -> None:
|
||||
commands.append(tuple(command))
|
||||
assert cwd == source
|
||||
assert check is True
|
||||
if command == ["bun", "run", "build"]:
|
||||
_touch(dist / "index.html", mtime_ns=40)
|
||||
|
||||
status = ensure_webui_bundle(
|
||||
mode="auto",
|
||||
source_dir=source,
|
||||
dist_dir=dist,
|
||||
runner="bun",
|
||||
subprocess_run=fake_run,
|
||||
)
|
||||
|
||||
assert status.stale is False
|
||||
assert commands == [("bun", "install"), ("bun", "run", "build")]
|
||||
|
||||
|
||||
def test_ensure_webui_bundle_warns_without_building(tmp_path: Path) -> None:
|
||||
source = tmp_path / "webui"
|
||||
dist = tmp_path / "nanobot" / "web" / "dist"
|
||||
_touch(source / "package.json", mtime_ns=10)
|
||||
_touch(source / "src" / "App.tsx", mtime_ns=30)
|
||||
_touch(dist / "index.html", mtime_ns=20)
|
||||
messages: list[str] = []
|
||||
|
||||
status = ensure_webui_bundle(
|
||||
mode="warn",
|
||||
source_dir=source,
|
||||
dist_dir=dist,
|
||||
output=messages.append,
|
||||
)
|
||||
|
||||
assert status.stale is True
|
||||
assert messages
|
||||
assert "Run `cd" in messages[0]
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.weixin import WeixinChannel
|
||||
from nanobot.config.loader import save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.webui.channel_connect import WeixinConnectStore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weixin_connect_store_saves_confirmed_qr_login(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state_dir = tmp_path / "weixin-state"
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-1", "https://qr.example/1"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
self: WeixinChannel,
|
||||
*,
|
||||
base_url: str,
|
||||
endpoint: str,
|
||||
params: dict[str, Any],
|
||||
auth: bool,
|
||||
) -> dict[str, str]:
|
||||
assert base_url == "https://ilinkai.weixin.qq.com"
|
||||
assert endpoint == "ilink/bot/get_qrcode_status"
|
||||
assert params == {"qrcode": "qr-1"}
|
||||
assert auth is False
|
||||
return {
|
||||
"status": "confirmed",
|
||||
"bot_token": "wx-token",
|
||||
"baseurl": "https://weixin.example",
|
||||
"ilink_user_id": "wx-user",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||
|
||||
store = WeixinConnectStore()
|
||||
|
||||
started = await store.start()
|
||||
assert started["status"] == "pending"
|
||||
assert started["qr_url"] == "https://qr.example/1"
|
||||
|
||||
completed = await store.poll(started["session_id"])
|
||||
assert completed["status"] == "succeeded"
|
||||
assert completed["account"] == "wx-user"
|
||||
|
||||
saved = json.loads((state_dir / "account.json").read_text())
|
||||
assert saved["token"] == "wx-token"
|
||||
assert saved["base_url"] == "https://weixin.example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state_dir = tmp_path / "weixin-state"
|
||||
state_dir.mkdir()
|
||||
existing = {
|
||||
"token": "working-token",
|
||||
"base_url": "https://working.weixin.example",
|
||||
"context_tokens": {"user-1": "context-1"},
|
||||
}
|
||||
state_file = state_dir / "account.json"
|
||||
state_file.write_text(json.dumps(existing), encoding="utf-8")
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-reconnect", "https://qr.example/reconnect"
|
||||
|
||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
||||
|
||||
store = WeixinConnectStore()
|
||||
started = await store.start(force=True)
|
||||
|
||||
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
||||
cancelled = await store.cancel(started["session_id"])
|
||||
assert cancelled["status"] == "cancelled"
|
||||
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
||||
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.webui import channel_validation
|
||||
from nanobot.webui.channel_validation import validate_channel_config
|
||||
|
||||
|
||||
def test_validate_channel_does_not_write_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"channels": {
|
||||
"slack": {
|
||||
"appToken": "xapp-old",
|
||||
"botToken": "xoxb-old",
|
||||
"groupPolicy": "mention",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr(channel_validation, "_http_post", lambda *_args, **_kwargs: {"ok": True})
|
||||
|
||||
payload = validate_channel_config(
|
||||
"slack",
|
||||
{
|
||||
"channels.slack.appToken": "",
|
||||
"channels.slack.botToken": "",
|
||||
},
|
||||
)
|
||||
|
||||
assert payload["status"] == "connected"
|
||||
saved = load_config(config_path)
|
||||
assert saved.channels.slack["appToken"] == "xapp-old"
|
||||
assert saved.channels.slack["botToken"] == "xoxb-old"
|
||||
|
||||
|
||||
def test_validate_telegram_bad_token_is_invalid(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
payload = validate_channel_config("telegram", {"channels.telegram.token": "not-a-token"})
|
||||
|
||||
assert payload["status"] == "invalid"
|
||||
assert payload["can_enable"] is False
|
||||
assert payload["missing_fields"] == []
|
||||
|
||||
|
||||
def test_validate_telegram_does_not_expose_saved_token_in_http_errors(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
token = "123456:abcdefghijklmnopqrstuvwxyz"
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({"channels": {"telegram": {"token": token}}}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
def raise_http_error(url: str, **_kwargs) -> dict:
|
||||
request = httpx.Request("GET", url)
|
||||
response = httpx.Response(401, request=request)
|
||||
raise httpx.HTTPStatusError("unauthorized", request=request, response=response)
|
||||
|
||||
monkeypatch.setattr(channel_validation, "_http_get", raise_http_error)
|
||||
|
||||
payload = validate_channel_config("telegram", {"channels.telegram.token": ""})
|
||||
|
||||
assert token not in str(payload)
|
||||
assert any("HTTP 401" in check.get("message", "") for check in payload["checks"])
|
||||
|
||||
|
||||
def test_validate_email_presets_are_checked_without_saving(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr(channel_validation, "_probe_tcp", lambda *_args, **_kwargs: None)
|
||||
|
||||
payload = validate_channel_config(
|
||||
"email",
|
||||
{
|
||||
"channels.email.consentGranted": "true",
|
||||
"channels.email.imapHost": "imap.gmail.com",
|
||||
"channels.email.imapUsername": "bot@example.com",
|
||||
"channels.email.imapPassword": "imap-secret",
|
||||
"channels.email.smtpHost": "smtp.gmail.com",
|
||||
"channels.email.smtpUsername": "bot@example.com",
|
||||
"channels.email.smtpPassword": "smtp-secret",
|
||||
},
|
||||
)
|
||||
|
||||
assert payload["status"] == "connected"
|
||||
assert payload["can_enable"] is True
|
||||
assert not hasattr(load_config(config_path).channels, "email")
|
||||
|
||||
|
||||
def test_validate_email_blocks_private_targets_when_local_access_is_disabled(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.tools.webui_allow_local_service_access = False
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr(
|
||||
channel_validation.socket,
|
||||
"create_connection",
|
||||
lambda *_args, **_kwargs: pytest.fail("blocked target must not be connected"),
|
||||
)
|
||||
|
||||
payload = validate_channel_config(
|
||||
"email",
|
||||
{
|
||||
"channels.email.consentGranted": "true",
|
||||
"channels.email.imapHost": "127.0.0.1",
|
||||
"channels.email.imapUsername": "bot@example.com",
|
||||
"channels.email.imapPassword": "imap-secret",
|
||||
"channels.email.smtpHost": "192.168.1.10",
|
||||
"channels.email.smtpUsername": "bot@example.com",
|
||||
"channels.email.smtpPassword": "smtp-secret",
|
||||
},
|
||||
)
|
||||
|
||||
warnings = [check["message"] for check in payload["checks"] if check["status"] == "warn"]
|
||||
assert len(warnings) == 2
|
||||
assert all("private/internal" in message for message in warnings)
|
||||
|
||||
|
||||
def test_probe_tcp_connects_to_the_validated_ip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
connected: list[tuple[str, int]] = []
|
||||
|
||||
class FakeSocket:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
channel_validation,
|
||||
"resolve_url_target",
|
||||
lambda *_args, **_kwargs: (True, "", ("203.0.113.10",)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
channel_validation.socket,
|
||||
"create_connection",
|
||||
lambda target, **_kwargs: connected.append(target) or FakeSocket(),
|
||||
)
|
||||
|
||||
channel_validation._probe_tcp("mail.example.com", 2525)
|
||||
|
||||
assert connected == [("203.0.113.10", 2525)]
|
||||
|
||||
|
||||
def test_validate_manual_channel_returns_configured(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate(
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"clientId": "ding-client",
|
||||
"clientSecret": "ding-secret",
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
payload = validate_channel_config("dingtalk", {})
|
||||
|
||||
assert payload["status"] == "configured"
|
||||
assert payload["can_enable"] is True
|
||||
assert any(check["status"] == "skipped" for check in payload["checks"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("credentials", "expected_status", "expected_missing"),
|
||||
[
|
||||
({}, "needs_setup", "password_or_accessToken"),
|
||||
({"channels.matrix.accessToken": "token"}, "needs_setup", "deviceId"),
|
||||
({"channels.matrix.password": "secret"}, "configured", None),
|
||||
(
|
||||
{
|
||||
"channels.matrix.accessToken": "token",
|
||||
"channels.matrix.deviceId": "DEVICE",
|
||||
},
|
||||
"configured",
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_validate_matrix_requires_a_complete_login_method(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
credentials: dict[str, str],
|
||||
expected_status: str,
|
||||
expected_missing: str | None,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
payload = validate_channel_config(
|
||||
"matrix",
|
||||
{
|
||||
"channels.matrix.homeserver": "https://matrix.example",
|
||||
"channels.matrix.userId": "@nanobot:matrix.example",
|
||||
**credentials,
|
||||
},
|
||||
)
|
||||
|
||||
assert payload["status"] == expected_status
|
||||
assert payload["can_enable"] is (expected_status == "configured")
|
||||
if expected_missing is None:
|
||||
assert payload["missing_fields"] == []
|
||||
else:
|
||||
assert expected_missing in payload["missing_fields"]
|
||||
@@ -12,6 +12,7 @@ from nanobot.config.schema import Config, ModelPresetConfig
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
_docs_version,
|
||||
_model_catalog_kind,
|
||||
_oauth_provider_status,
|
||||
create_model_configuration,
|
||||
@@ -20,6 +21,7 @@ from nanobot.webui.settings_api import (
|
||||
settings_payload,
|
||||
settings_usage_payload,
|
||||
update_agent_settings,
|
||||
update_api_settings,
|
||||
update_model_configuration,
|
||||
update_network_safety_settings,
|
||||
update_provider_settings,
|
||||
@@ -31,6 +33,100 @@ DYNAMIC_PROVIDER_NAME = "my-company-api"
|
||||
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
||||
|
||||
|
||||
def test_docs_version_uses_released_versions_and_falls_back_for_dev() -> None:
|
||||
assert _docs_version("0.2.3") == "0.2.3"
|
||||
assert _docs_version("0.2.3.post1") == "0.2.3.post1"
|
||||
assert _docs_version("0.2.3.dev0") == "latest"
|
||||
assert _docs_version("0.2.3+editable") == "latest"
|
||||
|
||||
|
||||
def test_settings_payload_includes_versioned_docs(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr("nanobot.webui.settings_api.__version__", "0.2.3")
|
||||
|
||||
payload = settings_payload()
|
||||
|
||||
assert payload["docs"] == {
|
||||
"version": "0.2.3",
|
||||
"base_url": "https://nanobot.wiki/docs/0.2.3",
|
||||
"chat_apps_url": "https://nanobot.wiki/docs/0.2.3/getting-started/chat-apps",
|
||||
"latest_url": "https://nanobot.wiki/docs/latest",
|
||||
}
|
||||
|
||||
|
||||
def test_settings_payload_includes_relocated_capabilities(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.api.port = 9910
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "secret")
|
||||
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "public")
|
||||
|
||||
payload = settings_payload()
|
||||
|
||||
assert payload["api"]["port"] == 9910
|
||||
assert payload["api"]["api_key_hint"] is None
|
||||
assert payload["observability"]["provider"] == "langfuse"
|
||||
assert payload["observability"]["configured"] is True
|
||||
|
||||
|
||||
def test_update_api_settings_requires_key_for_network_access(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
with pytest.raises(WebUISettingsError, match="API key"):
|
||||
update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]})
|
||||
|
||||
payload = update_api_settings({
|
||||
"host": ["0.0.0.0"],
|
||||
"port": ["9900"],
|
||||
"api_key": ["secret-token"],
|
||||
})
|
||||
saved = load_config(config_path)
|
||||
assert saved.api.host == "0.0.0.0"
|
||||
assert saved.api.port == 9900
|
||||
assert saved.api.api_key == "secret-token"
|
||||
assert payload["api"]["api_key_hint"]
|
||||
|
||||
|
||||
def test_update_api_settings_requires_key_for_specific_network_interface(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
with pytest.raises(WebUISettingsError, match="API key"):
|
||||
update_api_settings({"host": ["192.168.1.10"], "port": ["8900"]})
|
||||
|
||||
|
||||
def test_update_api_settings_allows_alternate_loopback_without_key(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
update_api_settings({"host": ["127.0.0.2"], "port": ["8900"]})
|
||||
|
||||
assert load_config(config_path).api.host == "127.0.0.2"
|
||||
|
||||
|
||||
def _dynamic_provider_config(
|
||||
*,
|
||||
api_base: str = DYNAMIC_PROVIDER_API_BASE,
|
||||
@@ -348,6 +444,42 @@ def test_settings_payload_includes_dynamic_custom_provider(
|
||||
assert providers[DYNAMIC_PROVIDER_NAME]["api_base"] == DYNAMIC_PROVIDER_API_BASE
|
||||
|
||||
|
||||
def test_settings_payload_groups_opencode_compatibility_alias(tmp_path, monkeypatch) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
payload = settings_payload()
|
||||
opencode_rows = [row for row in payload["providers"] if row["label"].startswith("OpenCode")]
|
||||
|
||||
assert [(row["name"], row["label"]) for row in opencode_rows] == [
|
||||
("opencode", "OpenCode Zen"),
|
||||
("opencode_go", "OpenCode Go"),
|
||||
]
|
||||
|
||||
|
||||
def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config.model_validate({
|
||||
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "opencode_zen",
|
||||
"model": "opencode/deepseek-v4-pro",
|
||||
}
|
||||
},
|
||||
})
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
payload = settings_payload()
|
||||
zen_rows = [row for row in payload["providers"] if row["label"] == "OpenCode Zen"]
|
||||
|
||||
assert len(zen_rows) == 1
|
||||
assert zen_rows[0]["name"] == "opencode_zen"
|
||||
assert zen_rows[0]["configured"] is True
|
||||
|
||||
|
||||
def test_settings_payload_marks_dynamic_custom_provider_without_api_base_unconfigured(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -952,6 +1084,26 @@ def test_provider_models_payload_fetches_openai_compatible_models(
|
||||
assert payload["models"][1]["context_window"] == 65536
|
||||
|
||||
|
||||
def test_provider_models_payload_returns_curated_openai_codex_models() -> None:
|
||||
payload = provider_models_payload({"provider": ["openai_codex"]})
|
||||
|
||||
assert payload["status"] == "available"
|
||||
assert payload["catalog_kind"] == "builtin"
|
||||
assert payload["model_count"] == 7
|
||||
assert payload["models"][0] == {
|
||||
"id": "openai-codex/gpt-5.6-sol",
|
||||
"label": "GPT-5.6-Sol",
|
||||
"description": "Latest frontier agentic coding model.",
|
||||
"owned_by": "OpenAI Codex",
|
||||
"context_window": 372000,
|
||||
}
|
||||
assert [model["id"] for model in payload["models"][:3]] == [
|
||||
"openai-codex/gpt-5.6-sol",
|
||||
"openai-codex/gpt-5.6-terra",
|
||||
"openai-codex/gpt-5.6-luna",
|
||||
]
|
||||
|
||||
|
||||
def test_provider_models_payload_fetches_dynamic_custom_provider_models(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -1044,6 +1196,7 @@ def test_model_catalog_kind_uses_provider_spec_metadata() -> None:
|
||||
assert _model_catalog_kind(find_by_name("skywork")) == "official"
|
||||
assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported"
|
||||
assert _model_catalog_kind(find_by_name("openrouter")) == "catalog"
|
||||
assert _model_catalog_kind(find_by_name("openai_codex")) == "builtin"
|
||||
|
||||
|
||||
def test_create_model_configuration_accepts_configured_oauth_provider(
|
||||
|
||||
Reference in New Issue
Block a user