Files
MeshCore/test/test_beacon_manager.py
T

241 lines
11 KiB
Python

"""Exercise production beacon CLI, scheduling, and transactional persistence.
The preset/modem helpers are production code; AES is deliberately not used by
this fixture. Real-AES wire vectors have their separate meshtext codec suite.
"""
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from cpp_source import body
ROOT = Path(__file__).resolve().parents[1]
class BeaconManagerTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = shutil.which("g++") or shutil.which("clang++")
if not compiler:
raise AssertionError("a C++ compiler is required")
cls.work = tempfile.TemporaryDirectory(prefix="meshcore-beacon-manager-")
cls.exe = Path(cls.work.name) / "manager"
command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-O1", "-g",
*(["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
if sys.platform.startswith("linux") else []),
"-DMESH_ENABLE_TX_BEACONS=1", "-DMESH_ENABLE_MESHTEXT_CHAT=1",
"-DMESH_ENABLE_MESHTEXT_WIRE=1",
"-I", str(ROOT / "test/fixtures/beacons/mocks"),
"-I", str(ROOT / "test/fixtures/radio_profiles/mocks"),
"-isystem", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"),
str(ROOT / "src/helpers/BeaconManager.cpp"),
str(ROOT / "src/helpers/MeshtextChat.cpp"),
str(ROOT / "test/fixtures/beacons/manager_test.cpp"), "-o", str(cls.exe)]
cls.build_command = command
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode:
raise AssertionError(result.stdout + result.stderr)
@classmethod
def tearDownClass(cls):
cls.work.cleanup()
def run_scenario(self, scenario):
result = subprocess.run([str(self.exe), scenario], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_commands_and_bounds(self):
self.run_scenario("commands")
def test_plain_decimal_parser_matches_radio_grammar(self):
production = (ROOT / "src/helpers/BeaconManager.cpp").read_text(encoding="ascii")
parser = body(production, "bool floatValue(")
source = Path(self.work.name) / "decimal.cpp"
binary = Path(self.work.name) / "decimal"
source.write_text(r'''
#include <helpers/CLICommandUtils.h>
#include <cassert>
#include <math.h>
#include <cstring>
#include <initializer_list>
namespace mesh {
''' + parser + r'''
}
int main() {
float value = 0;
struct Valid { const char* text; float value; };
for (const Valid& input : {Valid{"906.875", 906.875f}, {"+62.5", 62.5f},
{"-.5", -0.5f}, {".5", 0.5f}, {"906.", 906.0f}, {"0", 0.0f}}) {
assert(mesh::floatValue(input.text, value) && value == input.value);
}
for (const char* invalid : {"", ".", "+", "nan", "inf", "-inf", "0x1.c54p9",
"9.06875e2", "906.875MHz", " 906.875", "906.875 ", "906.875\t", "4294967296"})
assert(!mesh::floatValue(invalid, value));
assert(!mesh::floatValue(nullptr, value));
}
''', encoding="ascii")
compiled = subprocess.run([
self.build_command[0], "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
"-I", str(ROOT / "src"), str(source), "-o", str(binary),
], capture_output=True, text=True, timeout=60)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_all_intervals_and_wrap(self):
self.run_scenario("schedule")
def test_rolling_guards_and_no_retries(self):
self.run_scenario("rolling")
def test_every_config_short_write_and_recovery(self):
self.run_scenario("storage")
def test_structural_config_corruption_remains_held(self):
self.run_scenario("structural_storage")
def test_durable_meshtext_ids_fail_closed(self):
self.run_scenario("ids")
def test_only_committed_mutations_cancel_queued_frames(self):
self.run_scenario("mutations")
def test_missing_active_counter_never_rewinds_to_backup(self):
self.run_scenario("stale_backup")
def test_failed_live_promotion_never_restores_an_unrelated_backup(self):
self.run_scenario("live_backup")
def negative_control(self, name, source, scenario):
generated = Path(self.work.name) / (name + ".cpp")
generated.write_text(source, encoding="ascii")
executable = Path(self.work.name) / name
command = list(self.build_command)
command[command.index(str(ROOT / "src/helpers/BeaconManager.cpp"))] = str(generated)
command[-1] = str(executable)
command[1:1] = ["-I", str(ROOT / "src/helpers")]
compiled = subprocess.run(command, capture_output=True, text=True)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
result = subprocess.run([str(executable), scenario], capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0, "old behavior unexpectedly passed the regression")
self.assertIn("Assertion", result.stderr)
def test_negative_control_uncancelled_old_frame_is_detected(self):
source = (ROOT / "src/helpers/BeaconManager.cpp").read_text(encoding="ascii")
cancellation = "if (mesh_) mesh_->cancelBeaconRadioFrame();"
self.assertEqual(source.count(cancellation), 4)
self.negative_control("uncancelled", source.replace(cancellation, ""), "mutations")
def test_negative_control_counter_backup_rewind_is_detected(self):
source = (ROOT / "src/helpers/BeaconManager.cpp").read_text(encoding="ascii")
held = """ // Missing active metadata does not prove an interrupted rename. A
// stale backup can survive a cleanup failure after a newer ID was
// transmitted. Never rewind to it, even before the first MT setting.
ids_hold_ = true;"""
restored = """ if (result != ReadResult::Missing || !fs_->rename(IdBackup, IdPath)) ids_hold_ = true;
else result = ReadResult::Valid;"""
self.assertEqual(source.count(held), 1)
self.negative_control("rewound", source.replace(held, restored), "stale_backup")
def test_negative_control_live_counter_backup_rewind_is_detected(self):
source = (ROOT / "src/helpers/BeaconManager.cpp").read_text(encoding="ascii")
guard = "if (active_backed_up && moveFile(fs_, backup, path) != FileMove::Moved)"
self.assertEqual(source.count(guard), 1)
self.negative_control("live_rewound", source.replace(
guard, "if ((active_backed_up || backup_present) && moveFile(fs_, backup, path) != FileMove::Moved)"),
"live_backup")
class BeaconNamespaceTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = shutil.which("g++") or shutil.which("clang++")
if not compiler:
raise AssertionError("a C++ compiler is required")
cls.work = tempfile.TemporaryDirectory(prefix="meshcore-beacon-namespace-")
cls.addClassCleanup(cls.work.cleanup)
cls.exe = Path(cls.work.name) / "namespace"
# nRF's real filePresence path exposes metadata errors. Both replacing
# (LittleFS/Pico) and nonreplacing (SPIFFS) rename models run in C++.
cls.command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-O1", "-g",
*(["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
if sys.platform.startswith("linux") else []),
"-DNRF52_PLATFORM=1", "-DMESH_ENABLE_TX_BEACONS=1",
"-DMESH_ENABLE_MESHTEXT_CHAT=1", "-DMESH_ENABLE_MESHTEXT_WIRE=1",
"-I", str(ROOT / "test/fixtures/beacons/namespace_mocks"),
"-I", str(ROOT / "test/fixtures/beacons/mocks"),
"-I", str(ROOT / "test/fixtures/radio_profiles/mocks"),
"-isystem", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"),
"-I", str(ROOT / "src/helpers"),
str(ROOT / "src/helpers/BeaconManager.cpp"),
str(ROOT / "src/helpers/MeshtextChat.cpp"),
str(ROOT / "test/fixtures/beacons/namespace_test.cpp"), "-o", str(cls.exe)]
result = subprocess.run(cls.command, capture_output=True, text=True, timeout=60)
if result.returncode:
raise AssertionError(result.stdout + result.stderr)
def run_scenario(self, scenario, executable=None):
result = subprocess.run([str(executable or self.exe), scenario], capture_output=True,
text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_healthy_publications_and_id_reservations(self):
self.run_scenario("healthy")
def test_config_acknowledgement_follows_observed_publication(self):
self.run_scenario("config")
def test_uncertain_moves_preserve_authorities_and_cancel_admitted_old_frame(self):
self.run_scenario("uncertainty")
def test_rollback_does_not_overwrite_an_uncertain_candidate(self):
self.run_scenario("rollback")
def test_startup_backup_recovery_confirms_namespace_moves(self):
self.run_scenario("recovery")
def test_meshtext_ids_are_reserved_only_after_observed_publication(self):
self.run_scenario("ids")
def test_rename_return_only_negative_control_is_detected(self):
source = (ROOT / "src/helpers/BeaconManager.cpp").read_text(encoding="ascii")
original = body(source, "bool BeaconManager::commit(")
old = r'''bool BeaconManager::commit(const char* path, const char* backup,
const char* temporary, bool& hold) {
bool active_present = false, backup_present = false;
bool active_backed_up = false;
if (!filePresence(fs_, path, active_present) || !filePresence(fs_, backup, backup_present)) return false;
if (active_present) {
if (backup_present && !fs_->remove(backup)) return false;
if (!fs_->rename(path, backup)) return false;
active_backed_up = true;
backup_present = true;
}
if (!fs_->rename(temporary, path)) {
if (active_backed_up && !fs_->rename(backup, path)) hold = true;
return false;
}
if (backup_present) fs_->remove(backup);
return true;
}'''
self.assertNotEqual(original, old)
generated = Path(self.work.name) / "rename_return_only.cpp"
generated.write_text(source.replace(original, old), encoding="ascii")
executable = Path(self.work.name) / "rename_return_only"
command = list(self.command)
command[command.index(str(ROOT / "src/helpers/BeaconManager.cpp"))] = str(generated)
command[-1] = str(executable)
result = subprocess.run(command, capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.run_scenario("healthy", executable)
result = subprocess.run([str(executable), "config"], capture_output=True, text=True, timeout=30)
self.assertNotEqual(result.returncode, 0, "rename-return-only behavior escaped the matrix")
self.assertIn("Assertion", result.stderr)
if __name__ == "__main__":
unittest.main()