From b65fa9505c64017ef817daef40bad33d431e909e Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:51:05 -0500 Subject: [PATCH] Send admin-made owner changes and admin NodeInfo requests past the routine throttle An admin set_owner announced the new name through the routine NodeInfo path, so a rename made within 30 minutes of the last NodeInfo never went out - and the reboot set_owner schedules inherits that throttle from the persisted transmit history. Inside an edit transaction nothing was sent at all. The change is now announced on the 60 s interactive gate once it is saved, at commit for a transaction. A signed NodeInfo request from a node holding a live PKC admin session is likewise answered on the 60 s gate, past the 30 minute floor and the 12 h per-requester suppression. --- src/mesh/MeshService.cpp | 6 + src/mesh/MeshService.h | 3 + src/modules/AdminModule.cpp | 19 ++- src/modules/AdminModule.h | 7 ++ src/modules/NodeInfoModule.cpp | 16 ++- src/modules/NodeInfoModule.h | 2 + test/support/AdminModuleTestShim.h | 7 ++ test/test_admin_radio/test_main.cpp | 87 ++++++++++++++ test/test_admin_session_repro/test_main.cpp | 37 ++++++ test/test_nodeinfo_send_window/test_main.cpp | 119 +++++++++++++++++++ 10 files changed, 297 insertions(+), 6 deletions(-) diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 0430a7cf70..b461132e6e 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -188,6 +188,12 @@ void MeshService::reloadOwner(bool shouldSave) } } +void MeshService::announceOwnerChange() +{ + if (nodeInfoModule) + nodeInfoModule->sendOurNodeInfo(NODENUM_BROADCAST, false, 0, true); +} + // search the queue for a request id and return the matching nodenum NodeNum MeshService::getNodenumFromRequestId(uint32_t request_id) { diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index 8c48964fc3..dc8837b82f 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -191,6 +191,9 @@ class MeshService /// The owner User record just got updated, update our node DB and broadcast the info into the mesh void reloadOwner(bool shouldSave = true); + /// Broadcast our NodeInfo on the interactive 60 s throttle - an admin just changed the owner + virtual void announceOwnerChange(); + /// Called when the user wakes up our GUI, normally sends our latest location to the mesh (if we have it), otherwise at least /// sends our nodeinfo /// returns true if we sent a position diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 3b5ab8d021..daa3080280 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -190,6 +190,8 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta } else if (mp.pki_encrypted) { if (AdminKeys::isAuthorized(mp.public_key.bytes)) { LOG_INFO("PKC admin payload with authorized sender key"); + remoteSessionNode = mp.from; + remoteSessionMs = Time::stampMillis(); // Note: PKC admin does NOT automatically authorize the // originating local PhoneAPI connection for content @@ -831,7 +833,8 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) } if (changed) { // If nothing really changed, don't broadcast on the network or write to flash - service->reloadOwner(!hasOpenEditTransaction); + service->reloadOwner(false); + ownerAnnouncePending = true; saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE | (identityUpdated ? SEGMENT_CONFIG : 0) | (channelsSanitized ? SEGMENT_CHANNELS : 0)); } @@ -1856,6 +1859,11 @@ void AdminModule::saveChanges(int saveWhat, bool shouldReboot) if (!hasOpenEditTransaction) { LOG_INFO("Save changes to disk"); service->reloadConfig(saveWhat); // Calls saveToDisk among other things + // The routine NodeInfo throttle would drop it, and the reboot that follows inherits that throttle. + if (ownerAnnouncePending) { + ownerAnnouncePending = false; + service->announceOwnerChange(); + } } else { LOG_INFO("Delay disk save until open transaction commits"); editTransactionActivityMs = millis(); // still in use, so not the abandoned kind we time out @@ -1979,8 +1987,13 @@ bool AdminModule::checkPassKey(meshtastic_AdminMessage *res) printBytes("Incoming session key: ", res->session_passkey.bytes, 8); printBytes("Expected session key: ", session_passkey, 8); // Key is valid for 300s from issue; sessionPasskeyValid guards an unissued session. - return (sessionPasskeyValid && Throttle::isWithinTimespanMs(session_time, 300 * 1000UL) && res->session_passkey.size == 8 && - memcmp(res->session_passkey.bytes, session_passkey, 8) == 0); + return (sessionPasskeyValid && Throttle::isWithinTimespanMs(session_time, SESSION_EXPIRE_MS) && + res->session_passkey.size == 8 && memcmp(res->session_passkey.bytes, session_passkey, 8) == 0); +} + +bool AdminModule::hasRemoteAdminSession(NodeNum node) const +{ + return node != 0 && node == remoteSessionNode && Throttle::isWithinTimespanMs(remoteSessionMs, SESSION_EXPIRE_MS); } bool AdminModule::messageIsResponse(const meshtastic_AdminMessage *r) diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 0fbc8ac3b1..8be1e7716d 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -31,6 +31,9 @@ class AdminModule : public ProtobufModule, public Obser */ AdminModule(); + /// True while `node` holds a remote admin session: a PKC-authorized admin message from it within the session-key lifetime + bool hasRemoteAdminSession(NodeNum node) const; + protected: /** Called to handle a particular incoming message @@ -45,6 +48,7 @@ class AdminModule : public ProtobufModule, public Obser static constexpr uint32_t EDIT_TRANSACTION_IDLE_MS = 60 * 1000; uint32_t editTransactionActivityMs = 0; // millis() of the last save this transaction deferred int deferredEditSegments = 0; // segments that transaction has touched but not yet saved + bool ownerAnnouncePending = false; // owner changed; announce it once the change is saved /// Retire an open edit transaction whose client stopped talking, persisting what it applied. void expireStaleEditTransaction(); #ifdef PIO_UNIT_TESTING @@ -54,6 +58,9 @@ class AdminModule : public ProtobufModule, public Obser uint8_t session_passkey[8] = {0}; uint32_t session_time = 0; // millis() when the current session passkey was issued bool sessionPasskeyValid = false; // separate flag: millis() 0 at boot is a valid issue time + static constexpr uint32_t SESSION_EXPIRE_MS = 300 * 1000UL; + NodeNum remoteSessionNode = 0; // last PKC-authorized remote admin + uint32_t remoteSessionMs = 0; // Time::stampMillis() of its last admin message void saveChanges(int saveWhat, bool shouldReboot = true); diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index 10395fd7fc..9b313eb57a 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -9,6 +9,9 @@ #include "configuration.h" #include "gps/RTC.h" #include "main.h" +#if !MESHTASTIC_EXCLUDE_ADMIN +#include "modules/AdminModule.h" +#endif #include #include @@ -23,6 +26,7 @@ static constexpr uint32_t NodeInfoReplySuppressSeconds = USERPREFS_NODEINFO_REPL bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_User *pptr) { suppressReplyForCurrentRequest = false; + adminRequestForCurrentRequest = false; if (mp.from == nodeDB->getNodeNum()) { LOG_WARN("Ignoring packet supposed to be from our own node: 0x%08x", mp.from); @@ -34,11 +38,16 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes // Suppress replies to senders we've replied to recently (12H window) if (mp.decoded.want_response && !isFromUs(&mp)) { const NodeNum sender = getFrom(&mp); +#if !MESHTASTIC_EXCLUDE_ADMIN + // Only a verified signature proves `from`, so only a signed request can claim the sender's admin session. + adminRequestForCurrentRequest = mp.xeddsa_signed && adminModule && adminModule->hasRemoteAdminSession(sender); +#endif // A local dedup window, not a wall-clock reading - uptime avoids RTC jumps and replayed // packets' stale rx_time perturbing it. Seconds, not millis - this is a wide window. const uint32_t nowSecs = Time::getUptimeSecs(); auto it = lastNodeInfoSeen.find(sender); - if (it != lastNodeInfoSeen.end() && (uint32_t)(nowSecs - it->second) < NodeInfoReplySuppressSeconds) { + if (!adminRequestForCurrentRequest && it != lastNodeInfoSeen.end() && + (uint32_t)(nowSecs - it->second) < NodeInfoReplySuppressSeconds) { suppressReplyForCurrentRequest = true; } lastNodeInfoSeen[sender] = nowSecs; @@ -173,11 +182,12 @@ meshtastic_MeshPacket *NodeInfoModule::allocReply() timeoutMs = hamMs; } uint32_t lastNodeInfo = transmitHistory ? transmitHistory->getLastSentToMeshMillis(meshtastic_PortNum_NODEINFO_APP) : 0; - if (!shorterTimeout && lastNodeInfo && Throttle::isWithinTimespanMs(lastNodeInfo, timeoutMs)) { + const bool interactive = shorterTimeout || (adminRequestForCurrentRequest && isReplyingToExternalRequest); + if (!interactive && lastNodeInfo && Throttle::isWithinTimespanMs(lastNodeInfo, timeoutMs)) { LOG_DEBUG("Skip send NodeInfo since we sent it <%us ago", timeoutMs / 1000); ignoreRequest = true; // Mark it as ignored for MeshModule return NULL; - } else if (shorterTimeout && lastNodeInfo && Throttle::isWithinTimespanMs(lastNodeInfo, 60 * 1000)) { + } else if (interactive && lastNodeInfo && Throttle::isWithinTimespanMs(lastNodeInfo, 60 * 1000)) { // For interactive/urgent requests (e.g., user-triggered or implicit requests), use a shorter 60s timeout LOG_DEBUG("Skip send NodeInfo since we sent it <60s ago"); ignoreRequest = true; diff --git a/src/modules/NodeInfoModule.h b/src/modules/NodeInfoModule.h index 8520ffa7b1..5b66207381 100644 --- a/src/modules/NodeInfoModule.h +++ b/src/modules/NodeInfoModule.h @@ -61,6 +61,8 @@ class NodeInfoModule : public ProtobufModule, private concurren /// Set across sendOurNodeInfo()'s own allocReply(), so the transmit stamp waits for an accepted send. bool deferHistoryStamp = false; bool suppressReplyForCurrentRequest = false; + /// The request came from a node holding a remote admin session, so it is answered on the interactive path. + bool adminRequestForCurrentRequest = false; /// Sender -> uptime seconds (Time::getUptimeSecs()) at our last reply. Seconds, not millis: /// the suppression window is hours wide. See handleReceivedProtobuf(). std::map lastNodeInfoSeen; diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h index 15fe9e782e..11b06c2b24 100644 --- a/test/support/AdminModuleTestShim.h +++ b/test/support/AdminModuleTestShim.h @@ -2,6 +2,7 @@ // The one class AdminModule.h befriends (test seam): exposes the protected/private handlers to the // native suites and defers persistence so setters exercise in-RAM logic without disk/reboot effects. #include "MeshTypes.h" // packetPool +#include "UptimeClock.h" #include "modules/AdminModule.h" class AdminModuleTestShim : public AdminModule @@ -30,6 +31,12 @@ class AdminModuleTestShim : public AdminModule int savedSegments() const { return lastSaveWhatForTest; } bool editTransactionOpen() const { return hasOpenEditTransaction; } + // Stand in for a PKC-authorized admin message from `node`, without the key setup that path needs. + void openRemoteSession(NodeNum node) + { + remoteSessionNode = node; + remoteSessionMs = Time::stampMillis(); + } // Backdate past the idle window so a test sees an abandoned transaction without waiting it out. void ageEditTransaction() { editTransactionActivityMs = millis() - EDIT_TRANSACTION_IDLE_MS - 1; } diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index d45db0c59b..3223e32823 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -43,6 +43,7 @@ extern uint32_t hash(const char *str); // set of messages produced by a sequence of admin messages. This shadows test/support/MockMeshService.h's // release-only stub because these tests need to inspect the captured message text, not just avoid leaks. static std::vector capturedWarnings; +static int ownerAnnouncements; class MockMeshService : public MeshService { @@ -52,6 +53,7 @@ class MockMeshService : public MeshService capturedWarnings.push_back(n->message); releaseClientNotificationToPool(n); } + void announceOwnerChange() override { ownerAnnouncements++; } }; static MockMeshService *mockMeshService; @@ -2287,6 +2289,86 @@ static void test_warn_license_transaction_coalescedToSingleMessage() TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); } +// ----------------------------------------------------------------------- +// Owner change announcement +// ----------------------------------------------------------------------- +// +// An admin set_owner must put the new name on the air through MeshService::announceOwnerChange(), +// which sends on NodeInfo's 60 s interactive throttle. The routine path it replaced +// (reloadOwner(true) -> sendOurNodeInfo()) sat behind the 30 min floor, and the reboot set_owner +// schedules inherits that floor from the persisted transmit history, so a rename made within the +// window never reached the mesh - not even the admin node that made it. Inside an edit transaction +// the announcement waits for the save at commit; the old code skipped it there entirely. + +extern uint32_t rebootAtMsec; + +static void sendSetOwnerName(const char *longName) +{ + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_set_owner_tag; + strncpy(m.set_owner.long_name, longName, sizeof(m.set_owner.long_name) - 1); + sendAdmin(m); + testAdmin->drainReply(); +} + +static void primeOwnerAnnounceTest() +{ + owner = meshtastic_User_init_zero; + strncpy(owner.long_name, "Old Name", sizeof(owner.long_name) - 1); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + nodeInfoModule = reinterpret_cast(1); // reloadOwner(false) only checks presence +} + +static void test_setOwner_announcesOnSave() +{ + primeOwnerAnnounceTest(); + const uint32_t savedRebootAt = rebootAtMsec; + + sendSetOwnerName("New Name"); + rebootAtMsec = savedRebootAt; // set_owner schedules a reboot; don't leak it into later tests + + TEST_ASSERT_EQUAL_STRING("New Name", owner.long_name); + TEST_ASSERT_EQUAL_INT(1, ownerAnnouncements); +} + +static void test_setOwner_unchangedNameDoesNotAnnounce() +{ + primeOwnerAnnounceTest(); + + sendSetOwnerName("Old Name"); + + TEST_ASSERT_EQUAL_INT(0, ownerAnnouncements); +} + +static void test_setOwner_inTransaction_announcesOnceAtCommit() +{ + primeOwnerAnnounceTest(); + sendBeginEdit(); + + sendSetOwnerName("New Name"); + sendSetOwnerName("Newer Name"); + TEST_ASSERT_EQUAL_INT(0, ownerAnnouncements); + + sendCommitEdit(); + TEST_ASSERT_EQUAL_STRING("Newer Name", owner.long_name); + TEST_ASSERT_EQUAL_INT(1, ownerAnnouncements); +} + +// An abandoned transaction still commits what it applied, so the owner change still goes out. +static void test_setOwner_abandonedTransaction_announcesOnRetire() +{ + primeOwnerAnnounceTest(); + sendBeginEdit(); + sendSetOwnerName("New Name"); + TEST_ASSERT_EQUAL_INT(0, ownerAnnouncements); + + testAdmin->ageEditTransaction(); + sendGetDeviceMetadata(); + + TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); + TEST_ASSERT_EQUAL_INT(1, ownerAnnouncements); +} + // ----------------------------------------------------------------------- // Node-DB admin metadata: favorite / ignore / mute // ----------------------------------------------------------------------- @@ -2514,6 +2596,7 @@ void setUp(void) service = mockMeshService; testAdmin = new AdminModuleTestShim(); capturedWarnings.clear(); + ownerAnnouncements = 0; // Every test gets its own NodeDB and its own copy of the globals the admin handlers write. replaceAdminRadioGlobals(); } @@ -2661,6 +2744,10 @@ void setup() RUN_TEST(test_editTransaction_abandoned_isRetiredOnNextAdminMessage); RUN_TEST(test_editTransaction_abandoned_laterWriteIsNoLongerDeferred); RUN_TEST(test_editTransaction_active_isNotRetired); + RUN_TEST(test_setOwner_announcesOnSave); + RUN_TEST(test_setOwner_unchangedNameDoesNotAnnounce); + RUN_TEST(test_setOwner_inTransaction_announcesOnceAtCommit); + RUN_TEST(test_setOwner_abandonedTransaction_announcesOnRetire); RUN_TEST(test_warn_license_noTransaction_emittedImmediately); RUN_TEST(test_warn_license_transaction_coalescedToSingleMessage); diff --git a/test/test_admin_session_repro/test_main.cpp b/test/test_admin_session_repro/test_main.cpp index 36efc9a41e..46dfea8a4d 100644 --- a/test/test_admin_session_repro/test_main.cpp +++ b/test/test_admin_session_repro/test_main.cpp @@ -235,6 +235,41 @@ void test_session_gate_accepts_key_from_a_get_response(void) TEST_ASSERT_FALSE(admin->checkPassKey(&bad)); } +// hasRemoteAdminSession() lets NodeInfoModule answer a NodeInfo request past its routine throttles, +// so only a PKC-authorized admin message may open it, and only for the node that sent it. +static meshtastic_MeshPacket makeRemoteMetadataRequest(meshtastic_AdminMessage &out) +{ + meshtastic_MeshPacket mp = makeRemoteSetOwner("unused", nullptr, 0, out); + out = meshtastic_AdminMessage_init_zero; + out.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_request_tag; + out.get_device_metadata_request = true; + return mp; +} + +void test_pkc_admin_message_opens_session_for_its_sender_only(void) +{ + TEST_ASSERT_FALSE(admin->hasRemoteAdminSession(ADMIN_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeRemoteMetadataRequest(m); + admin->handleReceivedProtobuf(mp, &m); + admin->drainReply(); + + TEST_ASSERT_TRUE(admin->hasRemoteAdminSession(ADMIN_NODE)); + TEST_ASSERT_FALSE(admin->hasRemoteAdminSession(STRANGER_NODE)); +} + +void test_unauthorized_pkc_key_opens_no_session(void) +{ + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeRemoteMetadataRequest(m); + memset(mp.public_key.bytes, 0x77, 32); // not an admin key + admin->handleReceivedProtobuf(mp, &m); + admin->drainReply(); + + TEST_ASSERT_FALSE(admin->hasRemoteAdminSession(ADMIN_NODE)); +} + // Decode the SecurityConfig out of the get_config response a handler queued in myReply. static bool decodeSecurityFromReply(meshtastic_MeshPacket *reply, meshtastic_Config_SecurityConfig &out) { @@ -663,6 +698,8 @@ void setup() RUN_TEST(test_remote_setter_without_session_is_rejected); RUN_TEST(test_expected_session_key_is_zero_before_any_get); RUN_TEST(test_session_gate_accepts_key_from_a_get_response); + RUN_TEST(test_pkc_admin_message_opens_session_for_its_sender_only); + RUN_TEST(test_unauthorized_pkc_key_opens_no_session); RUN_TEST(test_remote_security_config_omits_private_key); RUN_TEST(test_local_security_config_keeps_private_key); RUN_TEST(test_local_security_config_applies_packet_signature_policy); diff --git a/test/test_nodeinfo_send_window/test_main.cpp b/test/test_nodeinfo_send_window/test_main.cpp index 59570ebaed..0b4ad2660f 100644 --- a/test/test_nodeinfo_send_window/test_main.cpp +++ b/test/test_nodeinfo_send_window/test_main.cpp @@ -29,6 +29,14 @@ // The window probes step an injected clock (Time::setTestMillis) rather than sleeping; // TransmitHistory, which is where allocReply() reads "last sent" from, reads the same clock. // +// 3. A NodeInfo request from a node that holds a remote admin session (AdminModule:: +// hasRemoteAdminSession()) is answered on the interactive 60 second gate, past both the 30 minute +// floor and the 12 hour per-requester reply suppression - an admin who just renamed or re-roled a +// node has to be able to see the result. The bypass needs a verified XEdDSA signature, because +// only that proves the request's `from`; an unsigned request naming the admin node gets the +// routine treatment. Regressions guarded: dropping the signature check (anyone can spoof the +// admin's node number), and a bypass that outlives the session or skips the 60 second gate. +// // Also covered here: a relayed NodeInfo payload leaves the module byte-for-byte as received, so the // sender's XEdDSA signature still verifies downstream, and coerceNodeInfoUserId() - which applies the // user.id fix to local copies instead - rewrites only a mismatched id. @@ -52,6 +60,7 @@ #include "mesh/TransmitHistory.h" #include "mesh/mesh-pb-constants.h" #include "modules/NodeInfoModule.h" +#include "support/AdminModuleTestShim.h" #include "support/MockMeshService.h" #include #include @@ -64,6 +73,19 @@ class NodeInfoModuleTestShim : public NodeInfoModule public: using MeshModule::alterReceived; using NodeInfoModule::runOnce; + + // The receive-then-reply sequence MeshModule::callModules() runs for a want_response request. + // Returns the reply, or nullptr when the module declined to answer. + meshtastic_MeshPacket *replyTo(const meshtastic_MeshPacket &req) + { + meshtastic_User user = meshtastic_User_init_zero; + pb_decode_from_bytes(req.decoded.payload.bytes, req.decoded.payload.size, &meshtastic_User_msg, &user); + currentRequest = &req; + handleReceivedProtobuf(req, &user); + meshtastic_MeshPacket *reply = allocReply(); + currentRequest = nullptr; + return reply; + } }; namespace @@ -403,6 +425,99 @@ static void test_coerceUserId_rewritesOnlyAMismatchedId(void) TEST_ASSERT_EQUAL_STRING("remote", user.long_name); } +constexpr NodeNum kAdminNode = 0x0B0B0B0B; +constexpr NodeNum kOtherNode = 0x0D0D0D0D; + +// A unicast NodeInfo request to us, as the router hands it over: xeddsa_signed is the router's +// verdict on the signature, never a field the sender controls. +static meshtastic_MeshPacket makeNodeInfoRequest(NodeNum from, bool signedBySender) +{ + meshtastic_MeshPacket p = makeNodeInfoPacket(from, ""); + p.to = nodeDB->getNodeNum(); + p.decoded.want_response = true; + p.xeddsa_signed = signedBySender; + return p; +} + +// True when the module answered `req`. Releases the reply so the pool does not leak. +static bool answers(const meshtastic_MeshPacket &req) +{ + meshtastic_MeshPacket *reply = mod->replyTo(req); + if (!reply) + return false; + packetPool.release(reply); + return true; +} + +// A fresh TransmitHistory: no "last sent" stamp, so only the per-requester suppression can refuse. +static void clearTransmitHistory() +{ + delete transmitHistory; + transmitHistory = nullptr; + transmitHistory = TransmitHistory::getInstance(); +} + +static AdminModuleTestShim *openAdminSession(NodeNum node) +{ + AdminModuleTestShim *admin = new AdminModuleTestShim(); + admin->openRemoteSession(node); + adminModule = admin; + return admin; +} + +static void closeAdminSession(AdminModuleTestShim *admin) +{ + adminModule = nullptr; + delete admin; +} + +static void test_adminRequest_answeredInsideTheFloor(void) +{ + transmitHistory->setLastSentToMesh(meshtastic_PortNum_NODEINFO_APP); + Time::advanceTestMillis(5 * 60 * 1000); + AdminModuleTestShim *admin = openAdminSession(kAdminNode); // after the jump: the session lasts 300 s + + TEST_ASSERT_FALSE_MESSAGE(answers(makeNodeInfoRequest(kOtherNode, true)), "a non-admin request is inside the floor"); + TEST_ASSERT_FALSE_MESSAGE(answers(makeNodeInfoRequest(kAdminNode, false)), + "an unsigned request cannot prove it came from the admin node"); + TEST_ASSERT_TRUE_MESSAGE(answers(makeNodeInfoRequest(kAdminNode, true)), "the admin's signed request is answered"); + closeAdminSession(admin); +} + +static void test_adminRequest_bypassesPerRequesterSuppression(void) +{ + AdminModuleTestShim *admin = openAdminSession(kAdminNode); + TEST_ASSERT_TRUE(answers(makeNodeInfoRequest(kOtherNode, true))); + clearTransmitHistory(); + TEST_ASSERT_TRUE(answers(makeNodeInfoRequest(kAdminNode, true))); + + clearTransmitHistory(); // take the floor out, leaving the 12 h suppression as the only gate + Time::advanceTestMillis(2 * 60 * 1000); + TEST_ASSERT_FALSE_MESSAGE(answers(makeNodeInfoRequest(kOtherNode, true)), "a repeat requester is suppressed for 12 h"); + TEST_ASSERT_TRUE_MESSAGE(answers(makeNodeInfoRequest(kAdminNode, true)), "the admin is not"); + closeAdminSession(admin); +} + +static void test_adminRequest_keepsTheSixtySecondGate(void) +{ + AdminModuleTestShim *admin = openAdminSession(kAdminNode); + transmitHistory->setLastSentToMesh(meshtastic_PortNum_NODEINFO_APP); + Time::advanceTestMillis(30 * 1000); + + TEST_ASSERT_FALSE(answers(makeNodeInfoRequest(kAdminNode, true))); + closeAdminSession(admin); +} + +static void test_adminRequest_endsWithTheSession(void) +{ + AdminModuleTestShim *admin = openAdminSession(kAdminNode); + transmitHistory->setLastSentToMesh(meshtastic_PortNum_NODEINFO_APP); + Time::advanceTestMillis(5 * 60 * 1000 + 1000); // past the 300 s session, still inside the floor + + TEST_ASSERT_FALSE(answers(makeNodeInfoRequest(kAdminNode, true))); + closeAdminSession(admin); +} + NI_TEST_ENTRY void setup() { initializeTestEnvironment(); @@ -421,6 +536,10 @@ NI_TEST_ENTRY void setup() RUN_TEST(test_presetChange_isConsumedOnlyByASendThatGoesOut); RUN_TEST(test_relay_userPayloadUnmodified); RUN_TEST(test_coerceUserId_rewritesOnlyAMismatchedId); + RUN_TEST(test_adminRequest_answeredInsideTheFloor); + RUN_TEST(test_adminRequest_bypassesPerRequesterSuppression); + RUN_TEST(test_adminRequest_keepsTheSixtySecondGate); + RUN_TEST(test_adminRequest_endsWithTheSession); exit(UNITY_END()); } NI_TEST_ENTRY void loop() {}