feat: NAPALM driver for Windows over PowerShell Remoting
Talks PSRP over WinRM via pypsrp, one runspace pool per connection. Each getter sends one PowerShell script that projects cmdlet results onto flat fields and ends in ConvertTo-Json, so the Python side parses JSON, not text. Covers facts, interfaces, IP addresses, ARP, routes and services, plus service start/stop/restart/enable/disable. Service names are validated and quoted as PowerShell verbatim strings, typographic quotes included. Fixtures are synthetic: they pin down the JSON the scripts are designed to emit. tools/harvest.py records the real output from a host. Refs christianmanivong/netork#300
This commit is contained in:
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
# Synthetic fixtures
|
||||
|
||||
These files are what the driver's PowerShell scripts *emit*, not raw device
|
||||
output: each script projects its cmdlet results onto flat, primitive fields and
|
||||
ends in `ConvertTo-Json`. The shape is therefore this driver's own contract,
|
||||
and the Python side is tested against it here.
|
||||
|
||||
What they cannot prove is that the scripts produce this shape on a real host
|
||||
(property names, Windows PowerShell 5.1 serialisation quirks). They are to be
|
||||
replaced by output recorded from a Windows Server and a Windows 11 client.
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{"interface": "Ethernet", "ip": "10.0.0.1", "mac": "00-0D-B9-11-22-33", "state": "Reachable"},
|
||||
{"interface": "Ethernet", "ip": "10.0.0.20", "mac": "00-15-5D-AA-BB-CC", "state": "Stale"},
|
||||
{"interface": "Ethernet", "ip": "10.0.0.255", "mac": "FF-FF-FF-FF-FF-FF", "state": "Permanent"},
|
||||
{"interface": "Ethernet", "ip": "224.0.0.22", "mac": "01-00-5E-00-00-16", "state": "Permanent"},
|
||||
{"interface": "Ethernet", "ip": "10.0.0.99", "mac": "00-00-00-00-00-00", "state": "Unreachable"},
|
||||
{"interface": "Ethernet", "ip": "10.0.0.98", "mac": "", "state": "Incomplete"}
|
||||
]
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"hostname": "DESKTOP-4F2K9", "dns_hostname": "DESKTOP-4F2K9", "domain": "WORKGROUP", "part_of_domain": false, "manufacturer": "LENOVO", "model": "21HDCTO1WW", "serial": "PF4ABCDE", "caption": "Microsoft Windows 11 Pro", "version": "10.0.26100", "display_version": "24H2", "ubr": 1742, "uptime": 3600, "interfaces": "Wi-Fi"}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"hostname": "SRV-APP01", "dns_hostname": "srv-app01", "domain": "corp.example", "part_of_domain": true, "manufacturer": "Microsoft Corporation", "model": "Virtual Machine", "serial": "0000-0001-2345-6789-0123-4567-89", "caption": "Microsoft Windows Server 2022 Standard", "version": "10.0.20348", "display_version": "21H2", "ubr": 2340, "uptime": 86400, "interfaces": ["Ethernet", "Ethernet 2"]}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"name": "Ethernet", "description": "Microsoft Hyper-V Network Adapter", "status": "Up", "mtu": 1500, "speed": 10000000000, "mac": "00-15-5D-01-02-03"},
|
||||
{"name": "Ethernet 2", "description": "Intel(R) Ethernet Connection I219-LM", "status": "Disconnected", "mtu": 1500, "speed": 0, "mac": "8C-16-45-AA-BB-CC"},
|
||||
{"name": "Wi-Fi", "description": "Intel(R) Wi-Fi 6E AX211 160MHz", "status": "Disabled", "mtu": null, "speed": null, "mac": ""}
|
||||
]
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{"interface": "Ethernet", "address": "10.0.0.5", "prefix": 24, "family": "IPv4"},
|
||||
{"interface": "Ethernet", "address": "10.0.0.6", "prefix": 24, "family": "IPv4"},
|
||||
{"interface": "Ethernet", "address": "fe80::1c2d:3e4f:5a6b:7c8d%6", "prefix": 64, "family": "IPv6"},
|
||||
{"interface": "Ethernet", "address": "2001:db8::5", "prefix": 64, "family": "IPv6"},
|
||||
{"interface": "Loopback Pseudo-Interface 1", "address": "127.0.0.1", "prefix": 8, "family": "IPv4"},
|
||||
{"interface": "Loopback Pseudo-Interface 1", "address": "::1", "prefix": 128, "family": "IPv6"}
|
||||
]
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{"destination": "0.0.0.0/0", "next_hop": "10.0.0.1", "interface": "Ethernet", "metric": 0, "protocol": "NetMgmt"},
|
||||
{"destination": "10.0.0.0/24", "next_hop": "0.0.0.0", "interface": "Ethernet", "metric": 256, "protocol": "Local"},
|
||||
{"destination": "192.168.50.0/24", "next_hop": "10.0.0.254", "interface": "Ethernet", "metric": 10, "protocol": "Dhcp"},
|
||||
{"destination": "2001:db8::/64", "next_hop": "::", "interface": "Ethernet", "metric": 256, "protocol": "RouterAdvertisement"},
|
||||
{"destination": "224.0.0.0/4", "next_hop": "0.0.0.0", "interface": "Ethernet", "metric": 256, "protocol": "Local"},
|
||||
{"destination": "255.255.255.255/32", "next_hop": "0.0.0.0", "interface": "Ethernet", "metric": 256, "protocol": "Local"},
|
||||
{"destination": "ff00::/8", "next_hop": "::", "interface": "Ethernet", "metric": 256, "protocol": "Local"},
|
||||
{"destination": "127.0.0.0/8", "next_hop": "0.0.0.0", "interface": "Loopback Pseudo-Interface 1", "metric": 256, "protocol": "Local"}
|
||||
]
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"name": "WinRM", "state": "Running", "start_mode": "Auto", "pid": 1234},
|
||||
{"name": "Spooler", "state": "Stopped", "start_mode": "Manual", "pid": 0},
|
||||
{"name": "MSSQL$SQLEXPRESS", "state": "Running", "start_mode": "Auto", "pid": 4321},
|
||||
{"name": "RemoteRegistry", "state": "Stopped", "start_mode": "Disabled", "pid": 0},
|
||||
{"name": "wuauserv", "state": "Stopped", "start_mode": "Manual", "pid": null}
|
||||
]
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Unit tests for the PSRP transport.
|
||||
|
||||
pypsrp is patched out at the module boundary: these tests pin down how the
|
||||
transport configures it and how its failures reach the driver, not pypsrp itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from napalm.base.exceptions import ConnectionClosedException, ConnectionException
|
||||
from pypsrp.exceptions import AuthenticationError, WinRMTransportError
|
||||
|
||||
from napalm_windows.transport import PowerShellError, PsrpTransport
|
||||
|
||||
|
||||
def _transport(**overrides):
|
||||
kwargs = {
|
||||
"port": 5986,
|
||||
"ssl": True,
|
||||
"cert_validation": True,
|
||||
"auth": "negotiate",
|
||||
"timeout": 60,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return PsrpTransport("win01", "admin", "secret", **kwargs)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def psrp():
|
||||
"""Patch WSMan, RunspacePool and PowerShell as the transport sees them."""
|
||||
with (
|
||||
patch("napalm_windows.transport.WSMan") as wsman,
|
||||
patch("napalm_windows.transport.RunspacePool") as pool,
|
||||
patch("napalm_windows.transport.PowerShell") as ps,
|
||||
):
|
||||
yield {"wsman": wsman, "pool": pool, "ps": ps}
|
||||
|
||||
|
||||
class TestOpen:
|
||||
def test_passes_connection_settings_to_wsman(self, psrp):
|
||||
_transport(port=5985, ssl=False, cert_validation=False, auth="ntlm").open()
|
||||
|
||||
kwargs = psrp["wsman"].call_args.kwargs
|
||||
assert psrp["wsman"].call_args.args == ("win01",)
|
||||
assert kwargs["port"] == 5985
|
||||
assert kwargs["ssl"] is False
|
||||
assert kwargs["cert_validation"] is False
|
||||
assert kwargs["auth"] == "ntlm"
|
||||
assert kwargs["username"] == "admin"
|
||||
assert kwargs["password"] == "secret" # noqa: S105
|
||||
|
||||
def test_opens_one_runspace_pool_on_the_connection(self, psrp):
|
||||
_transport().open()
|
||||
|
||||
psrp["pool"].assert_called_once_with(psrp["wsman"].return_value)
|
||||
psrp["pool"].return_value.open.assert_called_once_with()
|
||||
|
||||
def test_authentication_failure_is_a_connection_exception(self, psrp):
|
||||
psrp["pool"].return_value.open.side_effect = AuthenticationError("bad creds")
|
||||
|
||||
with pytest.raises(ConnectionException, match="Authentication failed"):
|
||||
_transport().open()
|
||||
|
||||
def test_http_401_is_an_authentication_failure(self, psrp):
|
||||
psrp["pool"].return_value.open.side_effect = WinRMTransportError(
|
||||
"http", 401, "Unauthorized"
|
||||
)
|
||||
|
||||
with pytest.raises(ConnectionException, match="Authentication failed"):
|
||||
_transport().open()
|
||||
|
||||
def test_unreachable_host_is_a_connection_exception(self, psrp):
|
||||
psrp["pool"].return_value.open.side_effect = requests.ConnectionError("refused")
|
||||
|
||||
with pytest.raises(ConnectionException, match="win01:5986"):
|
||||
_transport().open()
|
||||
|
||||
def test_is_open_only_after_open(self, psrp):
|
||||
t = _transport()
|
||||
assert t.is_open is False
|
||||
t.open()
|
||||
assert t.is_open is True
|
||||
|
||||
|
||||
class TestRun:
|
||||
def test_returns_output_objects_joined_by_newline(self, psrp):
|
||||
psrp["ps"].return_value.invoke.return_value = ['{"a":', "1}"]
|
||||
psrp["ps"].return_value.had_errors = False
|
||||
t = _transport()
|
||||
t.open()
|
||||
|
||||
assert t.run("Get-Thing") == '{"a":\n1}'
|
||||
psrp["ps"].return_value.add_script.assert_called_once_with("Get-Thing")
|
||||
|
||||
def test_none_objects_are_skipped(self, psrp):
|
||||
psrp["ps"].return_value.invoke.return_value = [None, "x", None]
|
||||
psrp["ps"].return_value.had_errors = False
|
||||
t = _transport()
|
||||
t.open()
|
||||
|
||||
assert t.run("Get-Thing") == "x"
|
||||
|
||||
def test_error_stream_raises_powershell_error_with_its_text(self, psrp):
|
||||
psrp["ps"].return_value.invoke.return_value = []
|
||||
psrp["ps"].return_value.had_errors = True
|
||||
psrp["ps"].return_value.streams.error = [
|
||||
"Cannot find any service with service name 'nope'."
|
||||
]
|
||||
t = _transport()
|
||||
t.open()
|
||||
|
||||
with pytest.raises(PowerShellError, match="service name 'nope'"):
|
||||
t.run("Start-Service nope")
|
||||
|
||||
def test_run_before_open_raises_connection_closed(self):
|
||||
with pytest.raises(ConnectionClosedException):
|
||||
_transport().run("Get-Thing")
|
||||
|
||||
|
||||
class TestClose:
|
||||
def test_close_closes_pool_and_connection(self, psrp):
|
||||
t = _transport()
|
||||
t.open()
|
||||
t.close()
|
||||
|
||||
psrp["pool"].return_value.close.assert_called_once_with()
|
||||
psrp["wsman"].return_value.close.assert_called_once_with()
|
||||
assert t.is_open is False
|
||||
|
||||
def test_close_swallows_errors_from_a_dead_connection(self, psrp):
|
||||
psrp["pool"].return_value.close.side_effect = requests.ConnectionError("gone")
|
||||
t = _transport()
|
||||
t.open()
|
||||
|
||||
t.close()
|
||||
|
||||
assert t.is_open is False
|
||||
|
||||
def test_close_without_open_is_a_no_op(self):
|
||||
_transport().close()
|
||||
|
||||
|
||||
def test_transport_does_not_print_password_in_repr():
|
||||
assert "secret" not in repr(_transport())
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Unit tests for the Windows driver.
|
||||
|
||||
Every getter sends one PowerShell script and parses the JSON it emits. A fake
|
||||
transport answers each script with a fixture from tests/fixtures/synthetic/ —
|
||||
see the README there for what those fixtures do and do not prove.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from napalm.base.exceptions import ConnectionClosedException
|
||||
from napalm_device_types import role_keys_of
|
||||
|
||||
from napalm_windows import WindowsDriver
|
||||
from napalm_windows import windows as mod
|
||||
from napalm_windows.transport import PowerShellError
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "synthetic"
|
||||
|
||||
|
||||
def _fixture(name: str) -> str:
|
||||
return (FIXTURES / name).read_text()
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
"""Answers the driver's scripts from fixtures and records what it was sent."""
|
||||
|
||||
def __init__(self, answers: dict[str, str] | None = None, error: str | None = None):
|
||||
self.answers = answers or {}
|
||||
self.error = error
|
||||
self.sent: list[str] = []
|
||||
self.is_open = True
|
||||
|
||||
def run(self, script: str) -> str:
|
||||
self.sent.append(script)
|
||||
if self.error:
|
||||
raise PowerShellError(self.error)
|
||||
return self.answers.get(script, "")
|
||||
|
||||
def close(self) -> None:
|
||||
self.is_open = False
|
||||
|
||||
|
||||
def _driver(**answers: str) -> WindowsDriver:
|
||||
d = WindowsDriver("win01", "admin", "secret")
|
||||
d._transport = FakeTransport({getattr(mod, k): v for k, v in answers.items()})
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction and class attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_defaults_to_winrm_over_https(self):
|
||||
d = WindowsDriver("win01", "admin", "secret")
|
||||
assert d.port == 5986
|
||||
assert d.ssl is True
|
||||
assert d.cert_validation is True
|
||||
assert d.auth == "negotiate"
|
||||
|
||||
def test_port_5985_means_plain_http(self):
|
||||
d = WindowsDriver("win01", "admin", "secret", optional_args={"port": 5985})
|
||||
assert d.ssl is False
|
||||
|
||||
def test_winrm_ssl_overrides_the_port_guess(self):
|
||||
d = WindowsDriver(
|
||||
"win01", "admin", "secret", optional_args={"port": 8443, "winrm_ssl": False}
|
||||
)
|
||||
assert d.ssl is False
|
||||
|
||||
def test_ssl_verify_false_disables_cert_validation(self):
|
||||
d = WindowsDriver("win01", "admin", "secret", optional_args={"ssl_verify": False})
|
||||
assert d.cert_validation is False
|
||||
|
||||
def test_winrm_auth_is_passed_through(self):
|
||||
d = WindowsDriver("win01", "admin", "secret", optional_args={"winrm_auth": "ntlm"})
|
||||
assert d.auth == "ntlm"
|
||||
|
||||
def test_construction_does_no_io(self):
|
||||
assert WindowsDriver("win01", "admin", "secret")._transport is None
|
||||
|
||||
|
||||
class TestClassAttributes:
|
||||
def test_driver_name_matches_the_entry_point(self):
|
||||
# netork/core/nvd/platform.py already keys on the driver name "windows".
|
||||
assert WindowsDriver.DRIVER_NAME == "windows"
|
||||
|
||||
def test_does_not_ask_for_ssh_credentials(self):
|
||||
assert WindowsDriver.USES_SSH is False
|
||||
|
||||
def test_default_port_is_winrm_https(self):
|
||||
assert WindowsDriver.default_port == 5986
|
||||
|
||||
def test_fills_the_general_purpose_os_role(self):
|
||||
# OSDriver's role key is "linux" but means "general-purpose OS host"
|
||||
# (poll timeout, OS tabs). Renaming it is tracked in netork#300.
|
||||
assert role_keys_of(WindowsDriver) == ["linux"]
|
||||
|
||||
def test_wsman_ports_are_probed_during_discovery(self):
|
||||
ports = {(p.scheme, p.port) for p in WindowsDriver.PORT_SPECS or []}
|
||||
assert ("http", 5985) in ports
|
||||
assert ("https", 5986) in ports
|
||||
|
||||
def test_http_sys_and_iis_server_headers_are_fingerprints(self):
|
||||
patterns = {r.pattern for r in WindowsDriver.HTTP_FINGERPRINT}
|
||||
assert {"microsoft-httpapi", "microsoft-iis"} <= patterns
|
||||
|
||||
def test_openssh_for_windows_banner_is_a_fingerprint(self):
|
||||
patterns = {r.pattern for r in WindowsDriver.SSH_FINGERPRINT}
|
||||
assert "openssh_for_windows" in patterns
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
def test_is_alive_false_before_open(self):
|
||||
assert WindowsDriver("win01", "admin", "secret").is_alive() == {"is_alive": False}
|
||||
|
||||
def test_is_alive_follows_the_transport(self):
|
||||
d = _driver()
|
||||
assert d.is_alive() == {"is_alive": True}
|
||||
|
||||
def test_close_drops_the_transport(self):
|
||||
d = _driver()
|
||||
fake = d._transport
|
||||
d.close()
|
||||
assert fake.is_open is False
|
||||
assert d._transport is None
|
||||
|
||||
def test_getter_before_open_raises_connection_closed(self):
|
||||
with pytest.raises(ConnectionClosedException):
|
||||
WindowsDriver("win01", "admin", "secret").get_facts()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRunPs:
|
||||
def test_empty_output_is_none(self):
|
||||
d = _driver()
|
||||
assert d._run_ps("Get-Nothing") is None
|
||||
|
||||
def test_output_is_parsed_as_json(self):
|
||||
d = WindowsDriver("win01", "admin", "secret")
|
||||
d._transport = FakeTransport({"Get-X": '{"a": 1}'})
|
||||
assert d._run_ps("Get-X") == {"a": 1}
|
||||
|
||||
|
||||
class TestAsList:
|
||||
"""ConvertTo-Json unwraps a one-element array into a bare object."""
|
||||
|
||||
def test_none_is_empty(self):
|
||||
assert mod._as_list(None) == []
|
||||
|
||||
def test_single_object_is_wrapped(self):
|
||||
assert mod._as_list({"a": 1}) == [{"a": 1}]
|
||||
|
||||
def test_list_is_unchanged(self):
|
||||
assert mod._as_list([1, 2]) == [1, 2]
|
||||
|
||||
|
||||
class TestPsQuote:
|
||||
def test_wraps_in_single_quotes(self):
|
||||
assert mod._ps_quote("Spooler") == "'Spooler'"
|
||||
|
||||
def test_doubles_ascii_single_quote(self):
|
||||
assert mod._ps_quote("a'b") == "'a''b'"
|
||||
|
||||
@pytest.mark.parametrize("quote", ["\u2018", "\u2019", "\u201a", "\u201b"])
|
||||
def test_doubles_typographic_quotes_powershell_also_accepts(self, quote):
|
||||
# PowerShell treats these as single-quote delimiters too; leaving one
|
||||
# undoubled would end the string early.
|
||||
assert mod._ps_quote(f"a{quote}b") == f"'a{quote}{quote}b'"
|
||||
|
||||
|
||||
class TestMac:
|
||||
def test_windows_dashes_become_colons(self):
|
||||
assert mod._mac("00-15-5d-01-02-03") == "00:15:5D:01:02:03"
|
||||
|
||||
def test_empty_stays_empty(self):
|
||||
assert mod._mac("") == ""
|
||||
assert mod._mac(None) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Getters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetFacts:
|
||||
def test_domain_member_server(self):
|
||||
facts = _driver(_PS_FACTS=_fixture("facts_server.json")).get_facts()
|
||||
|
||||
assert facts == {
|
||||
"hostname": "srv-app01",
|
||||
"fqdn": "srv-app01.corp.example",
|
||||
"vendor": "Microsoft Corporation",
|
||||
"model": "Virtual Machine",
|
||||
"serial_number": "0000-0001-2345-6789-0123-4567-89",
|
||||
"os_version": "Microsoft Windows Server 2022 Standard 21H2 (build 20348.2340)",
|
||||
"uptime": 86400,
|
||||
"interface_list": ["Ethernet", "Ethernet 2"],
|
||||
"running_kernel": "10.0.20348.2340",
|
||||
}
|
||||
|
||||
def test_workgroup_client_has_no_domain_suffix(self):
|
||||
facts = _driver(_PS_FACTS=_fixture("facts_client.json")).get_facts()
|
||||
|
||||
assert facts["hostname"] == "DESKTOP-4F2K9"
|
||||
assert facts["fqdn"] == "DESKTOP-4F2K9"
|
||||
|
||||
def test_single_interface_arrives_as_a_bare_string(self):
|
||||
facts = _driver(_PS_FACTS=_fixture("facts_client.json")).get_facts()
|
||||
|
||||
assert facts["interface_list"] == ["Wi-Fi"]
|
||||
|
||||
def test_missing_manufacturer_falls_back_to_microsoft(self):
|
||||
data = json.loads(_fixture("facts_client.json"))
|
||||
data["manufacturer"] = None
|
||||
facts = _driver(_PS_FACTS=json.dumps(data)).get_facts()
|
||||
|
||||
assert facts["vendor"] == "Microsoft"
|
||||
|
||||
def test_os_version_without_display_version(self):
|
||||
# Server 2016 has no DisplayVersion registry value.
|
||||
data = json.loads(_fixture("facts_server.json"))
|
||||
data["caption"] = "Microsoft Windows Server 2016 Standard"
|
||||
data["display_version"] = None
|
||||
data["version"] = "10.0.14393"
|
||||
data["ubr"] = 7428
|
||||
facts = _driver(_PS_FACTS=json.dumps(data)).get_facts()
|
||||
|
||||
assert facts["os_version"] == "Microsoft Windows Server 2016 Standard (build 14393.7428)"
|
||||
assert facts["running_kernel"] == "10.0.14393.7428"
|
||||
|
||||
|
||||
class TestGetInterfaces:
|
||||
def test_maps_adapters(self):
|
||||
ifaces = _driver(_PS_INTERFACES=_fixture("interfaces.json")).get_interfaces()
|
||||
|
||||
assert ifaces["Ethernet"] == {
|
||||
"is_up": True,
|
||||
"is_enabled": True,
|
||||
"description": "Microsoft Hyper-V Network Adapter",
|
||||
"last_flapped": -1.0,
|
||||
"speed": 10000.0,
|
||||
"mtu": 1500,
|
||||
"mac_address": "00:15:5D:01:02:03",
|
||||
}
|
||||
|
||||
def test_disconnected_is_enabled_but_down(self):
|
||||
ifaces = _driver(_PS_INTERFACES=_fixture("interfaces.json")).get_interfaces()
|
||||
|
||||
assert ifaces["Ethernet 2"]["is_up"] is False
|
||||
assert ifaces["Ethernet 2"]["is_enabled"] is True
|
||||
assert ifaces["Ethernet 2"]["speed"] == 0.0
|
||||
|
||||
def test_disabled_adapter_with_null_fields(self):
|
||||
ifaces = _driver(_PS_INTERFACES=_fixture("interfaces.json")).get_interfaces()
|
||||
|
||||
assert ifaces["Wi-Fi"]["is_enabled"] is False
|
||||
assert ifaces["Wi-Fi"]["mtu"] == 0
|
||||
assert ifaces["Wi-Fi"]["speed"] == 0.0
|
||||
assert ifaces["Wi-Fi"]["mac_address"] == ""
|
||||
|
||||
|
||||
class TestGetInterfacesIp:
|
||||
def test_groups_addresses_by_interface_and_family(self):
|
||||
ips = _driver(_PS_INTERFACES_IP=_fixture("interfaces_ip.json")).get_interfaces_ip()
|
||||
|
||||
assert ips == {
|
||||
"Ethernet": {
|
||||
"ipv4": {
|
||||
"10.0.0.5": {"prefix_length": 24},
|
||||
"10.0.0.6": {"prefix_length": 24},
|
||||
},
|
||||
"ipv6": {
|
||||
"fe80::1c2d:3e4f:5a6b:7c8d": {"prefix_length": 64},
|
||||
"2001:db8::5": {"prefix_length": 64},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestGetArpTable:
|
||||
def test_keeps_only_real_neighbours(self):
|
||||
arp = _driver(_PS_ARP=_fixture("arp.json")).get_arp_table()
|
||||
|
||||
assert arp == [
|
||||
{"interface": "Ethernet", "mac": "00:0D:B9:11:22:33", "ip": "10.0.0.1", "age": 0.0},
|
||||
{"interface": "Ethernet", "mac": "00:15:5D:AA:BB:CC", "ip": "10.0.0.20", "age": 0.0},
|
||||
]
|
||||
|
||||
|
||||
class TestGetRouteTo:
|
||||
def test_maps_protocols_and_drops_noise(self):
|
||||
routes = _driver(_PS_ROUTES=_fixture("routes.json")).get_route_to()
|
||||
|
||||
assert set(routes) == {"0.0.0.0/0", "10.0.0.0/24", "192.168.50.0/24", "2001:db8::/64"}
|
||||
assert routes["0.0.0.0/0"][0]["protocol"] == "static"
|
||||
assert routes["10.0.0.0/24"][0]["protocol"] == "connected"
|
||||
assert routes["192.168.50.0/24"][0]["protocol"] == "dhcp"
|
||||
assert routes["2001:db8::/64"][0]["protocol"] == "connected"
|
||||
|
||||
def test_entry_shape(self):
|
||||
routes = _driver(_PS_ROUTES=_fixture("routes.json")).get_route_to()
|
||||
|
||||
assert routes["192.168.50.0/24"] == [
|
||||
{
|
||||
"protocol": "dhcp",
|
||||
"family": "ipv4",
|
||||
"current_active": True,
|
||||
"last_active": False,
|
||||
"age": -1,
|
||||
"next_hop": "10.0.0.254",
|
||||
"outgoing_interface": "Ethernet",
|
||||
"selected_next_hop": True,
|
||||
"preference": 10,
|
||||
"routing_table": "global",
|
||||
"protocol_attributes": {},
|
||||
}
|
||||
]
|
||||
|
||||
def test_on_link_next_hop_is_empty(self):
|
||||
routes = _driver(_PS_ROUTES=_fixture("routes.json")).get_route_to()
|
||||
|
||||
assert routes["10.0.0.0/24"][0]["next_hop"] == ""
|
||||
assert routes["2001:db8::/64"][0]["next_hop"] == ""
|
||||
assert routes["2001:db8::/64"][0]["family"] == "ipv6"
|
||||
|
||||
def test_destination_filter(self):
|
||||
routes = _driver(_PS_ROUTES=_fixture("routes.json")).get_route_to(destination="0.0.0.0/0")
|
||||
|
||||
assert list(routes) == ["0.0.0.0/0"]
|
||||
|
||||
def test_protocol_filter(self):
|
||||
routes = _driver(_PS_ROUTES=_fixture("routes.json")).get_route_to(protocol="dhcp")
|
||||
|
||||
assert list(routes) == ["192.168.50.0/24"]
|
||||
|
||||
|
||||
class TestGetServices:
|
||||
def test_maps_state_and_start_mode(self):
|
||||
services = _driver(_PS_SERVICES=_fixture("services.json")).get_services()
|
||||
|
||||
assert services == [
|
||||
{"name": "WinRM", "running": True, "enabled": True, "pid": 1234},
|
||||
{"name": "Spooler", "running": False, "enabled": False, "pid": 0},
|
||||
{"name": "MSSQL$SQLEXPRESS", "running": True, "enabled": True, "pid": 4321},
|
||||
{"name": "RemoteRegistry", "running": False, "enabled": False, "pid": 0},
|
||||
{"name": "wuauserv", "running": False, "enabled": False, "pid": 0},
|
||||
]
|
||||
|
||||
|
||||
class TestManageService:
|
||||
@pytest.mark.parametrize(
|
||||
("action", "command"),
|
||||
[
|
||||
("start", "Start-Service -Name 'Spooler'"),
|
||||
("stop", "Stop-Service -Name 'Spooler'"),
|
||||
("restart", "Restart-Service -Name 'Spooler'"),
|
||||
("enable", "Set-Service -Name 'Spooler' -StartupType Automatic"),
|
||||
("disable", "Set-Service -Name 'Spooler' -StartupType Disabled"),
|
||||
],
|
||||
)
|
||||
def test_sends_the_matching_cmdlet(self, action, command):
|
||||
d = _driver()
|
||||
|
||||
result = d.manage_service("Spooler", action)
|
||||
|
||||
assert result["success"] is True
|
||||
assert command in d._transport.sent[0]
|
||||
assert "-ErrorAction Stop" in d._transport.sent[0]
|
||||
|
||||
def test_service_name_with_dollar_is_valid(self):
|
||||
d = _driver()
|
||||
|
||||
assert d.manage_service("MSSQL$SQLEXPRESS", "restart")["success"] is True
|
||||
assert "'MSSQL$SQLEXPRESS'" in d._transport.sent[0]
|
||||
|
||||
def test_unknown_action_is_rejected(self):
|
||||
with pytest.raises(ValueError, match="action"):
|
||||
_driver().manage_service("Spooler", "reload")
|
||||
|
||||
@pytest.mark.parametrize("name", ["", "a'; Remove-Item C:\\ -Recurse", "a b", "a`b"])
|
||||
def test_suspicious_name_is_rejected_before_anything_is_sent(self, name):
|
||||
d = _driver()
|
||||
|
||||
with pytest.raises(ValueError, match="service name"):
|
||||
d.manage_service(name, "start")
|
||||
assert d._transport.sent == []
|
||||
|
||||
def test_powershell_error_is_reported_not_raised(self):
|
||||
d = WindowsDriver("win01", "admin", "secret")
|
||||
d._transport = FakeTransport(error="Cannot find any service with service name 'nope'.")
|
||||
|
||||
result = d.manage_service("nope", "start")
|
||||
|
||||
assert result == {
|
||||
"success": False,
|
||||
"output": "Cannot find any service with service name 'nope'.",
|
||||
}
|
||||
Reference in New Issue
Block a user