Files
Christian Manivong 5e371db3af fix: do not claim update reading or applying QTS cannot do
QTS has no apt, dnf or opkg, so the update readers inherited from
LinuxDriver find no package manager, and QPKG updates are not implemented.
get_available_updates, refresh_available_updates and apply_updates are None,
the same opt-out as manage_service: netOrk's patch groups and compliance
report include a driver only when these are callable.
2026-10-06 00:20:12 +02:00

205 lines
8.2 KiB
Python

"""Unit tests for the QNAP QTS driver.
Command-output fixtures are captured from real hardware via tools/harvest.sh —
one QTS 4 box and one QTS 5 box — and pasted in as module constants. Parsers are
written against those, never against guessed output.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from napalm_device_types import primary_role_of, role_keys_of
from napalm_qnap_qts import QnapQtsDriver
#: Parsers need real command output to be written against. These tests are the
#: specification for the work that tools/harvest.sh unblocks. strict=True means
#: the suite goes red the moment one starts passing, which is the reminder to
#: delete the marker rather than leave it lying around.
_PENDING_HARVEST = "blocked on fixture harvest from real QTS 4 / QTS 5 hardware"
@pytest.fixture()
def driver():
"""A driver with its transport mocked out, bypassing __init__.
Same shape as napalm-linux's fixture: nothing in __init__ needs patching,
so constructing the object by hand is cheaper and clearer than mocking
ConnectHandler.
"""
d = QnapQtsDriver.__new__(QnapQtsDriver)
d.hostname = "testnas"
d.username = "admin"
d.password = "pass" # noqa: S105
d.timeout = 60
d.port = 22
d._secret = "pass" # noqa: S105
d._forced_pkg_manager = None
d._pkg_manager = None
d._sudo_password = None
d.netmiko_optional_args = {}
d._device = MagicMock()
d._qts_major = 5
d._docker_path = "docker"
d._virsh_path = None
return d
class TestDriverIdentity:
"""Read without a connection by netOrk's discovery and /drivers endpoint."""
def test_driver_name(self):
assert QnapQtsDriver.DRIVER_NAME == "qnap_qts"
def test_lowercase_driver_name_alias_matches(self):
"""netOrk's register_driver path reads the lowercase attribute."""
assert QnapQtsDriver.driver_name == QnapQtsDriver.DRIVER_NAME
def test_vendor(self):
assert QnapQtsDriver.VENDOR == "QNAP"
def test_type_label_is_storage(self):
assert QnapQtsDriver.TYPE_LABEL == "Storage"
def test_declares_every_role_it_fills(self):
"""A QNAP is a NAS, a hypervisor and a Linux host at once."""
assert role_keys_of(QnapQtsDriver) == ["storage", "hypervisor", "linux"]
def test_storage_leads_because_it_is_listed_first(self):
"""device_class comes from the order of the role bases, not from an
attribute restating it."""
assert primary_role_of(QnapQtsDriver) == "storage"
def test_declares_at_least_one_fingerprint_source(self):
"""Discovery silently skips a driver that declares no fingerprint data."""
assert (
QnapQtsDriver.HTTP_FINGERPRINT
or QnapQtsDriver.SNMP_FINGERPRINT
or QnapQtsDriver.SSH_FINGERPRINT
)
def test_snmp_object_id_is_the_qnap_enterprise_oid(self):
assert QnapQtsDriver.SNMP_OBJECT_ID_PREFIX == "1.3.6.1.4.1.24681"
def test_http_fingerprint_is_mandatory_to_avoid_matching_any_nas(self):
patterns = {r.pattern: r for r in QnapQtsDriver.HTTP_FINGERPRINT}
assert patterns["qnap"].mandatory is True
class TestRolesDoNotShadowLinux:
"""The role bases declare their methods; they implement none.
Before that change, ``StorageDriver`` preceded ``LinuxDriver`` in the MRO and
its ``NotImplementedError`` stubs replaced LinuxDriver's working
implementations, so this driver carried a hand-written forwarder for every
collision. There is nothing left to collide with.
"""
@pytest.mark.parametrize("method", ["get_services", "get_users", "get_docker_info"])
def test_os_surface_resolves_to_linux(self, method):
"""QTS really is Linux for these, so inheriting them is correct."""
from napalm_linux.linux import LinuxDriver
owner = next(k for k in QnapQtsDriver.__mro__ if method in k.__dict__)
assert owner is LinuxDriver
@pytest.mark.parametrize(
"method", ["get_packages", "install_package", "uninstall_package"]
)
def test_package_surface_is_refused_deliberately(self, method):
"""QTS has no apt/dnf, so LinuxDriver's versions must not be inherited
silently -- this driver overrides them to refuse."""
owner = next(k for k in QnapQtsDriver.__mro__ if method in k.__dict__)
assert owner is QnapQtsDriver
def test_no_forwarding_methods_remain(self):
"""A forwarder here would mean the shadowing came back."""
own = {
name for name, val in vars(QnapQtsDriver).items()
if callable(val) and not name.startswith("__")
}
assert "get_services" not in own
@pytest.mark.xfail(strict=True, reason=_PENDING_HARVEST)
def test_nas_services_live_under_a_separate_name(self):
"""get_storage_services is what netOrk's _collect.py reads for the
storage snapshot — get_services is the OS list."""
assert hasattr(QnapQtsDriver, "get_storage_services")
@pytest.mark.parametrize(
("method", "args"),
[
("install_package", ("qpkg-name",)),
("uninstall_package", ("qpkg-name",)),
],
)
def test_out_of_scope_writers_still_raise(self, driver, method, args):
"""v1 is read-only plus safe actions. These must fail loudly rather than
appear supported — netOrk's poller catches NotImplementedError."""
with pytest.raises(NotImplementedError):
getattr(driver, method)(*args)
def test_volume_snapshot_writer_is_not_implemented_yet(self):
"""Declared on StorageDriver for type checkers only, so it does not
exist until the harvest supplies a real implementation."""
assert not hasattr(QnapQtsDriver, "create_volume_snapshot")
class TestQtsVersionDetection:
def test_parses_major_version(self, driver):
with patch.object(driver, "_send", return_value="5.1.5"):
assert driver._detect_qts_major() == 5
def test_parses_qts_four(self, driver):
with patch.object(driver, "_send", return_value="4.5.4"):
assert driver._detect_qts_major() == 4
def test_tolerates_a_build_suffix(self, driver):
with patch.object(driver, "_send", return_value="5.2.0.2782 (2026/03/14)"):
assert driver._detect_qts_major() == 5
def test_unreadable_version_does_not_raise(self, driver):
"""An unknown version must degrade to the newer code path, not abort the
poll — a NAS answering nothing useful here is still worth polling."""
with patch.object(driver, "_send", return_value=""):
assert driver._detect_qts_major() is None
class TestDockerBinDiscovery:
"""Container Station does not put docker on PATH."""
def test_uses_the_container_station_path_when_present(self, driver):
found = "/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
with patch.object(driver, "_send", return_value=found):
assert driver._discover_docker_path() == found
def test_falls_back_to_plain_docker_when_nothing_is_found(self, driver):
with patch.object(driver, "_send", return_value=""):
assert driver._discover_docker_path() == "docker"
def test_docker_bin_hook_returns_the_discovered_path(self, driver):
driver._docker_path = "/opt/docker"
assert driver._docker_bin() == "/opt/docker"
def test_services_cannot_be_controlled():
"""QTS has no systemd: the systemctl actions inherited from LinuxDriver would
fail on every unit, so the driver says it cannot control services at all --
which is what every capability check asks (is ``manage_service`` callable?)."""
from napalm_linux.linux import LinuxDriver
assert callable(getattr(LinuxDriver, "manage_service", None))
assert not callable(getattr(QnapQtsDriver, "manage_service", None))
def test_updates_are_not_read_or_applied_here():
"""QTS has no apt, dnf or opkg: the readers inherited from LinuxDriver find no
package manager, and QPKG updates are not implemented. Saying so keeps QNAP out
of netOrk's patch groups and compliance report, which ask whether these
methods are callable."""
for method in ("get_available_updates", "refresh_available_updates", "apply_updates"):
assert not callable(getattr(QnapQtsDriver, method, None)), method