Files
ha-ninlight/tests_unit/test_firmware.py
T

232 lines
7.9 KiB
Python

"""Trailer, version order, Wi-Fi credential check and the firmware catalog."""
from __future__ import annotations
import gzip
from pathlib import Path
import pytest
from custom_components.ninlight import firmware
from custom_components.ninlight.firmware import (
FirmwareCatalog,
FirmwareVersion,
InvalidImageError,
contains_ssid,
is_newer,
parse_trailer,
parse_version,
)
from tests_unit.images import CONTENT_WITH_SSID, CONTENT_WITHOUT_SSID, build_image
# Strictly ascending. Every neighbour pair and every pair across the list is checked.
ORDER = [
"0.3.8",
"0.3.8-36-gca9bb",
"0.3.8-47-g71a57",
"0.3.9-rc1",
"0.3.9-rc1-4-gab12",
"0.3.9-rc2",
"0.3.9",
"0.4.0-rc1",
"0.10.0",
]
@pytest.mark.parametrize(("older", "newer"), list(zip(ORDER, ORDER[1:], strict=False))) # noqa: RUF007
def test_version_neighbours(older: str, newer: str) -> None:
assert is_newer(newer, older)
assert not is_newer(older, newer)
def test_version_order_holds_across_the_whole_list() -> None:
for i, older in enumerate(ORDER):
for newer in ORDER[i + 1 :]:
assert is_newer(newer, older), (newer, older)
@pytest.mark.parametrize(
("text", "expected"),
[
("0.3.8-36-gca9bb", FirmwareVersion((0, 3, 8), None, 36)),
("0.3.8-38-gd3cc9", FirmwareVersion((0, 3, 8), None, 38)), # exactly 15 chars, hash present
("0.3.8-36-g", FirmwareVersion((0, 3, 8), None, 36)), # hash cut to nothing
("0.3.8-36", FirmwareVersion((0, 3, 8), None, 36)),
("0.3.8-3-gab-dir", FirmwareVersion((0, 3, 8), None, 3)), # "-dirty" cut to "-dir"
("0.3.8-dirty", FirmwareVersion((0, 3, 8), None, 0)),
("0.4.0-rc1-12-ga", FirmwareVersion((0, 4, 0), 1, 12)), # 15 chars, hash cut to one char
("0.3.9-rc2", FirmwareVersion((0, 3, 9), 2, 0)),
],
)
def test_parse_version(text: str, expected: FirmwareVersion) -> None:
assert parse_version(text) == expected
@pytest.mark.parametrize(
"text",
[
"",
"gca9bb", # git describe --always without a tag
"v0.3.8", # the firmware strips the "v"
"0.3",
"0.3.8-",
"0.10.10-rc10-12", # 15 chars ending in digits without "-g": commit count may be cut
"0.3.8-36-gXYZ",
"0.3.8 ",
],
)
def test_unparseable_versions(text: str) -> None:
assert parse_version(text) is None
def test_same_commit_count_with_other_hash_is_not_newer() -> None:
assert not is_newer("0.3.8-36-gffff0", "0.3.8-36-gca9bb")
assert not is_newer("0.3.8-36-gca9bb", "0.3.8-36-gffff0")
def test_unparseable_side_is_never_newer() -> None:
assert not is_newer("gca9bb", "0.3.8")
assert not is_newer("0.3.9", "gca9bb")
def test_parse_trailer_reads_nul_padded_fields() -> None:
assert parse_trailer(build_image("g95_cw", "0.3.8-38-gd3cc9")) == ("g95_cw", "0.3.8-38-gd3cc9")
@pytest.mark.parametrize(
("data", "reason"),
[
(b"\xff" * 512, "short"),
(build_image(magic=b"NINX"), "NINL"),
(build_image(fmt=2), "format"),
(build_image(corrupt_hash=True), "sha256"),
],
)
def test_invalid_images(data: bytes, reason: str) -> None:
with pytest.raises(InvalidImageError, match=reason):
parse_trailer(data)
def test_ssid_found_when_nul_framed() -> None:
assert contains_ssid(build_image(content=CONTENT_WITH_SSID), "ninIOT")
def test_ssid_missing_in_image_without_credentials() -> None:
assert not contains_ssid(build_image(content=CONTENT_WITHOUT_SSID), "ninIOT")
@pytest.mark.parametrize("content", [b"\x00xninIOT\x00", b"\x00ninIOTx\x00", b"ninIOT"])
def test_ssid_must_be_nul_framed(content: bytes) -> None:
assert not contains_ssid(build_image(content=content), "ninIOT")
def test_other_ssid_does_not_match() -> None:
assert not contains_ssid(build_image(content=CONTENT_WITH_SSID), "ninIOT-gast")
def test_empty_ssid_never_matches() -> None:
assert not contains_ssid(build_image(content=b"\x00\x00\x00"), "")
def test_broken_gzip_is_not_a_match() -> None:
payload = b"\xab" * 56 + b"\x1f\x8b\x08" + bytes(7) + b"\xff" * 64
assert not contains_ssid(build_image(payload=payload), "ninIOT")
def test_gzip_stream_inside_the_header_is_ignored() -> None:
payload = gzip.compress(CONTENT_WITH_SSID, mtime=0).ljust(120, b"\x00")
assert not contains_ssid(build_image(payload=payload), "ninIOT")
def _write(directory: Path, name: str, data: bytes) -> Path:
path = directory / name
path.write_bytes(data)
return path
def test_scan_creates_directory_and_accepts_valid_images_only(tmp_path: Path) -> None:
directory = tmp_path / "ninlight_firmware"
catalog = FirmwareCatalog(directory)
assert catalog.scan() == ()
assert directory.is_dir()
_write(directory, "a.img", build_image("gu10_rgb", "0.3.8-47-g71a57"))
_write(directory, "broken.img", build_image(corrupt_hash=True))
_write(directory, "notes.txt", build_image())
images = catalog.scan()
assert [(i.path.name, i.device_type, i.version) for i in images] == [
("a.img", "gu10_rgb", "0.3.8-47-g71a57")
]
assert catalog.images == images
def test_invalid_file_is_logged_once(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
catalog = FirmwareCatalog(tmp_path)
_write(tmp_path, "broken.img", build_image(corrupt_hash=True))
catalog.scan()
catalog.scan()
assert sum("broken.img" in r.getMessage() for r in caplog.records) == 1
def test_rescan_sees_new_and_removed_files(tmp_path: Path) -> None:
catalog = FirmwareCatalog(tmp_path)
old = _write(tmp_path, "a.img", build_image(version="0.3.8-47-g71a57"))
catalog.scan()
_write(tmp_path, "b.img", build_image(version="0.3.9-rc1"))
old.unlink()
assert [i.version for i in catalog.scan()] == ["0.3.9-rc1"]
def test_newer_images_filters_exact_device_type_and_sorts_descending(tmp_path: Path) -> None:
catalog = FirmwareCatalog(tmp_path)
_write(tmp_path, "1.img", build_image("gu10_rgb", "0.3.8-47-g71a57"))
_write(tmp_path, "2.img", build_image("gu10_rgb", "0.3.9-rc1"))
_write(tmp_path, "3.img", build_image("gu10_cw", "0.4.0")) # newest, but other type
_write(tmp_path, "4.img", build_image("gu10", "0.5.0")) # prefix of the type
_write(tmp_path, "5.img", build_image("gu10_rgb", "0.3.8-36-gffff0")) # equal to installed
_write(tmp_path, "6.img", build_image("gu10_rgb", "0.3.8")) # older
catalog.scan()
newer = catalog.newer_images("gu10_rgb", "0.3.8-36-gca9bb")
assert [(i.device_type, i.version) for i in newer] == [
("gu10_rgb", "0.3.9-rc1"),
("gu10_rgb", "0.3.8-47-g71a57"),
]
def test_verify_detects_changed_and_removed_files(tmp_path: Path) -> None:
catalog = FirmwareCatalog(tmp_path)
path = _write(tmp_path, "a.img", build_image(version="0.3.8-47-g71a57"))
(image,) = catalog.scan()
assert catalog.verify(image)
path.write_bytes(build_image(version="0.3.9-rc1"))
assert not catalog.verify(image)
path.unlink()
assert not catalog.verify(image)
def test_catalog_ssid_check_is_cached_per_image_and_ssid(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
catalog = FirmwareCatalog(tmp_path)
_write(tmp_path, "a.img", build_image(content=CONTENT_WITH_SSID))
(image,) = catalog.scan()
calls: list[str] = []
real = firmware.contains_ssid
def spy(data: bytes, ssid: str) -> bool:
calls.append(ssid)
return real(data, ssid)
monkeypatch.setattr(firmware, "contains_ssid", spy)
assert catalog.contains_ssid(image, "ninIOT")
assert catalog.contains_ssid(image, "ninIOT")
assert not catalog.contains_ssid(image, "andere")
assert calls == ["ninIOT", "andere"]
def test_catalog_ssid_check_on_removed_file_is_false(tmp_path: Path) -> None:
catalog = FirmwareCatalog(tmp_path)
path = _write(tmp_path, "a.img", build_image(content=CONTENT_WITH_SSID))
(image,) = catalog.scan()
path.unlink()
assert not catalog.contains_ssid(image, "ninIOT")