"""Tests for modules/connectivity/bridge — transparent inline bridge. Validates bridge setup/teardown logic, ebtables rule application, watchdog failure counting, status reporting, and health checks without requiring root, ip/bridge commands, or physical interfaces. """ import os import subprocess import threading import time from pathlib import Path from unittest.mock import MagicMock, patch, call import pytest from modules.base import BaseModule from modules.connectivity.bridge import ( Bridge, BRIDGE_NAME, WATCHDOG_INTERVAL, WATCHDOG_MAX_FAILURES, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def bridge_config(tmp_path): """Return a valid bridge config dict.""" scripts_dir = tmp_path / "scripts" scripts_dir.mkdir() return { "connectivity": { "bridge": { "interface1": "eth0", "interface2": "usb0", } }, "device": { "install_path": str(tmp_path), }, } @pytest.fixture def bridge(mock_bus, mock_state, bridge_config): """Return a Bridge instance (not started).""" return Bridge(mock_bus, mock_state, bridge_config) # --------------------------------------------------------------------------- # Structure # --------------------------------------------------------------------------- class TestBridgeStructure: def test_is_base_module(self): assert issubclass(Bridge, BaseModule) def test_module_attributes(self): assert Bridge.name == "bridge" assert Bridge.module_type == "connectivity" assert Bridge.requires_root is True assert Bridge.priority == -300 def test_bridge_name_constant(self): assert BRIDGE_NAME == "br0" # --------------------------------------------------------------------------- # Config parsing # --------------------------------------------------------------------------- class TestConfigParsing: def test_no_bridge_config_sets_error(self, mock_bus, mock_state): mod = Bridge(mock_bus, mock_state, {}) mod.start() assert mod._running is not True def test_missing_interface2_sets_error(self, mock_bus, mock_state): config = {"connectivity": {"bridge": {"interface1": "eth0"}}} mod = Bridge(mock_bus, mock_state, config) mod.start() assert mod._running is not True def test_interfaces_parsed_from_config(self, bridge, bridge_config): br_cfg = bridge_config["connectivity"]["bridge"] assert br_cfg["interface1"] == "eth0" assert br_cfg["interface2"] == "usb0" # --------------------------------------------------------------------------- # Setup bridge (inline) # --------------------------------------------------------------------------- class TestInlineSetup: @patch("subprocess.run") def test_setup_bridge_inline_success(self, mock_run, bridge): mock_run.return_value = MagicMock(returncode=0) bridge._scripts_dir = "/nonexistent" result = bridge._setup_bridge_inline("eth0", "usb0") assert result is True cmds = [c[0][0] for c in mock_run.call_args_list] cmd_strs = [" ".join(c) for c in cmds] assert any("add name br0 type bridge" in s for s in cmd_strs) assert any("eth0 master br0" in s for s in cmd_strs) assert any("usb0 master br0" in s for s in cmd_strs) @patch("subprocess.run") def test_setup_bridge_inline_failure_teardown(self, mock_run, bridge): mock_run.side_effect = subprocess.CalledProcessError(1, "ip", stderr=b"error") bridge._scripts_dir = "/nonexistent" bridge._if1 = "eth0" bridge._if2 = "usb0" result = bridge._setup_bridge_inline("eth0", "usb0") assert result is False # --------------------------------------------------------------------------- # Teardown bridge (inline) # --------------------------------------------------------------------------- class TestInlineTeardown: @patch("subprocess.run") def test_teardown_runs_commands(self, mock_run, bridge): mock_run.return_value = MagicMock(returncode=0) bridge._if1 = "eth0" bridge._if2 = "usb0" result = bridge._teardown_bridge_inline() assert result is True cmd_strs = [" ".join(c[0][0]) for c in mock_run.call_args_list] assert any("del br0" in s for s in cmd_strs) @patch("subprocess.run") def test_teardown_is_best_effort(self, mock_run, bridge): mock_run.side_effect = subprocess.CalledProcessError(1, "ip", stderr=b"") bridge._if1 = "eth0" bridge._if2 = "usb0" result = bridge._teardown_bridge_inline() assert result is True # --------------------------------------------------------------------------- # Script-based setup/teardown # --------------------------------------------------------------------------- class TestScriptBasedOps: @patch("subprocess.run") def test_setup_uses_script_if_available(self, mock_run, bridge, tmp_path): script = tmp_path / "scripts" / "setup_bridge.sh" script.write_text("#!/bin/bash\necho ok") bridge._scripts_dir = str(tmp_path / "scripts") mock_run.return_value = MagicMock(returncode=0) result = bridge.setup_bridge("eth0", "usb0") assert result is True @patch("subprocess.run") def test_setup_falls_back_to_inline(self, mock_run, bridge): bridge._scripts_dir = "/nonexistent" mock_run.return_value = MagicMock(returncode=0) result = bridge.setup_bridge("eth0", "usb0") assert result is True # --------------------------------------------------------------------------- # Ebtables rules # --------------------------------------------------------------------------- class TestEbtablesRules: @patch("subprocess.run") def test_apply_ebtables_rules(self, mock_run, bridge): mock_run.return_value = MagicMock(returncode=0) bridge._apply_ebtables_rules("eth0", "usb0") cmd_strs = [" ".join(c[0][0]) for c in mock_run.call_args_list] assert any("01:80:C2:00:00:00" in s for s in cmd_strs) # STP assert any("01:00:0C:CC:CC:CC" in s for s in cmd_strs) # CDP assert any("01:80:C2:00:00:0E" in s for s in cmd_strs) # LLDP @patch("subprocess.run", side_effect=FileNotFoundError("ebtables not found")) def test_apply_ebtables_graceful_on_missing(self, mock_run, bridge): bridge._apply_ebtables_rules("eth0", "usb0") @patch("subprocess.run") def test_remove_ebtables_flushes(self, mock_run, bridge): mock_run.return_value = MagicMock(returncode=0) bridge._remove_ebtables_rules() cmd_strs = [" ".join(c[0][0]) for c in mock_run.call_args_list] assert any("ebtables -F" in s for s in cmd_strs) assert any("ebtables -X" in s for s in cmd_strs) # --------------------------------------------------------------------------- # Health check # --------------------------------------------------------------------------- class TestHealthCheck: @patch("subprocess.run") def test_healthy_bridge(self, mock_run, bridge): bridge._if1 = "eth0" bridge._if2 = "usb0" def fake_run(cmd, **kwargs): return MagicMock(returncode=0, stdout="br0 stuff") mock_run.side_effect = fake_run assert bridge.is_healthy() is True @patch("subprocess.run") def test_unhealthy_bridge_missing(self, mock_run, bridge): bridge._if1 = "eth0" bridge._if2 = "usb0" mock_run.return_value = MagicMock(returncode=1, stdout="") assert bridge.is_healthy() is False @patch("subprocess.run", side_effect=subprocess.TimeoutExpired("ip", 5)) def test_unhealthy_on_timeout(self, mock_run, bridge): bridge._if1 = "eth0" bridge._if2 = "usb0" assert bridge.is_healthy() is False # --------------------------------------------------------------------------- # Watchdog logic # --------------------------------------------------------------------------- class TestWatchdog: def test_consecutive_failures_tracked(self, bridge): bridge._consecutive_failures = 0 bridge._consecutive_failures += 1 assert bridge._consecutive_failures == 1 def test_watchdog_max_failures_constant(self): assert WATCHDOG_MAX_FAILURES == 3 def test_watchdog_interval_constant(self): assert WATCHDOG_INTERVAL == 5.0 # --------------------------------------------------------------------------- # Status # --------------------------------------------------------------------------- class TestStatus: def test_status_not_running(self, bridge): s = bridge.status() assert s["running"] is False assert s["bridge_up"] is False assert s["bridge_name"] == "br0" def test_status_tracks_interfaces(self, bridge): bridge._if1 = "eth0" bridge._if2 = "usb0" s = bridge.status() assert s["interface1"] == "eth0" assert s["interface2"] == "usb0" def test_status_tracks_failures(self, bridge): bridge._consecutive_failures = 2 s = bridge.status() assert s["consecutive_failures"] == 2 # --------------------------------------------------------------------------- # Start/stop lifecycle (mocked) # --------------------------------------------------------------------------- class TestLifecycle: @patch.object(Bridge, "setup_bridge", return_value=True) @patch.object(Bridge, "is_healthy", return_value=True) def test_start_sets_running(self, mock_health, mock_setup, bridge): bridge.start() assert bridge._running is True assert bridge._bridge_up is True @patch.object(Bridge, "setup_bridge", return_value=True) @patch.object(Bridge, "teardown_bridge", return_value=True) @patch.object(Bridge, "is_healthy", return_value=True) def test_stop_tears_down(self, mock_health, mock_teardown, mock_setup, bridge): bridge.start() bridge.stop() assert bridge._running is False assert bridge._bridge_up is False mock_teardown.assert_called_once() @patch.object(Bridge, "setup_bridge", return_value=False) def test_start_fails_on_setup_error(self, mock_setup, bridge): bridge.start() assert bridge._running is not True