Files
potato-mesh/tests/test_config_unit.py
T

952 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",
"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"})