Files
MeshCore/test/test_ota_identity_policy.py
T
mikecarper 24b1b1167f Fix storage recovery and deferred runtime state
Recover usable ESP32 preferences and channels before permitting replacement; keep identity and ACL persistence transactional and complete.

Preserve MQTT commit boundaries, restore OTA identity and policy after deferred allocation, retain radio gain retries, and reject invalid flood limits. Add production-path fault regressions and CI coverage.
2026-09-15 16:46:17 -07:00

283 lines
12 KiB
Python

#!/usr/bin/env python3
"""Exercise production OTA identity refresh and deferred persistent policy load."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <helpers/ota/OtaContext.h>
#include <helpers/ota/OtaProtocol.h>
#include <cassert>
#include <new>
#include <vector>
#include "vectors.h"
using namespace mesh::ota;
static bool allocation_fails = false;
static unsigned allocation_attempts = 0;
void* operator new(std::size_t size, const std::nothrow_t&) noexcept {
++allocation_attempts;
return allocation_fails ? nullptr : ::operator new(size);
}
namespace mesh { namespace ota {
bool ota_self_firmware(SelfFwInfo& info) { info = SelfFwInfo(); return false; }
} }
static std::vector<uint8_t> wire;
static bool send(void*, const uint8_t* data, uint16_t length, bool) {
wire.assign(data, data + length); return true;
}
static AdvMsg beacon(OtaManager& manager) {
manager.announce();
AdvMsg adv{};
assert(decode_adv(wire.data(), wire.size(), adv));
return adv;
}
#if defined(OTA_SHARED_COMPANION_QUEUE)
static OtaContext* borrowed_context = nullptr;
static OtaContext* acquire(void*) {
borrowed_context = new (std::nothrow) OtaContext;
return borrowed_context;
}
static void release(void*) { delete borrowed_context; borrowed_context = nullptr; }
#endif
struct NodePrefs {
uint8_t ota_autofetch=2, ota_max_hops=7, ota_autoinstall=1, ota_signer_count=1;
uint16_t ota_checkpoint_blocks=99, ota_advert_interval=180;
uint8_t ota_signers[MAX_OTA_SIGNERS][32]={{42}};
};
struct CommonCLI {
NodePrefs prefs;
NodePrefs* _prefs = &prefs;
void syncOtaConfigFromPrefs();
};
@SYNC@
struct TestIdentity { uint8_t pub_key[32] = {}; };
namespace mesh {
static bool generation_ok = true;
static unsigned generation_calls = 0;
static bool hasReservedIdentityPrefix(const TestIdentity& id) { return !id.pub_key[0]; }
static bool generateUsableLocalIdentity(TestIdentity& id, int) {
++generation_calls;
id.pub_key[0] = 0x91; return generation_ok;
}
static void discardESP32TrueRandom() {}
}
struct Store {
bool load_ok = true, can_create = true, save_ok = true;
unsigned saves = 0;
bool loadMainIdentity(TestIdentity& id) { id.pub_key[0] = load_ok ? 0x12 : 0; return load_ok; }
bool canCreateMainIdentity() const { return can_create; }
bool saveMainIdentity(const TestIdentity&) { ++saves; return save_ok; }
};
struct Companion {
Store store;
Store* _store = &store;
TestIdentity self_id;
struct Board { bool rebooted = false; void reboot() { rebooted = true; } } board;
int radio_new_identity = 0;
bool accepted = false;
void loadIdentity() {
@STARTUP@
accepted = true;
}
bool ok_reply = false;
void writeOKFrame() { ok_reply = true; }
void importIdentity(const TestIdentity& identity) {
@IMPORT@
}
};
static void checkPolicy(const OtaContext& c, const NodePrefs& prefs) {
assert(c.manager.max_hops() == prefs.ota_max_hops);
assert(c.manager.checkpoint_blocks() == prefs.ota_checkpoint_blocks);
assert(c.manager.advert_mins() == prefs.ota_advert_interval);
assert(c.allow.count() == prefs.ota_signer_count);
assert(c.allow.contains(prefs.ota_signers[0]));
#if defined(OTA_SEEDER_ONLY)
assert(c.manager.autofetch() == 0 && c.autoinstall == 0);
#else
assert(c.manager.autofetch() == prefs.ota_autofetch);
assert(c.autoinstall == prefs.ota_autoinstall);
#endif
}
int main() {
#if defined(OTA_SHARED_COMPANION_QUEUE)
ota_set_context_storage(nullptr, acquire, release);
#endif
uint8_t initial_id[32] = {};
ota_begin_context(SIM_TARGET_ID, send, nullptr, "test", initial_id);
// Loaded, generated, and failed startup identities run the real Companion block.
Companion loaded;
loaded.loadIdentity();
assert(loaded.accepted && !loaded.board.rebooted && loaded.store.saves == 0);
#if OTA_DYNAMIC_CONTEXT
assert(!ota_context_if_active()); // an identity refresh must not claim storage
#endif
assert(ota_acquire_context(nullptr, 0));
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x12);
Companion generated;
generated.store.load_ok = false;
generated.loadIdentity();
assert(generated.accepted && generated.store.saves == 1);
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x91);
Companion blocked;
blocked.store.load_ok = false; blocked.store.can_create = false;
blocked.loadIdentity();
assert(!blocked.accepted && blocked.board.rebooted && blocked.store.saves == 0);
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x91);
Companion failed_save;
failed_save.store.load_ok = false; failed_save.store.save_ok = false;
failed_save.loadIdentity();
assert(!failed_save.accepted && failed_save.board.rebooted);
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x91);
// Changing identity must preserve current fetch progress and the served set.
auto& manager = ota_ctx().manager;
assert(manager.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN));
MotaManifest manifest;
assert(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
assert(manager.pull_archive(manifest.merkle_root, SIM_TARGET_ID) == OtaManager::PULL_STARTED);
const auto prior_state = manager.fetchState();
assert(prior_state != OtaManager::IDLE);
TestIdentity imported; imported.pub_key[0] = 0x34;
loaded.importIdentity(imported);
assert(loaded.ok_reply && loaded.self_id.pub_key[0] == 0x34);
assert(manager.fetchState() == prior_state && manager.servedCount() == 1);
assert(beacon(manager).seeder_id[0] == 0x34);
loaded.store.save_ok = false; loaded.ok_reply = false;
imported.pub_key[0] = 0x56;
loaded.importIdentity(imported);
assert(!loaded.ok_reply && loaded.self_id.pub_key[0] == 0x34);
assert(beacon(manager).seeder_id[0] == 0x34);
ota_refresh_seeder_identity(nullptr);
assert(beacon(manager).seeder_id[0] == 0x34);
// Two actual identities retain separate discovery records.
OtaManager receiver;
receiver.begin(SIM_TARGET_ID, send, nullptr);
AdvMsg adv = beacon(manager);
adv.n_motas = 1;
uint8_t packet[256];
receiver.on_message(packet, encode_adv(packet, sizeof(packet), adv));
generated.loadIdentity();
adv = beacon(manager); adv.n_motas = 1;
receiver.on_message(packet, encode_adv(packet, sizeof(packet), adv));
assert(receiver.sourceCount() == 2);
manager.reset_session();
#if OTA_DYNAMIC_CONTEXT
ota_release_context_if_idle(false);
assert(!ota_context_if_active());
assert(ota_acquire_context(nullptr, 0));
assert(beacon(ota_ctx().manager).seeder_id[0] == 0x91);
ota_release_context_if_idle(false);
#endif
// Persisted policy registration works while allocation is impossible, and
// policy/keys remain available on every later successful workspace claim.
CommonCLI cli;
allocation_fails = true;
const auto attempts = allocation_attempts;
cli.syncOtaConfigFromPrefs();
assert(allocation_attempts == attempts);
assert(ota_hop_limit() == cli.prefs.ota_max_hops);
#if OTA_DYNAMIC_CONTEXT
assert(!ota_context_if_active());
assert(!ota_acquire_context(nullptr, 0));
assert(!ota_context_if_active());
#endif
allocation_fails = false;
for (unsigned cycle = 0; cycle < 3; ++cycle) {
assert(ota_acquire_context(nullptr, 0));
checkPolicy(ota_ctx(), cli.prefs);
#if OTA_DYNAMIC_CONTEXT
ota_release_context_if_idle(false);
assert(!ota_context_if_active());
++cli.prefs.ota_advert_interval; // a later saved value, no stale startup copy
#endif
}
// Re-registering against an active session refreshes policy without reset.
assert(ota_acquire_context(nullptr, 0));
assert(ota_ctx().manager.pull_archive(manifest.merkle_root, SIM_TARGET_ID)
== OtaManager::PULL_STARTED);
auto state = ota_ctx().manager.fetchState();
cli.prefs.ota_max_hops = 2;
cli.syncOtaConfigFromPrefs();
assert(ota_ctx().manager.fetchState() == state);
checkPolicy(ota_ctx(), cli.prefs);
ota_ctx().manager.reset_session();
#if OTA_DYNAMIC_CONTEXT
ota_release_context_if_idle(false);
#endif
ota_set_context_config_loader(nullptr);
}
'''
class OtaIdentityPolicyTest(unittest.TestCase):
def test_static_heap_and_borrowed_contexts_preserve_identity_and_policy(self):
companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
begin = extract_braced(companion, "void MyMesh::begin(")
startup = begin[begin.index("const bool identity_loaded"):
begin.index("// if name is provided")]
self.assertLess(begin.index("BaseChatMesh::begin()"),
begin.index("ota_refresh_seeder_identity(self_id.pub_key)"))
imported = companion[companion.index("cmd_frame[0] == CMD_IMPORT_PRIVATE_KEY"):]
# Execute the real publication prefix. Contact-cache refresh after its
# acknowledgement is unrelated to the OTA identity being checked here.
imported = extract_braced(imported, "if (_store->saveMainIdentity(identity))")
imported = imported[:imported.index("// re-load contacts")] + "}\n"
sync = extract_braced((ROOT / "src/helpers/CommonCLI.cpp").read_text(),
"void CommonCLI::syncOtaConfigFromPrefs()")
source_text = HARNESS.replace("@SYNC@", sync).replace(
"@STARTUP@", startup).replace("@IMPORT@", imported)
with tempfile.TemporaryDirectory(prefix="ota-identity-policy-") as directory:
path = Path(directory)
source = path / "test.cpp"
source.write_text(source_text)
(path / "vectors.h").write_text('#include "' +
(ROOT / "test/test_ota/mota_vectors.h").as_posix() + '"\n')
sanitizers = [] if os.name == "nt" else ["-fsanitize=address,undefined"]
tinf = path / "tinf.o"
subprocess.run([shutil.which("cc") or "gcc", "-DENABLE_OTA=1",
*sanitizers, "-c", str(ROOT / "src/helpers/ota/OtaTinf.c"),
"-o", str(tinf)], check=True)
for mode in ("static", "heap", "shared"):
for seeder in (False, True):
if mode == "shared" and not seeder:
continue # borrowing the queue is source-only by design
with self.subTest(mode=mode, seeder=seeder):
# Production installers use a flash-backed fetch store.
# A small RAM stand-in avoids platform flash APIs while
# keeping the real heap-context budget assertion valid.
flags = ["-DENABLE_OTA=1", "-DESP32_PLATFORM=1",
"-DOTA_FETCH_BUF_SIZE=4096"]
if mode == "heap":
flags += ["-DOTA_HEAP_CONTEXT=1"]
if mode == "shared":
flags += ["-DOTA_SHARED_COMPANION_QUEUE=1",
"-DCOMPANION_RADIO_FULL=1"]
if seeder:
flags += ["-DOTA_SEEDER_ONLY=1"]
binary = path / "identity-policy.exe"
sources = ["OtaContext.cpp", "OtaManager.cpp", "OtaProtocol.cpp",
"MotaContainer.cpp", "MerkleTree.cpp", "OtaDeflate.cpp"]
result = subprocess.run([
"c++", "-std=c++17", *sanitizers, *flags,
"-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"),
str(source),
*[str(ROOT / "src/helpers/ota" / name) for name in sources],
str(ROOT / "src/Utils.cpp"), str(tinf), "-o", str(binary),
], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
subprocess.run([str(binary)], check=True)
if __name__ == "__main__":
unittest.main()