mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
feat(relay): deliver through the maintainer's Home Assistant webhook (#43)
User-Visible: no Issue: #43
This commit is contained in:
@@ -419,6 +419,18 @@ report is written to the relay spool **before** delivery is attempted, so a fail
|
||||
delivery costs a promise, not the user's request. No public issue is created and no
|
||||
e-mail provider participates.
|
||||
|
||||
**Two delivery channels, chosen by deployment.** `telegram` posts to the Bot API
|
||||
directly. `ha_webhook` posts the summary to a private webhook of the maintainer's
|
||||
own Home Assistant, which performs the last mile. The second channel exists
|
||||
because the project node runs at a Russian hosting provider where every
|
||||
`api.telegram.org` address is unreachable — direct delivery failed with «Network
|
||||
is unreachable», not with a provider error. On this channel the package stays in
|
||||
the relay spool and the summary names its path: the webhook carries text only,
|
||||
so the address (which is its own access key) stays cheap to rotate, and the
|
||||
geometry of a stranger's home does not travel through a messenger. Either
|
||||
channel keeps the same contract: nothing is promised to the user until delivery
|
||||
is confirmed.
|
||||
|
||||
Production URL is an immutable backend constant supplied after relay deployment.
|
||||
No placeholder, localhost URL or configurable arbitrary endpoint may pass release
|
||||
gates. A staging relay and exact production URL are dependencies of S5/implementation;
|
||||
@@ -445,7 +457,8 @@ and reviewed as a security trade-off; a hard-coded shared key is explicitly forb
|
||||
|
||||
- Relay stores the accepted report (message, contact, safe metadata and the
|
||||
attachment) in a spool readable only by its own system user, and a daily timer
|
||||
deletes everything older than **30 days**; maintainers may delete earlier.
|
||||
deletes everything older than **30 days**; maintainers may delete earlier. On
|
||||
the `ha_webhook` channel the attachment never leaves that spool.
|
||||
Storage is the price of the chosen channel: Telegram delivery leaves no archive
|
||||
the project controls, so the deletion rule has to live where the project can
|
||||
enforce and prove it.
|
||||
@@ -609,7 +622,8 @@ available and recorded in #43:
|
||||
|
||||
1. project-controlled relay deployment target and production HTTPS hostname;
|
||||
2. private maintainer channel and its delivery credentials stored only in the
|
||||
relay environment, as a path to a secret file rather than a value;
|
||||
relay environment, as a path to a secret file rather than a value — for the
|
||||
`ha_webhook` channel the webhook address is that credential;
|
||||
3. configured and running 30-day deletion of the relay spool;
|
||||
4. staging endpoint usable by CI without production delivery;
|
||||
5. named maintainer responsible for relay alerts/disable switch.
|
||||
|
||||
@@ -49,9 +49,20 @@
|
||||
|
||||
## Доставка
|
||||
|
||||
Канал — Telegram (решение владельца 2026-09-01): сводка сообщением, пакет —
|
||||
документом. `parse_mode` не используется намеренно, текст пользователя
|
||||
отображается буквально. Ответ провайдера наружу не отражается: клиент получает
|
||||
Каналов два, `HP_RELAY_CHANNEL`.
|
||||
|
||||
**`ha_webhook` — рабочий канал стенда.** Relay отдаёт сводку вебхуку Home
|
||||
Assistant владельца, а последнюю милю до Telegram делает уже он. Так вышло не от
|
||||
хорошей жизни: узел стоит у российского хостера, откуда `api.telegram.org`
|
||||
недоступен по всем адресам — прямая доставка падала с «Network is unreachable».
|
||||
Побочный выигрыш: пакет остаётся в спуле стенда и в мессенджер не уходит, то
|
||||
есть геометрия чужого дома не гуляет по чатам. Сводка называет путь к пакету.
|
||||
|
||||
**`telegram` — прямой канал.** Сводка сообщением, пакет документом. Годится для
|
||||
узла, откуда Telegram доступен. `parse_mode` не используется намеренно: текст
|
||||
пользователя отображается буквально.
|
||||
|
||||
Ответ провайдера наружу не отражается ни в одном из каналов: клиент получает
|
||||
только `report_id` либо стабильный код отказа.
|
||||
|
||||
Отчёт кладётся на диск **до** попытки доставки. Если доставка не удалась,
|
||||
@@ -73,9 +84,10 @@
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
См. `deploy/env.example`. Секрет доставки задаётся **путём к файлу**
|
||||
(`HP_RELAY_TELEGRAM_TOKEN_FILE`), а не значением: так он не виден ни в
|
||||
`systemctl show`, ни в `ps`, ни в дампе окружения.
|
||||
См. `deploy/env.example`. Секреты доставки задаются **путём к файлу**
|
||||
(`HP_RELAY_TELEGRAM_TOKEN_FILE`, `HP_RELAY_WEBHOOK_URL_FILE`), а не значением:
|
||||
так они не видны ни в `systemctl show`, ни в `ps`, ни в дампе окружения. Адрес
|
||||
вебхука — такой же секрет, как токен: он сам себе ключ доступа.
|
||||
|
||||
## Установка
|
||||
|
||||
@@ -126,13 +138,19 @@ sudo systemctl restart hp-support-relay@prod
|
||||
важнее, чем кажется: принять и потерять — хуже, чем честно отказать, потому что
|
||||
пользователь считает обращение отправленным.
|
||||
|
||||
**Сменить токен доставки**
|
||||
**Сменить секрет доставки**
|
||||
|
||||
```bash
|
||||
# прямой Telegram
|
||||
sudo install -m 0400 -o hprelay -g hprelay /dev/stdin /etc/hp-support-relay/telegram.token
|
||||
# канал через Home Assistant (адрес вебхука целиком)
|
||||
sudo install -m 0400 -o hprelay -g hprelay /dev/stdin /etc/hp-support-relay/webhook.url
|
||||
sudo systemctl restart hp-support-relay@prod
|
||||
```
|
||||
|
||||
Ротация вебхука — это ещё и правка `webhook_id` в автоматизации Home Assistant
|
||||
«House Plan: приёмщик обратной связи → личка»: адрес и есть ключ.
|
||||
|
||||
**Прочитать обращение**
|
||||
|
||||
```bash
|
||||
@@ -151,11 +169,12 @@ sudo -u hprelay cat /var/lib/hp-support-relay/prod/reports/2026-09/hpr-…/repor
|
||||
cd scripts/support-relay && python3 -m unittest discover -s tests -q
|
||||
```
|
||||
|
||||
Тридцать одна проверка: схема, размеры, хеш, идемпотентность, частота,
|
||||
Тридцать пять проверок: схема, размеры, хеш, идемпотентность, частота,
|
||||
ретеншн, буквальность текста, отсутствие адреса в журналах, невозможность
|
||||
выбрать себе корзину лимита подделкой заголовка, поведение рубильника.
|
||||
Каждая проверялась отрицательным прогоном — одиннадцать мутаций рабочего кода
|
||||
Каждая проверялась отрицательным прогоном — тринадцать мутаций рабочего кода
|
||||
(снять сверку хеша, разрешить лишнюю часть, не чистить управляющие символы,
|
||||
снять лимит, писать адрес в журнал, игнорировать идемпотентность, отключить
|
||||
рубильник, отключить ретеншн, не проверять секции пакета, брать первый элемент
|
||||
`X-Forwarded-For`) роняют ровно те проверки, ради которых написаны.
|
||||
`X-Forwarded-For`, подменить `source` в вебхуке, приложить пакет к вебхуку)
|
||||
роняют ровно те проверки, ради которых написаны.
|
||||
|
||||
@@ -3,6 +3,9 @@ HP_RELAY_PORT=8130
|
||||
HP_RELAY_SPOOL=/var/lib/hp-support-relay/prod
|
||||
# deliver — отправлять мейнтейнеру; discard — принимать и складывать молча.
|
||||
HP_RELAY_MODE=deliver
|
||||
# ha_webhook — последняя миля через Home Assistant владельца (нужен, когда с узла
|
||||
# api.telegram.org недоступен); telegram — прямая отправка.
|
||||
HP_RELAY_CHANNEL=ha_webhook
|
||||
# Рубильник: 0 переводит эндпоинт в единообразный 503, ничего не принимая.
|
||||
HP_RELAY_ENABLED=1
|
||||
HP_RELAY_RETENTION_DAYS=30
|
||||
@@ -10,5 +13,7 @@ HP_RELAY_RETENTION_DAYS=30
|
||||
# владелец hprelay). Так он не попадает ни в `systemctl show`, ни в `ps`.
|
||||
HP_RELAY_TELEGRAM_TOKEN_FILE=/etc/hp-support-relay/telegram.token
|
||||
HP_RELAY_TELEGRAM_CHAT_ID=
|
||||
# Адрес вебхука — такой же секрет, как токен: он сам себе ключ доступа.
|
||||
HP_RELAY_WEBHOOK_URL_FILE=/etc/hp-support-relay/webhook.url
|
||||
# Источник берётся из X-Forwarded-For, потому что перед сервисом стоит Caddy.
|
||||
HP_RELAY_TRUSTED_PROXY=1
|
||||
|
||||
@@ -105,7 +105,10 @@ class Service:
|
||||
"attachment_sha256": request.attachment_sha256,
|
||||
}
|
||||
stored = self.store.save(report_id, meta, attachment.body if attachment else None)
|
||||
result = self.delivery.send(report_id, meta, attachment.body if attachment else None)
|
||||
# Путь добавляется только для доставки: в самом отчёте он избыточен, а в
|
||||
# сводке — единственный способ найти пакет, если канал его не несёт.
|
||||
delivered_meta = {**meta, "spool_path": str(stored.directory)}
|
||||
result = self.delivery.send(report_id, delivered_meta, attachment.body if attachment else None)
|
||||
self.store.mark_delivery(stored, "sent" if result.ok else "failed", result.detail)
|
||||
if not result.ok:
|
||||
LOG.warning("delivery failed for %s: %s", report_id, result.detail)
|
||||
|
||||
@@ -32,15 +32,21 @@ class Config:
|
||||
port: int
|
||||
spool: Path
|
||||
mode: str # 'deliver' | 'discard'
|
||||
channel: str # 'telegram' | 'ha_webhook'
|
||||
enabled: bool
|
||||
retention_days: int
|
||||
telegram_token: str # пусто = доставка выключена
|
||||
telegram_chat_id: str
|
||||
webhook_url: str # адрес вебхука Home Assistant (секрет: он же ключ доступа)
|
||||
trusted_proxy: bool # брать источник из X-Forwarded-For
|
||||
|
||||
@property
|
||||
def delivers(self) -> bool:
|
||||
return self.mode == "deliver" and bool(self.telegram_token and self.telegram_chat_id)
|
||||
if self.mode != "deliver":
|
||||
return False
|
||||
if self.channel == "ha_webhook":
|
||||
return bool(self.webhook_url)
|
||||
return bool(self.telegram_token and self.telegram_chat_id)
|
||||
|
||||
|
||||
def _read_secret(path_value: str) -> str:
|
||||
@@ -57,16 +63,21 @@ def load(env: dict[str, str] | None = None) -> Config:
|
||||
mode = env.get("HP_RELAY_MODE", "discard").strip().lower()
|
||||
if mode not in {"deliver", "discard"}:
|
||||
raise ValueError("HP_RELAY_MODE must be 'deliver' or 'discard'")
|
||||
channel = env.get("HP_RELAY_CHANNEL", "telegram").strip().lower()
|
||||
if channel not in {"telegram", "ha_webhook"}:
|
||||
raise ValueError("HP_RELAY_CHANNEL must be 'telegram' or 'ha_webhook'")
|
||||
spool = Path(env.get("HP_RELAY_SPOOL", "/var/lib/hp-support-relay"))
|
||||
return Config(
|
||||
port=int(env.get("HP_RELAY_PORT", "8130")),
|
||||
spool=spool,
|
||||
mode=mode,
|
||||
channel=channel,
|
||||
# Рубильник §19 ТЗ: выключенный relay обязан отвечать единообразным 503,
|
||||
# а не принимать отчёты и терять их.
|
||||
enabled=env.get("HP_RELAY_ENABLED", "1").strip() not in {"0", "false", "no"},
|
||||
retention_days=int(env.get("HP_RELAY_RETENTION_DAYS", "30")),
|
||||
telegram_token=_read_secret(env.get("HP_RELAY_TELEGRAM_TOKEN_FILE", "")),
|
||||
telegram_chat_id=env.get("HP_RELAY_TELEGRAM_CHAT_ID", "").strip(),
|
||||
webhook_url=_read_secret(env.get("HP_RELAY_WEBHOOK_URL_FILE", "")),
|
||||
trusted_proxy=env.get("HP_RELAY_TRUSTED_PROXY", "1").strip() not in {"0", "false", "no"},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"""Доставка отчёта мейнтейнеру.
|
||||
|
||||
Канал доставки — Telegram (решение владельца 2026-09-01): сообщение с
|
||||
идентификатором и безопасными версиями плюс сам пакет отдельным документом.
|
||||
Каналов два, и второй появился не от любви к вариантам. Прямой Telegram
|
||||
(`telegram`) — как задумывалось: сводка сообщением, пакет документом. Но узел
|
||||
проекта стоит у российского хостера, откуда `api.telegram.org` недоступен по
|
||||
всем адресам, — доставка молча падала с «Network is unreachable». Поэтому
|
||||
основной канал стенда — `ha_webhook`: relay отдаёт сводку вебхуку Home Assistant
|
||||
владельца, а последнюю милю до Telegram делает уже он, из сети, где Telegram
|
||||
доступен. Пакет при этом остаётся в спуле стенда и в мессенджер не уходит —
|
||||
геометрия чужого дома по чатам не гуляет.
|
||||
Разметка НЕ используется намеренно: без `parse_mode` Telegram показывает текст
|
||||
буквально, поэтому сообщение пользователя не может ничего разметить, подделать
|
||||
или скрыть.
|
||||
@@ -12,8 +18,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
@@ -28,16 +36,63 @@ class Result:
|
||||
detail: str
|
||||
|
||||
|
||||
def _connect_ipv4(address, timeout, source_address=None) -> socket.socket:
|
||||
"""Соединение строго по IPv4.
|
||||
|
||||
Узел проекта отдаёт для `api.telegram.org` и AAAA, и A, но связности по
|
||||
IPv6 у него нет: обычный `urlopen` выбирал IPv6 и молча висел до таймаута —
|
||||
доставка падала с «status 0», хотя сеть была в порядке. Явный выбор
|
||||
семейства делает поведение независимым от порядка, в котором резолвер
|
||||
вернул адреса.
|
||||
"""
|
||||
host, port = address
|
||||
last: OSError | None = None
|
||||
for family, kind, proto, _canon, sockaddr in socket.getaddrinfo(
|
||||
host, port, socket.AF_INET, socket.SOCK_STREAM,
|
||||
):
|
||||
sock = socket.socket(family, kind, proto)
|
||||
try:
|
||||
sock.settimeout(timeout)
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
sock.connect(sockaddr)
|
||||
return sock
|
||||
except OSError as error:
|
||||
last = error
|
||||
sock.close()
|
||||
raise last or OSError(f"no IPv4 address for {host}")
|
||||
|
||||
|
||||
class _IPv4HTTPSConnection(http.client.HTTPSConnection):
|
||||
def connect(self) -> None:
|
||||
self.sock = _connect_ipv4((self.host, self.port), self.timeout, self.source_address)
|
||||
if self._tunnel_host:
|
||||
self._tunnel()
|
||||
self.sock = self._context.wrap_socket(
|
||||
self.sock, server_hostname=self._tunnel_host or self.host,
|
||||
)
|
||||
|
||||
|
||||
class _IPv4HTTPSHandler(urllib.request.HTTPSHandler):
|
||||
def https_open(self, req): # noqa: D102 - контракт базового класса
|
||||
return self.do_open(_IPv4HTTPSConnection, req, context=self._context)
|
||||
|
||||
|
||||
def _post(url: str, body: bytes, content_type: str) -> tuple[int, bytes]:
|
||||
request = urllib.request.Request(url, data=body, method="POST")
|
||||
request.add_header("Content-Type", content_type)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
|
||||
return response.status, response.read(4096)
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, error.read(4096)
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||
return 0, str(error).encode("utf-8", "replace")[:4096]
|
||||
openers = [urllib.request.build_opener(_IPv4HTTPSHandler()), urllib.request.build_opener()]
|
||||
last_error = b""
|
||||
for opener in openers:
|
||||
try:
|
||||
with opener.open(request, timeout=TIMEOUT_SECONDS) as response:
|
||||
return response.status, response.read(4096)
|
||||
except urllib.error.HTTPError as error:
|
||||
# Ответ провайдера — это ответ, а не сбой связи: второй попытки не нужно.
|
||||
return error.code, error.read(4096)
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||
last_error = str(error).encode("utf-8", "replace")[:4096]
|
||||
return 0, last_error
|
||||
|
||||
|
||||
def _multipart(fields: dict[str, str], filename: str, blob: bytes) -> tuple[bytes, str]:
|
||||
@@ -59,11 +114,16 @@ def _multipart(fields: dict[str, str], filename: str, blob: bytes) -> tuple[byte
|
||||
|
||||
def summary_text(report_id: str, meta: dict) -> str:
|
||||
versions = meta.get("versions") or {}
|
||||
attachment = "—"
|
||||
if meta.get("attachment_size"):
|
||||
attachment = f"{meta['attachment_size']} B"
|
||||
if meta.get("spool_path"):
|
||||
attachment += f" — {meta['spool_path']}"
|
||||
lines = [
|
||||
f"House Plan support report {report_id}",
|
||||
"",
|
||||
"versions: " + (", ".join(f"{k}={v}" for k, v in sorted(versions.items())) or "—"),
|
||||
"attachment: " + (f"{meta.get('attachment_size', 0)} B" if meta.get("attachment_size") else "—"),
|
||||
"attachment: " + attachment,
|
||||
"contact: " + (meta.get("contact") or "—"),
|
||||
"",
|
||||
"message:",
|
||||
@@ -89,9 +149,9 @@ class TelegramDelivery:
|
||||
"text": summary_text(report_id, meta),
|
||||
"disable_web_page_preview": True,
|
||||
}).encode("utf-8")
|
||||
status, _ = _post(f"{API}/bot{self._token}/sendMessage", body, "application/json")
|
||||
status, detail = _post(f"{API}/bot{self._token}/sendMessage", body, "application/json")
|
||||
if status != 200:
|
||||
return Result(False, f"sendMessage status {status}")
|
||||
return Result(False, f"sendMessage status {status}: {detail.decode('utf-8', 'replace')[:200]}")
|
||||
if attachment is None:
|
||||
return Result(True, "message only")
|
||||
payload, content_type = _multipart(
|
||||
@@ -99,12 +159,35 @@ class TelegramDelivery:
|
||||
f"houseplan-support-{report_id}.json",
|
||||
attachment,
|
||||
)
|
||||
status, _ = _post(f"{API}/bot{self._token}/sendDocument", payload, content_type)
|
||||
status, detail = _post(f"{API}/bot{self._token}/sendDocument", payload, content_type)
|
||||
if status != 200:
|
||||
return Result(False, f"sendDocument status {status}")
|
||||
return Result(False, f"sendDocument status {status}: {detail.decode('utf-8', 'replace')[:200]}")
|
||||
return Result(True, "message and document")
|
||||
|
||||
|
||||
class HaWebhookDelivery:
|
||||
"""Последняя миля через Home Assistant владельца.
|
||||
|
||||
Вебхук отдаёт только текст: адрес вебхука — сам себе ключ доступа, и чем
|
||||
меньше через него проходит, тем дешевле его ротация. Вложение остаётся на
|
||||
стенде, а сводка называет путь к нему.
|
||||
"""
|
||||
|
||||
def __init__(self, url: str) -> None:
|
||||
self._url = url
|
||||
|
||||
def send(self, report_id: str, meta: dict, attachment: bytes | None) -> Result:
|
||||
body = json.dumps({
|
||||
"source": "houseplan-support-relay",
|
||||
"report_id": report_id,
|
||||
"text": summary_text(report_id, meta),
|
||||
}, ensure_ascii=False).encode("utf-8")
|
||||
status, detail = _post(self._url, body, "application/json")
|
||||
if status != 200:
|
||||
return Result(False, f"webhook status {status}: {detail.decode('utf-8', 'replace')[:200]}")
|
||||
return Result(True, "forwarded through Home Assistant")
|
||||
|
||||
|
||||
class DiscardDelivery:
|
||||
"""Staging: отчёт принимается и складывается, но никуда не уходит."""
|
||||
|
||||
@@ -113,6 +196,8 @@ class DiscardDelivery:
|
||||
|
||||
|
||||
def build(cfg) -> object:
|
||||
if cfg.delivers:
|
||||
return TelegramDelivery(cfg.telegram_token, cfg.telegram_chat_id)
|
||||
return DiscardDelivery()
|
||||
if not cfg.delivers:
|
||||
return DiscardDelivery()
|
||||
if cfg.channel == "ha_webhook":
|
||||
return HaWebhookDelivery(cfg.webhook_url)
|
||||
return TelegramDelivery(cfg.telegram_token, cfg.telegram_chat_id)
|
||||
|
||||
@@ -309,6 +309,79 @@ class RelayTestCase(unittest.TestCase):
|
||||
self.assertEqual(config.RATE_TTL_SECONDS, 24 * 3600)
|
||||
|
||||
|
||||
class WebhookChannelTestCase(unittest.TestCase):
|
||||
"""Канал «через Home Assistant»: что уходит и что остаётся."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._tmp = TemporaryDirectory()
|
||||
secret = Path(self._tmp.name) / "webhook.url"
|
||||
secret.write_text("https://ha.example/api/webhook/xyz\n", encoding="utf-8")
|
||||
self.cfg = config.load({
|
||||
"HP_RELAY_SPOOL": str(Path(self._tmp.name) / "spool"),
|
||||
"HP_RELAY_MODE": "deliver",
|
||||
"HP_RELAY_CHANNEL": "ha_webhook",
|
||||
"HP_RELAY_WEBHOOK_URL_FILE": str(secret),
|
||||
})
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_channel_is_live_with_only_the_webhook_url(self):
|
||||
self.assertTrue(self.cfg.delivers)
|
||||
self.assertIsInstance(delivery.build(self.cfg), delivery.HaWebhookDelivery)
|
||||
|
||||
def test_webhook_sends_text_and_keeps_the_package_on_the_node(self):
|
||||
posted: list[tuple[str, bytes, str]] = []
|
||||
real = delivery._post
|
||||
delivery._post = lambda url, body, ctype: (posted.append((url, body, ctype)), (200, b"ok"))[1]
|
||||
try:
|
||||
channel = delivery.HaWebhookDelivery(self.cfg.webhook_url)
|
||||
result = channel.send("hpr-42", {"message": "сломалось", "attachment_size": 12,
|
||||
"spool_path": "/var/lib/x/hpr-42"}, b"{}")
|
||||
finally:
|
||||
delivery._post = real
|
||||
self.assertTrue(result.ok)
|
||||
url, body, ctype = posted[0]
|
||||
self.assertEqual(url, "https://ha.example/api/webhook/xyz")
|
||||
self.assertEqual(ctype, "application/json")
|
||||
payload = json.loads(body)
|
||||
self.assertEqual(payload["source"], "houseplan-support-relay") # условие автоматизации
|
||||
self.assertIn("сломалось", payload["text"])
|
||||
self.assertIn("/var/lib/x/hpr-42", payload["text"]) # где искать пакет
|
||||
self.assertNotIn("attachment_bytes", payload) # сам пакет не уходит
|
||||
self.assertEqual(sorted(payload), ["report_id", "source", "text"])
|
||||
|
||||
def test_webhook_failure_is_not_reported_as_success(self):
|
||||
real = delivery._post
|
||||
delivery._post = lambda url, body, ctype: (502, b"bad gateway")
|
||||
try:
|
||||
result = delivery.HaWebhookDelivery(self.cfg.webhook_url).send("hpr-1", {"message": "x"}, None)
|
||||
finally:
|
||||
delivery._post = real
|
||||
self.assertFalse(result.ok)
|
||||
self.assertIn("502", result.detail)
|
||||
|
||||
|
||||
class DeliveryTransportTestCase(unittest.TestCase):
|
||||
"""Транспорт доставки: узел без IPv6 не должен молча висеть."""
|
||||
|
||||
def test_connect_asks_for_ipv4_addresses_only(self):
|
||||
seen: list[int] = []
|
||||
real = delivery.socket.getaddrinfo
|
||||
|
||||
def fake(host, port, family=0, *args, **kwargs):
|
||||
seen.append(family)
|
||||
return real("127.0.0.1", port, family, *args, **kwargs)
|
||||
|
||||
delivery.socket.getaddrinfo = fake
|
||||
try:
|
||||
with self.assertRaises(OSError):
|
||||
delivery._connect_ipv4(("example.invalid", 9), 0.2)
|
||||
finally:
|
||||
delivery.socket.getaddrinfo = real
|
||||
self.assertEqual(seen, [delivery.socket.AF_INET])
|
||||
|
||||
|
||||
class HttpSurfaceTestCase(unittest.TestCase):
|
||||
"""Проверки, которые живут только на транспортном уровне."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user