Files
esh-pfi-infrastructure/services/althing-alert-bridge/test_bridge.py
T
vh 6f0a9b9fae feat(alerts): generalize the althing bridge, wire Kuma to it, retire chamber
THE BRIDGE. `beszel-althing` hardcoded a "[Beszel] " subject prefix and a
Beszel hub footer from when Beszel was its only caller. Routing Uptime Kuma
through it unchanged would have delivered Kuma outages labelled [Beszel],
pointing the reader at the wrong dashboard -- an alert that lies about its own
source is worse than no alert.

Now a route registry: /beszel and /kuma, each with its own prefix, footer and
payload parser, because the tools do not agree on a shape (Beszel sends
{title, message}; Kuma sends {heartbeat, monitor, msg}). Generalising cost a
dict; a sibling service would have cost a second unit, a second port and a
second thing to notice had died.

Renamed beszel-althing -> althing-alert-bridge with it. A service named after
one consumer that carries two is the invisible coupling that sends a future
session looking in the wrong place.

⚠ /beszel IS FROZEN and this refactor proves it rather than claiming it. The
three original tests were kept BYTE-UNCHANGED -- including the one asserting
the exact postbox argv -- and deliver() still defaults to the Beszel route so
they exercise it. A new test asserts the Kuma footer never leaks into a Beszel
body or vice versa. Verified live after the rename: a real POST to /beszel
landed as "[Beszel] BRIDGE RENAME CHECK" with the correct hub footer, read back
from the thread rather than trusted from the receipt.

Payload shapes are parsed HERE, not via Kuma's custom-webhook-body feature,
because Kuma's notification config lives in its own database -- and that
database was destroyed and rebuilt from scratch hours ago. Anything living only
in a tool's DB is lost on the next rebuild; format knowledge belongs in git,
next to a test.

parse_kuma also handles the monitorless case. testNotification and cert-expiry
alerts carry no monitor and no heartbeat, and the first cut fabricated "unknown
monitor is ?" from them -- caught by sending a real one and reading the subject,
not by the suite. Fixed, pinned, and the earlier test asserting the bad
behaviour was corrected rather than worked around.

KUMA IS NOW WIRED. scripts/kuma gained notification support and the channel is
in monitors.yaml, seeded BEFORE the monitors and with applyExisting, so a
rebuild restores alerting and not just detection. Ground truth from the DB:
13 of 13 monitors carry the channel.

⚠ A THIRD instance of the same class of bug, worth naming: notifications() is
pushed as `notificationList` at LOGIN ONLY -- there is no event to ask with. The
first cut cleared the captured value before waiting, discarding the only copy it
would ever be sent, then blocked for the full timeout and reported an empty
list. That reads exactly like "no channels configured" and is a lie. Same family
as the getMonitorList ack-vs-push trap, different shape.

End-to-end, both shapes, read back from the inbox:
  [Uptime Kuma] Homepage is DOWN          + target + board link
  [Uptime Kuma] althing (infra-ops) Testing   (no fabricated subject)
  [Beszel] BRIDGE RENAME CHECK            + hub footer, unchanged

ALTHING CHAMBER RETIRED (operator). Three of its four containers had never
started -- created 2026-09-19, StartedAt epoch-zero, 0 restarts -- so :7881
refused, and nothing was watching it. Only its valkey was running, on the
project's own network with no external consumer. Stack, compose/build/conf dirs
and the local image removed; the Homepage card went with the label.
2026-09-21 23:11:54 -07:00

110 lines
6.1 KiB
Python

import importlib.util
import os
from pathlib import Path
import subprocess
import unittest
from unittest.mock import patch
spec=importlib.util.spec_from_file_location('bridge',Path(__file__).with_name('bridge.py'))
bridge=importlib.util.module_from_spec(spec);spec.loader.exec_module(bridge)
class DeliveryTests(unittest.TestCase):
def setUp(self):
self.env=patch.dict(os.environ,POSTBOX='/bin/postbox',BESZEL_ALERT_RECIPIENT='infra-ops')
self.env.start();self.addCleanup(self.env.stop)
def test_posts_body_as_stdin_and_returns_receipt(self):
with patch.object(bridge.subprocess,'run',return_value=subprocess.CompletedProcess([],0,'{"message_id": 42}','')) as run:
self.assertEqual(bridge.deliver({'title':'Disk 85%','message':'tank is full'})['message_id'],42)
args,kw=run.call_args
self.assertEqual(args[0],['/bin/postbox','--json','send','--to','infra-ops','--subject','[Beszel] Disk 85%'])
self.assertIn('tank is full',kw['input'])
def test_delivery_failure_is_not_success(self):
with patch.object(bridge.subprocess,'run',return_value=subprocess.CompletedProcess([],2,'','post office unavailable')):
with self.assertRaises(RuntimeError):bridge.deliver({'message':'disk full'})
def test_rejects_empty_message_without_sending(self):
with patch.object(bridge.subprocess,'run') as run:
with self.assertRaises(ValueError):bridge.deliver({'message':''})
run.assert_not_called()
class KumaRouteTests(unittest.TestCase):
"""The /kuma route. The point of generalising was that an alert must not
lie about its own source -- a Kuma outage labelled [Beszel] sends you to
the wrong dashboard, which is worse than no alert."""
def setUp(self):
self.env=patch.dict(os.environ,POSTBOX='/bin/postbox',ALERT_RECIPIENT='infra-ops')
self.env.start();self.addCleanup(self.env.stop)
DOWN={'monitor':{'name':'Homepage','url':'http://10.0.50.45:5100/'},
'heartbeat':{'status':0,'msg':'connect ECONNREFUSED'},
'msg':'[Homepage] [Down] connect ECONNREFUSED'}
def test_subject_names_the_service_and_the_direction(self):
with patch.object(bridge.subprocess,'run',return_value=subprocess.CompletedProcess([],0,'{"message_id":7}','')) as run:
bridge.deliver(self.DOWN,'/kuma')
argv=run.call_args[0][0]
self.assertIn('[Uptime Kuma] Homepage is DOWN',argv)
self.assertNotIn('[Beszel] Homepage is DOWN',argv)
def test_up_transition_reads_as_up(self):
up={'monitor':{'name':'Gitea'},'heartbeat':{'status':1,'msg':'200 - OK'},'msg':'[Gitea] [Up] 200 - OK'}
with patch.object(bridge.subprocess,'run',return_value=subprocess.CompletedProcess([],0,'{"message_id":8}','')) as run:
bridge.deliver(up,'/kuma')
self.assertIn('[Uptime Kuma] Gitea is UP',run.call_args[0][0])
def test_body_carries_the_target_and_the_kuma_board_not_the_beszel_hub(self):
with patch.object(bridge.subprocess,'run',return_value=subprocess.CompletedProcess([],0,'{"message_id":9}','')) as run:
bridge.deliver(self.DOWN,'/kuma')
body=run.call_args[1]['input']
self.assertIn('http://10.0.50.45:5100/',body)
self.assertIn('Board: http://10.250.50.70:3001',body)
self.assertNotIn('Hub: http://10.250.50.70:8090',body) # the Beszel footer must not leak
def test_beszel_footer_is_unchanged_by_the_refactor(self):
with patch.object(bridge.subprocess,'run',return_value=subprocess.CompletedProcess([],0,'{"message_id":10}','')) as run:
bridge.deliver({'title':'Disk 85%','message':'tank is full'},'/beszel')
body=run.call_args[1]['input']
self.assertIn('Hub: http://10.250.50.70:8090',body)
self.assertNotIn('Board:',body)
def test_a_nameless_alert_prefers_its_message_over_a_fabricated_subject(self):
"""Superseded an earlier assertion that this should read "unknown
monitor is DOWN". It should not: a real monitor always carries a name,
so a nameless payload is degenerate, and "something broke" tells the
reader more than a placeholder that looks like a bug."""
with patch.object(bridge.subprocess,'run',return_value=subprocess.CompletedProcess([],0,'{"message_id":11}','')) as run:
argv=None
bridge.deliver({'heartbeat':{'status':0},'msg':'something broke'},'/kuma')
argv=run.call_args[0][0]
self.assertIn('[Uptime Kuma] something broke',argv)
self.assertNotIn('unknown monitor',' '.join(argv))
def test_empty_kuma_message_is_refused_without_sending(self):
with patch.object(bridge.subprocess,'run') as run:
with self.assertRaises(ValueError):
bridge.deliver({'monitor':{'name':'X'},'heartbeat':{'status':0},'msg':''},'/kuma')
run.assert_not_called()
def test_a_monitorless_notification_uses_its_own_message_as_the_subject(self):
"""testNotification and cert-expiry arrive with no monitor and no
heartbeat. Fabricating "unknown monitor is ?" from those makes a real
alert read like a bug -- observed live on 2026-09-21."""
with patch.object(bridge.subprocess,'run',return_value=subprocess.CompletedProcess([],0,'{"message_id":13}','')) as run:
bridge.deliver({'msg':'althing (infra-ops) Testing'},'/kuma')
argv=run.call_args[0][0]
self.assertIn('[Uptime Kuma] althing (infra-ops) Testing',argv)
self.assertNotIn('unknown monitor',' '.join(argv))
self.assertNotIn('is ?',' '.join(argv))
def test_legacy_beszel_env_names_still_resolve(self):
"""A half-finished deploy (new bridge, old unit) must start, not crash."""
with patch.dict(os.environ,{'ALERT_RECIPIENT':'','BESZEL_ALERT_RECIPIENT':'infra-ops'}):
with patch.object(bridge.subprocess,'run',return_value=subprocess.CompletedProcess([],0,'{"message_id":12}','')) as run:
bridge.deliver({'title':'t','message':'m'})
self.assertIn('infra-ops',run.call_args[0][0])
if __name__ == '__main__':
unittest.main()