mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
953 lines
38 KiB
Python
953 lines
38 KiB
Python
# Copyright © 2025-26 l5yth & contributors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Unit tests for :mod:`data.mesh_ingestor.config`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
import data.mesh_ingestor.config as config
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_channel_names
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseChannelNames:
|
|
"""Tests for :func:`config._parse_channel_names`."""
|
|
|
|
def test_none_returns_empty(self):
|
|
"""None input returns empty tuple."""
|
|
assert config._parse_channel_names(None) == ()
|
|
|
|
def test_empty_string_returns_empty(self):
|
|
"""Empty string returns empty tuple."""
|
|
assert config._parse_channel_names("") == ()
|
|
|
|
def test_single_name(self):
|
|
"""Single channel name is returned as a one-element tuple."""
|
|
assert config._parse_channel_names("LongFast") == ("LongFast",)
|
|
|
|
def test_comma_separated(self):
|
|
"""Comma-separated names are split and returned."""
|
|
result = config._parse_channel_names("LongFast,Chat")
|
|
assert result == ("LongFast", "Chat")
|
|
|
|
def test_strips_whitespace(self):
|
|
"""Leading/trailing whitespace around names is stripped."""
|
|
result = config._parse_channel_names(" LongFast , Chat ")
|
|
assert result == ("LongFast", "Chat")
|
|
|
|
def test_deduplicates_case_insensitively(self):
|
|
"""Duplicate names (case-insensitively) are deduplicated."""
|
|
result = config._parse_channel_names("LongFast,longfast,LONGFAST")
|
|
assert result == ("LongFast",)
|
|
|
|
def test_preserves_order(self):
|
|
"""Original order is preserved, first occurrence kept on dedup."""
|
|
result = config._parse_channel_names("B,A,B,C")
|
|
assert result == ("B", "A", "C")
|
|
|
|
def test_empty_segments_skipped(self):
|
|
"""Empty segments from consecutive commas are skipped."""
|
|
result = config._parse_channel_names("A,,B,,,C")
|
|
assert result == ("A", "B", "C")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_hidden_channels
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseHiddenChannels:
|
|
"""Tests for :func:`config._parse_hidden_channels`."""
|
|
|
|
def test_delegates_to_parse_channel_names(self):
|
|
"""_parse_hidden_channels delegates to _parse_channel_names."""
|
|
assert config._parse_hidden_channels(
|
|
"Chat,Admin"
|
|
) == config._parse_channel_names("Chat,Admin")
|
|
|
|
def test_none_returns_empty(self):
|
|
"""None input returns empty tuple."""
|
|
assert config._parse_hidden_channels(None) == ()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _resolve_instance_domain
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveInstanceDomains:
|
|
"""Tests for :func:`config._resolve_instance_domains`."""
|
|
|
|
def test_single_domain(self, monkeypatch):
|
|
"""Single domain produces one-element tuple."""
|
|
monkeypatch.setenv("INSTANCE_DOMAIN", "foo.tld")
|
|
monkeypatch.setenv("API_TOKEN", "secret")
|
|
result = config._resolve_instance_domains()
|
|
assert result == (("https://foo.tld", "secret"),)
|
|
|
|
def test_multi_domain_broadcast_token(self, monkeypatch):
|
|
"""Multiple domains with a single token broadcast the token."""
|
|
monkeypatch.setenv("INSTANCE_DOMAIN", "foo.tld, bar.tld")
|
|
monkeypatch.setenv("API_TOKEN", "shared")
|
|
result = config._resolve_instance_domains()
|
|
assert result == (
|
|
("https://foo.tld", "shared"),
|
|
("https://bar.tld", "shared"),
|
|
)
|
|
|
|
def test_multi_domain_per_instance_tokens(self, monkeypatch):
|
|
"""Comma-separated tokens are positionally paired with domains."""
|
|
monkeypatch.setenv("INSTANCE_DOMAIN", "a.tld,b.tld")
|
|
monkeypatch.setenv("API_TOKEN", "tok1,tok2")
|
|
result = config._resolve_instance_domains()
|
|
assert result == (("https://a.tld", "tok1"), ("https://b.tld", "tok2"))
|
|
|
|
def test_token_count_mismatch_raises(self, monkeypatch):
|
|
"""Mismatched counts raise ValueError at parse time."""
|
|
monkeypatch.setenv("INSTANCE_DOMAIN", "a.tld,b.tld")
|
|
monkeypatch.setenv("API_TOKEN", "t1,t2,t3")
|
|
with pytest.raises(ValueError, match="counts must match"):
|
|
config._resolve_instance_domains()
|
|
|
|
def test_deduplicates_domains(self, monkeypatch):
|
|
"""Duplicate domains are collapsed to a single entry."""
|
|
monkeypatch.setenv("INSTANCE_DOMAIN", "foo.tld, foo.tld")
|
|
monkeypatch.setenv("API_TOKEN", "tok")
|
|
result = config._resolve_instance_domains()
|
|
assert result == (("https://foo.tld", "tok"),)
|
|
|
|
def test_preserves_explicit_scheme(self, monkeypatch):
|
|
"""Domains with explicit schemes keep them; others get https://."""
|
|
monkeypatch.setenv("INSTANCE_DOMAIN", "http://local:41447,bar.tld")
|
|
monkeypatch.setenv("API_TOKEN", "tok")
|
|
result = config._resolve_instance_domains()
|
|
assert result == (
|
|
("http://local:41447", "tok"),
|
|
("https://bar.tld", "tok"),
|
|
)
|
|
|
|
def test_empty_domain(self, monkeypatch):
|
|
"""Empty INSTANCE_DOMAIN returns an empty tuple."""
|
|
monkeypatch.setenv("INSTANCE_DOMAIN", "")
|
|
monkeypatch.setenv("API_TOKEN", "tok")
|
|
result = config._resolve_instance_domains()
|
|
assert result == ()
|
|
|
|
def test_strips_trailing_slashes(self, monkeypatch):
|
|
"""Trailing slashes are stripped from domains."""
|
|
monkeypatch.setenv("INSTANCE_DOMAIN", "foo.tld/")
|
|
monkeypatch.setenv("API_TOKEN", "tok")
|
|
result = config._resolve_instance_domains()
|
|
assert result == (("https://foo.tld", "tok"),)
|
|
|
|
def test_empty_token_broadcast(self, monkeypatch):
|
|
"""Empty API_TOKEN broadcasts empty string to all instances."""
|
|
monkeypatch.setenv("INSTANCE_DOMAIN", "a.tld,b.tld")
|
|
monkeypatch.setenv("API_TOKEN", "")
|
|
result = config._resolve_instance_domains()
|
|
assert result == (("https://a.tld", ""), ("https://b.tld", ""))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _resolve_instance_domain (legacy, kept for backward compatibility)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveInstanceDomain:
|
|
"""Tests for :func:`config._resolve_instance_domain`."""
|
|
|
|
def test_returns_instance_domain_when_set(self, monkeypatch):
|
|
"""Uses INSTANCE_DOMAIN when set."""
|
|
monkeypatch.setenv("INSTANCE_DOMAIN", "mesh.example.com")
|
|
result = config._resolve_instance_domain()
|
|
assert result == "https://mesh.example.com"
|
|
|
|
def test_adds_https_when_no_scheme(self, monkeypatch):
|
|
"""Adds https:// prefix when no scheme is present."""
|
|
monkeypatch.setenv("INSTANCE_DOMAIN", "example.com")
|
|
assert config._resolve_instance_domain() == "https://example.com"
|
|
|
|
def test_preserves_existing_scheme(self, monkeypatch):
|
|
"""Leaves existing http:// scheme intact."""
|
|
monkeypatch.setenv("INSTANCE_DOMAIN", "http://example.com")
|
|
assert config._resolve_instance_domain() == "http://example.com"
|
|
|
|
def test_strips_trailing_slash(self, monkeypatch):
|
|
"""Strips trailing slash from instance domain."""
|
|
monkeypatch.setenv("INSTANCE_DOMAIN", "https://example.com/")
|
|
assert config._resolve_instance_domain() == "https://example.com"
|
|
|
|
def test_returns_empty_when_not_set(self, monkeypatch):
|
|
"""Returns empty string when INSTANCE_DOMAIN is unset."""
|
|
monkeypatch.delenv("INSTANCE_DOMAIN", raising=False)
|
|
assert config._resolve_instance_domain() == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _debug_log
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDebugLog:
|
|
"""Tests for :func:`config._debug_log`."""
|
|
|
|
def test_suppressed_when_debug_false(self, monkeypatch, capsys):
|
|
"""Nothing is printed when DEBUG is False and severity is debug."""
|
|
monkeypatch.setattr(config, "DEBUG", False)
|
|
config._debug_log("silent", severity="debug")
|
|
assert capsys.readouterr().out == ""
|
|
|
|
def test_prints_when_debug_true(self, monkeypatch, capsys):
|
|
"""Message is printed when DEBUG is True."""
|
|
monkeypatch.setattr(config, "DEBUG", True)
|
|
config._debug_log("hello world")
|
|
out = capsys.readouterr().out
|
|
assert "hello world" in out
|
|
|
|
def test_always_flag_bypasses_debug_guard(self, monkeypatch, capsys):
|
|
"""always=True forces output even when DEBUG is False."""
|
|
monkeypatch.setattr(config, "DEBUG", False)
|
|
config._debug_log("force print", always=True)
|
|
out = capsys.readouterr().out
|
|
assert "force print" in out
|
|
|
|
def test_context_included_in_output(self, monkeypatch, capsys):
|
|
"""Context label is included in log output."""
|
|
monkeypatch.setattr(config, "DEBUG", True)
|
|
config._debug_log("msg", context="test.ctx")
|
|
out = capsys.readouterr().out
|
|
assert "context=test.ctx" in out
|
|
|
|
def test_severity_included_in_output(self, monkeypatch, capsys):
|
|
"""Severity level is included in log output."""
|
|
monkeypatch.setattr(config, "DEBUG", True)
|
|
config._debug_log("msg", severity="warn")
|
|
out = capsys.readouterr().out
|
|
assert "[warn]" in out
|
|
|
|
def test_metadata_included_in_output(self, monkeypatch, capsys):
|
|
"""Additional metadata key=value pairs are included in output."""
|
|
monkeypatch.setattr(config, "DEBUG", True)
|
|
config._debug_log("msg", node_id="!aabb1234")
|
|
out = capsys.readouterr().out
|
|
assert "node_id=" in out
|
|
|
|
def test_warn_severity_printed_even_when_debug_false(self, monkeypatch, capsys):
|
|
"""Non-debug severity is printed regardless of DEBUG flag."""
|
|
monkeypatch.setattr(config, "DEBUG", False)
|
|
config._debug_log("warn msg", severity="warn")
|
|
out = capsys.readouterr().out
|
|
assert "warn msg" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PROTOCOL validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestProtocolValidation:
|
|
"""Tests for PROTOCOL environment validation at import time."""
|
|
|
|
def test_valid_protocol_does_not_raise(self, monkeypatch):
|
|
"""Importing config with a valid PROTOCOL succeeds."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("PROTOCOL", "meshtastic")
|
|
# Re-importing should not raise
|
|
importlib.reload(config)
|
|
|
|
def test_invalid_protocol_raises_value_error(self, monkeypatch):
|
|
"""An invalid PROTOCOL value raises ValueError at module load."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("PROTOCOL", "bogus_protocol_xyz")
|
|
with pytest.raises(ValueError, match="Unknown PROTOCOL"):
|
|
importlib.reload(config)
|
|
# Restore to valid value so subsequent tests work
|
|
monkeypatch.setenv("PROTOCOL", "meshtastic")
|
|
importlib.reload(config)
|
|
|
|
|
|
class TestComposeDefaults:
|
|
"""No packaged default may embed a quote character.
|
|
|
|
Compose substitutes a ``${VAR:-default}`` default as **literal text**, not
|
|
as shell, so ``:-""`` delivers the two-character string ``""`` rather than
|
|
an empty value. Any parser that treats a non-empty string as meaningful
|
|
then receives one bogus entry. For ``ALLOWED_CHANNELS`` that meant a
|
|
one-entry channel allowlist matching no real channel, so every stock
|
|
Compose deployment silently dropped every message
|
|
(``handlers/generic.py``, ``reason="disallowed-channel"``).
|
|
|
|
The guard **discovers** compose files rather than listing them, and matches
|
|
*any* quoted default rather than the one spelling that caused the outage —
|
|
``${VAR-""}`` (single dash), ``${VAR:-''}``, and ``${VAR:- ""}`` all deliver
|
|
the same literal.
|
|
"""
|
|
|
|
#: ``${VAR:-…}`` or ``${VAR-…}`` whose default text contains a quote.
|
|
_QUOTED_DEFAULT = re.compile(r"""\$\{[A-Za-z_][A-Za-z0-9_]*:?-[^}]*["'][^}]*\}""")
|
|
|
|
@staticmethod
|
|
def _compose_files() -> list[Path]:
|
|
"""Every compose file in the repo, wherever it lives."""
|
|
return sorted(
|
|
path
|
|
for path in REPO_ROOT.rglob("*compose*.y*ml")
|
|
if ".git" not in path.parts and "node_modules" not in path.parts
|
|
)
|
|
|
|
def test_discovery_finds_the_interpolating_compose_files(self):
|
|
"""Guard the guard: a glob that matched nothing would assert nothing.
|
|
|
|
Two of the repo's compose files carry no ``${...}`` interpolation at
|
|
all, so a per-file check over them is structurally vacuous. Assert that
|
|
at least one discovered file *does* interpolate, or this whole class is
|
|
theatre (the same trap RN-A3 was written against).
|
|
"""
|
|
files = self._compose_files()
|
|
assert len(files) >= 3, f"compose discovery found only {files}"
|
|
interpolating = [p for p in files if "${" in p.read_text(encoding="utf-8")]
|
|
assert interpolating, "no discovered compose file interpolates anything"
|
|
|
|
def test_no_quoted_defaults_anywhere(self):
|
|
"""An empty Compose default must be bare (`:-`), never quoted."""
|
|
offenders = {}
|
|
for path in self._compose_files():
|
|
hits = self._QUOTED_DEFAULT.findall(path.read_text(encoding="utf-8"))
|
|
if hits:
|
|
offenders[path.relative_to(REPO_ROOT).as_posix()] = hits
|
|
assert not offenders, (
|
|
f"Compose substitutes defaults as literal text, not as shell, so "
|
|
f"these deliver the quote characters verbatim: {offenders}"
|
|
)
|
|
|
|
|
|
class TestChannelNameQuoting:
|
|
"""Channel allowlists must not be defeated by a quoted value.
|
|
|
|
Quotes reach the ingestor from two directions: a Compose ``:-""`` default
|
|
(see :class:`TestComposeDefaults`) and an operator writing
|
|
``ALLOWED_CHANNELS='"LongFast"'``. Either way an unstripped quote makes the
|
|
entry match no real channel name.
|
|
"""
|
|
|
|
def test_quote_only_value_means_no_allowlist(self):
|
|
"""A quote-only value must disable the filter, not match nothing."""
|
|
assert config._parse_channel_names('""') == ()
|
|
assert config._parse_channel_names("''") == ()
|
|
assert config._parse_channel_names('" "') == ()
|
|
assert config._parse_hidden_channels('""') == ()
|
|
|
|
def test_a_name_containing_quotes_is_taken_literally(self):
|
|
"""Quotes inside a fragment are content, and must survive parsing.
|
|
|
|
A Meshtastic channel name is arbitrary UTF-8, and the filters match it
|
|
casefold-**exact** against the on-air name. Stripping quotes from the
|
|
configured value would make the two stop matching: an allowlist would
|
|
black the channel out, and a hidden-channel entry would fail *open*.
|
|
"""
|
|
assert config._parse_channel_names('"LongFast"') == ('"LongFast"',)
|
|
assert config._parse_channel_names("Ops'") == ("Ops'",)
|
|
assert config._parse_channel_names("'Private'") == ("'Private'",)
|
|
assert config._parse_channel_names("Bob's, O'Brien's") == (
|
|
"Bob's",
|
|
"O'Brien's",
|
|
)
|
|
|
|
def test_unquoted_behaviour_is_unchanged(self):
|
|
"""The established parsing — order-preserving, case-insensitive dedupe."""
|
|
assert config._parse_channel_names("LongFast, longfast ,MediumFast") == (
|
|
"LongFast",
|
|
"MediumFast",
|
|
)
|
|
assert config._parse_channel_names(None) == ()
|
|
|
|
|
|
class TestReticulumConfigDir:
|
|
"""Tests for the Reticulum config directory (SPEC RN3 as amended)."""
|
|
|
|
def test_explicit_env_wins_and_expands_tilde(self, monkeypatch):
|
|
"""An operator naming a directory gets exactly that directory."""
|
|
monkeypatch.setenv("RETICULUM_CONFIG_DIR", "~/shared/rns")
|
|
monkeypatch.setenv("HOME", "/home/operator")
|
|
assert config._resolve_reticulum_config_dir() == "/home/operator/shared/rns"
|
|
|
|
def test_defaults_to_the_operators_own_stack(self, monkeypatch):
|
|
"""Default is RNS's own ``~/.reticulum`` — the stack already running.
|
|
|
|
An earlier revision defaulted to a private, app-owned directory to avoid
|
|
adopting the operator's transport config. That turned out to be
|
|
counterproductive: interface scoping asks the shared instance which
|
|
interface an announce arrived on, and that RPC authenticates with a key
|
|
derived from the config dir's identity — so a private dir attaches to
|
|
the operator's ``rnsd`` but cannot query it (SPEC RE-A4).
|
|
"""
|
|
monkeypatch.delenv("RETICULUM_CONFIG_DIR", raising=False)
|
|
monkeypatch.setenv("HOME", "/home/operator")
|
|
assert config._resolve_reticulum_config_dir() == "/home/operator/.reticulum"
|
|
|
|
def test_xdg_config_home_is_not_consulted(self, monkeypatch):
|
|
"""The default is RNS's convention, not the XDG one."""
|
|
monkeypatch.delenv("RETICULUM_CONFIG_DIR", raising=False)
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", "/cfg")
|
|
monkeypatch.setenv("HOME", "/home/operator")
|
|
assert config._resolve_reticulum_config_dir() == "/home/operator/.reticulum"
|
|
|
|
def test_blank_env_falls_back_to_the_default(self, monkeypatch):
|
|
"""A blank value is treated as unset, not as the current directory."""
|
|
monkeypatch.setenv("RETICULUM_CONFIG_DIR", " ")
|
|
monkeypatch.setenv("HOME", "/home/operator")
|
|
assert config._resolve_reticulum_config_dir() == "/home/operator/.reticulum"
|
|
|
|
|
|
class TestReticulumInterfaces:
|
|
"""Tests for the RETICULUM_INTERFACES ingest allowlist (#888)."""
|
|
|
|
def test_unset_is_the_rnode_default(self):
|
|
"""Unset or blank parses to ``()``: the RNode scope (SPEC RN4, amended).
|
|
|
|
The provider reads ``()`` as "RNode interfaces only"; every interface
|
|
takes an explicit ``*`` (see :meth:`test_star_means_every_interface`).
|
|
An unset variable reaches the parser as ``""``.
|
|
"""
|
|
assert config._parse_reticulum_interfaces("") == ()
|
|
assert config._parse_reticulum_interfaces(" ") == ()
|
|
assert config._parse_reticulum_interfaces(",, ,") == ()
|
|
assert config._parse_reticulum_interfaces("") != (
|
|
config._parse_reticulum_interfaces("*")
|
|
)
|
|
|
|
def test_splits_lowercases_dedupes_and_sorts(self):
|
|
"""Entries are normalised so matching is case-insensitive and stable."""
|
|
assert config._parse_reticulum_interfaces("RNode, serial ,rnode") == (
|
|
"rnode",
|
|
"serial",
|
|
)
|
|
|
|
def test_quote_only_value_is_the_rnode_default(self):
|
|
"""A literal '\"\"' is the RNode default, not an allowlist matching nothing.
|
|
|
|
Compose substitutes a ``${VAR:-""}`` default as literal text, so the
|
|
ingestor can receive the two-character string ``""``. Read naively
|
|
that becomes a one-entry allowlist that matches no interface, and the
|
|
listener silently ingests zero announces.
|
|
"""
|
|
assert config._parse_reticulum_interfaces('""') == ()
|
|
assert config._parse_reticulum_interfaces("''") == ()
|
|
assert config._parse_reticulum_interfaces('" "') == ()
|
|
|
|
def test_star_means_every_interface(self):
|
|
"""``*`` anywhere in the list is every interface, alone; ``all`` is a name.
|
|
|
|
The provider admits every interface on ``("*",)`` (SPEC RN4, amended),
|
|
so the rest of a list naming ``*`` is dropped rather than shown.
|
|
"""
|
|
for raw in ("*", " * ", "rnode, *", "*,serial,RNode"):
|
|
assert config._parse_reticulum_interfaces(raw) == ("*",), raw
|
|
assert config._parse_reticulum_interfaces("all") == ("all",)
|
|
assert config.RETICULUM_ALL_INTERFACES == "*"
|
|
|
|
def test_fragments_with_content_are_taken_literally(self):
|
|
"""Only quote-*only* fragments are dropped; content is never rewritten.
|
|
|
|
The same rule the channel filters need (see
|
|
:class:`TestChannelNameQuoting`), applied through the shared
|
|
:func:`config._clean_env_fragment`.
|
|
"""
|
|
assert config._parse_reticulum_interfaces('"rnode", serial') == (
|
|
'"rnode"',
|
|
"serial",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_lora_freq_env
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseLoraFreqEnv:
|
|
"""Tests for :func:`config._parse_lora_freq_env`."""
|
|
|
|
def test_none_returns_none(self):
|
|
"""None input returns None."""
|
|
assert config._parse_lora_freq_env(None) is None
|
|
|
|
def test_empty_string_returns_none(self):
|
|
"""Empty string returns None."""
|
|
assert config._parse_lora_freq_env("") is None
|
|
|
|
def test_whitespace_only_returns_none(self):
|
|
"""Whitespace-only string returns None."""
|
|
assert config._parse_lora_freq_env(" ") is None
|
|
|
|
def test_integer_string_returns_int(self):
|
|
"""Whole-number string returns int."""
|
|
result = config._parse_lora_freq_env("868")
|
|
assert result == 868
|
|
assert isinstance(result, int)
|
|
|
|
def test_float_integer_value_returns_int(self):
|
|
"""String like '915.0' (whole float) returns int 915."""
|
|
result = config._parse_lora_freq_env("915.0")
|
|
assert result == 915
|
|
assert isinstance(result, int)
|
|
|
|
def test_decimal_string_returns_float(self):
|
|
"""Decimal string returns float."""
|
|
result = config._parse_lora_freq_env("869.525")
|
|
assert result == pytest.approx(869.525)
|
|
assert isinstance(result, float)
|
|
|
|
def test_non_numeric_label_returns_none(self):
|
|
"""Non-numeric string returns None so auto-detection is not blocked."""
|
|
assert config._parse_lora_freq_env("EU_868") is None
|
|
|
|
def test_unit_suffixed_string_returns_none(self):
|
|
"""String like '915MHz' returns None (not numeric)."""
|
|
assert config._parse_lora_freq_env("915MHz") is None
|
|
|
|
def test_inf_returns_none(self):
|
|
"""'inf' is non-finite and returns None."""
|
|
assert config._parse_lora_freq_env("inf") is None
|
|
|
|
def test_large_exponent_returns_none(self):
|
|
"""'1e309' overflows to inf and returns None."""
|
|
assert config._parse_lora_freq_env("1e309") is None
|
|
|
|
def test_nan_returns_none(self):
|
|
"""'nan' is non-finite and returns None."""
|
|
assert config._parse_lora_freq_env("nan") is None
|
|
|
|
def test_whitespace_stripped(self):
|
|
"""Leading/trailing whitespace is ignored."""
|
|
assert config._parse_lora_freq_env(" 919 ") == 919
|
|
|
|
def test_frequency_env_preseeds_lora_freq(self, monkeypatch):
|
|
"""FREQUENCY env var pre-seeds LORA_FREQ at module load."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("FREQUENCY", "915")
|
|
importlib.reload(config)
|
|
assert config.LORA_FREQ == 915
|
|
# Restore
|
|
monkeypatch.delenv("FREQUENCY")
|
|
importlib.reload(config)
|
|
|
|
def test_no_frequency_env_leaves_lora_freq_none(self, monkeypatch):
|
|
"""Absent FREQUENCY env var leaves LORA_FREQ as None."""
|
|
import importlib
|
|
|
|
monkeypatch.delenv("FREQUENCY", raising=False)
|
|
importlib.reload(config)
|
|
assert config.LORA_FREQ is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TRANSPORT / PRIMARY_CHANNEL_ONLY / PRIMARY_CHANNEL_KEY / MESH_UDP_* /
|
|
# INGESTOR_NODE_ID
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Every new env-driven config name introduced for the passive UDP transport.
|
|
_UDP_ENV_VARS = (
|
|
"TRANSPORT",
|
|
"PRIMARY_CHANNEL_ONLY",
|
|
"PRIMARY_CHANNEL_KEY",
|
|
"PRIMARY_CHANNEL_NAME",
|
|
"MESH_UDP_GROUP",
|
|
"MESH_UDP_PORT",
|
|
"MESH_UDP_ALLOWED_SOURCES",
|
|
"INGESTOR_NODE_ID",
|
|
)
|
|
|
|
|
|
def _clear_udp_env(monkeypatch) -> None:
|
|
"""Delete every UDP-transport env var so tests start from a clean slate."""
|
|
for name in _UDP_ENV_VARS:
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
|
class TestTransportConfig:
|
|
"""Tests for :data:`config.TRANSPORT`."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate(self, monkeypatch):
|
|
"""Clear UDP-transport env vars and reload config to defaults after each test.
|
|
|
|
Prevents state set by one test (or leaked into later test modules
|
|
sharing this process) from affecting subsequent tests, since
|
|
``config`` attributes set via :func:`importlib.reload` persist in
|
|
``sys.modules`` beyond the env vars that produced them.
|
|
"""
|
|
import importlib
|
|
|
|
_clear_udp_env(monkeypatch)
|
|
yield
|
|
_clear_udp_env(monkeypatch)
|
|
importlib.reload(config)
|
|
|
|
def test_transport_defaults_to_api(self, monkeypatch):
|
|
"""TRANSPORT defaults to 'api' when unset."""
|
|
import importlib
|
|
|
|
monkeypatch.delenv("TRANSPORT", raising=False)
|
|
importlib.reload(config)
|
|
assert config.TRANSPORT == "api"
|
|
|
|
def test_transport_udp_lowercased(self, monkeypatch):
|
|
"""TRANSPORT values are lower-cased; 'UDP' becomes 'udp'."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("TRANSPORT", "UDP")
|
|
importlib.reload(config)
|
|
assert config.TRANSPORT == "udp"
|
|
|
|
def test_transport_invalid_raises_value_error(self, monkeypatch):
|
|
"""An unrecognised TRANSPORT value raises ValueError at import time."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("TRANSPORT", "bogus")
|
|
with pytest.raises(ValueError, match="Unknown TRANSPORT"):
|
|
importlib.reload(config)
|
|
|
|
|
|
class TestPrimaryChannelOnlyConfig:
|
|
"""Tests for :data:`config.PRIMARY_CHANNEL_ONLY`."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate(self, monkeypatch):
|
|
"""Clear UDP-transport env vars and reload config to defaults after each test."""
|
|
import importlib
|
|
|
|
_clear_udp_env(monkeypatch)
|
|
yield
|
|
_clear_udp_env(monkeypatch)
|
|
importlib.reload(config)
|
|
|
|
def test_primary_channel_only_flag(self, monkeypatch):
|
|
"""PRIMARY_CHANNEL_ONLY is True when the env var is '1'."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("PRIMARY_CHANNEL_ONLY", "1")
|
|
importlib.reload(config)
|
|
assert config.PRIMARY_CHANNEL_ONLY is True
|
|
|
|
def test_primary_channel_only_defaults_false(self, monkeypatch):
|
|
"""PRIMARY_CHANNEL_ONLY defaults to False when unset."""
|
|
import importlib
|
|
|
|
monkeypatch.delenv("PRIMARY_CHANNEL_ONLY", raising=False)
|
|
importlib.reload(config)
|
|
assert config.PRIMARY_CHANNEL_ONLY is False
|
|
|
|
@pytest.mark.parametrize("value", ["true", "YES", " on "])
|
|
def test_primary_channel_only_accepts_truthy_spellings(self, monkeypatch, value):
|
|
"""``true``/``yes``/``on`` enable the filter like ``1`` (SPEC CF5)."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("PRIMARY_CHANNEL_ONLY", value)
|
|
importlib.reload(config)
|
|
assert config.PRIMARY_CHANNEL_ONLY is True
|
|
|
|
@pytest.mark.parametrize("value", ["0", "false", "off", ""])
|
|
def test_primary_channel_only_falsy_spellings(self, monkeypatch, value):
|
|
"""``0``/``false``/``off`` and a blank value leave the filter off."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("PRIMARY_CHANNEL_ONLY", value)
|
|
importlib.reload(config)
|
|
assert config.PRIMARY_CHANNEL_ONLY is False
|
|
|
|
def test_primary_channel_only_unrecognised_value_filters(self, monkeypatch):
|
|
"""A value it cannot parse fails toward filtering, never open."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("PRIMARY_CHANNEL_ONLY", "maybe")
|
|
importlib.reload(config)
|
|
assert config.PRIMARY_CHANNEL_ONLY is True
|
|
|
|
|
|
class TestDropViaMqttConfig:
|
|
"""Tests for :data:`config.DROP_VIA_MQTT` (SPEC VM1)."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate(self, monkeypatch):
|
|
"""Clear the flag and reload config to defaults after each test."""
|
|
import importlib
|
|
|
|
monkeypatch.delenv("DROP_VIA_MQTT", raising=False)
|
|
yield
|
|
monkeypatch.delenv("DROP_VIA_MQTT", raising=False)
|
|
importlib.reload(config)
|
|
|
|
def test_defaults_off(self):
|
|
"""Unset means via_mqtt traffic is kept."""
|
|
import importlib
|
|
|
|
importlib.reload(config)
|
|
assert config.DROP_VIA_MQTT is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"value, expected", [("1", True), ("TRUE", True), ("0", False), ("no", False)]
|
|
)
|
|
def test_parses_like_the_tx_flags(self, monkeypatch, value, expected):
|
|
"""The shared fail-safe parser accepts the common spellings."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("DROP_VIA_MQTT", value)
|
|
importlib.reload(config)
|
|
assert config.DROP_VIA_MQTT is expected
|
|
|
|
def test_unrecognised_value_drops(self, monkeypatch):
|
|
"""A value it cannot parse fails toward dropping, never open."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("DROP_VIA_MQTT", "sometimes")
|
|
importlib.reload(config)
|
|
assert config.DROP_VIA_MQTT is True
|
|
|
|
def test_exported_in_all(self):
|
|
"""The flag is part of the config module's public surface."""
|
|
assert "DROP_VIA_MQTT" in config.__all__
|
|
|
|
|
|
class TestPrimaryChannelNameConfig:
|
|
"""Tests for :data:`config.PRIMARY_CHANNEL_NAME`."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate(self, monkeypatch):
|
|
"""Clear UDP-transport env vars and reload config to defaults after each test."""
|
|
import importlib
|
|
|
|
_clear_udp_env(monkeypatch)
|
|
yield
|
|
_clear_udp_env(monkeypatch)
|
|
importlib.reload(config)
|
|
|
|
def test_defaults_to_empty_string(self, monkeypatch):
|
|
"""PRIMARY_CHANNEL_NAME defaults to '' (fail-closed sentinel) when unset."""
|
|
import importlib
|
|
|
|
monkeypatch.delenv("PRIMARY_CHANNEL_NAME", raising=False)
|
|
importlib.reload(config)
|
|
assert config.PRIMARY_CHANNEL_NAME == ""
|
|
|
|
def test_reads_and_strips_env_value(self, monkeypatch):
|
|
"""A configured name is read and surrounding whitespace stripped."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("PRIMARY_CHANNEL_NAME", " MediumFast ")
|
|
importlib.reload(config)
|
|
assert config.PRIMARY_CHANNEL_NAME == "MediumFast"
|
|
|
|
def test_exported_in_all(self):
|
|
"""PRIMARY_CHANNEL_NAME is part of the module's public surface."""
|
|
assert "PRIMARY_CHANNEL_NAME" in config.__all__
|
|
|
|
|
|
class TestUdpTransportDefaults:
|
|
"""Tests for PRIMARY_CHANNEL_KEY, MESH_UDP_GROUP, MESH_UDP_PORT, INGESTOR_NODE_ID."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate(self, monkeypatch):
|
|
"""Clear UDP-transport env vars and reload config to defaults after each test."""
|
|
import importlib
|
|
|
|
_clear_udp_env(monkeypatch)
|
|
yield
|
|
_clear_udp_env(monkeypatch)
|
|
importlib.reload(config)
|
|
|
|
def test_udp_defaults(self, monkeypatch):
|
|
"""All four vars fall back to their documented defaults when unset."""
|
|
import importlib
|
|
|
|
for k in (
|
|
"PRIMARY_CHANNEL_KEY",
|
|
"MESH_UDP_GROUP",
|
|
"MESH_UDP_PORT",
|
|
"INGESTOR_NODE_ID",
|
|
):
|
|
monkeypatch.delenv(k, raising=False)
|
|
importlib.reload(config)
|
|
assert config.PRIMARY_CHANNEL_KEY == "AQ=="
|
|
assert config.MESH_UDP_GROUP == "239.0.0.69,224.0.0.69"
|
|
assert config.MESH_UDP_PORT == 4403
|
|
assert config.INGESTOR_NODE_ID is None
|
|
|
|
def test_primary_channel_key_custom_value(self, monkeypatch):
|
|
"""A custom PRIMARY_CHANNEL_KEY overrides the default."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("PRIMARY_CHANNEL_KEY", "c3VwZXJzZWNyZXQ=")
|
|
importlib.reload(config)
|
|
assert config.PRIMARY_CHANNEL_KEY == "c3VwZXJzZWNyZXQ="
|
|
|
|
def test_primary_channel_key_blank_falls_back_to_default(self, monkeypatch):
|
|
"""A whitespace-only PRIMARY_CHANNEL_KEY falls back to the default."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("PRIMARY_CHANNEL_KEY", " ")
|
|
importlib.reload(config)
|
|
assert config.PRIMARY_CHANNEL_KEY == "AQ=="
|
|
|
|
def test_mesh_udp_group_custom_value(self, monkeypatch):
|
|
"""A custom MESH_UDP_GROUP overrides the default."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("MESH_UDP_GROUP", "239.1.2.3")
|
|
importlib.reload(config)
|
|
assert config.MESH_UDP_GROUP == "239.1.2.3"
|
|
|
|
def test_mesh_udp_group_blank_falls_back_to_default(self, monkeypatch):
|
|
"""A whitespace-only MESH_UDP_GROUP falls back to the default."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("MESH_UDP_GROUP", " ")
|
|
importlib.reload(config)
|
|
assert config.MESH_UDP_GROUP == "239.0.0.69,224.0.0.69"
|
|
|
|
def test_mesh_udp_port_custom_value(self, monkeypatch):
|
|
"""A custom MESH_UDP_PORT is parsed to an int."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("MESH_UDP_PORT", "5000")
|
|
importlib.reload(config)
|
|
assert config.MESH_UDP_PORT == 5000
|
|
assert isinstance(config.MESH_UDP_PORT, int)
|
|
|
|
def test_mesh_udp_port_blank_falls_back_to_default(self, monkeypatch):
|
|
"""A whitespace/empty MESH_UDP_PORT falls back to 4403 without raising.
|
|
|
|
A blank value is common in ``.env`` files; parsing it with a bare
|
|
``int()`` would raise ``ValueError`` at import and stop the service.
|
|
"""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("MESH_UDP_PORT", " ")
|
|
importlib.reload(config) # must not raise
|
|
assert config.MESH_UDP_PORT == 4403
|
|
|
|
def test_ingestor_node_id_custom_value(self, monkeypatch):
|
|
"""A custom INGESTOR_NODE_ID is returned stripped of surrounding whitespace."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("INGESTOR_NODE_ID", " !deadbeef ")
|
|
importlib.reload(config)
|
|
assert config.INGESTOR_NODE_ID == "!deadbeef"
|
|
|
|
def test_ingestor_node_id_blank_is_none(self, monkeypatch):
|
|
"""A whitespace-only INGESTOR_NODE_ID normalises to None."""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("INGESTOR_NODE_ID", " ")
|
|
importlib.reload(config)
|
|
assert config.INGESTOR_NODE_ID is None
|
|
|
|
|
|
class TestPrimaryChannelKeyValidation:
|
|
"""Tests for import-time base64 validation of :data:`config.PRIMARY_CHANNEL_KEY`."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate(self, monkeypatch):
|
|
"""Clear UDP-transport env vars and reload config to defaults after each test."""
|
|
import importlib
|
|
|
|
_clear_udp_env(monkeypatch)
|
|
yield
|
|
_clear_udp_env(monkeypatch)
|
|
importlib.reload(config)
|
|
|
|
def test_invalid_base64_raises_value_error_at_import(self, monkeypatch):
|
|
"""A non-base64 PRIMARY_CHANNEL_KEY raises ValueError at import time.
|
|
|
|
Regression guard: the key used to be stored raw and only decoded
|
|
lazily inside ``channel_hash``/``decrypt_meshpacket``, so a malformed
|
|
key first surfaced as ``binascii.Error`` out of ``connect()`` — which
|
|
the daemon's generic handler swallowed, logging only "Failed to create
|
|
mesh interface" and retrying forever instead of stopping the service
|
|
with a clear startup error (parity with ``TRANSPORT``/``PROTOCOL``).
|
|
"""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("PRIMARY_CHANNEL_KEY", "not-base64!!")
|
|
with pytest.raises(ValueError, match="PRIMARY_CHANNEL_KEY"):
|
|
importlib.reload(config)
|
|
|
|
def test_non_ascii_key_raises_value_error_at_import(self, monkeypatch):
|
|
"""A non-ASCII PRIMARY_CHANNEL_KEY is rejected at import time as well.
|
|
|
|
``expand_default_key`` encodes the key as ASCII before decoding, so a
|
|
non-ASCII value raises ``UnicodeEncodeError`` (a ``ValueError``) on the
|
|
same lazy path; import-time validation must catch this shape too.
|
|
"""
|
|
import importlib
|
|
|
|
monkeypatch.setenv("PRIMARY_CHANNEL_KEY", "ÄQ==")
|
|
with pytest.raises(ValueError, match="PRIMARY_CHANNEL_KEY"):
|
|
importlib.reload(config)
|
|
|
|
def test_valid_padded_base64_key_is_accepted(self, monkeypatch):
|
|
"""A well-formed padded base64 PSK (32-byte key) imports cleanly."""
|
|
import importlib
|
|
|
|
key = base64.b64encode(bytes(range(32))).decode("ascii")
|
|
monkeypatch.setenv("PRIMARY_CHANNEL_KEY", key)
|
|
importlib.reload(config)
|
|
assert config.PRIMARY_CHANNEL_KEY == key
|
|
|
|
|
|
class TestUdpTransportAllExports:
|
|
"""Tests that the new config names are exported via ``__all__``."""
|
|
|
|
def test_new_names_in_all(self):
|
|
"""TRANSPORT, PRIMARY_CHANNEL_ONLY, and the UDP vars are in __all__."""
|
|
for name in _UDP_ENV_VARS:
|
|
assert name in config.__all__
|
|
|
|
|
|
# _parse_24h_exempt_nodes
|
|
|
|
|
|
class TestParse24hExemptNodes:
|
|
"""Tests for :func:`config._parse_24h_exempt_nodes`."""
|
|
|
|
def test_none_empty_and_blank_fragments_yield_empty_set(self):
|
|
"""Unset, empty, and separator-only values disable the exemption."""
|
|
assert config._parse_24h_exempt_nodes(None) == frozenset()
|
|
assert config._parse_24h_exempt_nodes("") == frozenset()
|
|
assert config._parse_24h_exempt_nodes(" , ,") == frozenset()
|
|
|
|
def test_entries_normalise_to_canonical_ids(self):
|
|
"""Whitespace, case, and 0x-prefixed entries all canonicalise."""
|
|
result = config._parse_24h_exempt_nodes(" !AABBCCDD , 0xDEADBEEF ")
|
|
assert result == frozenset({"!aabbccdd", "!deadbeef"})
|
|
|
|
def test_junk_entries_are_dropped_not_fatal(self):
|
|
"""An unparseable entry is dropped; valid siblings survive."""
|
|
result = config._parse_24h_exempt_nodes("!aabbccdd,not-a-node-id")
|
|
assert result == frozenset({"!aabbccdd"})
|