Files
bambuddy/backend/tests/unit/services/test_network_utils.py
T
maziggy 3a5f802cdc fix(diagnostics): read the subnet the host is actually on (issue #3092)
The Network subnet check told the reporter that 192.168.98.170 and
192.168.96.9 were on different networks and to go configure routing
between them. They are four hundred addresses apart inside one
192.168.96.0/22 LAN.

An IPv4 address does not carry its prefix, and the check supplied /24
for both sides. That is the most common LAN and not the only one, and
the guess is wrong in both directions: it splits a /22 and it merges a
/25. Read the prefix off the interface that owns the address instead.

find_local_ipv4_network() enumerates every interface, including the ones
EXCLUDED_INTERFACE_PREFIXES hides. That list keeps docker0 and friends
out of the Virtual Printer's bind dropdown; here the caller is asking
about an address the kernel has already picked as a route source, and
answering "unknown" because it sits on a bridge would be a worse answer
than the truth. When nothing claims the address the check skips, which
is what it always did with no host IP at all -- it must not assert a
split it cannot see.

The same check chose which of Bambuddy's own addresses to compare by
probing a route toward 10.255.255.255, which on a multi-homed host is
not the interface the printer is on. It asks for the route toward the
printer now. On a two-NIC dev box that alone was warning about a printer
sitting on the second card's own subnet.

The probe takes IPv4 literals only. connect() on a name would resolve
it on the event loop, and _same_subnet rejects names anyway, so nothing
is lost. Resolving the prefix shells out to `ip -j addr show`, so it
moves off the loop too.

-----

fix(diagnostics): name the container engine instead of asking about Docker (issue #3092)

"Not running in Docker - not applicable", said to a Bambuddy inside a
Podman container. It reads as "you are on bare metal", and it sent the
reporter looking for his problem somewhere else.

Podman runs Bambuddy in exactly the two shapes Docker does, and the
shape is the thing that breaks printer discovery and the Virtual
Printer. detect_container_runtime() names the engine -- Docker, Podman,
Kubernetes, containerd, LXC, or a container it cannot place -- and the
check became Container network mode.

is_running_in_docker() is deliberately left alone rather than rewritten
on top of it. Three callers key real behaviour off that flag, and one of
them switches the Add Printer flow from SSDP to subnet scanning. SSDP
works for a host-networked Podman container, so answering True there
would take a working feature away to fix a sentence. Widening it is a
separate decision from naming the engine, so it is made separately.

Mode detection keeps the original signal first, which also makes the
Docker path incapable of regressing: a Docker host always has a docker0,
so a container that sees one shares its namespace, and the new rules can
only turn a warning into a pass. That signal says nothing about Podman,
which creates no such interface on a host running no bridge containers --
which is how host networking came to be reported as bridge. The general
form of the same idea answers for Podman: an interface whose iflink
equals its ifindex was created in this namespace, and a NAT-networked
container only ever receives one end of a veth pair. tun/tap is skipped,
because a container may run its own WireGuard and that tun is native to
a namespace it is not evidence of. The interface also has to be the one
the kernel just named -- sysfs is namespace-tagged but a bind-mounted
host /sys is not, and reading a colliding name's numbers would be
reading another namespace's answer.

What is still unreadable now says so and suggests host networking if
discovery is failing, rather than guessing bridge and telling a healthy
install to recreate itself. An LXC or LXD system container is named and
told the question does not apply: it is on the LAN like a small virtual
machine, so there is no network mode to recommend -- and its subnet
check still runs.

An engine we cannot name is a sentinel the frontend localizes, not a
word interpolated into thirteen other languages.

The support bundle carries the engine name beside the Docker flag, so
the next report of this shape is answerable from the bundle.
2026-09-19 12:19:19 +02:00

131 lines
5.9 KiB
Python

"""Tests for network interface enumeration.
Focus: the platform routing in get_network_interfaces(). macOS/BSD have fcntl
but not the Linux SIOCGIFADDR/SIOCGIFNETMASK ioctls, so the ioctl path there
silently returns nothing and the VP bind-interface dropdown comes up empty.
Everything that isn't Linux must go through the cross-platform psutil path.
"""
import socket
from collections import namedtuple
from unittest.mock import patch
from backend.app.services import network_utils
# Mimic the shape of psutil.net_if_addrs() / net_if_stats() entries we read.
_Addr = namedtuple("snicaddr", ["family", "address", "netmask", "broadcast", "ptp"])
_Stats = namedtuple("snicstats", ["isup", "duplex", "speed", "mtu", "flags"])
def _fake_psutil():
addrs = {
"en0": [_Addr(socket.AF_INET, "192.168.1.50", "255.255.255.0", None, None)],
"lo0": [_Addr(socket.AF_INET, "127.0.0.1", "255.0.0.0", None, None)],
"awdl0": [_Addr(socket.AF_INET, "169.254.10.20", "255.255.0.0", None, None)],
"utun3": [_Addr(socket.AF_INET, "100.64.0.7", "255.255.255.255", None, None)],
"en5": [_Addr(socket.AF_INET, "10.0.0.9", "255.255.255.0", None, None)],
}
stats = {
"en0": _Stats(True, 0, 0, 1500, 0),
"lo0": _Stats(True, 0, 0, 16384, 0),
"awdl0": _Stats(True, 0, 0, 1500, 0),
"utun3": _Stats(True, 0, 0, 1500, 0),
"en5": _Stats(False, 0, 0, 1500, 0), # down → skipped
}
return addrs, stats
@patch("backend.app.services.network_utils.sys")
def test_macos_routes_to_psutil(mock_sys):
"""On darwin, get_network_interfaces() must use psutil, not the ioctl path."""
mock_sys.platform = "darwin"
with patch.object(network_utils, "_get_network_interfaces_psutil", return_value=[{"name": "en0"}]) as psutil_path:
result = network_utils.get_network_interfaces()
psutil_path.assert_called_once()
assert result == [{"name": "en0"}]
@patch("backend.app.services.network_utils.sys")
def test_windows_routes_to_psutil(mock_sys):
mock_sys.platform = "win32"
with patch.object(network_utils, "_get_network_interfaces_psutil", return_value=[]) as psutil_path:
network_utils.get_network_interfaces()
psutil_path.assert_called_once()
@patch("backend.app.services.network_utils.sys")
def test_linux_does_not_use_psutil(mock_sys):
"""Linux keeps the ioctl path — psutil helper must not be invoked."""
mock_sys.platform = "linux"
with patch.object(network_utils, "_get_network_interfaces_psutil") as psutil_path:
# The ioctl path runs for real here; we only assert it wasn't short-circuited
# to psutil. Its actual return depends on the host, so we don't assert on it.
network_utils.get_network_interfaces()
psutil_path.assert_not_called()
def test_psutil_path_filters_and_returns_bindable_ips():
"""The psutil path drops loopback/link-local/down ifaces, keeps real + VPN ones."""
addrs, stats = _fake_psutil()
with (
patch("psutil.net_if_addrs", return_value=addrs),
patch("psutil.net_if_stats", return_value=stats),
):
result = network_utils._get_network_interfaces_psutil()
by_name = {i["name"]: i for i in result}
assert "en0" in by_name # normal LAN interface
assert by_name["en0"]["ip"] == "192.168.1.50"
assert by_name["en0"]["subnet"] == "192.168.1.0/24"
assert "utun3" in by_name # Tailscale/VPN — legitimately bindable
assert "lo0" not in by_name # loopback filtered
assert "awdl0" not in by_name # link-local (169.254) filtered
assert "en5" not in by_name # interface down, skipped
_IP_ADDR_JSON = """[
{"ifname": "lo", "addr_info": [{"family": "inet", "local": "127.0.0.1", "prefixlen": 8}]},
{"ifname": "enp3s0", "addr_info": [{"family": "inet", "local": "192.168.96.9", "prefixlen": 22}]},
{"ifname": "enp4s0", "addr_info": [
{"family": "inet", "local": "10.0.0.5", "prefixlen": 24},
{"family": "inet", "local": "10.0.0.6", "prefixlen": 24, "label": "enp4s0:vp1"}
]},
{"ifname": "docker0", "addr_info": [{"family": "inet", "local": "172.17.0.1", "prefixlen": 16}]}
]"""
def _fake_ip_addr():
"""Patch `ip -j addr show` with a fixed multi-homed Linux host."""
result = namedtuple("CompletedProcess", ["returncode", "stdout", "stderr"])(0, _IP_ADDR_JSON, "")
return patch.object(network_utils, "subprocess", **{"run.return_value": result})
class TestFindLocalIPv4Network:
"""#3092: an address carries no prefix, so it has to be read off the interface."""
def test_reads_the_configured_prefix_not_a_guessed_24(self):
with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
assert str(network_utils.find_local_ipv4_network("192.168.96.9")) == "192.168.96.0/22"
def test_an_alias_address_resolves_too(self):
# The VP binds aliases; an alias is a perfectly good route source.
with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
assert str(network_utils.find_local_ipv4_network("10.0.0.6")) == "10.0.0.0/24"
def test_an_excluded_interface_still_answers(self):
"""EXCLUDED_INTERFACE_PREFIXES keeps docker0 out of the VP dropdown.
It must not also make the kernel's own choice of route source
unanswerable — "unknown" would be a worse answer than the truth.
"""
with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
assert str(network_utils.find_local_ipv4_network("172.17.0.1")) == "172.17.0.0/16"
assert not [i for i in network_utils.get_all_interface_ips() if i["name"] == "docker0"]
def test_an_address_no_interface_holds_is_none(self):
with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
assert network_utils.find_local_ipv4_network("192.168.1.1") is None
def test_a_hostname_is_none(self):
assert network_utils.find_local_ipv4_network("printer.local") is None