Files
MeshCore/test/test_webconfig_password_sessions.py
T

145 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Execute production credential-generation and AP-rekey decisions offline."""
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
class WebConfigPasswordSessionsTest(unittest.TestCase):
def compile_run(self, source, expect_rejection=False, includes=()):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
with tempfile.TemporaryDirectory(prefix="webconfig-password-sessions-") as directory:
work = Path(directory)
generated = work / "fixture.cpp"
generated.write_text(source)
binary = work / "fixture"
sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
compiled = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra",
*sanitizer, "-I" + str(ROOT / "src"),
*("-I" + str(path) for path in includes), str(generated), "-o", str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(compiled.returncode, 0, compiled.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
if expect_rejection:
self.assertNotEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("Assertion", checked.stderr)
else:
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
def rekey_fixture(self):
source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
method = extract_braced(source, "void WebConfigServer::serviceSetupPassword(")
return (ROOT / "test/fixtures/webconfig_password_sessions.cpp").read_text().replace("@METHODS@", method)
def test_rekey_receipt_upload_and_failure_lifecycle(self):
self.compile_run(self.rekey_fixture())
def test_failed_rekey_must_remove_old_key_access_point(self):
fixture = self.rekey_fixture()
# An early platform failure leaves the old AP intact unless the owner
# explicitly removes it. Prove the fixture detects that original bug.
old = fixture.replace(" WiFi.softAPdisconnect(true);\n", "", 1)
self.assertNotEqual(old, fixture)
self.compile_run(old, expect_rejection=True)
def batch_fixture(self):
source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
methods = "\n".join(extract_braced(source, signature) for signature in (
"static bool fixedTimeEquals(",
"void WebConfigServer::clearSessionLocked(",
"void WebConfigServer::beginBatchAuthorizationLocked(",
"void WebConfigServer::cancelStaleBatchLocked("))
drain = extract_braced(source, "void WebConfigServer::drainBatch(")
start = drain[drain.index(" if (_batch_next == 0)"):drain.index(" } else if")]
methods += "\nvoid WebConfigServer::startBatch(uint32_t now) {\n" + start + "\n }\n}\n"
first_guard = drain.index(" WCLock prefs_lock", drain.index(" } else if"))
guard = drain[first_guard:drain.index(" BatchEntry& e", first_guard)]
methods += "\nvoid WebConfigServer::beforeCommand(uint32_t now) {\n" + guard + "\n++executed;\n}\n"
post = drain[drain.index(" mesh::esp32::managementPassword::Snapshot committed;"):drain.index(" _batch_last_cmd =")]
methods += "\nvoid WebConfigServer::afterCommand(bool password_command, const char* requested, const char* reply, uint32_t now) {\nchar requested_password[16] = {}; strcpy(requested_password, requested);\nBatchEntry e = {}; strcpy(e.reply, reply);\n" + post + "\npost_completed = true;\n}\n"
return (ROOT / "test/fixtures/webconfig_password_batch.cpp").read_text().replace("@METHODS@", methods)
def test_committed_password_revokes_session_and_stale_queue(self):
self.compile_run(self.batch_fixture())
def test_successful_commit_must_revoke_general_session(self):
fixture = self.batch_fixture()
old = fixture.replace(" clearSessionLocked();", "", 1)
self.assertNotEqual(old, fixture)
self.compile_run(old, expect_rejection=True)
def receipt_fixture(self):
source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
config = extract_braced(source, "void WebConfigServer::handleConfigResult(")
cli = extract_braced(source, "void WebConfigServer::handleCliResult(")
config_loop = extract_braced(config, " for (int i = 0;")
cli_loop = extract_braced(cli, " for (int i = from;")
return (ROOT / "test/fixtures/webconfig_password_receipt.cpp").read_text().replace(
"@CONFIG_LOOP@", config_loop).replace("@CLI_LOOP@", cli_loop)
def receipt_json_include(self):
header = next((ROOT / ".pio/libdeps").glob("*/ArduinoJson/src/ArduinoJson.h"), None)
self.assertIsNotNone(header, "native dependency headers are required")
return (header.parent,)
def test_old_cookie_receipt_sanitizes_every_arbitrary_reply(self):
self.compile_run(self.receipt_fixture(), includes=self.receipt_json_include())
def test_unfiltered_cli_receipt_fails_executable_regression(self):
fixture = self.receipt_fixture()
old = fixture.replace('r["reply"] = !receipt_only ?', 'r["reply"] = true ?', 1)
self.assertNotEqual(old, fixture)
self.compile_run(old, expect_rejection=True, includes=self.receipt_json_include())
def command_fixture(self):
source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
methods = "\n".join(extract_braced(source, signature) for signature in (
"static bool fixedTimeEquals(", "static inline bool wcCliEchoesSecret(",
"static inline bool wcIsDeferredReboot(", "static const char* wcCliUnavailable(",
"static bool isAllowedSetKey(", "static bool isSecretKey(",
"void WebConfigServer::clearSessionLocked(",
"void WebConfigServer::beginBatchAuthorizationLocked(",
"void WebConfigServer::cancelStaleBatchLocked(",
"void WebConfigServer::handleCliPost("))
drain = extract_braced(source, "void WebConfigServer::drainBatch(")
# Run actual command dispatch, masking, and committed-generation hook.
# Subsequent WiFi/reboot handoff is covered by its dedicated fixtures.
methods += "\n" + drain[:drain.index(" if (_standalone_wifi_dirty)")] + "\n}\n"
config = extract_braced(source, "void WebConfigServer::handleConfigPost(")
config_loop = extract_braced(config[config.index(" int count = 0;"):], " for (JsonPair kv : set)")
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
start = cli.index(' } else if (memcmp(command, "password ", 9) == 0) {')
end = cli.index(' } else if (memcmp(command, "clear stats",', start)
setter = cli[start:end].replace('} else if (', 'if (', 1) + '\n }\n'
companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
start = companion.index('bool MyMesh::handleLocalControlCommand(')
end = companion.index(' if (mesh::powerdiag::handleCommand(', start)
companion = companion[start:end] + ' return false;\n}\n'
return (ROOT / "test/fixtures/webconfig_password_command.cpp").read_text().replace(
"@METHODS@", methods).replace("@CONFIG_LOOP@", config_loop).replace(
"@SETTER@", setter).replace("@COMPANION@", companion)
def test_actual_parser_drain_and_role_setters_preserve_password_bytes(self):
self.compile_run(self.command_fixture(), includes=self.receipt_json_include())
def test_old_cli_suffix_trimming_fails_executable_regression(self):
fixture = self.command_fixture()
old = fixture.replace(' if (wcCliEchoesSecret(e.cmd)) {',
" while (pos > 0 && (e.cmd[pos - 1] == ' ' || e.cmd[pos - 1] == '\\t')) --pos;\n"
" e.cmd[pos] = 0;\n if (wcCliEchoesSecret(e.cmd)) {", 1)
self.assertNotEqual(old, fixture)
self.compile_run(old, expect_rejection=True, includes=self.receipt_json_include())
if __name__ == "__main__":
unittest.main()