Files
MeshCore/test/test_webconfig_lost_reply.py
T
mikecarper cd02e55824 Share the saved admin password across ESP32 management services
Use the committed node credential for setup WiFi, WebConfig and browser OTA. Add persistent ESP32 WiFi Companion management credentials without changing preferences layouts, revoke stale browser sessions on rotation, retain scoped save receipts, and coordinate credential changes with flash uploads. Keep external-service credentials and trusted TCP protocol compatibility independent. Update live join QR codes, failure handling and documentation.

Validated 51 native cases, 158 related host and browser methods, and serial Full V4 Companion, Station G3 Repeater, ESP32-C6 Room Server and V4 Sensor firmware builds.
2026-10-18 13:09:48 -07:00

290 lines
14 KiB
Python

#!/usr/bin/env python3
"""Exercise the shipped Lost-status control and actual WebConfig JSON handlers."""
import json
import html
from pathlib import Path
import re
import unittest
from unittest import mock
from test_replay_reset_integration import extract_braced
import test_webconfig_recovery_auth as auth_fixture
import test_webconfig_ui_runtime as browser_fixture
ROOT = Path(__file__).resolve().parents[1]
class LostReplyWebConfigTest(unittest.TestCase):
compile_run = auth_fixture.WebConfigRecoveryAuthTest.compile_run
setup_values = browser_fixture.WebConfigUiRuntimeTest.setup_values
def run_page(self, prelude, virtual_time=1500):
# Snap Chromium has a private /tmp. Put the HTML in the shared
# workspace, as the Room browser fixture does, so it can load it.
temporary_directory = browser_fixture.tempfile.TemporaryDirectory
cache = ROOT / ".pio"
cache.mkdir(exist_ok=True)
def workspace_temporary_directory(*args, **kwargs):
kwargs.setdefault("dir", cache)
return temporary_directory(*args, **kwargs)
with mock.patch.object(browser_fixture.tempfile, "TemporaryDirectory",
workspace_temporary_directory):
return browser_fixture.WebConfigUiRuntimeTest.run_page(self, prelude, virtual_time)
def fixture(self):
server = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
header = (ROOT / "src/helpers/esp32/WebConfigServer.h").read_text()
capability = extract_braced(header, "enum Capability : uint32_t") + ";"
snapshot = extract_braced(header, "struct NodeSnapshot") + ";"
batch_entry = extract_braced(header, "struct BatchEntry") + ";"
allowed = extract_braced(server, "static bool isAllowedSetKey(")
secret = extract_braced(server, "static bool isSecretKey(")
post = extract_braced(server, "void WebConfigServer::handleConfigPost(")
auth = "\n".join(extract_braced(server, signature) for signature in (
"static bool fixedTimeEquals(", "static bool webConfigCookieMatches(",
"void WebConfigServer::clearSessionLocked(", "bool WebConfigServer::checkAuthLocked(",
"bool WebConfigServer::checkAuth(", "void WebConfigServer::beginBatchAuthorizationLocked("))
get = extract_braced(server, "void WebConfigServer::handleConfigGet(")
# Execute the actual snapshot-to-radio JSON branch. WiFi/MQTT fields
# after this boundary are unrelated and require peripheral fixtures.
get = get[:get.index(' JsonObject wifi =')] + r'''
}
String out;
serializeJson(doc, out);
req->send(200, "application/json", out);
}
'''
return r'''
#include <cassert>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <string>
#include <ArduinoJson.h>
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
#include <helpers/esp32/ManagementPassword.h>
#include "helpers/WebConfigKeys.h"
#include "helpers/WebConfigBatch.h"
struct String : std::string {
using std::string::string;
String(std::string value) : std::string(std::move(value)) {}
int indexOf(const char* value) const { auto pos = find(value); return pos == npos ? -1 : int(pos); }
String substring(int begin, int end) const { return substr(begin, end - begin); }
size_t write(uint8_t value) { push_back(char(value)); return 1; }
size_t write(const uint8_t* data, size_t count) { append(reinterpret_cast<const char*>(data), count); return count; }
};
struct WCLock { explicit WCLock(int) {} };
uint32_t millis() { return 1; }
static constexpr uint32_t WEBCONFIG_SESSION_TTL_MS = 120000;
struct WiFiMock { int softAPIP() const { return 4; } } WiFi;
struct Client { int localIP() const { return 4; } };
struct Header { String cookies = "wcs=0123456789abcdef0123456789abcdef"; const String& value() const { return cookies; } };
namespace mesh {
struct Port { template<typename... Args> void printf(const char*, Args...) {} };
Port& usbDebugPort() { static Port port; return port; }
namespace wifi { bool parseEspNowChannel(const char*, uint8_t&) { return false; } }
}
static const char SECRET_SENTINEL[] = "********";
@ALLOWED@
@SECRET@
struct AsyncWebServerRequest {
Client socket;
Header header;
String input, output;
void* _tempObject = nullptr;
int status = 0;
explicit AsyncWebServerRequest(String body = "") : input(body) { _tempObject = input.data(); }
size_t contentLength() const { return input.size(); }
Client* client() { return &socket; }
bool hasHeader(const char*) const { return !header.cookies.empty(); }
Header* getHeader(const char*) { return &header; }
void send(int code, const char* = nullptr, const String& body = "") { status = code; output = body; }
};
struct WebConfigServer {
@CAPABILITY@
@SNAPSHOT@
@BATCH_ENTRY@
struct Callbacks {
NodeSnapshot node = {};
void getNodeSnapshot(NodeSnapshot& result) { result = node; }
} callbacks;
Callbacks* _cb = &callbacks;
enum { MODE_OFF, MODE_SETUP, MODE_LAN, BATCH_CONFIG, BATCH_CLI };
static constexpr size_t MAX_BODY = 4096;
static constexpr int MAX_BATCH = WebConfigBatch::kMaxBatch;
int _mode = MODE_LAN, _mux = 0, _batch_kind = BATCH_CONFIG;
bool _initial_setup = false, _setup_provisioning_privilege = false, _batch_reboot = false;
bool _batch_reboot_armed = false, _batch_all_ok = true;
bool _standalone_wifi_dirty = false, _setup_wifi_handoff_pending = false;
bool _batch_reauth_required = false, _batch_reauth_delivered = false, _batch_started = false;
uint32_t _session_generation = 0, _session_last_seen = 1, _last_activity = 0;
uint32_t _batch_auth_generation = 0, _batch_reauth_until = 0;
uint32_t _ap_auth_generation = 0;
char _session_token[33] = "0123456789abcdef0123456789abcdef";
char _batch_owner_token[33] = {}, _wifi_ssid[33] = "fixture-network";
uint32_t _setup_wifi_handoff_deadline = 0, _diag_until = 0;
char _setup_wifi_handoff_ip[32] = {}, _batch_reqid[17] = {};
int _batch_count = 0, _batch_next = 0;
void* _mqtt_prefs = nullptr;
BatchEntry _batch[MAX_BATCH] = {};
WebConfigBatch::State _batch_state = WebConfigBatch::State::Idle;
static constexpr auto BATCH_PENDING = WebConfigBatch::State::Pending;
static WebConfigBatch::State toSpecState(WebConfigBatch::State state) { return state; }
WebConfigServer() {
mesh::esp32::managementPassword::Snapshot current;
assert(mesh::esp32::managementPassword::snapshot(current));
_session_generation = current.generation;
}
void noteNetworkActivity() {}
void clearSessionLocked();
bool checkAuthLocked(AsyncWebServerRequest*, uint32_t* generation = nullptr);
bool checkAuth(AsyncWebServerRequest*);
void beginBatchAuthorizationLocked(uint32_t);
void handleConfigGet(AsyncWebServerRequest*);
void handleConfigPost(AsyncWebServerRequest*);
};
@AUTH_HELPERS@
@GET@
@POST@
String writeBody(const char* value) {
return String("{\"reqid\":\"0123456789abcdef\",\"set\":{\"lost.reply\":") + value + "}}";
}
int main() {
assert(mesh::esp32::managementPassword::publish("password"));
const char* modes[] = {"off", "no", "yes"};
for (int value : {0, 1, 2, 255}) {
WebConfigServer server;
server.callbacks.node.lost_reply = uint8_t(value);
server.callbacks.node.capabilities = WebConfigServer::CAP_LOST_REPLY;
AsyncWebServerRequest get;
server.handleConfigGet(&get);
JsonDocument snapshot;
assert(get.status == 200 && deserializeJson(snapshot, get.output) == DeserializationError::Ok);
assert(strcmp(snapshot["radio"]["lost_reply"].as<const char*>(), modes[value <= 2 ? value : 0]) == 0);
assert(snapshot["radio"]["capabilities"].as<uint32_t>() == (1UL << 18));
}
for (const char* mode : modes) {
WebConfigServer server;
server.callbacks.node.capabilities = WebConfigServer::CAP_LOST_REPLY;
AsyncWebServerRequest save(writeBody((String("\"") + mode + "\"").c_str()));
server.handleConfigPost(&save);
assert(save.status == 202 && server._batch_count == 1 && !server._batch_reboot);
assert(strcmp(server._batch[0].key, "lost.reply") == 0);
assert(String(server._batch[0].cmd) == String("set lost.reply ") + mode);
// A correlated retry retains the established batch, including when the
// retry carries an invalid value. Classification precedes new validation.
AsyncWebServerRequest retry(writeBody("\"invalid\""));
server.handleConfigPost(&retry);
assert(retry.status == 202 && String(server._batch[0].cmd) == String("set lost.reply ") + mode);
}
for (const char* invalid : {"null", "true", "2", "\"on\"", "\"YES\"", "\"yes\\n\""}) {
WebConfigServer server;
server.callbacks.node.capabilities = WebConfigServer::CAP_LOST_REPLY;
AsyncWebServerRequest save(writeBody(invalid));
server.handleConfigPost(&save);
assert(save.status == 400 && save.output.find("expected off, no or yes") != String::npos);
assert(server._batch_count == 0 && server._batch_state == WebConfigBatch::State::Idle);
}
WebConfigServer infrastructure;
AsyncWebServerRequest forged(writeBody("\"yes\""));
infrastructure.handleConfigPost(&forged);
assert(forged.status == 400 && forged.output.find("setting unavailable") != String::npos);
assert(infrastructure._batch_count == 0);
infrastructure.clearSessionLocked();
AsyncWebServerRequest unauthorized(writeBody("\"yes\""));
infrastructure.handleConfigPost(&unauthorized);
assert(unauthorized.status == 401 && infrastructure._batch_count == 0);
assert(wcIsAllowedSetKey("lost.reply") && !wcIsAllowedSetKey("lost.reply.extra"));
assert(!wcSetKeyRequiresReboot("lost.reply"));
}
'''.replace("@CAPABILITY@", capability).replace("@SNAPSHOT@", snapshot).replace(
"@BATCH_ENTRY@", batch_entry).replace("@ALLOWED@", allowed).replace(
"@SECRET@", secret).replace("@AUTH_HELPERS@", auth).replace("@GET@", get).replace("@POST@", post)
def test_actual_json_strings_and_capability_gated_settings_batch(self):
# The shared compiler helper needs the production helper include path.
self.compile_run(self.fixture().replace('"helpers/', '"' + (ROOT / "src/helpers").as_posix() + '/'))
def test_shipped_control_defaults_off_and_saves_no_yes_off(self):
status, config = self.setup_values()
status.update(mode="lan", capabilities=1 << 18)
config["radio"]["lost_reply"] = "off"
prelude = r'''<script>
(function(){
var status=%s,config=%s,submitted=null,saves=[];
function reply(value,code){return Promise.resolve({ok:true,status:code||200,json:function(){return Promise.resolve(value)}})}
window.fetch=function(path,options){
if(path==="/api/status")return reply(status);
if(path==="/api/config"&&(!options||options.method!=="POST"))return reply(config);
if(path==="/api/config"){
submitted=JSON.parse(options.body);saves.push(submitted);
config.radio.lost_reply=submitted.set["lost.reply"];
return reply({state:"pending",reqid:submitted.reqid,count:1},202);
}
if(path.indexOf("/api/config/result?")===0)return reply({state:"done",reqid:submitted.reqid,results:[{key:"lost.reply",reply:"OK"}]});
return reply({});
};
function mark(key,value){document.body.setAttribute("data-test-"+key,String(value))}
function waitFor(check,next,attempt){
if(check()){next();return}
if((attempt||0)>100){mark("error","save did not complete");return}
setTimeout(function(){waitFor(check,next,(attempt||0)+1)},20);
}
window.addEventListener("load",function(){setTimeout(function(){
var field=document.querySelector('#v-app [data-k="lost.reply"]');
mark("visible",!field.closest('[data-cap]').classList.contains("hide"));
mark("default",field.value);mark("choices",Array.prototype.map.call(field.options,function(o){return o.value}).join(","));
mark("enum",cliEnum("lost.reply").join(","));
var values=["no","yes","off"],index=0;
function saveNext(){
var value=values[index];field.value=value;
field.dispatchEvent(new Event("input",{bubbles:true}));field.dispatchEvent(new Event("change",{bubbles:true}));
appSave();
waitFor(function(){return saves.length===index+1&&st.orig["lost.reply"]===value&&!("lost.reply" in st.dirty)},function(){
mark("saved-"+value,field.value);index++;
if(index<values.length){saveNext();return}
mark("saves",JSON.stringify(saves.map(function(s){return s.set})));mark("reboot",saves.some(function(s){return !!s.reboot}));
mark("result",field.closest('.f').textContent);mark("done",true);
});
}
saveNext();
},300)});
})();
</script>''' % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude, virtual_time=3500)
for key, value in (("visible", "true"), ("default", "off"), ("choices", "off,no,yes"),
("enum", "off,no,yes"), ("saved-no", "no"), ("saved-yes", "yes"),
("saved-off", "off"), ("reboot", "false"), ("done", "true")):
self.assertIn('data-test-%s="%s"' % (key, value), dom)
saved = re.search(r'data-test-saves="([^"]*)"', dom)
self.assertIsNotNone(saved)
self.assertEqual(json.loads(html.unescape(saved.group(1))),
[{"lost.reply": "no"}, {"lost.reply": "yes"}, {"lost.reply": "off"}])
self.assertIn("Lost-status auto reply", dom)
self.assertIn("Am I lost?", dom)
def test_other_roles_hide_setting_and_do_not_offer_cli_key(self):
for role in ("Repeater", "Room Server"):
with self.subTest(role=role):
status, config = self.setup_values()
status.update(mode="lan", role=role, capabilities=1)
config["radio"]["lost_reply"] = "off"
prelude = r'''<script>
window.fetch=function(path){var value=path==="/api/status"?%s:%s;return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(value)}})};
window.addEventListener("load",function(){setTimeout(function(){
document.body.setAttribute("data-test-hidden",document.getElementById("lost-reply-setting").classList.contains("hide"));
document.body.setAttribute("data-test-no-cli",!cliTable().some(function(row){return row[0]==="get lost.reply"||row[0]==="set lost.reply "}));
},300)});
</script>''' % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-hidden="true"', dom)
self.assertIn('data-test-no-cli="true"', dom)
if __name__ == "__main__":
unittest.main()