c04ec0b9ea
Cover packet_capture (rotation/zstd/AES), credential_db (dedup/hashcat/bulk), ja3_spoofer (profiles/cipher mapping/randomization), responder_mgr (hash capture/dedup/conf gen), ntlm_relay (protocol inference/command building/ coordination), ids_tester (risk assessment/preflight/caplet), and bridge (setup/teardown/ebtables/watchdog). 187 tests total, all passing.
310 lines
10 KiB
Python
310 lines
10 KiB
Python
"""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
|