#!/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 #include #include #include #include #include #define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1 #include #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(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 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(), modes[value <= 2 ? value : 0]) == 0); assert(snapshot["radio"]["capabilities"].as() == (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'''''' % (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'''''' % (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()