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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user