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.
This commit is contained in:
James Rich committed 2026-10-30 13:59:44 -05:00
1 parent 332b5b0a2c
commit b65fa9505c
10 files changed
+297 -6

No files matched your search

+6
View File
@@ -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)
{
+3
View File
@@ -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
+16 -3
View File
@@ -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)
+7
View File
@@ -31,6 +31,9 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, 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<meshtastic_AdminMessage>, 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<meshtastic_AdminMessage>, 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);
+13 -3
View File
@@ -9,6 +9,9 @@
#include "configuration.h"
#include "gps/RTC.h"
#include "main.h"
#if !MESHTASTIC_EXCLUDE_ADMIN
#include "modules/AdminModule.h"
#endif
#include <Throttle.h>
#include <algorithm>
@@ -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;
+2
View File
@@ -61,6 +61,8 @@ class NodeInfoModule : public ProtobufModule<meshtastic_User>, 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<NodeNum, uint32_t> lastNodeInfoSeen;
+7
View File
@@ -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; }
+87
View File
@@ -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<std::string> 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<NodeInfoModule *>(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);
@@ -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);
@@ -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 <memory>
#include <vector>
@@ -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() {}