mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-09-16 08:10:15 -04:00
resolve_host_mac() returns the manually configured MAC immediately, with no Socket Proxy /info call at all - the config.json text still described it as a fallback used only when auto-detection fails. Reworded both the setting's own description and the parent "Docker hosts" description to match actual behavior. The case-insensitivity regression test for lookup_device_mac() stubbed DeviceInstance.getByMac() to return a fixed row regardless of input, so it passed even without exercising real collation - functionally a duplicate of test_lookup_device_mac_found. Replaced it with a delegation check, and added real SQLite-backed coverage for DeviceInstance.getByMac()'s case-insensitivity in test/backend/test_device_instance.py. That surfaced a gap in the shared db_test_helpers.py fixture: its Devices.devMac column was missing the COLLATE NOCASE that the real schema declares, so it could not have exercised this behavior. Fixed the fixture to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011meLPKCzVpdZyAUfv5U6mm
577 lines
26 KiB
Python
577 lines
26 KiB
Python
"""Tests for the dockerdisc (DOCKERDISC) plugin.
|
|
|
|
script.py is loaded with its NetAlertX-internal dependencies
|
|
(plugin_helper, logger, helper, const, models.device_instance, conf, pytz)
|
|
stubbed out, the same approach test_pihole_monitor.py uses - it keeps these
|
|
tests runnable without the full devcontainer environment and without a live
|
|
Docker Socket Proxy. `requests` itself is left real; individual HTTP calls
|
|
are mocked per test. `handleEmpty`/`normalize_mac`/`decode_settings_base64`
|
|
are reimplemented locally (same shape as plugin_helper's) rather than
|
|
imported, to avoid pulling in plugin_helper's own dependency chain.
|
|
`models.device_instance` is stubbed with a fake `DeviceInstance` rather than
|
|
letting the real one load, since the real module pulls in its own separate
|
|
dependency chain (db.db_helper, workflows.constants, ...) this harness
|
|
doesn't otherwise need.
|
|
|
|
Layout:
|
|
- pick_lan_network() / first_network_driver(): pure-function unit tests
|
|
for the macvlan/ipvlan network-selection logic (spec §6), given a
|
|
{NetworkID: Driver} lookup - Driver isn't inline on a container's own
|
|
NetworkSettings.Networks entry (confirmed 2026-09-08 against a real
|
|
Socket Proxy - see script.py's module docstring), so these always take
|
|
that lookup as a separate argument. A container with no LAN-visible
|
|
network must still get a driver name, just no MAC/IP. A container with
|
|
*more than one* macvlan/ipvlan network at once deterministically picks
|
|
the alphabetically-first network name (spec §9's decided tie-break).
|
|
- DockerHost.get_network_drivers(): unit tests for the batched
|
|
GET /networks?filters=... call - one request for every unique
|
|
NetworkID, not one per network, and a safe {} (not a crash) when the
|
|
Socket Proxy denies it (missing NETWORKS=1).
|
|
- DockerHost._get(): unit tests for the shared timeout-budget/shape-
|
|
validation logic every request goes through - a run whose deadline is
|
|
already exhausted skips the request entirely, a request's own timeout
|
|
is capped by however much budget is left, and a response whose shape
|
|
doesn't match what the caller expects (e.g. /info returning a list
|
|
instead of a dict) is rejected the same as a network failure, rather
|
|
than crashing a caller further down that assumes the expected shape.
|
|
- resolve_host_mac(): unit tests for the manual-MAC-short-circuits-
|
|
without-any-request-first, else /info -> devName match chain (spec
|
|
§3.2) - a configured DOCKERDISC_HOST_MAC wins immediately with zero
|
|
Socket Proxy calls (deliberate: no auto-re-verification once you've
|
|
told us the answer), so auto-detection only ever runs when it's
|
|
empty, and only then can hostname-unmatched, ambiguous (more than one
|
|
device sharing that name), or /info-unreachable resolve to None.
|
|
Delegates the actual devName lookup to DeviceInstance.getAllByName().
|
|
- lookup_device_mac(): unit test for the "host must already exist, this
|
|
plugin never creates it" gate - delegates to DeviceInstance.getByMac().
|
|
- process_host(): integration tests with DockerHost's network-touching
|
|
methods stubbed at the object level - covers a mixed macvlan+bridge
|
|
container list (only the macvlan one gets a MAC/IP), an unconfigured
|
|
entry (no proxy URL) short-circuiting before any request, and a host
|
|
MAC that doesn't resolve to a known device short-circuiting before
|
|
/containers/json is ever called.
|
|
|
|
All of the above was additionally run live, end to end, against a real
|
|
Docker Engine + a real tecnativa/docker-socket-proxy on 2026-09-08 (ad-hoc
|
|
harness, not part of this repo) - that run is what caught the Driver-not-
|
|
inline bug these tests now guard against.
|
|
"""
|
|
|
|
import base64
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
import time
|
|
import types
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import requests
|
|
|
|
|
|
def _handle_empty(value):
|
|
"""Same shape as plugin_helper.handleEmpty, without its import chain."""
|
|
return value if value else 'null'
|
|
|
|
|
|
def _normalize_mac(mac):
|
|
"""Same shape as plugin_helper.normalize_mac, without its import chain."""
|
|
s = str(mac).strip().lower()
|
|
if s == "internet":
|
|
return "internet"
|
|
if ':' in s:
|
|
parts = s.split(':')
|
|
elif '-' in s:
|
|
parts = s.split('-')
|
|
else:
|
|
parts = [s[i:i + 2] for i in range(0, len(s), 2)]
|
|
return ':'.join(p if p == '*' else p.zfill(2) for p in (part.strip() for part in parts))
|
|
|
|
|
|
def _decode_settings_base64(encoded_str):
|
|
"""Same shape as plugin_helper.decode_settings_base64 (convert_types=True)."""
|
|
settings_list = json.loads(base64.b64decode(encoded_str).decode("utf-8"))
|
|
out = {}
|
|
for _, key, _type, value in settings_list:
|
|
t = _type.lower()
|
|
if t == "boolean":
|
|
out[key] = value.lower() == "true"
|
|
elif t == "integer":
|
|
out[key] = int(value)
|
|
elif t == "float":
|
|
out[key] = float(value)
|
|
else:
|
|
out[key] = value
|
|
return out
|
|
|
|
|
|
def _encode_host_entry(proxy_url, host_mac):
|
|
"""Builds a base64-encoded popUpForm entry matching what NetAlertX
|
|
would send for one `DOCKERDISC_hosts` row."""
|
|
settings_list = [
|
|
["DOCKERDISC_hosts", "DOCKERDISC_SOCKET_PROXY_URL", "string", proxy_url],
|
|
["DOCKERDISC_hosts", "DOCKERDISC_HOST_MAC", "string", host_mac or ""],
|
|
]
|
|
return base64.b64encode(json.dumps(settings_list).encode("utf-8")).decode("ascii")
|
|
|
|
|
|
def _load_dockerdisc_module():
|
|
missing_module = object()
|
|
previous_modules = {}
|
|
|
|
def stub(name, **attributes):
|
|
previous_modules[name] = sys.modules.get(name, missing_module)
|
|
module = types.ModuleType(name)
|
|
for attribute, value in attributes.items():
|
|
setattr(module, attribute, value)
|
|
sys.modules[name] = module
|
|
|
|
stub(
|
|
"plugin_helper",
|
|
Plugin_Objects=MagicMock,
|
|
handleEmpty=_handle_empty,
|
|
normalize_mac=_normalize_mac,
|
|
decode_settings_base64=_decode_settings_base64,
|
|
)
|
|
stub("logger", mylog=MagicMock(), Logger=MagicMock())
|
|
stub("helper", get_setting_value=MagicMock(return_value="UTC"))
|
|
stub("const", logPath="/tmp")
|
|
stub("models.device_instance", DeviceInstance=MagicMock)
|
|
stub("conf", tz=None)
|
|
stub("pytz", timezone=MagicMock(return_value="UTC"))
|
|
|
|
module_path = Path(__file__).resolve().parents[2] / "server" / "plugins" / "dockerdisc" / "script.py"
|
|
spec = importlib.util.spec_from_file_location("dockerdisc_script", module_path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
try:
|
|
spec.loader.exec_module(module)
|
|
finally:
|
|
for name, previous_module in previous_modules.items():
|
|
if previous_module is missing_module:
|
|
sys.modules.pop(name, None)
|
|
else:
|
|
sys.modules[name] = previous_module
|
|
|
|
return module
|
|
|
|
|
|
dockerdisc = _load_dockerdisc_module()
|
|
|
|
|
|
def _deadline(seconds=5):
|
|
"""A `time.monotonic()`-based deadline `seconds` in the future - what
|
|
DockerHost's real constructor now takes (a shared run deadline, not a
|
|
per-request timeout duration). Computed fresh per call so tests never
|
|
share a slowly-expiring value."""
|
|
return time.monotonic() + seconds
|
|
|
|
|
|
def _resp(json_data):
|
|
resp = MagicMock()
|
|
resp.raise_for_status = MagicMock()
|
|
resp.json = MagicMock(return_value=json_data)
|
|
return resp
|
|
|
|
|
|
def _stub_device_instance(get_all_by_name=None, get_by_mac=None):
|
|
"""A DeviceInstance *class* replacement, for `patch.object(dockerdisc,
|
|
"DeviceInstance", ...)` - calling it (as resolve_host_mac()/
|
|
lookup_device_mac() do: `DeviceInstance()`) returns a fixed instance
|
|
whose getAllByName()/getByMac() return the given values, mirroring the
|
|
real model's method names/shapes (getAllByName -> list of device
|
|
dicts, getByMac -> a device dict or None)."""
|
|
instance = MagicMock()
|
|
instance.getAllByName.return_value = get_all_by_name if get_all_by_name is not None else []
|
|
instance.getByMac.return_value = get_by_mac
|
|
return MagicMock(return_value=instance)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# pick_lan_network() / first_network_driver()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_pick_lan_network_prefers_macvlan():
|
|
networks = {
|
|
"bridge": {"NetworkID": "net-bridge", "MacAddress": "02:aa:aa:aa:aa:aa", "IPAddress": "172.17.0.2"},
|
|
"lan": {"NetworkID": "net-lan", "MacAddress": "aa:bb:cc:dd:ee:ff", "IPAddress": "192.168.1.50"},
|
|
}
|
|
driver_by_id = {"net-bridge": "bridge", "net-lan": "macvlan"}
|
|
name, driver, network = dockerdisc.pick_lan_network(networks, driver_by_id)
|
|
assert driver == "macvlan"
|
|
assert network["MacAddress"] == "aa:bb:cc:dd:ee:ff"
|
|
|
|
|
|
def test_pick_lan_network_none_for_bridge_only():
|
|
networks = {"bridge": {"NetworkID": "net-bridge", "MacAddress": "02:aa:aa:aa:aa:aa"}}
|
|
assert dockerdisc.pick_lan_network(networks, {"net-bridge": "bridge"}) is None
|
|
|
|
|
|
def test_pick_lan_network_empty_networks():
|
|
assert dockerdisc.pick_lan_network({}, {}) is None
|
|
assert dockerdisc.pick_lan_network(None, {}) is None
|
|
|
|
|
|
def test_pick_lan_network_multiple_lan_networks_ties_broken_alphabetically_by_name():
|
|
"""A container with two simultaneous macvlan/ipvlan networks must
|
|
deterministically pick the alphabetically-first network *name* (spec
|
|
§9's decided tie-break) - not whatever order the dict happens to
|
|
iterate in."""
|
|
networks = {
|
|
"zzz-lan": {"NetworkID": "net-z", "MacAddress": "aa:aa:aa:aa:aa:zz"},
|
|
"aaa-lan": {"NetworkID": "net-a", "MacAddress": "aa:aa:aa:aa:aa:aa"},
|
|
}
|
|
driver_by_id = {"net-z": "macvlan", "net-a": "ipvlan"}
|
|
|
|
name, driver, network = dockerdisc.pick_lan_network(networks, driver_by_id)
|
|
|
|
assert name == "aaa-lan"
|
|
assert driver == "ipvlan"
|
|
assert network["MacAddress"] == "aa:aa:aa:aa:aa:aa"
|
|
|
|
|
|
def test_pick_lan_network_missing_from_driver_lookup_is_treated_as_no_match():
|
|
"""If get_network_drivers() failed (Socket Proxy denied NETWORKS=1) the
|
|
lookup is {} - every network must be treated as unknown/non-LAN, not
|
|
crash on a missing key."""
|
|
networks = {"lan": {"NetworkID": "net-lan", "MacAddress": "aa:bb:cc:dd:ee:ff"}}
|
|
assert dockerdisc.pick_lan_network(networks, {}) is None
|
|
|
|
|
|
def test_first_network_driver_bridge_only():
|
|
networks = {"bridge": {"NetworkID": "net-bridge"}}
|
|
assert dockerdisc.first_network_driver(networks, {"net-bridge": "bridge"}) == "bridge"
|
|
|
|
|
|
def test_first_network_driver_empty():
|
|
assert dockerdisc.first_network_driver({}, {}) is None
|
|
|
|
|
|
def test_first_network_driver_missing_from_driver_lookup():
|
|
networks = {"lan": {"NetworkID": "net-lan"}}
|
|
assert dockerdisc.first_network_driver(networks, {}) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DockerHost.get_network_drivers()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_network_drivers_batches_into_one_request():
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", _deadline())
|
|
networks_resp = _resp([
|
|
{"Id": "net-a", "Driver": "macvlan"},
|
|
{"Id": "net-b", "Driver": "bridge"},
|
|
])
|
|
with patch("requests.get", return_value=networks_resp) as mock_get:
|
|
result = host.get_network_drivers(["net-a", "net-b", "net-a"]) # duplicate on purpose
|
|
|
|
assert result == {"net-a": "macvlan", "net-b": "bridge"}
|
|
assert mock_get.call_count == 1 # one batched call, not one per network
|
|
|
|
|
|
def test_get_network_drivers_empty_input_makes_no_request():
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", _deadline())
|
|
with patch("requests.get") as mock_get:
|
|
assert host.get_network_drivers([]) == {}
|
|
mock_get.assert_not_called()
|
|
|
|
|
|
def test_get_network_drivers_denied_permission_returns_empty_dict():
|
|
"""Socket Proxy without NETWORKS=1 - request fails, callers must fall
|
|
back to an empty lookup rather than crash."""
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", _deadline())
|
|
with patch("requests.get", side_effect=requests.exceptions.ConnectionError("denied")):
|
|
assert host.get_network_drivers(["net-a"]) == {}
|
|
|
|
|
|
def test_get_network_drivers_skips_non_dict_entries():
|
|
"""A malformed element inside an otherwise-list /networks response
|
|
(still passes the list-shape check) must be skipped, not crash on
|
|
`'Id' in n` for a non-dict `n`."""
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", _deadline())
|
|
with patch("requests.get", return_value=_resp(["not-a-dict", {"Id": "net-a", "Driver": "macvlan"}])):
|
|
assert host.get_network_drivers(["net-a"]) == {"net-a": "macvlan"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DockerHost._get() - shared timeout-budget and shape-validation logic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_dockerhost_get_timeout_returns_none():
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", _deadline())
|
|
with patch("requests.get", side_effect=requests.exceptions.Timeout("slow")):
|
|
assert host.get_info() is None
|
|
|
|
|
|
def test_dockerhost_get_connection_error_returns_none():
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", _deadline())
|
|
with patch("requests.get", side_effect=requests.exceptions.ConnectionError("no route")):
|
|
assert host.get_containers() == []
|
|
|
|
|
|
def test_dockerhost_get_containers_success():
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", _deadline())
|
|
with patch("requests.get", return_value=_resp([{"Id": "abc123"}])):
|
|
assert host.get_containers() == [{"Id": "abc123"}]
|
|
|
|
|
|
def test_dockerhost_get_skips_request_when_deadline_already_passed():
|
|
"""The whole run's timeout budget is already exhausted (e.g. an
|
|
earlier host/request ate it all) - the request must not even be
|
|
attempted, not fail after a full REQUEST_TIMEOUT_DEFAULT wait."""
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", time.monotonic() - 1)
|
|
with patch("requests.get") as mock_get:
|
|
assert host.get_info() is None
|
|
mock_get.assert_not_called()
|
|
|
|
|
|
def test_dockerhost_get_caps_request_timeout_to_remaining_budget():
|
|
"""With only a little run budget left, the individual request's own
|
|
timeout must be capped to that remaining amount, not the full
|
|
REQUEST_TIMEOUT_DEFAULT - so one host near the end of the shared
|
|
deadline can't still block for the plugin's whole default timeout."""
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", time.monotonic() + 2)
|
|
with patch("requests.get", return_value=_resp({"Name": "x"})) as mock_get:
|
|
host.get_info()
|
|
used_timeout = mock_get.call_args.kwargs["timeout"]
|
|
assert 0 < used_timeout <= 2
|
|
|
|
|
|
def test_dockerhost_get_caps_request_timeout_to_request_default_when_budget_is_large():
|
|
"""The reverse: plenty of run budget left, but a single request still
|
|
shouldn't be allowed to run longer than REQUEST_TIMEOUT_DEFAULT."""
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", time.monotonic() + 3600)
|
|
with patch("requests.get", return_value=_resp({"Name": "x"})) as mock_get:
|
|
host.get_info()
|
|
assert mock_get.call_args.kwargs["timeout"] == dockerdisc.REQUEST_TIMEOUT_DEFAULT
|
|
|
|
|
|
def test_dockerhost_get_rejects_wrong_shape_dict_expected_got_list():
|
|
"""/info returning a list instead of a dict (malformed/incompatible
|
|
Socket Proxy) must be rejected the same as a network failure - not
|
|
handed to a caller that assumes `.get()` works on it."""
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", _deadline())
|
|
with patch("requests.get", return_value=_resp(["unexpected"])):
|
|
assert host.get_info() is None
|
|
|
|
|
|
def test_dockerhost_get_rejects_wrong_shape_list_expected_got_dict():
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", _deadline())
|
|
with patch("requests.get", return_value=_resp({"unexpected": True})):
|
|
assert host.get_containers() == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_host_mac()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_resolve_host_mac_manual_mac_short_circuits_without_any_request():
|
|
"""A configured DOCKERDISC_HOST_MAC is used immediately, with zero
|
|
Socket Proxy calls - it's a stable value, there's nothing to gain by
|
|
spending an /info request "confirming" it every scheduled run. This
|
|
is a deliberate design choice (not an oversight): auto-detection only
|
|
ever runs when the field is left blank - filling it in trades away
|
|
the self-healing "auto-detect keeps re-verifying it" behavior for the
|
|
saved request, on purpose."""
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "11:22:33:44:55:66", _deadline())
|
|
with patch.object(host, "get_info") as mock_get_info:
|
|
assert dockerdisc.resolve_host_mac(host) == "11:22:33:44:55:66"
|
|
mock_get_info.assert_not_called()
|
|
|
|
|
|
def test_resolve_host_mac_auto_detect_success():
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", _deadline())
|
|
with patch.object(host, "get_info", return_value={"Name": "docker-host-1"}):
|
|
with patch.object(dockerdisc, "DeviceInstance", _stub_device_instance(get_all_by_name=[{"devMac": "AA:BB:CC:DD:EE:FF"}])):
|
|
assert dockerdisc.resolve_host_mac(host) == "aa:bb:cc:dd:ee:ff"
|
|
|
|
|
|
def test_resolve_host_mac_none_when_hostname_unmatched_and_no_manual_mac():
|
|
"""No manual fallback configured, so an unmatched hostname resolves to
|
|
nothing - the "fall back to manual" path only exists when manual is
|
|
actually set, and when it is, it short-circuits before this branch is
|
|
ever reached (see the short-circuit test above)."""
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", _deadline())
|
|
with patch.object(host, "get_info", return_value={"Name": "unknown-host"}):
|
|
with patch.object(dockerdisc, "DeviceInstance", _stub_device_instance(get_all_by_name=[])):
|
|
assert dockerdisc.resolve_host_mac(host) is None
|
|
|
|
|
|
def test_resolve_host_mac_none_when_info_unreachable_and_no_manual_mac():
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", _deadline())
|
|
with patch.object(host, "get_info", return_value=None):
|
|
assert dockerdisc.resolve_host_mac(host) is None
|
|
|
|
|
|
def test_resolve_host_mac_ambiguous_hostname_falls_back_to_none_without_manual():
|
|
"""Two devices share the auto-detected hostname (devName isn't unique)
|
|
- picking either one arbitrarily could attach every container to the
|
|
wrong device, so this must resolve the same as "no match" rather than
|
|
guessing."""
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "", _deadline())
|
|
with patch.object(host, "get_info", return_value={"Name": "htpc"}):
|
|
with patch.object(
|
|
dockerdisc, "DeviceInstance",
|
|
_stub_device_instance(get_all_by_name=[{"devMac": "AA:BB:CC:DD:EE:01"}, {"devMac": "AA:BB:CC:DD:EE:02"}]),
|
|
):
|
|
assert dockerdisc.resolve_host_mac(host) is None
|
|
|
|
|
|
def test_resolve_host_mac_ambiguous_hostname_falls_back_to_manual_when_set():
|
|
"""Same ambiguous-hostname case, but with a manual MAC configured -
|
|
that manual value wins immediately (see the short-circuit test), so
|
|
the ambiguous auto-detect branch is never even reached."""
|
|
host = dockerdisc.DockerHost("http://proxy:2375", "11:22:33:44:55:66", _deadline())
|
|
with patch.object(host, "get_info") as mock_get_info:
|
|
assert dockerdisc.resolve_host_mac(host) == "11:22:33:44:55:66"
|
|
mock_get_info.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# lookup_device_mac()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_lookup_device_mac_found():
|
|
with patch.object(dockerdisc, "DeviceInstance", _stub_device_instance(get_by_mac={"devMac": "aa:bb:cc:dd:ee:ff"})):
|
|
assert dockerdisc.lookup_device_mac("aa:bb:cc:dd:ee:ff") is True
|
|
|
|
|
|
def test_lookup_device_mac_not_found():
|
|
with patch.object(dockerdisc, "DeviceInstance", _stub_device_instance(get_by_mac=None)):
|
|
assert dockerdisc.lookup_device_mac("aa:bb:cc:dd:ee:ff") is False
|
|
|
|
|
|
def test_lookup_device_mac_passes_mac_through_unchanged():
|
|
"""lookup_device_mac() must not do any of its own case massaging - it
|
|
delegates entirely to DeviceInstance.getByMac(), which relies on
|
|
Devices.devMac's schema-level `COLLATE NOCASE` (server/db/schema/app.sql)
|
|
for case-insensitive matching. That guarantee is exercised against a
|
|
real SQLite connection in test/backend/test_device_instance.py's
|
|
TestGetByMac; a mocked DeviceInstance can't exercise real collation, so
|
|
this test only checks that the mac argument reaches getByMac() as-is."""
|
|
stub = _stub_device_instance(get_by_mac={"devMac": "aa:bb:cc:dd:ee:ff"})
|
|
with patch.object(dockerdisc, "DeviceInstance", stub):
|
|
dockerdisc.lookup_device_mac("AA:BB:CC:DD:EE:FF")
|
|
stub.return_value.getByMac.assert_called_once_with("AA:BB:CC:DD:EE:FF")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# process_host()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _container(name, image, driver, mac=None, ip=None, project=None, service=None, extra_bridge=False):
|
|
# NetworkID only - Driver is deliberately NOT set here, matching what a
|
|
# real Socket Proxy actually returns (see module docstring); tests
|
|
# supply Driver separately via a {NetworkID: Driver} lookup, same as
|
|
# process_host() gets it from DockerHost.get_network_drivers().
|
|
networks = {}
|
|
if extra_bridge:
|
|
networks["bridge"] = {"NetworkID": "net-bridge"}
|
|
if driver:
|
|
networks["lan"] = {"NetworkID": "net-lan", "MacAddress": mac, "IPAddress": ip}
|
|
labels = {}
|
|
if project:
|
|
labels["com.docker.compose.project"] = project
|
|
if service:
|
|
labels["com.docker.compose.service"] = service
|
|
return {
|
|
"Id": "deadbeef0000",
|
|
"Names": [f"/{name}"],
|
|
"Image": image,
|
|
"Labels": labels,
|
|
"NetworkSettings": {"Networks": networks},
|
|
}
|
|
|
|
|
|
def test_process_host_mixed_macvlan_and_bridge_containers():
|
|
host_entry = {
|
|
"DOCKERDISC_SOCKET_PROXY_URL": "http://proxy:2375",
|
|
"DOCKERDISC_HOST_MAC": "aa:bb:cc:dd:ee:ff",
|
|
}
|
|
containers = [
|
|
_container("pihole", "pihole/pihole:latest", "macvlan", mac="aa:aa:aa:aa:aa:01", ip="192.168.1.50", project="dns", service="pihole"),
|
|
_container("redis", "redis:7", None, extra_bridge=True),
|
|
]
|
|
plugin_objects = MagicMock()
|
|
plugin_objects.add_object = MagicMock()
|
|
|
|
host = dockerdisc.DockerHost(host_entry["DOCKERDISC_SOCKET_PROXY_URL"], host_entry["DOCKERDISC_HOST_MAC"], _deadline())
|
|
with patch.object(dockerdisc, "DockerHost", return_value=host):
|
|
with patch.object(host, "get_info", return_value=None): # unused - manual_mac short-circuits before this would ever be called
|
|
with patch.object(host, "get_containers", return_value=containers):
|
|
with patch.object(host, "get_network_drivers", return_value={"net-lan": "macvlan", "net-bridge": "bridge"}) as mock_drivers:
|
|
with patch.object(dockerdisc, "DeviceInstance", _stub_device_instance(get_by_mac={"devMac": host_entry["DOCKERDISC_HOST_MAC"]})):
|
|
added = dockerdisc.process_host(host_entry, _deadline(), plugin_objects)
|
|
|
|
# one batched call for both containers' networks, not two
|
|
mock_drivers.assert_called_once()
|
|
assert sorted(mock_drivers.call_args.args[0]) == ["net-bridge", "net-lan"]
|
|
|
|
assert added == 2
|
|
assert plugin_objects.add_object.call_count == 2
|
|
|
|
pihole_call = plugin_objects.add_object.call_args_list[0].kwargs
|
|
assert pihole_call["primaryId"] == "aa:bb:cc:dd:ee:ff" # host MAC, not the container's
|
|
assert pihole_call["foreignKey"] == "aa:bb:cc:dd:ee:ff"
|
|
assert pihole_call["secondaryId"] == "pihole"
|
|
assert pihole_call["watched2"] == "dns / pihole"
|
|
assert pihole_call["watched3"] == "macvlan"
|
|
assert pihole_call["watched4"] == "aa:aa:aa:aa:aa:01"
|
|
assert pihole_call["extra"] == "192.168.1.50"
|
|
|
|
redis_call = plugin_objects.add_object.call_args_list[1].kwargs
|
|
assert redis_call["primaryId"] == "aa:bb:cc:dd:ee:ff" # same host, not skipped for lacking a LAN MAC
|
|
assert redis_call["watched3"] == "bridge"
|
|
assert redis_call["watched4"] == "null" # no LAN-visible MAC for a bridge-only container
|
|
assert redis_call["extra"] == "null"
|
|
|
|
|
|
def test_process_host_skips_unconfigured_entry_without_any_request():
|
|
plugin_objects = MagicMock()
|
|
with patch("requests.get") as mock_get:
|
|
added = dockerdisc.process_host({"DOCKERDISC_SOCKET_PROXY_URL": "", "DOCKERDISC_HOST_MAC": ""}, _deadline(), plugin_objects)
|
|
assert added == 0
|
|
mock_get.assert_not_called()
|
|
plugin_objects.add_object.assert_not_called()
|
|
|
|
|
|
def test_process_host_skips_when_host_mac_unresolved():
|
|
host_entry = {"DOCKERDISC_SOCKET_PROXY_URL": "http://proxy:2375", "DOCKERDISC_HOST_MAC": ""}
|
|
plugin_objects = MagicMock()
|
|
with patch.object(dockerdisc.DockerHost, "get_info", return_value=None):
|
|
added = dockerdisc.process_host(host_entry, _deadline(), plugin_objects)
|
|
assert added == 0
|
|
plugin_objects.add_object.assert_not_called()
|
|
|
|
|
|
def test_process_host_skips_when_host_not_a_known_device_without_listing_containers():
|
|
host_entry = {"DOCKERDISC_SOCKET_PROXY_URL": "http://proxy:2375", "DOCKERDISC_HOST_MAC": "aa:bb:cc:dd:ee:ff"}
|
|
plugin_objects = MagicMock()
|
|
with patch.object(dockerdisc.DockerHost, "get_info", return_value=None):
|
|
with patch.object(dockerdisc, "DeviceInstance", _stub_device_instance(get_by_mac=None)): # not found
|
|
with patch.object(dockerdisc.DockerHost, "get_containers") as mock_get_containers:
|
|
added = dockerdisc.process_host(host_entry, _deadline(), plugin_objects)
|
|
assert added == 0
|
|
mock_get_containers.assert_not_called()
|
|
plugin_objects.add_object.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _encode_host_entry() / decode_settings_base64 round trip (sanity check
|
|
# that the test helper matches what NetAlertX actually sends)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_encode_decode_host_entry_round_trip():
|
|
encoded = _encode_host_entry("http://proxy:2375", "aa:bb:cc:dd:ee:ff")
|
|
decoded = _decode_settings_base64(encoded)
|
|
assert decoded == {
|
|
"DOCKERDISC_SOCKET_PROXY_URL": "http://proxy:2375",
|
|
"DOCKERDISC_HOST_MAC": "aa:bb:cc:dd:ee:ff",
|
|
}
|