Files
Anthias/tests/test_utils.py
Viktor Petersson 7aa52b262e fix(server): allow LAN/private hosts in asset reachability probe (#3103)
* fix(server): allow LAN/private hosts in asset reachability probe

The reachability probe `url_fails()` short-circuited on
`_is_private_address()`: any URL whose host resolved into a
private / loopback / link-local range was marked un-reachable
without ever being fetched. A sibling Docker container (resolving
to a 172.16.0.0/12 bridge address), a NAS on 192.168.x.x, or any
intranet host was therefore flagged dead even though it served
HTTP 200 fine.

Serving signage from the LAN is a first-class Anthias use case, so
remove the guard entirely (the public demo node it protected has
been retired). `url_fails()` now probes private hosts exactly like
any public host, fixing both the periodic `revalidate_asset_urls`
sweep and the create-time reachability check.

Fixes #3101

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: tighten LAN-host regression test, drop duplicate scaffolding

Collapse the private-host reachability test onto the existing
requests.head-mocking pattern. The reachable-verdict case is already
covered by test_url_fails_returns_false_on_2xx_response; this test now
only guards the distinct behaviour (no private-address short-circuit)
by asserting the probe actually fires. Drops the misleading
urlunparse/_FAKE_PRIVATE_HTTP scaffolding that implied a private-only
code path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: drop IP-literal from LAN-probe test comment

Avoid a hardcoded CIDR literal in the comment, matching this file's
convention of keeping static analyzers' hardcoded-IP rules quiet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: silence S5332 hotspot on mocked test URL

Annotate the clear-text http:// test literal with NOSONAR(S5332),
matching the repo convention (see tests/test_youtube_helper.py). The
URL is mocked and never fetched, so the clear-text-transport hotspot
is a false positive; this clears the SonarCloud new-hotspots gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:54:43 +01:00

450 lines
14 KiB
Python

# coding=utf-8
import io
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
import requests
import sh
from anthias_common import utils
from anthias_common.utils import (
generate_perfect_paper_password,
handler,
is_balena_app,
is_ci,
is_demo_node,
is_docker,
json_dump,
string_to_bool,
template_handle_unicode,
url_fails,
validate_url,
)
def test_unicode_correctness_in_bottle_templates() -> None:
assert template_handle_unicode('hello') == 'hello'
assert template_handle_unicode('Привет') == 'Привет'
def test_json_tz() -> None:
json_str = handler(datetime(2016, 7, 19, 12, 42))
assert json_str == '2016-07-19T12:42:00+00:00'
@pytest.mark.django_db
def test_url_fails_returns_true_on_connection_error() -> None:
with patch(
'anthias_common.utils.requests.head',
side_effect=requests.ConnectionError,
):
assert url_fails('http://doesnotwork.example.com') is True
@pytest.mark.django_db
def test_url_fails_returns_false_on_2xx_response() -> None:
fake = MagicMock()
fake.ok = True
with patch('anthias_common.utils.requests.head', return_value=fake):
assert url_fails('http://example.com') is False
@pytest.mark.django_db
def test_url_fails_short_circuits_for_invalid_url() -> None:
# validate_url() rejects schemeless paths, so url_fails should
# return False without ever touching the network layer.
with patch('anthias_common.utils.requests.head') as mock_head:
assert url_fails('/home/user/file') is False
mock_head.assert_not_called()
@pytest.mark.django_db
def test_rtsp_ffprobe_success_returns_false() -> None:
with patch('anthias_common.utils.sh.Command') as mock_command:
mock_command.return_value.return_value = ''
assert not url_fails('rtsp://example.com/stream')
mock_command.assert_called_once_with('ffprobe')
@pytest.mark.django_db
def test_rtmp_ffprobe_nonzero_exit_returns_true() -> None:
err = sh.ErrorReturnCode_1('ffprobe', b'', b'cannot open stream')
with patch('anthias_common.utils.sh.Command') as mock_command:
mock_command.return_value.side_effect = err
assert url_fails('rtmp://example.com/live')
@pytest.mark.django_db
def test_rtsp_ffprobe_timeout_returns_true() -> None:
with patch('anthias_common.utils.sh.Command') as mock_command:
mock_command.return_value.side_effect = sh.TimeoutException(
124, 'ffprobe ...'
)
assert url_fails('rtsp://example.com/stream')
@pytest.mark.django_db
def test_rtsp_ffprobe_missing_returns_false() -> None:
with patch('anthias_common.utils.sh.Command') as mock_command:
mock_command.side_effect = sh.CommandNotFound('ffprobe')
assert not url_fails('rtsp://example.com/stream')
@pytest.mark.parametrize(
'value,expected',
[
('y', True),
('Yes', True),
('t', True),
('TRUE', True),
('on', True),
('1', True),
('n', False),
('No', False),
('f', False),
('FALSE', False),
('off', False),
('0', False),
(1, True),
(0, False),
(True, True),
(False, False),
],
)
def test_string_to_bool_valid(value: Any, expected: bool) -> None:
assert string_to_bool(value) is expected
@pytest.mark.parametrize('value', ['maybe', 'foo', '', '2'])
def test_string_to_bool_invalid_raises(value: str) -> None:
with pytest.raises(ValueError):
string_to_bool(value)
@pytest.mark.parametrize(
'url,expected',
[
('http://wireload.net/logo.png', True),
('https://wireload.net/logo.png', True),
('rtsp://example.com/stream', True),
# rtmp is rejected — Qt6's QMediaPlayer can't open it, so we
# don't let operators add a stream that renders black.
('rtmp://example.com/stream', False),
('hello', False),
('ftp://example.com', False),
('http://', False),
('', False),
],
)
def test_validate_url(url: str, expected: bool) -> None:
assert validate_url(url) is expected
def test_is_ci_true(monkeypatch: Any) -> None:
monkeypatch.setenv('CI', 'true')
assert is_ci() is True
def test_is_ci_false(monkeypatch: Any) -> None:
monkeypatch.delenv('CI', raising=False)
assert is_ci() is False
def test_is_balena_app_true(monkeypatch: Any) -> None:
monkeypatch.setenv('BALENA', '1')
assert is_balena_app() is True
def test_is_balena_app_false(monkeypatch: Any) -> None:
monkeypatch.delenv('BALENA', raising=False)
assert is_balena_app() is False
def test_is_demo_node_true(monkeypatch: Any) -> None:
monkeypatch.setenv('IS_DEMO_NODE', '1')
assert is_demo_node() is True
def test_is_demo_node_false(monkeypatch: Any) -> None:
monkeypatch.delenv('IS_DEMO_NODE', raising=False)
assert is_demo_node() is False
def test_is_docker_uses_dockerenv_marker() -> None:
with patch('anthias_common.utils.os.path.isfile', return_value=True):
assert is_docker() is True
with patch('anthias_common.utils.os.path.isfile', return_value=False):
assert is_docker() is False
def test_generate_perfect_paper_password_length() -> None:
pw = generate_perfect_paper_password(pw_length=12)
assert len(pw) == 12
def test_generate_perfect_paper_password_no_symbols_excludes_punctuation() -> (
None
):
pw = generate_perfect_paper_password(pw_length=200, has_symbols=False)
# !#%+ etc. removed when has_symbols=False.
for ch in '!#%+:?@=':
assert ch not in pw, f'Symbol {ch!r} should not appear'
def test_json_dump_serialises_datetime() -> None:
out = json_dump({'when': datetime(2026, 1, 1, 12, 0, 0)})
assert '"2026-01-01T12:00:00+00:00"' in out
def test_handler_raises_for_non_serializable() -> None:
with pytest.raises(TypeError):
handler(object())
def test_get_balena_supervisor_api_response_uses_env(
monkeypatch: Any,
) -> None:
monkeypatch.setenv('BALENA_SUPERVISOR_ADDRESS', 'http://supervisor:5000')
monkeypatch.setenv('BALENA_SUPERVISOR_API_KEY', 'k')
fake = MagicMock()
with patch(
'anthias_common.utils.requests.get', return_value=fake
) as mock_get:
result = utils.get_balena_supervisor_api_response('get', 'device')
assert result is fake
url = mock_get.call_args.args[0]
assert 'http://supervisor:5000/v1/device?apikey=k' == url
def test_get_balena_device_info_calls_v1_device(monkeypatch: Any) -> None:
monkeypatch.setenv('BALENA_SUPERVISOR_ADDRESS', 'http://x')
monkeypatch.setenv('BALENA_SUPERVISOR_API_KEY', 'k')
fake = MagicMock()
with patch(
'anthias_common.utils.requests.get', return_value=fake
) as mock_get:
utils.get_balena_device_info()
assert '/v1/device' in mock_get.call_args.args[0]
def test_reboot_via_balena_supervisor_uses_post(monkeypatch: Any) -> None:
monkeypatch.setenv('BALENA_SUPERVISOR_ADDRESS', 'http://x')
monkeypatch.setenv('BALENA_SUPERVISOR_API_KEY', 'k')
fake = MagicMock()
with patch(
'anthias_common.utils.requests.post', return_value=fake
) as mock_post:
utils.reboot_via_balena_supervisor()
assert '/v1/reboot' in mock_post.call_args.args[0]
def test_shutdown_via_balena_supervisor_uses_post(monkeypatch: Any) -> None:
monkeypatch.setenv('BALENA_SUPERVISOR_ADDRESS', 'http://x')
monkeypatch.setenv('BALENA_SUPERVISOR_API_KEY', 'k')
fake = MagicMock()
with patch(
'anthias_common.utils.requests.post', return_value=fake
) as mock_post:
utils.shutdown_via_balena_supervisor()
assert '/v1/shutdown' in mock_post.call_args.args[0]
def test_get_balena_supervisor_version_ok(monkeypatch: Any) -> None:
monkeypatch.setenv('BALENA_SUPERVISOR_ADDRESS', 'http://x')
monkeypatch.setenv('BALENA_SUPERVISOR_API_KEY', 'k')
fake = MagicMock()
fake.ok = True
fake.json.return_value = {'version': '14.2.3'}
with patch('anthias_common.utils.requests.get', return_value=fake):
assert utils.get_balena_supervisor_version() == '14.2.3'
def test_get_balena_supervisor_version_error(monkeypatch: Any) -> None:
monkeypatch.setenv('BALENA_SUPERVISOR_ADDRESS', 'http://x')
monkeypatch.setenv('BALENA_SUPERVISOR_API_KEY', 'k')
fake = MagicMock()
fake.ok = False
with patch('anthias_common.utils.requests.get', return_value=fake):
assert (
utils.get_balena_supervisor_version()
== 'Error getting the Supervisor version'
)
def test_template_handle_unicode_non_string() -> None:
assert template_handle_unicode(42) == '42'
assert template_handle_unicode(None) == 'None'
# ---------------------------------------------------------------------------
# Resolution detection — the helpers detect_screen_resolution() chains
# through. Each is pure I/O so we mock /sys readers with monkeypatch.
def test_drm_resolution_picks_first_connected_mode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A /sys/class/drm/card?-HDMI-A-1 dir reads as 'connected' with a
1920x1080 mode → _drm_resolution() returns '1920x1080'."""
from anthias_common import utils
class FakeEntry:
def __init__(self, name: str, path: str) -> None:
self.name = name
self.path = path
monkeypatch.setattr(
'anthias_common.utils.os.scandir',
lambda _p: [FakeEntry('card1-HDMI-A-1', '/fake/drm/card1-HDMI-A-1')],
)
def fake_open(path: str, *_a: Any, **_k: Any) -> io.StringIO:
if path.endswith('/status'):
return io.StringIO('connected\n')
if path.endswith('/modes'):
return io.StringIO('1920x1080\n1280x720\n')
raise OSError('unexpected path')
monkeypatch.setattr('builtins.open', fake_open)
assert utils._drm_resolution() == '1920x1080'
def test_fb_resolution_parses_comma_pair(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from anthias_common import utils
monkeypatch.setattr(
'builtins.open', lambda *_a, **_k: io.StringIO('1920,1080\n')
)
assert utils._fb_resolution() == '1920x1080'
def test_fb_resolution_handles_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from anthias_common import utils
def boom(*_a: Any, **_k: Any) -> None:
raise OSError('no fb0')
monkeypatch.setattr('builtins.open', boom)
assert utils._fb_resolution() is None
# ---------------------------------------------------------------------------
# MAC interface detection — _detect_local_mac() picks default-route
# iface from /proc/net/route then reads /sys/class/net/<iface>/address.
def test_default_route_iface_picks_up_flag_set(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from anthias_common import utils
sample = (
'Iface\tDestination\tGateway\tFlags\n'
'eth0\t00000000\t0100A8C0\t0003\t0\t0\t100\n'
'eth0\t0000A8C0\t00000000\t0001\t0\t0\t0\n'
)
monkeypatch.setattr('builtins.open', lambda *_a, **_k: io.StringIO(sample))
assert utils._default_route_iface() == 'eth0'
def test_default_route_iface_skips_down_route(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from anthias_common import utils
# Same destination but RTF_UP=0 (flags=0002) — should NOT match.
sample = (
'Iface\tDestination\tGateway\tFlags\n'
'eth0\t00000000\t0100A8C0\t0002\t0\t0\t100\n'
)
monkeypatch.setattr('builtins.open', lambda *_a, **_k: io.StringIO(sample))
assert utils._default_route_iface() is None
def test_read_iface_mac_skips_zero_mac(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from anthias_common import utils
monkeypatch.setattr(
'builtins.open', lambda *_a, **_k: io.StringIO('00:00:00:00:00:00\n')
)
assert utils._read_iface_mac('eth0') is None
def test_read_iface_mac_returns_real_mac(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from anthias_common import utils
monkeypatch.setattr(
'builtins.open', lambda *_a, **_k: io.StringIO('aa:bb:cc:dd:ee:ff\n')
)
assert utils._read_iface_mac('eth0') == 'aa:bb:cc:dd:ee:ff'
def test_first_non_loopback_mac_skips_docker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from anthias_common import utils
monkeypatch.setattr(
'anthias_common.utils.os.listdir',
lambda _p: ['lo', 'docker0', 'eth0', 'br-foo'],
)
monkeypatch.setattr(
utils,
'_read_iface_mac',
lambda iface: 'aa:bb:cc:dd:ee:ff' if iface == 'eth0' else None,
)
assert utils._first_non_loopback_mac() == 'aa:bb:cc:dd:ee:ff'
def test_detect_local_mac_prefers_default_route(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from anthias_common import utils
monkeypatch.setattr('anthias_common.utils.os.path.isdir', lambda _p: True)
monkeypatch.setattr(utils, '_default_route_iface', lambda: 'wlan0')
monkeypatch.setattr(
utils,
'_read_iface_mac',
lambda iface: '11:22:33:44:55:66' if iface == 'wlan0' else None,
)
assert utils._detect_local_mac() == '11:22:33:44:55:66'
# ---------------------------------------------------------------------------
# LAN content — url_fails must probe a private/LAN host, not short-circuit
# on its address class. Serving signage from an intranet host, a NAS on a
# private subnet, or a sibling Docker container (which resolves to a
# private bridge address) is a first-class use case. See GH #3101.
@pytest.mark.django_db
def test_url_fails_probes_private_host_instead_of_rejecting() -> None:
"""A LAN host is probed like any other — HEAD actually fires.
The reachable/unreachable verdict itself is already covered by
``test_url_fails_returns_false_on_2xx_response``; this guards the
distinct behaviour that there is no private-address short-circuit
(a regression guard for #3101), so it asserts the probe ran.
"""
fake = MagicMock()
fake.ok = True
# NOSONAR(S5332): mocked test URL, never fetched over the wire.
url = 'http://menu-webserver/index.html' # NOSONAR(S5332)
with patch(
'anthias_common.utils.requests.head', return_value=fake
) as mock_head:
assert url_fails(url) is False
mock_head.assert_called_once()