Files
napalm-qnap-qts/tests/test_qnap_qts.py
T
Christian Manivong 927e39bf36 feat: declare all three roles, and refuse QPKG writes deliberately
A QNAP is a NAS, a hypervisor and a Linux host. It can now say so, because role
bases in napalm-device-types v1.0 declare their methods without implementing
them:

    class QnapQtsDriver(StorageDriver, HypervisorDriver, LinuxDriver):

device_class comes from the first base, so DEVICE_CLASS is gone. The get_services
forwarder is gone with it — nothing shadows LinuxDriver's version any more — and
so is the TestMroForwarding class that guarded the collisions.

Removing the shadowing exposed something the shadowing had been hiding by
accident. This driver never implemented package management; StorageDriver's stub
for install_package covered LinuxDriver's working one, so calling it raised and
looked correct. It was a coincidence, not a decision: without the stub the call
falls through to LinuxDriver, which would run apt or dnf against a NAS that has
neither. QTS uses QPKG.

get_packages, install_package and uninstall_package are therefore overridden
here to refuse with a reason, until the device harvest supplies real QPKG
parsing. See netork#114.
2026-08-21 12:50:27 +07:00

185 lines
7.3 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"