The channel sends through the Bot API with httpx only: HTML with escaped text, cut to fit a message, silent for a warning, a short 429 waited out once, and errors that say what to do about a blocked bot, a lost chat or a bad token and never quote the request URL. The bot token is a stored secret: saving a channel refuses a literal one, the card's "Store token" writes the secret and a reference, and no endpoint returns it. A chat id is never typed. Connect starts a ten minute code, and the chat that sends `/start <code>` back through the bot is the one connected, so it is a chat somebody opened a link for. The engine fetches updates only while a code is waiting (no standing poll, no webhook, no inbound address) and ignores everything else without a reply. Connect needs a saved form, and the form takes the connected chat in without touching what was typed since. The token is part of every Bot API URL, so the httpx logger gets the same filter the request lines have, which now also masks an httpx.URL argument.
277 lines
8.3 KiB
Python
277 lines
8.3 KiB
Python
"""The Test button: it exists to tell a working channel from a broken one."""
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from fluksio.core.config import settings
|
|
from fluksio.flow.alerts import AlertsConfig, Channel
|
|
|
|
PREFIX = f"{settings.API_V1_STR}/alerts"
|
|
|
|
|
|
def _one_channel(client: TestClient) -> Any:
|
|
alerts = client.app.state.flow_controller.alerts # type: ignore[attr-defined]
|
|
alerts.config = AlertsConfig(
|
|
channels=[Channel(name="phone", kind="ntfy", config={"topic": "t"})]
|
|
)
|
|
return alerts
|
|
|
|
|
|
def test_a_channel_that_cannot_deliver_is_not_a_success(
|
|
client: TestClient,
|
|
superuser_token_headers: dict[str, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
alerts = _one_channel(client)
|
|
|
|
async def refuse(_config: dict[str, Any], _alert: Any) -> None:
|
|
raise RuntimeError("nobody home")
|
|
|
|
monkeypatch.setattr(alerts, "_send_ntfy", refuse)
|
|
|
|
response = client.post(f"{PREFIX}/test/phone", headers=superuser_token_headers)
|
|
|
|
assert response.status_code == 502
|
|
assert "nobody home" in response.json()["detail"]
|
|
|
|
|
|
def test_a_channel_that_delivers_says_so(
|
|
client: TestClient,
|
|
superuser_token_headers: dict[str, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
alerts = _one_channel(client)
|
|
sent = []
|
|
|
|
async def accept(_config: dict[str, Any], alert: Any) -> None:
|
|
sent.append(alert)
|
|
|
|
monkeypatch.setattr(alerts, "_send_ntfy", accept)
|
|
|
|
response = client.post(f"{PREFIX}/test/phone", headers=superuser_token_headers)
|
|
|
|
assert response.status_code == 200
|
|
assert len(sent) == 1
|
|
|
|
|
|
def test_an_unknown_channel_is_a_404(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
_one_channel(client)
|
|
|
|
response = client.post(f"{PREFIX}/test/nowhere", headers=superuser_token_headers)
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_an_ordinary_account_cannot_read_or_change_the_alert_setup(
|
|
client: TestClient, normal_user_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""Channels hold credential references, topics and urls: a superuser's."""
|
|
config = {"channels": [], "rules": []}
|
|
|
|
assert (
|
|
client.get(f"{PREFIX}/config", headers=normal_user_token_headers).status_code
|
|
== 403
|
|
)
|
|
assert (
|
|
client.put(
|
|
f"{PREFIX}/config", headers=normal_user_token_headers, json=config
|
|
).status_code
|
|
== 403
|
|
)
|
|
assert (
|
|
client.post(
|
|
f"{PREFIX}/test/phone", headers=normal_user_token_headers
|
|
).status_code
|
|
== 403
|
|
)
|
|
|
|
|
|
def test_a_stored_secret_cannot_be_pointed_at_a_host_an_ordinary_account_chose(
|
|
client: TestClient,
|
|
superuser_token_headers: dict[str, str],
|
|
normal_user_token_headers: dict[str, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""An ntfy `server` plus a `$secret` token used to send the secret there."""
|
|
import httpx
|
|
|
|
client.put(
|
|
f"{settings.API_V1_STR}/secrets/vault-token",
|
|
headers=superuser_token_headers,
|
|
json={"value": "S3CRET"},
|
|
)
|
|
reached: list[str] = []
|
|
|
|
async def post(_self: Any, url: Any, **_kwargs: Any) -> httpx.Response:
|
|
reached.append(str(url))
|
|
return httpx.Response(200, request=httpx.Request("POST", url))
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "post", post)
|
|
config = {
|
|
"channels": [
|
|
{
|
|
"name": "x",
|
|
"kind": "ntfy",
|
|
"config": {
|
|
"server": "http://attacker.invalid",
|
|
"topic": "t",
|
|
"token": {"$secret": "vault-token"},
|
|
},
|
|
}
|
|
],
|
|
"rules": [],
|
|
}
|
|
|
|
client.put(f"{PREFIX}/config", headers=normal_user_token_headers, json=config)
|
|
client.post(f"{PREFIX}/test/x", headers=normal_user_token_headers)
|
|
|
|
assert reached == []
|
|
|
|
|
|
def test_any_account_can_subscribe_its_own_browser(
|
|
client: TestClient, normal_user_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""Where this device can be reached is not a superuser's to hold."""
|
|
response = client.get(f"{PREFIX}/webpush/key", headers=normal_user_token_headers)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["key"]
|
|
|
|
|
|
def _telegram_config(token: Any) -> dict[str, Any]:
|
|
return {
|
|
"channels": [
|
|
{
|
|
"name": "garage",
|
|
"kind": "telegram",
|
|
"config": {"token": token} if token else {},
|
|
}
|
|
],
|
|
"rules": [],
|
|
}
|
|
|
|
|
|
def test_a_bot_token_has_to_be_a_stored_secret(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
plain = client.put(
|
|
f"{PREFIX}/config",
|
|
headers=superuser_token_headers,
|
|
json=_telegram_config("123456:literal-token"),
|
|
)
|
|
stored = client.put(
|
|
f"{PREFIX}/config",
|
|
headers=superuser_token_headers,
|
|
json=_telegram_config({"$secret": "telegram-bot"}),
|
|
)
|
|
|
|
assert plain.status_code == 422
|
|
assert "stored secret" in plain.json()["detail"]
|
|
assert stored.status_code == 200
|
|
|
|
|
|
def test_a_chat_connects_through_the_link_and_the_token_is_never_returned(
|
|
client: TestClient,
|
|
superuser_token_headers: dict[str, str],
|
|
normal_user_token_headers: dict[str, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
import json
|
|
import time
|
|
|
|
import httpx
|
|
|
|
from fluksio.flow import telegram
|
|
|
|
token = "123456:SECRET-token"
|
|
client.put(
|
|
f"{settings.API_V1_STR}/secrets/telegram-bot",
|
|
headers=superuser_token_headers,
|
|
json={"value": token},
|
|
)
|
|
client.put(
|
|
f"{PREFIX}/config",
|
|
headers=superuser_token_headers,
|
|
json=_telegram_config({"$secret": "telegram-bot"}),
|
|
)
|
|
|
|
def bot(request: httpx.Request) -> httpx.Response:
|
|
method = request.url.path.rsplit("/", 1)[1]
|
|
assert request.url.path.startswith(f"/bot{token}/")
|
|
result: Any = True
|
|
if method == "getMe":
|
|
result = {"username": "fluksio_bot"}
|
|
elif method == "getUpdates":
|
|
time.sleep(0.05)
|
|
codes = [c for c, p in telegram.linker._pending.items() if p.token == token]
|
|
result = (
|
|
[
|
|
{
|
|
"update_id": 1,
|
|
"message": {
|
|
"text": f"/start {codes[0]}",
|
|
"chat": {"id": 42, "first_name": "Anna"},
|
|
},
|
|
}
|
|
]
|
|
if codes
|
|
else []
|
|
)
|
|
return httpx.Response(200, content=json.dumps({"ok": True, "result": result}))
|
|
|
|
transport = httpx.MockTransport(bot)
|
|
monkeypatch.setattr(
|
|
telegram,
|
|
"_client",
|
|
lambda timeout: httpx.AsyncClient(transport=transport, timeout=timeout),
|
|
)
|
|
|
|
link_url = f"{PREFIX}/telegram/garage/link"
|
|
assert client.post(link_url, headers=normal_user_token_headers).status_code == 403
|
|
response = client.post(link_url, headers=superuser_token_headers)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["url"].startswith("https://t.me/fluksio_bot?start=")
|
|
assert body["group_url"].startswith("https://t.me/fluksio_bot?startgroup=")
|
|
assert token not in response.text
|
|
|
|
channel: dict[str, Any] = {}
|
|
for _ in range(100):
|
|
config = client.get(f"{PREFIX}/config", headers=superuser_token_headers)
|
|
channel = config.json()["channels"][0]
|
|
if channel["config"].get("chat_id"):
|
|
break
|
|
time.sleep(0.05)
|
|
|
|
assert channel["config"]["chat_id"] == "42"
|
|
assert channel["config"]["label"] == "Anna"
|
|
assert channel["config"]["token"] == {"$secret": "telegram-bot"}
|
|
assert token not in config.text
|
|
status = client.get(link_url, headers=superuser_token_headers).json()
|
|
assert status["waiting"] is False
|
|
|
|
|
|
def test_a_link_needs_a_stored_token(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
client.put(
|
|
f"{PREFIX}/config",
|
|
headers=superuser_token_headers,
|
|
json=_telegram_config(None),
|
|
)
|
|
|
|
response = client.post(
|
|
f"{PREFIX}/telegram/garage/link", headers=superuser_token_headers
|
|
)
|
|
unknown = client.post(
|
|
f"{PREFIX}/telegram/nowhere/link", headers=superuser_token_headers
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert unknown.status_code == 404
|