232 lines
7.7 KiB
Python
232 lines
7.7 KiB
Python
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"]
|