mirror of
https://github.com/ruvnet/RuView
synced 2026-06-19 11:53:19 +00:00
dc7f6cd096
Closes #391 (full-replace footgun). Phase 1 of #574 (esp32-csi-node provisioning UX). The mDNS discovery + USB-CDC pairing work in #574 remains future work; this PR handles only the provision.py-side fix. Background: provision.py flashed a fresh NVS partition at 0x9000 every invocation. The previous behaviour built that partition only from the CLI flags passed on the current run — every key you didn't pass was silently erased. We hit it ourselves earlier today: --force-partial only suppressed the safety check but still wiped the SSID. This PR replaces the full-replace semantic with a per-port state file that captures every config value previously flashed from this machine. On each invocation: 1. Read ~/.config/wifi-densepose/esp32-provision-state/<port>.json (or %APPDATA%/... on Windows). 2. Overlay the new CLI flags on top — CLI wins where set. 3. Generate + flash NVS from the merged dict. 4. Persist the merged dict back to the state file. Net effect: the exact scenario from #391 + today's incident now passes (test_partial_invocation_does_not_drop_unrelated_keys): python provision.py --port COM7 --ssid Net --password p --target-ip 10.0.0.5 # later: python provision.py --port COM7 --seed-url http://10.0.0.99:8080 # WiFi creds preserved, seed_url added. New flags: --reset Wipe per-port state before merging (recycled-board path). --state-dir Override per-user state dir (XDG / %APPDATA% by default). --state Print the merged state and exit (debug / inspection). --force-partial preserved as a deprecation-flagged escape hatch. State file caveats (in the module docstring): per-machine, atomic write via .tmp + os.replace, future follow-up to add USB-CDC NVS dump for device-authoritative merging is tracked in #574. Tests: tests/test_provision_state.py — 11 tests covering load/save round-trip, corrupt-JSON resilience, CLI-wins-over-prior, the exact #391 case, falsy-but-not-None CLI override (node_id=0 must survive), and serial-port path sanitization for /dev/ttyUSB0. 11/11 pass. Live-tested end-to-end with --dry-run + --state inspection: first run: ssid + password + target_ip persisted second run: --seed-url added — WiFi creds intact in final state.
130 lines
4.8 KiB
Python
130 lines
4.8 KiB
Python
"""Tests for provision.py's additive-by-default merge behaviour (#391, #574)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
# Allow `python -m unittest` from anywhere in the repo.
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, os.path.dirname(HERE))
|
|
|
|
import provision # noqa: E402 — sibling import after sys.path tweak
|
|
|
|
|
|
def _mk_args(**overrides) -> argparse.Namespace:
|
|
"""Build a Namespace with every mergeable attr set to None unless overridden."""
|
|
base = {name: None for name in provision.MERGEABLE_ATTRS}
|
|
base.update(overrides)
|
|
return argparse.Namespace(**base)
|
|
|
|
|
|
class TestStateFile(unittest.TestCase):
|
|
def setUp(self):
|
|
self.dir = tempfile.mkdtemp(prefix="provision-state-")
|
|
|
|
def tearDown(self):
|
|
import shutil
|
|
shutil.rmtree(self.dir, ignore_errors=True)
|
|
|
|
def test_load_state_empty_when_missing(self):
|
|
self.assertEqual(provision.load_state("COM7", self.dir), {})
|
|
|
|
def test_save_then_load_roundtrip(self):
|
|
provision.save_state("COM7", self.dir, {"ssid": "x", "password": "y"})
|
|
self.assertEqual(
|
|
provision.load_state("COM7", self.dir),
|
|
{"ssid": "x", "password": "y"},
|
|
)
|
|
|
|
def test_save_creates_per_port_files(self):
|
|
provision.save_state("COM7", self.dir, {"ssid": "a"})
|
|
provision.save_state("/dev/ttyUSB0", self.dir, {"ssid": "b"})
|
|
self.assertEqual(provision.load_state("COM7", self.dir), {"ssid": "a"})
|
|
self.assertEqual(provision.load_state("/dev/ttyUSB0", self.dir), {"ssid": "b"})
|
|
|
|
def test_load_state_handles_corrupt_json(self):
|
|
path = provision._state_path_for("COM7", self.dir)
|
|
os.makedirs(self.dir, exist_ok=True)
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write("{not valid json")
|
|
# Should warn but not raise.
|
|
self.assertEqual(provision.load_state("COM7", self.dir), {})
|
|
|
|
|
|
class TestMerge(unittest.TestCase):
|
|
def test_cli_wins_over_prior(self):
|
|
args = _mk_args(ssid="new-ssid")
|
|
prior = {"ssid": "old-ssid", "password": "abc"}
|
|
merged = provision.merge_state_into_args(args, prior)
|
|
self.assertEqual(args.ssid, "new-ssid") # CLI value preserved
|
|
self.assertEqual(args.password, "abc") # filled from prior
|
|
self.assertEqual(merged["ssid"], "new-ssid")
|
|
self.assertEqual(merged["password"], "abc")
|
|
|
|
def test_prior_fills_missing_cli(self):
|
|
args = _mk_args() # all None
|
|
prior = {
|
|
"ssid": "MyWiFi",
|
|
"password": "secret",
|
|
"target_ip": "192.168.1.20",
|
|
"node_id": 3,
|
|
}
|
|
merged = provision.merge_state_into_args(args, prior)
|
|
self.assertEqual(args.ssid, "MyWiFi")
|
|
self.assertEqual(args.password, "secret")
|
|
self.assertEqual(args.target_ip, "192.168.1.20")
|
|
self.assertEqual(args.node_id, 3)
|
|
for key, val in prior.items():
|
|
self.assertEqual(merged[key], val)
|
|
|
|
def test_partial_invocation_does_not_drop_unrelated_keys(self):
|
|
# The exact #391 scenario: user previously provisioned WiFi, now adds
|
|
# only --seed-url. Old behaviour wiped SSID. New behaviour keeps it.
|
|
args = _mk_args(seed_url="http://10.1.10.236")
|
|
prior = {
|
|
"ssid": "ruv.net",
|
|
"password": "<secret>",
|
|
"target_ip": "192.168.1.20",
|
|
}
|
|
merged = provision.merge_state_into_args(args, prior)
|
|
self.assertEqual(args.ssid, "ruv.net")
|
|
self.assertEqual(args.password, "<secret>")
|
|
self.assertEqual(args.target_ip, "192.168.1.20")
|
|
self.assertEqual(args.seed_url, "http://10.1.10.236")
|
|
# And the on-disk merged dict carries all four keys.
|
|
self.assertEqual(set(merged.keys()),
|
|
{"ssid", "password", "target_ip", "seed_url"})
|
|
|
|
def test_empty_prior_is_noop(self):
|
|
args = _mk_args(ssid="x")
|
|
merged = provision.merge_state_into_args(args, {})
|
|
self.assertEqual(merged, {"ssid": "x"})
|
|
|
|
def test_falsy_but_not_none_cli_value_overrides_prior(self):
|
|
# node_id=0 is a legal value; must NOT be replaced by prior["node_id"]=5.
|
|
args = _mk_args(node_id=0)
|
|
prior = {"node_id": 5}
|
|
merged = provision.merge_state_into_args(args, prior)
|
|
self.assertEqual(args.node_id, 0)
|
|
self.assertEqual(merged["node_id"], 0)
|
|
|
|
|
|
class TestStatePathSanitization(unittest.TestCase):
|
|
def test_slashes_in_port_are_safe(self):
|
|
path = provision._state_path_for("/dev/ttyUSB0", "/tmp/x")
|
|
# Must not contain a raw slash in the basename
|
|
self.assertNotIn("/", os.path.basename(path))
|
|
|
|
def test_windows_com_port_is_safe(self):
|
|
path = provision._state_path_for("COM7", "/tmp/x")
|
|
self.assertTrue(path.endswith("COM7.json"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|