Files
napalm-qnap-qts/tests/test_qnap_qts.py
T
Christian Manivong 742b8f044b
CI / test (3.10) (push) Failing after 25s
CI / test (3.11) (push) Failing after 23s
CI / test (3.12) (push) Failing after 26s
CI / test (3.10) (pull_request) Failing after 23s
CI / test (3.11) (pull_request) Failing after 19s
CI / test (3.12) (pull_request) Failing after 25s
feat: switch off the fix templates QTS cannot take (0.4.0)
napalm-linux 0.8 implements napalm-device-types' fix templates. QTS has
no apt, dnf or opkg and sets its SNMP agent in its own UI, so
configure_snmp_agent, configure_package_proxy and ensure_package are
None here: a capability check answers no instead of offering a fix
that fails, as the inherited fix_snmp and fix_apt_proxy did.

get_device_warnings returns nothing: the inherited one asked for
updates, which is None here, and logged a TypeError on every poll.

The role test follows napalm-linux's get_os_users.

Closes #6. Part of #7.
2026-10-17 00:53:56 +02:00

246 lines
10 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_os_users"])
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_the_old_docker_methods_are_gone(self):
"""netOrk reaches Container Station over the Engine API (NetOrk/netork#765)."""
for name in ("get_docker_info", "get_docker_outdated", "_docker_bin"):
assert not hasattr(QnapQtsDriver, name), name
class TestContainerEngineBinary:
"""The container engine connection (napalm-device-types 2.6.0) reaches
Container Station's docker, not one on PATH: the driver decides where the
binary lives, and netOrk never sees it (NetOrk/netork#765)."""
def test_the_engine_binary_is_the_discovered_path(self, driver):
driver._docker_path = "/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
assert driver._container_engine_binary("docker") == driver._docker_path
def test_the_api_stream_runs_dial_stdio_from_that_path(self, driver):
driver._docker_path = "/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
opened = []
with patch.object(driver, "open_stream", side_effect=lambda cmd, **kw: opened.append(cmd)):
driver.open_container_engine("docker").open_api()
assert opened == [f"{driver._docker_path} system dial-stdio"]
def test_without_a_discovered_path_it_falls_back_to_plain_docker(self, driver):
driver._docker_path = None
assert driver._container_engine_binary("docker") == "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
class TestFixesQtsCannotTake:
"""QTS has no apt, dnf or opkg, and its SNMP agent is configured in its own UI:
the fix templates inherited from LinuxDriver are switched off (#7), so a
capability check -- is it callable? -- answers no."""
@pytest.mark.parametrize(
"name", ["configure_snmp_agent", "configure_package_proxy", "ensure_package"]
)
def test_switched_off(self, name):
assert getattr(QnapQtsDriver, name) is None
def test_device_warnings_do_not_ask_for_updates_qts_cannot_list():
"""The inherited get_device_warnings called get_available_updates, which is
None here, and logged a TypeError on every poll (#6)."""
driver = QnapQtsDriver("nas", "admin", "pw")
assert driver.get_device_warnings() == []