// Tests for XEdDSA packet-signing *policy* - the receive-path accept/reject behavior and the
// send-path signing policy - as opposed to the raw sign/verify primitive (covered in test_crypto).
//
// The decision logic under test lives in Router.cpp free functions. Groups A/B drive a real
// encode -> decode round-trip through the default channel (perhapsEncode/perhapsDecode, black-box,
// no production changes); later groups exercise routing order and policy helpers directly.
//
//   Group A  receive-side accept/reject matrix (verify, downgrade protection, signer-bit learning)
//   Group B  send-side signing policy (which outgoing packets perhapsEncode signs)
//   Group C  routing pipeline ordering (authenticate before duplicate/retry/relay state)
//   Group D  encoding invariants the routing gates depend on
//   Group E  decoded-ingress policy (checkXeddsaReceivePolicy, the plaintext-MQTT trust boundary)

#include "MeshTypes.h" // include BEFORE TestUtil.h
#include "NodeStatus.h"
#include "TestUtil.h"
#include "airtime.h"
#include "support/MockMeshService.h"
#include <unity.h>

// The whole suite exercises XEdDSA sign/verify and checkXeddsaReceivePolicy, all of which are
// compiled out unless both PKI and XEdDSA are enabled (e.g. stm32 sets MESHTASTIC_EXCLUDE_XEDDSA).
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)

#include "UptimeClock.h"
#include "mesh/Channels.h"
#include "mesh/CryptoEngine.h"
#include "mesh/MeshRadio.h"
#include "mesh/MeshService.h"
#include "mesh/NodeDB.h"
#include "mesh/ReliableRouter.h"
#include "mesh/Router.h"
#include "mesh/SinglePortModule.h"
#include "modules/NodeInfoModule.h"
#include "modules/RoutingModule.h"
#include "mqtt/MQTT.h"
#include <ErriezCRC32.h>
#include <cstdio>
#include <cstring>
#include <memory>
#include <pb_decode.h>
#include <pb_encode.h>
#include <vector>

// ---------------------------------------------------------------------------
// Test fixture identifiers
// ---------------------------------------------------------------------------
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
static constexpr NodeNum REMOTE_NODE = 0x0B0B0B0B;

// A "small" broadcast payload whose signed encoding easily fits a LoRa frame, and an "oversized"
// one whose signed encoding does not, yet still encodes within a LoRa frame unsigned.
static constexpr size_t SMALL_PAYLOAD = 16;
static constexpr size_t OVERSIZED_PAYLOAD = 180;

// ---------------------------------------------------------------------------
// MockNodeDB - inject nodes with controlled public keys / signer bits.
// Mirrors the pattern in test/test_hop_scaling. meshNodes/numMeshNodes are public on NodeDB.
// ---------------------------------------------------------------------------
class MockNodeDB : public NodeDB
{
  public:
    void installDefaultsPreservingIdentity() { installDefaultConfig(true); }

    void clearTestNodes()
    {
        testNodes.clear();
        meshNodes = &testNodes;
        numMeshNodes = 0;
    }

    // Add a bare node and return a stable handle (fetch via getMeshNode so the pointer stays valid
    // even if the vector reallocates after later adds).
    void addNode(NodeNum num)
    {
        meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
        node.num = num;
        testNodes.push_back(node);
        meshNodes = &testNodes;
        numMeshNodes = testNodes.size();
    }

    void setPublicKey(NodeNum num, const uint8_t *pubKey)
    {
        meshtastic_NodeInfoLite *n = getMeshNode(num);
        TEST_ASSERT_NOT_NULL(n);
        n->public_key.size = 32;
        memcpy(n->public_key.bytes, pubKey, 32);
    }

    void setSignerBit(NodeNum num, bool value)
    {
        meshtastic_NodeInfoLite *n = getMeshNode(num);
        TEST_ASSERT_NOT_NULL(n);
        nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, value);
    }

    void setLongName(NodeNum num, const char *name)
    {
        meshtastic_NodeInfoLite *n = getMeshNode(num);
        TEST_ASSERT_NOT_NULL(n);
        strncpy(n->long_name, name, sizeof(n->long_name) - 1);
        n->long_name[sizeof(n->long_name) - 1] = '\0';
    }

    const char *longName(NodeNum num)
    {
        meshtastic_NodeInfoLite *n = getMeshNode(num);
        TEST_ASSERT_NOT_NULL(n);
        return n->long_name;
    }

    std::vector<meshtastic_NodeInfoLite> testNodes;
};

static MockNodeDB *mockNodeDB = nullptr;

class AuthPipelineRadio : public RadioInterface
{
  public:
    ErrorCode send(meshtastic_MeshPacket *p) override
    {
        sendCalls++;
        packetPool.release(p);
        return failSend ? ERRNO_DISABLED : ERRNO_OK;
    }
    bool cancelSending(NodeNum, PacketId) override
    {
        cancelCalls++;
        return true;
    }
    bool findInTxQueue(NodeNum, PacketId) override
    {
        findCalls++;
        return false;
    }
    bool removePendingTXPacket(NodeNum, PacketId, uint32_t) override
    {
        removeCalls++;
        return true;
    }
    uint32_t getPacketTime(uint32_t, bool = false) override { return 7; }
    void reset()
    {
        sendCalls = cancelCalls = findCalls = removeCalls = 0;
        failSend = false;
    }

    bool failSend = false;
    uint32_t sendCalls = 0;
    uint32_t cancelCalls = 0;
    uint32_t findCalls = 0;
    uint32_t removeCalls = 0;
};

class AuthPipelineRouter : public ReliableRouter
{
  public:
    bool filter(meshtastic_MeshPacket *p) { return ReliableRouter::shouldFilterReceived(p); }
    bool historyContains(const meshtastic_MeshPacket *p) { return wasSeenRecently(p, false); }
    void remember(const meshtastic_MeshPacket *p) { wasSeenRecently(p, true); }
    void forgetRelayer(uint8_t relay, PacketId id, NodeNum from) { removeRelayer(relay, id, from); }
    bool handleUpgrade(meshtastic_MeshPacket *p) { return perhapsHandleUpgradedPacket(p); }
    void addPending(const meshtastic_MeshPacket &p, uint32_t nextTx)
    {
        auto *copy = packetPool.allocCopy(p);
        TEST_ASSERT_NOT_NULL(copy);
        const GlobalPacketId key(copy);
        pending.emplace(key, PendingPacket(copy, NUM_INTERMEDIATE_RETX));
        pending.at(key).nextTxMsec = nextTx;
    }
    uint32_t pendingNextTx(NodeNum from, PacketId id)
    {
        PendingPacket *entry = findPendingPacket(from, id);
        return entry ? entry->nextTxMsec : 0;
    }
    uint8_t pendingTotalAttempts(NodeNum from, PacketId id)
    {
        PendingPacket *entry = findPendingPacket(from, id);
        return entry ? entry->initialNumRetransmissions + 1 : 0;
    }
    size_t pendingCount() const { return pending.size(); }
    void clearPending()
    {
        for (auto &entry : pending)
            packetPool.release(entry.second.packet);
        pending.clear();
    }
};

class AuthPipelineRoutingModule : public RoutingModule
{
  public:
    void sendAckNak(meshtastic_Routing_Error, NodeNum, PacketId, ChannelIndex, uint8_t = 0, bool = false) override { ackCalls++; }
    uint32_t ackCalls = 0;
};

class AuthPipelineModule : public SinglePortModule
{
  public:
    AuthPipelineModule() : SinglePortModule("authPipeline", meshtastic_PortNum_POSITION_APP) {}
    ProcessMessage handleReceived(const meshtastic_MeshPacket &) override
    {
        calls++;
        return ProcessMessage::CONTINUE;
    }
    uint32_t calls = 0;
};

class AuthPipelineMqtt : public MQTT
{
  public:
    int queueSize() { return mqttQueue.numUsed(); }
    void clearQueue()
    {
        while (QueueEntry *entry = mqttQueue.dequeuePtr(0))
            delete entry;
    }
};

static AuthPipelineRouter *pipelineRouter = nullptr;
static AuthPipelineRadio *pipelineRadio = nullptr;
static AuthPipelineRoutingModule *pipelineRouting = nullptr;
static AuthPipelineModule *pipelineModule = nullptr;
static AuthPipelineMqtt *pipelineMqtt = nullptr;
static MeshService *pipelineService = nullptr;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

// Build a decoded packet with a deterministic payload of the requested size.
static meshtastic_MeshPacket makeDecoded(NodeNum from, NodeNum to, meshtastic_PortNum port, size_t payloadLen)
{
    meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
    p.from = from;
    p.to = to;
    p.id = 0x12345678;
    p.channel = 0; // primary channel index (perhapsEncode rewrites this to the channel hash)
    p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
    p.decoded.portnum = port;
    p.decoded.payload.size = payloadLen;
    for (size_t i = 0; i < payloadLen; i++)
        p.decoded.payload.bytes[i] = (uint8_t)(i & 0xff);
    return p;
}

// Sign a decoded packet with the CryptoEngine's current key - used to simulate a *remote* signer,
// because perhapsEncode only auto-signs packets that originate from us.
static void signWithCurrentKey(meshtastic_MeshPacket *p)
{
    bool ok = crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size,
                                  p->decoded.xeddsa_signature.bytes);
    TEST_ASSERT_TRUE_MESSAGE(ok, "xeddsa_sign failed in test setup");
    p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
}

// Encrypt (perhapsEncode) then decrypt+evaluate (perhapsDecode) the same packet in place.
static DecodeState roundTrip(meshtastic_MeshPacket *p)
{
    meshtastic_Routing_Error enc = perhapsEncode(p);
    TEST_ASSERT_EQUAL_MESSAGE(meshtastic_Routing_Error_NONE, enc, "perhapsEncode did not succeed");
    TEST_ASSERT_EQUAL_MESSAGE(meshtastic_MeshPacket_encrypted_tag, p->which_payload_variant,
                              "perhapsEncode left packet unencrypted");
    return perhapsDecode(p);
}

static meshtastic_MeshPacket channelEncode(meshtastic_MeshPacket p)
{
    uint8_t encoded[MAX_LORA_PAYLOAD_LEN + 1] = {};
    const size_t encodedSize = pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_Data_msg, &p.decoded);
    TEST_ASSERT_GREATER_THAN(0, encodedSize);
    const int16_t hash = channels.setActiveByIndex(p.channel);
    TEST_ASSERT_GREATER_OR_EQUAL(0, hash);
    crypto->encryptPacket(p.from, p.id, encodedSize, encoded);
    memcpy(p.encrypted.bytes, encoded, encodedSize);
    p.encrypted.size = encodedSize;
    p.channel = hash;
    p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
    return p;
}

static meshtastic_MeshPacket makeSignedWirePacket(NodeNum from, NodeNum to, PacketId id, uint8_t hopLimit = 1,
                                                  uint8_t hopStart = 2, uint8_t nextHop = NO_NEXT_HOP_PREFERENCE,
                                                  uint8_t relayNode = 0x33, bool valid = true)
{
    meshtastic_MeshPacket p = makeDecoded(from, to, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
    p.id = id;
    p.hop_limit = hopLimit;
    p.hop_start = hopStart;
    p.next_hop = nextHop;
    p.relay_node = relayNode;
    p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
    signWithCurrentKey(&p);
    if (!valid)
        p.decoded.xeddsa_signature.bytes[0] ^= 0x80;
    return channelEncode(p);
}

static bool remoteSignerBit()
{
    return nodeInfoLiteHasXeddsaSigned(mockNodeDB->getMeshNode(REMOTE_NODE));
}

static void setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy policy)
{
    config.security.packet_signature_policy = policy;
}

// Size a Data message exactly as the wire encoder would.
static size_t encodedDataSize(const meshtastic_Data *d)
{
    size_t s = 0;
    TEST_ASSERT_TRUE_MESSAGE(pb_get_encoded_size(&s, &meshtastic_Data_msg, d), "pb_get_encoded_size failed");
    return s;
}

// Would this Data still fit a LoRa frame with a 64-byte signature attached? Mirror of the
// production gate in Router.cpp (signedDataFits / the perhapsDecode downgrade predicate).
static bool signedEncodingFits(const meshtastic_Data *d)
{
    meshtastic_Data copy = *d;
    copy.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
    return encodedDataSize(&copy) + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN;
}

// Append a length-delimited field whose tag this build's Data schema does not define, as a sender
// on a newer schema would emit. nanopb skips unknown fields at decode, so these bytes count toward
// the raw wire size but not the decoded struct. Returns the number of bytes appended.
static size_t appendUnknownField(uint8_t *dst, size_t dstLen, size_t contentLen)
{
    constexpr uint32_t UNKNOWN_FIELD_NUMBER = 100; // not a field of meshtastic_Data
    std::vector<uint8_t> content(contentLen, 0x77);
    pb_ostream_t stream = pb_ostream_from_buffer(dst, dstLen);
    TEST_ASSERT_TRUE(pb_encode_tag(&stream, PB_WT_STRING, UNKNOWN_FIELD_NUMBER));
    TEST_ASSERT_TRUE(pb_encode_string(&stream, content.data(), content.size()));
    return stream.bytes_written;
}

// Channel-encrypt raw Data bytes into a packet, exactly as perhapsEncode's non-PKI path does.
// Used to inject wire bytes perhapsEncode would never produce (it only encodes p->decoded).
static void encryptAsChannelPacket(meshtastic_MeshPacket *p, uint8_t *wire, size_t size)
{
    const int16_t hash = channels.setActiveByIndex(0);
    TEST_ASSERT_GREATER_OR_EQUAL_MESSAGE(0, hash, "no usable primary channel");
    crypto->encryptPacket(getFrom(p), p->id, size, wire);
    memcpy(p->encrypted.bytes, wire, size);
    p->encrypted.size = size;
    p->channel = hash; // on the wire the channel field carries the hash, not the index
    p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
}

// Build A10's frame: an unsigned broadcast carrying a POSITION payload plus unknown fields, sized
// so the raw wire length exceeds the signature-fit threshold while the decoded fields stay under
// it. Channel-encrypted like a normal sender. The asserts pin that split, which is what makes A10
// and A11 meaningful.
static meshtastic_MeshPacket makeBroadcastWithUnknownFields()
{
    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);

    uint8_t wire[MAX_LORA_PAYLOAD_LEN + 1];
    const size_t base = pb_encode_to_bytes(wire, sizeof(wire), &meshtastic_Data_msg, &p.decoded);
    TEST_ASSERT_GREATER_THAN_MESSAGE(0, base, "failed to encode the base Data");
    const size_t raw = base + appendUnknownField(wire + base, sizeof(wire) - base, 160);

    // The decoded fields fit a signature, so a sender that signs would have signed this Data.
    TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, base + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH,
                                      "decoded fields must fit a signature, else the test is vacuous");
    // The unknown fields put the raw size over that threshold, so the two sizings disagree here.
    TEST_ASSERT_GREATER_THAN_MESSAGE(MAX_LORA_PAYLOAD_LEN, raw + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH,
                                     "unknown fields must push the raw size past the fit threshold");
    // The frame is still one a radio could actually send.
    TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, raw + MESHTASTIC_HEADER_LENGTH, "frame must still fit a LoRa frame");

    encryptAsChannelPacket(&p, wire, raw);
    return p;
}

// ---------------------------------------------------------------------------
// Unity lifecycle
// ---------------------------------------------------------------------------
void setUp(void)
{
    service = pipelineService;

    // Construct the mock FIRST: the NodeDB constructor can reload persisted state from the
    // host filesystem (portduino VFS) and repopulate the globals - a saved private key
    // re-enables the PKI encrypt path and fails the unicast tests on hosts with leftover prefs.
    mockNodeDB = new MockNodeDB();
    mockNodeDB->clearTestNodes();
#if WARM_NODE_COUNT > 0
    mockNodeDB->warmStore.clear();
#endif
    nodeDB = mockNodeDB;

    // Clean global config/owner AFTER the ctor; zeroed config => rebroadcast ALL (no KNOWN_ONLY
    // drop) and security.private_key.size == 0 (PKI encrypt path skipped => simple channel crypto).
    config = meshtastic_LocalConfig_init_zero;
    moduleConfig = meshtastic_LocalModuleConfig_init_zero;
    owner = meshtastic_User_init_zero;
    // Exercise the downgrade-protection matrix by default. Production defaults to
    // COMPATIBLE so existing meshes remain interoperable; tests that cover that
    // mode opt in explicitly.
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED);
    myNodeInfo.my_node_num = LOCAL_NODE; // drives isFromUs()/getFrom()/isToUs()

    // Working primary channel with the default PSK so encrypt/decrypt round-trips.
    channels.initDefaults();
    channels.onConfigChanged();

    pipelineRouter->clearPending();
    pipelineRouter->rxDupe = 0;
    pipelineRouter->txRelayCanceled = 0;
    pipelineRadio->reset();
    pipelineRouting->ackCalls = 0;
    pipelineModule->calls = 0;
    pipelineMqtt->clearQueue();
    while (meshtastic_MeshPacket *queued = pipelineService->getForPhone())
        packetPool.release(queued);
    while (meshtastic_QueueStatus *queued = pipelineService->getQueueStatusForPhone())
        pipelineService->releaseQueueStatusToPool(queued);
    resetRoutingAuthEvaluationCount();
}

// Set while C14's saturated AirTime is installed; see useDutyCycleSaturatedAirTime() below.
static AirTime *c14SavedAirTime = nullptr;

void tearDown(void)
{
    delete mockNodeDB;
    mockNodeDB = nullptr;
    nodeDB = nullptr;

    // Restore globals here, not at the end of a test body: an assertion aborts the body, and these
    // would otherwise leak into every later case. The injected clock is the one the N8-N11
    // suppression-window cases drive; the region and the AirTime swap are C14's duty-cycle setup.
    Time::useRealClock();
    Time::resetMonotonicForTests();
    config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
    initRegion();
    if (c14SavedAirTime) {
        airTime = c14SavedAirTime;
        c14SavedAirTime = nullptr;
    }
}

// ===========================================================================
// Group A - receive-side accept/reject matrix
// ===========================================================================

// A1: valid signature from a node whose key we know -> accepted, marked signed, signer bit learned.
void test_A1_valid_signature_accepted_and_learns_signer(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv); // engine now holds REMOTE's key
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setPublicKey(REMOTE_NODE, pub);

    TEST_ASSERT_FALSE(remoteSignerBit()); // not known as a signer yet

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
    signWithCurrentKey(&p);

    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
    TEST_ASSERT_TRUE(p.xeddsa_signed);
    TEST_ASSERT_TRUE_MESSAGE(remoteSignerBit(), "verified signature must set the signer bit");
}

// A2: a tampered signature from a known key -> dropped.
void test_A2_bad_signature_dropped(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE);
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setPublicKey(REMOTE_NODE, pub);

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
    signWithCurrentKey(&p);
    p.decoded.xeddsa_signature.bytes[0] ^= 0xFF; // corrupt the signature

    TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p));
}

// A3: signed packet but we have no key for the sender -> accepted unverified, signer bit NOT set.
void test_A3_signed_no_pubkey_accepted_unverified(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED);
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);
    mockNodeDB->addNode(REMOTE_NODE); // node exists, but no public key stored

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
    signWithCurrentKey(&p);

    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
    TEST_ASSERT_FALSE_MESSAGE(p.xeddsa_signed, "cannot be marked verified without a key");
    TEST_ASSERT_FALSE_MESSAGE(remoteSignerBit(), "must not learn signer without verifying");
}

// A4: downgrade protection - unsigned small broadcast from a known signer -> dropped.
void test_A4_downgrade_unsigned_broadcast_from_signer_dropped(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true); // we've seen this node sign before

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
    // from != us, so perhapsEncode leaves it unsigned.

    TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p));
}

// A5: no prior knowledge - unsigned small broadcast from a non-signer -> accepted.
void test_A5_unsigned_broadcast_from_nonsigner_accepted(void)
{
    mockNodeDB->addNode(REMOTE_NODE); // signer bit clear

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);

    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
    TEST_ASSERT_FALSE(p.xeddsa_signed);
}

// A6: unsigned UNICAST from a known signer -> accepted (unicasts are never signed).
void test_A6_unsigned_unicast_from_signer_accepted(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    // Unicast to us; PRIVATE_APP avoids the unrelated legacy-DM rejection for TEXT_MESSAGE_APP.
    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD);

    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
}

// A7: unsigned OVERSIZED broadcast from a known signer -> accepted (couldn't have carried a sig).
void test_A7_unsigned_oversized_broadcast_from_signer_accepted(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, OVERSIZED_PAYLOAD);

    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
}

// A8: F2 regression - unsigned broadcast from a signer in the old "dead band": its *encoded* Data
// can't take a 64-byte signature and still fit a LoRa frame, but the old payload-size heuristic
// (payload + 64 < DATA_PAYLOAD_LEN) judged it signable and dropped it as a downgrade. Must be
// accepted: an honest signer physically cannot sign this packet.
void test_A8_unsigned_deadband_broadcast_from_signer_accepted(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    // Shape it like a real sender's Data: perhapsEncode adds the bitfield to packets a node
    // originates, so remote broadcast traffic carries it too.
    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, 167);
    p.decoded.has_bitfield = true;
    p.decoded.bitfield = 0;

    // Pin the payload inside the dead band; if Data's encoding ever shifts, retune the payload
    // size above instead of letting this test pass vacuously.
    TEST_ASSERT_TRUE_MESSAGE(p.decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN,
                             "payload must sit in the old heuristic's drop range");
    TEST_ASSERT_FALSE_MESSAGE(signedEncodingFits(&p.decoded), "signed encoding must NOT fit a LoRa frame");

    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
    TEST_ASSERT_FALSE(p.xeddsa_signed);
}

// A9: the boundary holds - the largest broadcast whose signed encoding still fits is still
// subject to the downgrade drop when it arrives unsigned from a known signer.
// (Deliberately non-discriminating: the old heuristic dropped this packet too. A9 pins the
// boundary against over-correction; A8 and B4 are the F2 regression discriminators.)
void test_A9_unsigned_boundary_broadcast_from_signer_still_dropped(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, 166);
    p.decoded.has_bitfield = true;
    p.decoded.bitfield = 0;

    // Exactly at the limit: signed encoding fills the frame to the last byte. Pinned so the
    // boundary can't silently drift.
    meshtastic_Data signedCopy = p.decoded;
    signedCopy.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
    TEST_ASSERT_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, encodedDataSize(&signedCopy) + MESHTASTIC_HEADER_LENGTH,
                              "payload no longer sits exactly on the fit boundary - retune it");

    TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p));
}

void test_A10_compatible_accepts_unsigned_broadcast_from_signer(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE);
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
}

void test_A11_strict_rejects_unsigned_all_portnums_destinations_and_sizes(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    const meshtastic_PortNum ports[] = {
        meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_TELEMETRY_APP,
        meshtastic_PortNum_NODEINFO_APP,     meshtastic_PortNum_WAYPOINT_APP,
    };
    for (const auto port : ports) {
        meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, port, SMALL_PAYLOAD);
        TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p));
    }

    meshtastic_MeshPacket unicast = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
    TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&unicast));

    meshtastic_MeshPacket oversized =
        makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, OVERSIZED_PAYLOAD);
    TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&oversized));
}

void test_A12_strict_rejects_signed_packet_without_key(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
    signWithCurrentKey(&p);
    TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p));
}

void test_A13_strict_accepts_locally_authenticated_pki_packet(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32];
    crypto->generateKeyPair(localPub, localPriv);
    crypto->generateKeyPair(remotePub, remotePriv);
    mockNodeDB->addNode(LOCAL_NODE);
    mockNodeDB->setPublicKey(LOCAL_NODE, localPub);
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setPublicKey(REMOTE_NODE, remotePub);

    meshtastic_Data data = meshtastic_Data_init_zero;
    data.portnum = meshtastic_PortNum_PRIVATE_APP;
    data.payload.size = SMALL_PAYLOAD;
    memset(data.payload.bytes, 0x5A, data.payload.size);
    uint8_t plaintext[MAX_LORA_PAYLOAD_LEN + 1] = {};
    const size_t plaintextSize = pb_encode_to_bytes(plaintext, sizeof(plaintext), &meshtastic_Data_msg, &data);
    TEST_ASSERT_GREATER_THAN(0, plaintextSize);

    meshtastic_NodeInfoLite_public_key_t localKey = {32, {0}};
    memcpy(localKey.bytes, localPub, sizeof(localPub));
    meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
    p.from = REMOTE_NODE;
    p.to = LOCAL_NODE;
    p.id = 0x0CC01234;
    p.channel = 0;
    p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
    crypto->setDHPrivateKey(remotePriv);
    TEST_ASSERT_TRUE(crypto->encryptCurve25519(p.to, p.from, localKey, p.id, plaintextSize, plaintext, p.encrypted.bytes));
    p.encrypted.size = plaintextSize + MESHTASTIC_PKC_OVERHEAD;

    // Only the receiver's private key can establish the local pki_encrypted authentication marker.
    crypto->setDHPrivateKey(localPriv);
    TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p));
    TEST_ASSERT_TRUE(p.pki_encrypted);
    TEST_ASSERT_EQUAL(meshtastic_PortNum_PRIVATE_APP, p.decoded.portnum);
}

void test_A13b_strict_rejects_spoofed_pki_flag_on_encrypted_ingress(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
    TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p));
    p.pki_encrypted = true;
    p.public_key.size = 32;
    memset(p.public_key.bytes, 0xAB, p.public_key.size);

    TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, perhapsDecode(&p));
    TEST_ASSERT_FALSE(p.pki_encrypted);
    TEST_ASSERT_EQUAL(0, p.public_key.size);
}

void test_A14_strict_bootstraps_identity_bound_signed_nodeinfo(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);
    const NodeNum signer = crc32Buffer(pub, sizeof(pub));

    meshtastic_User user = meshtastic_User_init_zero;
    user.public_key.size = sizeof(pub);
    memcpy(user.public_key.bytes, pub, sizeof(pub));
    meshtastic_MeshPacket p = makeDecoded(signer, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, 0);
    p.decoded.payload.size =
        pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_User_msg, &user);
    signWithCurrentKey(&p);

    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
    const meshtastic_NodeInfoLite *node = mockNodeDB->getMeshNode(signer);
    TEST_ASSERT_NOT_NULL(node);
    TEST_ASSERT_EQUAL_UINT8_ARRAY(pub, node->public_key.bytes, sizeof(pub));
    TEST_ASSERT_TRUE(p.xeddsa_signed);
}

void test_A15_strict_rejects_nodeinfo_key_without_identity_binding(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);

    meshtastic_User user = meshtastic_User_init_zero;
    user.public_key.size = sizeof(pub);
    memcpy(user.public_key.bytes, pub, sizeof(pub));
    meshtastic_MeshPacket p =
        makeDecoded(crc32Buffer(pub, sizeof(pub)) ^ 1, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, 0);
    p.decoded.payload.size =
        pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_User_msg, &user);
    signWithCurrentKey(&p);

    TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p));
    TEST_ASSERT_NULL(mockNodeDB->getMeshNode(p.from));
}

void test_A16_compatible_rejects_invalid_first_contact_nodeinfo(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE);
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);

    meshtastic_User user = meshtastic_User_init_zero;
    user.public_key.size = sizeof(pub);
    memcpy(user.public_key.bytes, pub, sizeof(pub));
    meshtastic_MeshPacket p =
        makeDecoded(crc32Buffer(pub, sizeof(pub)) ^ 1, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, 0);
    p.decoded.payload.size =
        pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_User_msg, &user);
    signWithCurrentKey(&p);

    TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p));
}

#if WARM_NODE_COUNT > 0
void test_A17_strict_verifies_signer_from_warm_key_store(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);
    TEST_ASSERT_TRUE(mockNodeDB->warmStore.absorb(REMOTE_NODE, 1, pub));
    TEST_ASSERT_NULL(mockNodeDB->getMeshNode(REMOTE_NODE));

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
    signWithCurrentKey(&p);
    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
    TEST_ASSERT_TRUE(p.xeddsa_signed);
    const meshtastic_NodeInfoLite *rehydrated = mockNodeDB->getMeshNode(REMOTE_NODE);
    TEST_ASSERT_NOT_NULL_MESSAGE(rehydrated, "verified warm signer must be re-admitted to the hot store");
    TEST_ASSERT_EQUAL_UINT8_ARRAY(pub, rehydrated->public_key.bytes, sizeof(pub));
    TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasXeddsaSigned(rehydrated), "re-admitted signer must retain Balanced downgrade memory");

    // Model its next hot-store eviction and prove Balanced still remembers the signer without
    // allocating a hot node merely to evaluate an unsigned packet.
    // Mirror what NodeDB eviction actually stores for a signer: warmProtectedCategory() yields
    // XeddsaSigner *and* the dedicated warm signer bit is set from nodeInfoLiteHasXeddsaSigned().
    // isKnownXeddsaSigner() reads that signer bit, not the protected category.
    TEST_ASSERT_TRUE(mockNodeDB->warmStore.absorb(REMOTE_NODE, 2, pub, meshtastic_Config_DeviceConfig_Role_CLIENT,
                                                  static_cast<uint8_t>(WarmProtected::XeddsaSigner), /*signer=*/true));
    mockNodeDB->clearTestNodes();
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED);
    meshtastic_MeshPacket unsignedPacket =
        makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
    TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&unsignedPacket),
                              "Balanced downgrade memory must survive repeated hot-store eviction");
}
#endif

void test_A18_unsigned_broadcast_from_signer_with_unknown_fields_dropped(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    meshtastic_MeshPacket p = makeBroadcastWithUnknownFields();

    TEST_ASSERT_EQUAL_MESSAGE(DECODE_POLICY_REJECT, perhapsDecode(&p),
                              "unsigned broadcast from a signer must be dropped despite unknown fields");
}

void test_A19_unsigned_broadcast_from_nonsigner_with_unknown_fields_accepted(void)
{
    mockNodeDB->addNode(REMOTE_NODE);

    meshtastic_MeshPacket p = makeBroadcastWithUnknownFields();
    const size_t rawSize = p.encrypted.size;

    TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&p), "frame from a non-signer must still decode");
    TEST_ASSERT_EQUAL_MESSAGE(meshtastic_PortNum_POSITION_APP, p.decoded.portnum, "unknown fields must not disturb the portnum");
    TEST_ASSERT_EQUAL_MESSAGE(SMALL_PAYLOAD, p.decoded.payload.size, "payload must survive the unknown fields");
    TEST_ASSERT_FALSE(p.xeddsa_signed);
    TEST_ASSERT_LESS_THAN_MESSAGE(rawSize, encodedDataSize(&p.decoded),
                                  "unknown fields must drop at decode, leaving decoded size < raw");
}

// ===========================================================================
// Group B - send-side signing policy (perhapsEncode)
// ===========================================================================

// B1: our own small broadcast is auto-signed (and verifies on the way back in).
void test_B1_local_broadcast_is_signed(void)
{
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv); // engine signs with this; store the matching pubkey for us
    mockNodeDB->addNode(LOCAL_NODE);
    mockNodeDB->setPublicKey(LOCAL_NODE, pub);

    meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);

    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
    TEST_ASSERT_EQUAL_MESSAGE(XEDDSA_SIGNATURE_SIZE, p.decoded.xeddsa_signature.size, "broadcast should be auto-signed");
    TEST_ASSERT_TRUE(p.xeddsa_signed);
}

// B2: preserve the existing wire behavior: non-PKI unicast is not signed.
void test_B2_local_unicast_not_signed(void)
{
    mockNodeDB->addNode(REMOTE_NODE);

    meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);

    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
    TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "unicast must remain unsigned");
}

// B3: our own oversized broadcast is NOT signed (signature wouldn't fit).
void test_B3_local_oversized_broadcast_not_signed(void)
{
    meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, OVERSIZED_PAYLOAD);

    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
    TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "oversized broadcast must not be signed");
}

// B4: F2 regression sweep - every broadcast payload size that fits a LoRa frame unsigned must
// still be deliverable: signing steps aside exactly when the signed encoding stops fitting,
// never producing TOO_LARGE (the old heuristic dead-banded payloads 167-168). Because the first
// verified packet sets our signer bit in the mock DB, the later unsigned sizes also prove the
// receiver's downgrade predicate stays exactly symmetric with the sender's sign gate.
void test_B4_all_broadcast_sizes_deliverable_no_deadband(void)
{
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);
    mockNodeDB->addNode(LOCAL_NODE);
    mockNodeDB->setPublicKey(LOCAL_NODE, pub);

    bool sawSigned = false, sawUnsigned = false;
    for (size_t n = 1; n <= 232; n++) {
        char msg[32];
        snprintf(msg, sizeof(msg), "payload size %u", (unsigned)n);

        meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, n);
        TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, roundTrip(&p), msg);

        // Exact oracle: signed iff the signed encoding fits the frame. signedEncodingFits() forces
        // the signature size itself, so it reads the same whether or not p.decoded came back signed.
        const bool isSigned = p.decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE;
        TEST_ASSERT_EQUAL_MESSAGE(signedEncodingFits(&p.decoded), isSigned, msg);

        if (isSigned) {
            TEST_ASSERT_FALSE_MESSAGE(sawUnsigned, msg);    // monotonic: once too big, never signed again
            TEST_ASSERT_TRUE_MESSAGE(p.xeddsa_signed, msg); // and it verified on the way back in
            sawSigned = true;
        } else {
            sawUnsigned = true;
        }
    }
    TEST_ASSERT_TRUE_MESSAGE(sawSigned, "sweep never produced a signed packet");
    TEST_ASSERT_TRUE_MESSAGE(sawUnsigned, "sweep never crossed the fit boundary");
}

// B5: a client-preset signature on a packet outside the existing broadcast sign class is discarded.
void test_B5_preset_signature_on_local_packet_cleared(void)
{
    mockNodeDB->addNode(REMOTE_NODE);

    meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
    p.decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
    memset(p.decoded.xeddsa_signature.bytes, 0xAB, XEDDSA_SIGNATURE_SIZE);

    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
    TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "preset signature must be discarded on unicast");
}

// B6: the exact-fit gate tracks Data *shape*, not just payload size. A tapback-style broadcast
// (want_response + reply_id + emoji) carries extra wire bytes that shift the fit boundary; the
// sweep proves no dead band exists for that shape either, and - once the signer bit is learned -
// that the receiver's downgrade predicate stays symmetric for it too. Window
// straddles this shape's boundary; capped at 200 so even the unsigned rich encoding stays well
// inside the frame (at n=221 it first hits the pre-existing, signing-unrelated TOO_LARGE).
void test_B6_rich_shape_sweep_no_deadband(void)
{
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);
    mockNodeDB->addNode(LOCAL_NODE);
    mockNodeDB->setPublicKey(LOCAL_NODE, pub);

    bool sawSigned = false, sawUnsigned = false;
    for (size_t n = 100; n <= 200; n++) {
        char msg[32];
        snprintf(msg, sizeof(msg), "payload size %u", (unsigned)n);

        meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, n);
        p.decoded.want_response = true;
        p.decoded.reply_id = 0x11223344;
        p.decoded.emoji = 1;
        TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, roundTrip(&p), msg);

        const bool isSigned = p.decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE;
        TEST_ASSERT_EQUAL_MESSAGE(signedEncodingFits(&p.decoded), isSigned, msg);

        if (isSigned) {
            TEST_ASSERT_FALSE_MESSAGE(sawUnsigned, msg);
            TEST_ASSERT_TRUE_MESSAGE(p.xeddsa_signed, msg);
            sawSigned = true;
        } else {
            sawUnsigned = true;
        }
    }
    TEST_ASSERT_TRUE_MESSAGE(sawSigned, "rich sweep never produced a signed packet");
    TEST_ASSERT_TRUE_MESSAGE(sawUnsigned, "rich sweep never crossed the fit boundary");
}

void test_B7_infrastructure_port_signing_matrix(void)
{
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);
    mockNodeDB->addNode(LOCAL_NODE);
    mockNodeDB->setPublicKey(LOCAL_NODE, pub);

    const meshtastic_PortNum ports[] = {
        meshtastic_PortNum_NODEINFO_APP,
        meshtastic_PortNum_ROUTING_APP,
        meshtastic_PortNum_TRACEROUTE_APP,
        meshtastic_PortNum_POSITION_APP,
    };
    for (const auto port : ports) {
        meshtastic_MeshPacket broadcast = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, port, SMALL_PAYLOAD);
        TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&broadcast));
        TEST_ASSERT_EQUAL_MESSAGE(XEDDSA_SIGNATURE_SIZE, broadcast.decoded.xeddsa_signature.size,
                                  "signable infrastructure broadcast must be signed");

        meshtastic_MeshPacket unicast = makeDecoded(LOCAL_NODE, REMOTE_NODE, port, SMALL_PAYLOAD);
        TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&unicast));
        TEST_ASSERT_EQUAL_MESSAGE(0, unicast.decoded.xeddsa_signature.size,
                                  "infrastructure unicast must preserve existing unsigned behavior");
    }
}

void test_B8_licensed_broadcast_and_unicast_are_signed(void)
{
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);
    mockNodeDB->addNode(LOCAL_NODE);
    mockNodeDB->setPublicKey(LOCAL_NODE, pub);
    owner.is_licensed = true;
    channels.ensureLicensedOperation();

    meshtastic_MeshPacket broadcast =
        makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&broadcast));
    TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, broadcast.decoded.xeddsa_signature.size);
    TEST_ASSERT_TRUE(broadcast.xeddsa_signed);

    meshtastic_MeshPacket direct = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&direct));
    TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, direct.decoded.xeddsa_signature.size);
    TEST_ASSERT_TRUE(direct.xeddsa_signed);
}

void test_B9_licensed_unicast_never_uses_pki_encryption(void)
{
    uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32];
    crypto->generateKeyPair(localPub, localPriv);
    memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv));
    config.security.private_key.size = sizeof(localPriv);
    mockNodeDB->addNode(LOCAL_NODE);
    mockNodeDB->setPublicKey(LOCAL_NODE, localPub);
    mockNodeDB->addNode(REMOTE_NODE);
    crypto->generateKeyPair(remotePub, remotePriv);
    mockNodeDB->setPublicKey(REMOTE_NODE, remotePub);
    crypto->setDHPrivateKey(localPriv);

    owner.is_licensed = true;
    channels.ensureLicensedOperation();
    meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);

    TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p));
    TEST_ASSERT_FALSE(p.pki_encrypted);
    meshtastic_Data plaintext = meshtastic_Data_init_zero;
    TEST_ASSERT_TRUE(pb_decode_from_bytes(p.encrypted.bytes, p.encrypted.size, &meshtastic_Data_msg, &plaintext));
    TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, plaintext.xeddsa_signature.size);
}

void test_B10_licensed_oversized_unicast_remains_unsigned(void)
{
    owner.is_licensed = true;
    channels.ensureLicensedOperation();
    meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, OVERSIZED_PAYLOAD);

    TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
    TEST_ASSERT_EQUAL(0, p.decoded.xeddsa_signature.size);
}

void test_B11_normal_unicast_still_uses_pki(void)
{
    uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32];
    crypto->generateKeyPair(localPub, localPriv);
    crypto->generateKeyPair(remotePub, remotePriv);
    mockNodeDB->addNode(LOCAL_NODE);
    mockNodeDB->setPublicKey(LOCAL_NODE, localPub);
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setPublicKey(REMOTE_NODE, remotePub);
    memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv));
    config.security.private_key.size = sizeof(localPriv);
    crypto->setDHPrivateKey(localPriv);

    meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
    TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p));
    TEST_ASSERT_TRUE(p.pki_encrypted);

    myNodeInfo.my_node_num = REMOTE_NODE;
    crypto->setDHPrivateKey(remotePriv);
    TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p));
    TEST_ASSERT_TRUE(p.pki_encrypted);
    TEST_ASSERT_EQUAL(0, p.decoded.xeddsa_signature.size);
}

void test_B12_licensed_receiver_does_not_decrypt_pki(void)
{
    uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32];
    crypto->generateKeyPair(localPub, localPriv);
    crypto->generateKeyPair(remotePub, remotePriv);
    mockNodeDB->addNode(LOCAL_NODE);
    mockNodeDB->setPublicKey(LOCAL_NODE, localPub);
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setPublicKey(REMOTE_NODE, remotePub);
    memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv));
    config.security.private_key.size = sizeof(localPriv);
    crypto->setDHPrivateKey(localPriv);

    meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
    TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p));
    TEST_ASSERT_TRUE(p.pki_encrypted);

    owner.is_licensed = true;
    channels.ensureLicensedOperation();
    myNodeInfo.my_node_num = REMOTE_NODE;
    crypto->setDHPrivateKey(remotePriv);
    TEST_ASSERT_EQUAL(DECODE_FAILURE, perhapsDecode(&p));
}

void test_B13_licensed_port_and_destination_signing_matrix(void)
{
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);
    mockNodeDB->addNode(LOCAL_NODE);
    mockNodeDB->setPublicKey(LOCAL_NODE, pub);
    owner.is_licensed = true;
    channels.ensureLicensedOperation();

    const meshtastic_PortNum ports[] = {
        meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_TELEMETRY_APP,
        meshtastic_PortNum_ROUTING_APP,      meshtastic_PortNum_NODEINFO_APP,
    };
    const NodeNum destinations[] = {NODENUM_BROADCAST, REMOTE_NODE};
    for (const auto port : ports) {
        for (const auto destination : destinations) {
            meshtastic_MeshPacket packet = makeDecoded(LOCAL_NODE, destination, port, SMALL_PAYLOAD);
            TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&packet));
            TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, packet.decoded.xeddsa_signature.size);
            TEST_ASSERT_TRUE(packet.xeddsa_signed);
            TEST_ASSERT_FALSE(packet.pki_encrypted);
        }
    }
}

// ===========================================================================
// Group C - routing pipeline and NodeInfo authentication ordering
// ===========================================================================

class NodeInfoTestShim : public NodeInfoModule
{
  public:
    using MeshModule::currentRequest; // allocReply() only suppresses while a request is in flight
    using NodeInfoModule::allocReply;
    using NodeInfoModule::handleReceivedProtobuf;
};

static meshtastic_MeshPacket makeNodeInfoPacket(bool signed_)
{
    meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD);
    mp.xeddsa_signed = signed_;
    return mp;
}

void test_N1_unsigned_nodeinfo_from_signer_dropped(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    NodeInfoTestShim shim;
    meshtastic_MeshPacket mp = makeNodeInfoPacket(false);
    meshtastic_User user = meshtastic_User_init_zero;
    user.is_licensed = owner.is_licensed;

    TEST_ASSERT_TRUE_MESSAGE(shim.handleReceivedProtobuf(mp, &user), "unsigned NodeInfo from signer must be dropped");
}

void test_N2_signed_nodeinfo_from_signer_not_dropped(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    NodeInfoTestShim shim;
    meshtastic_MeshPacket mp = makeNodeInfoPacket(true);
    meshtastic_User user = meshtastic_User_init_zero;
    user.is_licensed = owner.is_licensed;

    TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user));
}

void test_N3_unsigned_nodeinfo_from_nonsigner_not_dropped(void)
{
    mockNodeDB->addNode(REMOTE_NODE);

    NodeInfoTestShim shim;
    meshtastic_MeshPacket mp = makeNodeInfoPacket(false);
    meshtastic_User user = meshtastic_User_init_zero;
    user.is_licensed = owner.is_licensed;

    TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user));
}

void test_N4_unsigned_unicast_nodeinfo_from_signer_accepted(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    NodeInfoTestShim shim;
    meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD);
    mp.xeddsa_signed = false;
    meshtastic_User user = meshtastic_User_init_zero;
    user.is_licensed = owner.is_licensed;

    TEST_ASSERT_FALSE_MESSAGE(shim.handleReceivedProtobuf(mp, &user),
                              "unsigned unicast NodeInfo from signer must not be dropped");
}

static void preparePipelineSigner(NodeNum sender)
{
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);
    mockNodeDB->addNode(sender);
    mockNodeDB->setPublicKey(sender, pub);
}

static void runPipelineIngress(const meshtastic_MeshPacket &p)
{
    meshtastic_MeshPacket *copy = packetPool.allocCopy(p);
    TEST_ASSERT_NOT_NULL(copy);
    pipelineRouter->enqueueReceivedMessage(copy);
    pipelineRouter->runOnce();
}

static void assertNoRejectedPipelineEffects(NodeNum sender, uint32_t lastHeardBefore)
{
    TEST_ASSERT_EQUAL(0, pipelineRadio->sendCalls);
    TEST_ASSERT_EQUAL(0, pipelineRadio->cancelCalls);
    TEST_ASSERT_EQUAL(0, pipelineRadio->findCalls);
    TEST_ASSERT_EQUAL(0, pipelineRadio->removeCalls);
    TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls);
    TEST_ASSERT_EQUAL(0, pipelineRouter->rxDupe);
    TEST_ASSERT_EQUAL(0, pipelineRouter->txRelayCanceled);
    TEST_ASSERT_EQUAL(0, pipelineModule->calls);
    TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize());
    TEST_ASSERT_NULL(pipelineService->getForPhone());
    const meshtastic_NodeInfoLite *node = mockNodeDB->getMeshNode(sender);
    TEST_ASSERT_NOT_NULL(node);
    TEST_ASSERT_EQUAL_UINT32(lastHeardBefore, node->last_heard);
}

void test_C1_invalid_first_copy_does_not_poison_valid_same_id(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    preparePipelineSigner(REMOTE_NODE);
    const PacketId id = 0xC1000001;
    const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard;

    meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 1, 2, 0, 0x31, false);
    moduleConfig.mqtt.enabled = true;
    runPipelineIngress(invalid);
    assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard);
    TEST_ASSERT_FALSE(pipelineRouter->historyContains(&invalid));

    meshtastic_MeshPacket valid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id);
    TEST_ASSERT_EQUAL(static_cast<int>(RoutingAuthVerdict::ACCEPT), static_cast<int>(passesRoutingAuthGate(&valid)));
    TEST_ASSERT_EQUAL_MESSAGE(meshtastic_MeshPacket_encrypted_tag, valid.which_payload_variant,
                              "routing auth gate must preserve encrypted relay/MQTT bytes");
    TEST_ASSERT_FALSE_MESSAGE(pipelineRouter->filter(&valid), "valid same-ID packet was poisoned by rejected first copy");
    TEST_ASSERT_TRUE(pipelineRouter->historyContains(&valid));
}

void test_C2_invalid_ordinary_duplicate_has_no_cancel_or_delivery_effects(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    preparePipelineSigner(REMOTE_NODE);
    const PacketId id = 0xC2000002;
    meshtastic_MeshPacket prior = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
    prior.id = id;
    prior.hop_limit = 1;
    prior.hop_start = 2;
    prior.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
    pipelineRouter->remember(&prior);
    const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard;

    meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 1, 2, 0, 0x32, false);
    runPipelineIngress(invalid);
    assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard);
    TEST_ASSERT_TRUE(pipelineRouter->historyContains(&prior));
}

void test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    preparePipelineSigner(LOCAL_NODE);
    const PacketId id = 0xC3000003;
    meshtastic_MeshPacket prior = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
    prior.id = id;
    prior.hop_limit = 2;
    prior.hop_start = 2;
    prior.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
    pipelineRouter->remember(&prior);
    // "Far future, so no retransmission is due." Must be a representable future time, not
    // UINT32_MAX: doRetransmissions() compares with an unsigned half-range test, under which
    // UINT32_MAX is ~1ms in the *past* and would fire a retransmit and rewrite nextTxMsec.
    const uint32_t notDueTxMsec = Time::getMillis() + 3600000UL;
    pipelineRouter->addPending(prior, notDueTxMsec);
    const uint32_t lastHeard = mockNodeDB->getMeshNode(LOCAL_NODE)->last_heard;

    meshtastic_MeshPacket invalid = makeSignedWirePacket(LOCAL_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x34, false);
    runPipelineIngress(invalid);
    assertNoRejectedPipelineEffects(LOCAL_NODE, lastHeard);
    TEST_ASSERT_EQUAL(1, pipelineRouter->pendingCount());
    TEST_ASSERT_EQUAL_UINT32(notDueTxMsec, pipelineRouter->pendingNextTx(LOCAL_NODE, id));
}

void test_C4_invalid_fallback_packet_cannot_relay(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    preparePipelineSigner(REMOTE_NODE);
    const PacketId id = 0xC4000004;
    const uint8_t ourRelay = mockNodeDB->getLastByteOfNodeNum(LOCAL_NODE);
    meshtastic_MeshPacket prior = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
    prior.id = id;
    prior.next_hop = 0x22;
    prior.relay_node = ourRelay;
    prior.hop_limit = 1;
    prior.hop_start = 2;
    pipelineRouter->remember(&prior);
    meshtastic_MeshPacket relayed = prior;
    relayed.relay_node = 0x35;
    pipelineRouter->remember(&relayed);
    pipelineRouter->forgetRelayer(ourRelay, id, REMOTE_NODE);
    const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard;

    meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, LOCAL_NODE, id, 1, 2, 0, 0x35, false);
    runPipelineIngress(invalid);
    assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard);
}

void test_C5_invalid_upgrade_cannot_remove_pending_valid_send(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    preparePipelineSigner(REMOTE_NODE);
    const PacketId id = 0xC5000005;
    meshtastic_MeshPacket prior = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
    prior.id = id;
    prior.hop_limit = 1;
    prior.hop_start = 2;
    pipelineRouter->remember(&prior);
    const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard;

    meshtastic_MeshPacket directInvalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x36, false);
    TEST_ASSERT_TRUE(pipelineRouter->handleUpgrade(&directInvalid));
    TEST_ASSERT_EQUAL(0, pipelineRadio->removeCalls);

    meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x36, false);
    runPipelineIngress(invalid);
    assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard);

    // The rejected upgrade did not raise the history watermark or remove the queued valid copy;
    // a later authenticated replacement still performs the intended upgrade.
    meshtastic_MeshPacket valid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x36, true);
    TEST_ASSERT_EQUAL(static_cast<int>(RoutingAuthVerdict::ACCEPT), static_cast<int>(passesRoutingAuthGate(&valid)));
    TEST_ASSERT_TRUE(pipelineRouter->filter(&valid));
    TEST_ASSERT_EQUAL(1, pipelineRadio->removeCalls);
}

void test_C6_opaque_unknown_channel_is_relay_only(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    meshtastic_MeshPacket opaque = meshtastic_MeshPacket_init_zero;
    opaque.from = REMOTE_NODE;
    opaque.to = NODENUM_BROADCAST;
    opaque.id = 0xC6000006;
    opaque.channel = 0xFE;
    opaque.hop_limit = 1;
    opaque.hop_start = 2;
    opaque.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
    opaque.encrypted.size = 16;
    memset(opaque.encrypted.bytes, 0xA5, opaque.encrypted.size);

    TEST_ASSERT_EQUAL(static_cast<int>(RoutingAuthVerdict::OPAQUE_RELAY_ONLY), static_cast<int>(passesRoutingAuthGate(&opaque)));
    moduleConfig.mqtt.enabled = true;
    runPipelineIngress(opaque);
    TEST_ASSERT_EQUAL_MESSAGE(1, pipelineRadio->sendCalls, "opaque broadcast should take only the safety-controlled relay path");
    TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls);
    TEST_ASSERT_EQUAL(0, pipelineModule->calls);
    TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize());
    TEST_ASSERT_NULL(pipelineService->getForPhone());
    TEST_ASSERT_FALSE(pipelineRouter->historyContains(&opaque));
    TEST_ASSERT_NULL(mockNodeDB->getMeshNode(REMOTE_NODE));

    pipelineRadio->reset();
    meshtastic_MeshPacket addressed = opaque;
    addressed.to = LOCAL_NODE;
    addressed.id++;
    runPipelineIngress(addressed);
    TEST_ASSERT_EQUAL_MESSAGE(0, pipelineRadio->sendCalls, "opaque packet addressed to us must not be relayed");
    TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls);
    TEST_ASSERT_EQUAL(0, pipelineModule->calls);
    TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize());
    TEST_ASSERT_NULL(pipelineService->getForPhone());
    TEST_ASSERT_FALSE(pipelineRouter->historyContains(&addressed));

    const meshtastic_Config_DeviceConfig_RebroadcastMode blockedModes[] = {
        meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY,
        meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY,
        meshtastic_Config_DeviceConfig_RebroadcastMode_NONE,
    };
    for (const auto mode : blockedModes) {
        pipelineRadio->reset();
        config.device.rebroadcast_mode = mode;
        meshtastic_MeshPacket blocked = opaque;
        blocked.id++;
        blocked.id += static_cast<uint32_t>(mode);
        blocked.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP;
        runPipelineIngress(blocked);
        TEST_ASSERT_EQUAL_MESSAGE(0, pipelineRadio->sendCalls, "restricted rebroadcast mode must suppress opaque relay");
        TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls);
        TEST_ASSERT_EQUAL(0, pipelineModule->calls);
        TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize());
        TEST_ASSERT_NULL(pipelineService->getForPhone());
        TEST_ASSERT_FALSE(pipelineRouter->historyContains(&blocked));
    }
}

void test_C7_strict_rejects_unsigned_decoded_simradio_ingress(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    mockNodeDB->addNode(REMOTE_NODE);
    const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard;
    meshtastic_MeshPacket injected = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
    injected.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
    runPipelineIngress(injected);
    assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard);
    TEST_ASSERT_FALSE(pipelineRouter->historyContains(&injected));
}

void test_C8_trusted_local_decoded_delivery_is_not_filtered(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    meshtastic_MeshPacket *local =
        packetPool.allocCopy(makeDecoded(0, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD));
    TEST_ASSERT_NOT_NULL(local);
    TEST_ASSERT_EQUAL(ERRNO_SHOULD_RELEASE, pipelineRouter->sendLocal(local, RX_SRC_USER));
    TEST_ASSERT_EQUAL_MESSAGE(1, pipelineModule->calls, "trusted phone-origin packet must reach local modules");
    packetPool.release(local);
}

void test_C9_known_channel_malformed_plaintext_is_not_relayed_as_opaque(void)
{
    meshtastic_MeshPacket malformed = meshtastic_MeshPacket_init_zero;
    malformed.from = REMOTE_NODE;
    malformed.to = NODENUM_BROADCAST;
    malformed.id = 0xC9000009;
    malformed.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
    malformed.encrypted.size = 3;
    malformed.encrypted.bytes[0] = 0xFF;
    malformed.encrypted.bytes[1] = 0xFF;
    malformed.encrypted.bytes[2] = 0xFF;
    malformed.channel = channels.setActiveByIndex(0);
    crypto->encryptPacket(malformed.from, malformed.id, malformed.encrypted.size, malformed.encrypted.bytes);
    mockNodeDB->addNode(REMOTE_NODE);
    const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard;
    runPipelineIngress(malformed);
    assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard);
    TEST_ASSERT_FALSE(pipelineRouter->historyContains(&malformed));
}

void test_C10_legacy_channel_dm_failure_has_no_pipeline_effects(void)
{
    meshtastic_MeshPacket legacyDm = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
    legacyDm = channelEncode(legacyDm);
    mockNodeDB->addNode(REMOTE_NODE);
    const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard;
    moduleConfig.mqtt.enabled = true;
    runPipelineIngress(legacyDm);
    assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard);
    TEST_ASSERT_FALSE(pipelineRouter->historyContains(&legacyDm));
}

void test_C11_malformed_pki_plaintext_has_no_pipeline_effects(void)
{
    uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32];
    crypto->generateKeyPair(localPub, localPriv);
    crypto->generateKeyPair(remotePub, remotePriv);
    mockNodeDB->addNode(LOCAL_NODE);
    mockNodeDB->setPublicKey(LOCAL_NODE, localPub);
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setPublicKey(REMOTE_NODE, remotePub);

    const uint8_t malformedPlaintext[] = {0xFF, 0xFF, 0xFF};
    meshtastic_NodeInfoLite_public_key_t localKey = {32, {0}};
    memcpy(localKey.bytes, localPub, sizeof(localPub));
    meshtastic_MeshPacket malformed = meshtastic_MeshPacket_init_zero;
    malformed.from = REMOTE_NODE;
    malformed.to = LOCAL_NODE;
    malformed.id = 0xCB00000B;
    malformed.channel = 0;
    malformed.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
    crypto->setDHPrivateKey(remotePriv);
    TEST_ASSERT_TRUE(crypto->encryptCurve25519(malformed.to, malformed.from, localKey, malformed.id, sizeof(malformedPlaintext),
                                               malformedPlaintext, malformed.encrypted.bytes));
    malformed.encrypted.size = sizeof(malformedPlaintext) + MESHTASTIC_PKC_OVERHEAD;
    crypto->setDHPrivateKey(localPriv);

    const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard;
    moduleConfig.mqtt.enabled = true;
    runPipelineIngress(malformed);
    assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard);
    TEST_ASSERT_FALSE(pipelineRouter->historyContains(&malformed));
}

void test_C12_exact_authenticated_replay_reuses_verdict_without_collision_bypass(void)
{
    setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
    preparePipelineSigner(REMOTE_NODE);
    meshtastic_MeshPacket valid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, 0xCC00000C);
    // Full ingress replaces this nonzero wire timestamp with the local arrival time. The exact
    // authentication handoff must be consumed before that mutation, avoiding a second evaluation.
    valid.rx_time = 0x12345678;
    runPipelineIngress(valid);
    TEST_ASSERT_EQUAL_MESSAGE(1, routingAuthEvaluationCount(), "full ingress must consume the primed verdict exactly once");
    runPipelineIngress(valid);
    TEST_ASSERT_EQUAL_MESSAGE(2, routingAuthEvaluationCount(), "consumed verdict must not authenticate a later replay");

    meshtastic_MeshPacket collision = valid;
    collision.encrypted.bytes[0] ^= 0x80;
    TEST_ASSERT_EQUAL(static_cast<int>(RoutingAuthVerdict::REJECT), static_cast<int>(passesRoutingAuthGate(&collision)));
    TEST_ASSERT_EQUAL_MESSAGE(3, routingAuthEvaluationCount(), "same packet ID with different bytes must be reevaluated");
}

// A local reliable send that fails before reaching the radio must not outlive the error as a scheduled retransmission.
void test_C13_failed_initial_reliable_send_does_not_retry(void)
{
    meshtastic_MeshPacket initial = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD);
    initial.id = 0xC13C13C1;
    initial.want_ack = true;
    initial.channel = MAX_NUM_CHANNELS; // Out of range, so encoding returns NO_CHANNEL.

    auto *packet = packetPool.allocCopy(initial);
    TEST_ASSERT_NOT_NULL(packet);

    pipelineRouter->send(packet);
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouting->ackCalls,
                                     "initial encoding failure must be reported to the originating client");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, pipelineRouter->pendingCount(),
                                     "failed initial send must not leave a retransmission pending");

    pipelineRadio->failSend = true;
    meshtastic_MeshPacket interfaceFailure = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD);
    interfaceFailure.id = 0xC13C13C2;
    interfaceFailure.want_ack = true;
    packet = packetPool.allocCopy(interfaceFailure);
    TEST_ASSERT_NOT_NULL(packet);

    pipelineService->sendToMesh(packet, RX_SRC_USER);
    meshtastic_QueueStatus *status = pipelineService->getQueueStatusForPhone();
    TEST_ASSERT_NOT_NULL(status);
    TEST_ASSERT_EQUAL(ERRNO_DISABLED, status->res);
    TEST_ASSERT_EQUAL_UINT32(interfaceFailure.id, status->mesh_packet_id);
    pipelineService->releaseQueueStatusToPool(status);
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouting->ackCalls,
                                     "interface errors are reported to the client through QueueStatus, not a routing NAK");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, pipelineRouter->pendingCount(),
                                     "failed interface enqueue must not leave a retransmission pending");
}

// C14 needs a node that has used its whole hourly duty-cycle allowance. Swaps in a separate AirTime
// rather than poking the global's buckets, which are private now.
//
// Deliberately NOT a scoped guard: Unity's TEST_ABORT() is longjmp, which does not run destructors
// of automatic objects, so a guard would leave `airTime` dangling into an abandoned stack frame on
// any assertion failure - and later cases dereference it (NodeInfoModule::allocReply). tearDown()
// restores the global unconditionally instead. The instance is a function-local static so it
// outlives the longjmp.
//
// Note it also parks channel utilisation at ~6000%, because logAirtime() credits that for every
// report type. C14 gates on utilizationTXPercent() alone; do not reuse this for an
// isTxAllowedChannelUtil() path, which would then pass for the wrong reason.
static void useDutyCycleSaturatedAirTime()
{
    static AirTime saturated;
    c14SavedAirTime = airTime;
    airTime = &saturated;
    saturated.logAirtime(TX_LOG, MS_IN_HOUR); // utilizationTXPercent() sums every bucket -> 100%
}

void test_C14_duty_cycle_limited_reliable_send_remains_pending(void)
{
    config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
    config.lora.override_duty_cycle = false;
    initRegion();
    useDutyCycleSaturatedAirTime();

    meshtastic_MeshPacket initial = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD);
    initial.id = 0xC14C14C1;
    initial.want_ack = true;
    auto *packet = packetPool.allocCopy(initial);
    TEST_ASSERT_NOT_NULL(packet);

    TEST_ASSERT_EQUAL(meshtastic_Routing_Error_DUTY_CYCLE_LIMIT, pipelineRouter->send(packet));
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouting->ackCalls,
                                     "duty-cycle rejection must still notify the originating client");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouter->pendingCount(),
                                     "duty-cycle rejection must retain the retry for when airtime is available");

    config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
    initRegion();
}

void test_C15_reliable_unicast_tracks_five_total_attempts(void)
{
    meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD);
    p.id = 0x51530001;
    p.want_ack = true;

    TEST_ASSERT_EQUAL(ERRNO_OK, pipelineRouter->send(packetPool.allocCopy(p)));
    TEST_ASSERT_EQUAL_UINT8(5, pipelineRouter->pendingTotalAttempts(LOCAL_NODE, p.id));
}

void test_C16_reliable_broadcast_keeps_three_total_attempts(void)
{
    meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD);
    p.id = 0x51530002;
    p.want_ack = true;

    TEST_ASSERT_EQUAL(ERRNO_OK, pipelineRouter->send(packetPool.allocCopy(p)));
    TEST_ASSERT_EQUAL_UINT8(3, pipelineRouter->pendingTotalAttempts(LOCAL_NODE, p.id));
}

// C5: the packet survives (C4) but the identity claim inside it must not land - the pubkey guard
// can't tell a signer from an impersonator replaying its (public) key. Only the write is refused.
void test_N5_unsigned_unicast_nodeinfo_from_signer_does_not_change_name(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);
    mockNodeDB->setLongName(REMOTE_NODE, "Genuine");

    NodeInfoTestShim shim;
    meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD);
    mp.xeddsa_signed = false;
    meshtastic_User user = meshtastic_User_init_zero;
    user.is_licensed = owner.is_licensed;
    strcpy(user.long_name, "Spoofed");
    strcpy(user.short_name, "SPF");

    TEST_ASSERT_FALSE_MESSAGE(shim.handleReceivedProtobuf(mp, &user), "the packet itself must still be accepted");
    TEST_ASSERT_EQUAL_STRING_MESSAGE("Genuine", mockNodeDB->longName(REMOTE_NODE),
                                     "unsigned unicast NodeInfo from a signer must not rewrite its stored name");
}

// C6: the same exchange signed - the update is authenticated and must land, pinning C5 as a
// targeted refusal rather than a blanket block on unicast NodeInfo from signers.
void test_N6_signed_unicast_nodeinfo_from_signer_changes_name(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);
    mockNodeDB->setLongName(REMOTE_NODE, "Genuine");

    NodeInfoTestShim shim;
    meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD);
    mp.xeddsa_signed = true;
    meshtastic_User user = meshtastic_User_init_zero;
    user.is_licensed = owner.is_licensed;
    strcpy(user.long_name, "Renamed");
    strcpy(user.short_name, "RNM");

    TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user));
    TEST_ASSERT_EQUAL_STRING_MESSAGE("Renamed", mockNodeDB->longName(REMOTE_NODE),
                                     "a signed update from a signer must still be learned");
}

// C7: a node that has never signed is unaffected - the ordinary case for most of the mesh.
void test_N7_unsigned_unicast_nodeinfo_from_nonsigner_changes_name(void)
{
    mockNodeDB->addNode(REMOTE_NODE); // signer bit clear
    mockNodeDB->setLongName(REMOTE_NODE, "Genuine");

    NodeInfoTestShim shim;
    meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD);
    mp.xeddsa_signed = false;
    meshtastic_User user = meshtastic_User_init_zero;
    user.is_licensed = owner.is_licensed;
    strcpy(user.long_name, "Renamed");
    strcpy(user.short_name, "RNM");

    TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user));
    TEST_ASSERT_EQUAL_STRING_MESSAGE("Renamed", mockNodeDB->longName(REMOTE_NODE),
                                     "non-signer identity learning must be unaffected");
}

// ---------------------------------------------------------------------------
// N8-N11: the 12h reply-suppression window.
//
// The stamp is uptime SECONDS, not milliseconds: entries live for as long as the node stays in the
// DB, so a 32-bit millisecond stamp aliased back into the window once uptime passed 49.7 days and
// suppressed a legitimate reply for up to 12h. Driven through Time::setTestMillis() rather than by
// waiting.
// ---------------------------------------------------------------------------

static constexpr uint32_t kSuppressSecs = 12 * 60 * 60;

// Deliver a NodeInfo request from `sender` and report whether we would reply to it.
static bool wouldReplyToNodeInfoRequest(NodeInfoTestShim &shim, NodeNum sender)
{
    meshtastic_MeshPacket mp = makeDecoded(sender, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD);
    mp.decoded.want_response = true;
    meshtastic_User user = meshtastic_User_init_zero;
    user.is_licensed = owner.is_licensed;

    shim.handleReceivedProtobuf(mp, &user);

    NodeInfoTestShim::currentRequest = &mp;
    meshtastic_MeshPacket *reply = shim.allocReply();
    NodeInfoTestShim::currentRequest = nullptr;

    if (reply) {
        packetPool.release(reply);
        return true;
    }
    return false;
}

// Step the injected clock the way the main loop does - advance, then publish the wrap carry.
static void advanceUptime(uint32_t deltaMs)
{
    Time::advanceTestMillis(deltaMs);
    Time::serviceMonotonic();
}

void test_N8_second_request_inside_the_window_is_suppressed(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    Time::setTestMillis(60 * 1000);
    Time::serviceMonotonic();

    NodeInfoTestShim shim;
    TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), "first request must be answered");

    advanceUptime(60 * 60 * 1000); // 1h later, well inside the 12h window
    TEST_ASSERT_FALSE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), "repeat request inside 12h must be suppressed");
}

void test_N9_request_after_the_window_is_answered(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    Time::setTestMillis(60 * 1000);
    Time::serviceMonotonic();

    NodeInfoTestShim shim;
    TEST_ASSERT_TRUE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE));

    advanceUptime((kSuppressSecs + 60) * 1000); // 12h + a minute
    TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), "request after 12h must be answered");
}

// The regression. A stamp is only aliased by a counter that wraps underneath it, so the failure
// needs a *full* 2^32 ms of uptime to elapse, not merely a crossing of the boundary: with 32-bit
// millisecond stamps `now - stamp` then computes as 0 and the sender looks like it was answered
// this instant. Uptime seconds do not wrap for 136 years, so the entry reads as ~49.7 days old.
void test_N10_stale_stamp_does_not_alias_after_a_full_wrap(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    Time::setTestMillis(0x80000000u); // ~24.8 days of uptime
    Time::serviceMonotonic();

    NodeInfoTestShim shim;
    TEST_ASSERT_TRUE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE));

    // A whole millis() cycle, in two serviced halves - one publish per window is the contract.
    advanceUptime(0x80000000u);
    advanceUptime(0x80000000u);

    TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE),
                             "a stamp one full wrap old must read as ~49.7 days, not as this instant");
}

// Suppression must still behave normally either side of the boundary: still suppressing inside the
// window, and answering again once 12h have passed, with the stamp and the reading on opposite
// sides of the wrap.
void test_N11_window_still_applies_across_the_wrap(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    Time::setTestMillis(0xFFFF0000u); // just short of the wrap
    Time::serviceMonotonic();

    NodeInfoTestShim shim;
    TEST_ASSERT_TRUE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE));

    advanceUptime(0x20000u); // ~131s later, and now past the wrap
    TEST_ASSERT_FALSE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE),
                              "the window must still bite when the stamp sits the other side of the wrap");

    advanceUptime((kSuppressSecs + 60) * 1000);
    TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE),
                             "and must still release once 12h have passed across the wrap");
}

void test_L1_licensed_nodeinfo_publishes_public_key(void)
{
    owner.is_licensed = true;
    owner.public_key.size = 32;
    memset(owner.public_key.bytes, 0x5A, owner.public_key.size);

    NodeInfoTestShim shim;
    meshtastic_MeshPacket *reply = shim.allocReply();
    TEST_ASSERT_NOT_NULL(reply);
    meshtastic_User published = meshtastic_User_init_zero;
    TEST_ASSERT_TRUE(
        pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_User_msg, &published));
    TEST_ASSERT_TRUE(published.is_licensed);
    TEST_ASSERT_EQUAL(32, published.public_key.size);
    TEST_ASSERT_EQUAL_UINT8_ARRAY(owner.public_key.bytes, published.public_key.bytes, 32);
    packetPool.release(reply);
}

void test_L2_licensed_identity_key_is_generated_and_preserved(void)
{
    owner.is_licensed = true;
    config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
    config.security = meshtastic_Config_SecurityConfig_init_zero;

    TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair());
    uint8_t privateKey[32], publicKey[32];
    memcpy(privateKey, config.security.private_key.bytes, sizeof(privateKey));
    memcpy(publicKey, config.security.public_key.bytes, sizeof(publicKey));
    const NodeNum migratedNodeNum = myNodeInfo.my_node_num;
    TEST_ASSERT_NOT_EQUAL(LOCAL_NODE, migratedNodeNum);
    TEST_ASSERT_TRUE(mockNodeDB->licensedIdentityMigrationPending);

    MockMeshService mockService;
    service = &mockService;
    TEST_ASSERT_TRUE(mockNodeDB->notifyPendingLicensedIdentityMigration());
    TEST_ASSERT_EQUAL(1, mockService.notificationCount);
    TEST_ASSERT_FALSE(mockNodeDB->licensedIdentityMigrationPending);
    TEST_ASSERT_FALSE(mockNodeDB->notifyPendingLicensedIdentityMigration());
    TEST_ASSERT_EQUAL(1, mockService.notificationCount);
    service = pipelineService;

    config.security.public_key.size = 0;
    owner.public_key.size = 0;
    TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair());
    TEST_ASSERT_EQUAL(migratedNodeNum, myNodeInfo.my_node_num);
    TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey));
    TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, config.security.public_key.bytes, sizeof(publicKey));
    TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, owner.public_key.bytes, sizeof(publicKey));
}

void test_L3_factory_config_reset_preserves_valid_identity_private_key(void)
{
    uint8_t publicKey[32], privateKey[32];
    crypto->generateKeyPair(publicKey, privateKey);
    config.has_security = true;
    config.security.private_key.size = 32;
    memcpy(config.security.private_key.bytes, privateKey, sizeof(privateKey));

    mockNodeDB->installDefaultsPreservingIdentity();
    TEST_ASSERT_EQUAL(32, config.security.private_key.size);
    TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey));
    TEST_ASSERT_EQUAL(0, config.security.public_key.size);

    owner.is_licensed = true;
    config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
    TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair());
    TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey));
    TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, config.security.public_key.bytes, sizeof(publicKey));
}

void test_L4_licensed_low_entropy_identity_is_regenerated(void)
{
    static const uint8_t compromisedPublicKey[32] = {
        0xac, 0xaf, 0x8c, 0x1c, 0x3c, 0x1c, 0x37, 0xac, 0x4f, 0x03, 0xa1, 0xe9, 0xfc, 0x37, 0x23, 0x29,
        0xc8, 0xa3, 0x5d, 0x7f, 0x05, 0x26, 0xeb, 0x00, 0xbd, 0x26, 0xb8, 0x2e, 0xb1, 0x94, 0x7d, 0x24,
    };
    owner.is_licensed = true;
    config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
    config.security.private_key.size = 32;
    memset(config.security.private_key.bytes, 0xA5, 32);
    config.security.public_key.size = 32;
    memcpy(config.security.public_key.bytes, compromisedPublicKey, sizeof(compromisedPublicKey));
    TEST_ASSERT_TRUE(mockNodeDB->checkLowEntropyPublicKey(config.security.public_key));

    uint8_t oldPrivateKey[32];
    memcpy(oldPrivateKey, config.security.private_key.bytes, sizeof(oldPrivateKey));
    TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair());
    TEST_ASSERT_TRUE(mockNodeDB->keyIsLowEntropy);
    TEST_ASSERT_FALSE(mockNodeDB->checkLowEntropyPublicKey(config.security.public_key));
    TEST_ASSERT_FALSE(memcmp(oldPrivateKey, config.security.private_key.bytes, sizeof(oldPrivateKey)) == 0);
}

// ===========================================================================
// Group D - encoding invariants the routing gates depend on
// ===========================================================================

// D1: the encoded overhead of the signature field must be exactly XEDDSA_SIGNATURE_FIELD_BYTES
// (1 tag byte + 1 length byte + 64 signature bytes). The receiver downgrade predicate adds this
// constant to the unsigned size; this test pins that it matches the real wire overhead the
// sender's encoder produces, keeping the two sides symmetric. It drifts if the field number ever
// moves to >= 16 or the signature grows past 127 bytes.
void test_D1_signature_field_overhead_exact(void)
{
    meshtastic_Data d = meshtastic_Data_init_zero;
    d.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
    d.payload.size = 100;

    const size_t without = encodedDataSize(&d);
    d.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
    const size_t with = encodedDataSize(&d);

    TEST_ASSERT_EQUAL_MESSAGE(XEDDSA_SIGNATURE_FIELD_BYTES, with - without, "signature field wire overhead drifted");
}

// ===========================================================================
// Group E - decoded-ingress policy (checkXeddsaReceivePolicy)
// ===========================================================================
// Already-decoded packets never reach perhapsDecode's crypto path (it early-returns), so
// plaintext-MQTT downlink applies this policy function directly at ingress (MQTT.cpp). These
// tests drive it the same way: decoded packets, sized from p->decoded exactly as the RF path is.
// End-to-end MQTT wiring is covered in test_mqtt.

// E1: unsigned small broadcast from a known signer -> dropped (downgrade protection holds on
// the decoded-ingress path too - the F3 bypass).
void test_E1_decoded_unsigned_broadcast_from_signer_dropped(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);

    TEST_ASSERT_FALSE(checkXeddsaReceivePolicy(&p));
}

// E2: unsigned broadcast from a non-signer -> accepted.
void test_E2_decoded_unsigned_broadcast_from_nonsigner_accepted(void)
{
    mockNodeDB->addNode(REMOTE_NODE); // signer bit clear

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);

    TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p));
    TEST_ASSERT_FALSE(p.xeddsa_signed);
}

// E3: valid signature with a known key -> accepted, marked verified, signer bit learned.
void test_E3_decoded_valid_signature_verified_and_learns_signer(void)
{
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setPublicKey(REMOTE_NODE, pub);

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
    signWithCurrentKey(&p);

    TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p));
    TEST_ASSERT_TRUE(p.xeddsa_signed);
    TEST_ASSERT_TRUE_MESSAGE(remoteSignerBit(), "verified signature must set the signer bit");
}

// E4: corrupted signature with a known key -> dropped.
void test_E4_decoded_bad_signature_dropped(void)
{
    uint8_t pub[32], priv[32];
    crypto->generateKeyPair(pub, priv);
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setPublicKey(REMOTE_NODE, pub);

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
    signWithCurrentKey(&p);
    p.decoded.xeddsa_signature.bytes[0] ^= 0xFF;

    TEST_ASSERT_FALSE(checkXeddsaReceivePolicy(&p));
    TEST_ASSERT_FALSE(p.xeddsa_signed);
}

// E5: unsigned oversized broadcast from a signer -> accepted (packets whose signed encoding
// wouldn't fit are exempt, identically to the RF path: both size p->decoded).
void test_E5_decoded_unsigned_oversized_broadcast_from_signer_accepted(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, OVERSIZED_PAYLOAD);

    TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p));
}

// E6: Balanced accepts unsigned unicast from a signer for legacy compatibility.
void test_E6_decoded_unsigned_unicast_from_signer_accepted(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD);

    TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p));
}

// E8: a crafted partial (non-0, non-64) signature must not let a forged broadcast dodge the
// downgrade drop. A 63-byte junk signature inflates the encoded size past the fit threshold, so
// a size-only predicate would treat the packet as "too big to sign" and accept it as an
// impersonation of signer REMOTE. The malformed-size reject drops it before that math runs.
void test_E8_decoded_partial_signature_from_signer_dropped(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    // 146-byte payload sits in the band that WOULD fit a signature (so an honest unsigned one is a
    // downgrade), but the 63 bogus signature bytes push the raw size over the frame limit.
    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, 146);
    p.decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE - 1;
    memset(p.decoded.xeddsa_signature.bytes, 0xCD, p.decoded.xeddsa_signature.size);

    TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "partial signature from a signer must be dropped");
    TEST_ASSERT_FALSE(p.xeddsa_signed);
}

// E9: the malformed-size reject is unconditional - a partial signature is dropped even from a
// node we've never seen sign (an honest sender never emits a 1..63-byte signature field).
void test_E9_decoded_partial_signature_from_nonsigner_dropped(void)
{
    mockNodeDB->addNode(REMOTE_NODE); // signer bit clear

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
    p.decoded.xeddsa_signature.size = 10;
    memset(p.decoded.xeddsa_signature.bytes, 0x5A, p.decoded.xeddsa_signature.size);

    TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "partial signature must be dropped as malformed");
}

// Build an unsigned broadcast whose inner message is padded with an unknown field, and pin that the
// padding pushes the RAW size past the fit threshold - the exemption the attacker is buying - while
// the frame stays sendable. Without canonical inner sizing these packets are wrongly accepted.
static meshtastic_MeshPacket makePayloadPaddedBroadcast(meshtastic_PortNum port, const pb_msgdesc_t *fields, const void *inner,
                                                        size_t padLen)
{
    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, port, 0);
    const size_t innerLen = pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), fields, inner);
    TEST_ASSERT_GREATER_THAN_MESSAGE(0, innerLen, "failed to encode the spoofed inner message");
    p.decoded.payload.size =
        innerLen + appendUnknownField(p.decoded.payload.bytes + innerLen, sizeof(p.decoded.payload.bytes) - innerLen, padLen);

    TEST_ASSERT_FALSE_MESSAGE(signedEncodingFits(&p.decoded), "padding must push the raw size past the fit threshold");
    TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, encodedDataSize(&p.decoded) + MESHTASTIC_HEADER_LENGTH,
                                      "padded frame must still be one a radio could send");
    return p;
}

// E10: unknown fields buried inside Data.payload are discarded by the module's own pb_decode, so
// they must not sway the downgrade decision the way A10 already pins for Data-level unknown fields.
void test_E10_decoded_unsigned_position_padded_inside_payload_dropped(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    meshtastic_Position pos = meshtastic_Position_init_zero;
    pos.has_latitude_i = pos.has_longitude_i = true;
    pos.latitude_i = 371234567;
    pos.longitude_i = -1221234567;

    meshtastic_MeshPacket p = makePayloadPaddedBroadcast(meshtastic_PortNum_POSITION_APP, &meshtastic_Position_msg, &pos, 163);

    TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "payload-padded unsigned Position from a signer must be dropped");
    TEST_ASSERT_FALSE(p.xeddsa_signed);
}

// E11: over-correction guard. Telemetry (272 bytes max) and Waypoint (199) can legitimately exceed
// the signable budget, so canonical sizing must not shrink an honest one into the drop range.
void test_E11_decoded_unsigned_oversized_telemetry_from_signer_accepted(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    meshtastic_Telemetry t = meshtastic_Telemetry_init_zero;
    t.which_variant = meshtastic_Telemetry_host_metrics_tag;
    t.variant.host_metrics.uptime_seconds = 123456;
    t.variant.host_metrics.has_user_string = true;
    memset(t.variant.host_metrics.user_string, 'x', sizeof(t.variant.host_metrics.user_string) - 1);

    meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TELEMETRY_APP, 0);
    p.decoded.payload.size =
        pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_Telemetry_msg, &t);
    TEST_ASSERT_GREATER_THAN_MESSAGE(0, p.decoded.payload.size, "failed to encode the oversized Telemetry");

    // Every byte here is a field this build understands, so canonical sizing must leave it alone.
    TEST_ASSERT_FALSE_MESSAGE(signedEncodingFits(&p.decoded), "telemetry must be too big to sign, else the test is vacuous");

    TEST_ASSERT_TRUE_MESSAGE(checkXeddsaReceivePolicy(&p), "honest oversized telemetry from a signer must not be dropped");
}

// E12: E10 for the Waypoint branch of the canonical-sizing switch.
void test_E12_decoded_unsigned_waypoint_padded_inside_payload_dropped(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    meshtastic_Waypoint w = meshtastic_Waypoint_init_zero;
    w.id = 42;
    w.has_latitude_i = w.has_longitude_i = true;
    w.latitude_i = 371234567;
    w.longitude_i = -1221234567;
    strcpy(w.name, "spoofed");

    meshtastic_MeshPacket p = makePayloadPaddedBroadcast(meshtastic_PortNum_WAYPOINT_APP, &meshtastic_Waypoint_msg, &w, 150);

    TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "payload-padded unsigned Waypoint from a signer must be dropped");
    TEST_ASSERT_FALSE(p.xeddsa_signed);
}

// E13: E10 for the NodeInfo/User branch. Router drops it before rebroadcast; NodeInfoModule's own
// check (Group C) is receiver-local and would not stop the packet propagating.
void test_E13_decoded_unsigned_nodeinfo_padded_inside_payload_dropped(void)
{
    mockNodeDB->addNode(REMOTE_NODE);
    mockNodeDB->setSignerBit(REMOTE_NODE, true);

    meshtastic_User u = meshtastic_User_init_zero;
    strcpy(u.id, "!0b0b0b0b");
    strcpy(u.long_name, "spoofed node");
    strcpy(u.short_name, "SPF");

    meshtastic_MeshPacket p = makePayloadPaddedBroadcast(meshtastic_PortNum_NODEINFO_APP, &meshtastic_User_msg, &u, 150);

    TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "payload-padded unsigned NodeInfo from a signer must be dropped");
    TEST_ASSERT_FALSE(p.xeddsa_signed);
}

void setup()
{
    initializeTestEnvironment();
    AirTime *savedAirTime = airTime;
    meshtastic::NodeStatus *savedNodeStatus = nodeStatus;
    AirTime testAirTime;
    meshtastic::NodeStatus testNodeStatus;
    airTime = &testAirTime;
    nodeStatus = &testNodeStatus;

    config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
    initRegion();
    pipelineRouter = new AuthPipelineRouter();
    auto pipelineRadioOwner = std::make_unique<AuthPipelineRadio>();
    pipelineRadio = pipelineRadioOwner.get();
    pipelineRouter->addInterface(std::move(pipelineRadioOwner));
    router = pipelineRouter;
    routingModule = pipelineRouting = new AuthPipelineRoutingModule();
    pipelineModule = new AuthPipelineModule();
    service = pipelineService = new MeshService();
    mqtt = pipelineMqtt = new AuthPipelineMqtt();

    UNITY_BEGIN();

    printf("\n=== Group A: receive-side accept/reject ===\n");
    RUN_TEST(test_A1_valid_signature_accepted_and_learns_signer);
    RUN_TEST(test_A2_bad_signature_dropped);
    RUN_TEST(test_A3_signed_no_pubkey_accepted_unverified);
    RUN_TEST(test_A4_downgrade_unsigned_broadcast_from_signer_dropped);
    RUN_TEST(test_A5_unsigned_broadcast_from_nonsigner_accepted);
    RUN_TEST(test_A6_unsigned_unicast_from_signer_accepted);
    RUN_TEST(test_A7_unsigned_oversized_broadcast_from_signer_accepted);
    RUN_TEST(test_A8_unsigned_deadband_broadcast_from_signer_accepted);
    RUN_TEST(test_A9_unsigned_boundary_broadcast_from_signer_still_dropped);
    RUN_TEST(test_A10_compatible_accepts_unsigned_broadcast_from_signer);
    RUN_TEST(test_A11_strict_rejects_unsigned_all_portnums_destinations_and_sizes);
    RUN_TEST(test_A12_strict_rejects_signed_packet_without_key);
    RUN_TEST(test_A13_strict_accepts_locally_authenticated_pki_packet);
    RUN_TEST(test_A13b_strict_rejects_spoofed_pki_flag_on_encrypted_ingress);
    RUN_TEST(test_A14_strict_bootstraps_identity_bound_signed_nodeinfo);
    RUN_TEST(test_A15_strict_rejects_nodeinfo_key_without_identity_binding);
    RUN_TEST(test_A16_compatible_rejects_invalid_first_contact_nodeinfo);
#if WARM_NODE_COUNT > 0
    RUN_TEST(test_A17_strict_verifies_signer_from_warm_key_store);
#endif
    RUN_TEST(test_A18_unsigned_broadcast_from_signer_with_unknown_fields_dropped);
    RUN_TEST(test_A19_unsigned_broadcast_from_nonsigner_with_unknown_fields_accepted);

    printf("\n=== Group B: send-side signing policy ===\n");
    RUN_TEST(test_B1_local_broadcast_is_signed);
    RUN_TEST(test_B2_local_unicast_not_signed);
    RUN_TEST(test_B3_local_oversized_broadcast_not_signed);
    RUN_TEST(test_B4_all_broadcast_sizes_deliverable_no_deadband);
    RUN_TEST(test_B5_preset_signature_on_local_packet_cleared);
    RUN_TEST(test_B6_rich_shape_sweep_no_deadband);
    RUN_TEST(test_B7_infrastructure_port_signing_matrix);
    RUN_TEST(test_B8_licensed_broadcast_and_unicast_are_signed);
    RUN_TEST(test_B9_licensed_unicast_never_uses_pki_encryption);
    RUN_TEST(test_B10_licensed_oversized_unicast_remains_unsigned);
    RUN_TEST(test_B11_normal_unicast_still_uses_pki);
    RUN_TEST(test_B12_licensed_receiver_does_not_decrypt_pki);
    RUN_TEST(test_B13_licensed_port_and_destination_signing_matrix);

    printf("\n=== Group C: routing pipeline authentication ordering ===\n");
    RUN_TEST(test_C1_invalid_first_copy_does_not_poison_valid_same_id);
    RUN_TEST(test_C2_invalid_ordinary_duplicate_has_no_cancel_or_delivery_effects);
    RUN_TEST(test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state);
    RUN_TEST(test_C4_invalid_fallback_packet_cannot_relay);
    RUN_TEST(test_C5_invalid_upgrade_cannot_remove_pending_valid_send);
    RUN_TEST(test_C6_opaque_unknown_channel_is_relay_only);
    RUN_TEST(test_C7_strict_rejects_unsigned_decoded_simradio_ingress);
    RUN_TEST(test_C8_trusted_local_decoded_delivery_is_not_filtered);
    RUN_TEST(test_C9_known_channel_malformed_plaintext_is_not_relayed_as_opaque);
    RUN_TEST(test_C10_legacy_channel_dm_failure_has_no_pipeline_effects);
    RUN_TEST(test_C11_malformed_pki_plaintext_has_no_pipeline_effects);
    RUN_TEST(test_C12_exact_authenticated_replay_reuses_verdict_without_collision_bypass);
    RUN_TEST(test_C13_failed_initial_reliable_send_does_not_retry);
    RUN_TEST(test_C14_duty_cycle_limited_reliable_send_remains_pending);
    RUN_TEST(test_C15_reliable_unicast_tracks_five_total_attempts);
    RUN_TEST(test_C16_reliable_broadcast_keeps_three_total_attempts);
    printf("\n=== Group N: NodeInfoModule authentication ===\n");
    RUN_TEST(test_N1_unsigned_nodeinfo_from_signer_dropped);
    RUN_TEST(test_N2_signed_nodeinfo_from_signer_not_dropped);
    RUN_TEST(test_N3_unsigned_nodeinfo_from_nonsigner_not_dropped);
    RUN_TEST(test_N4_unsigned_unicast_nodeinfo_from_signer_accepted);
    RUN_TEST(test_N5_unsigned_unicast_nodeinfo_from_signer_does_not_change_name);
    RUN_TEST(test_N6_signed_unicast_nodeinfo_from_signer_changes_name);
    RUN_TEST(test_N7_unsigned_unicast_nodeinfo_from_nonsigner_changes_name);
    RUN_TEST(test_N8_second_request_inside_the_window_is_suppressed);
    RUN_TEST(test_N9_request_after_the_window_is_answered);
    RUN_TEST(test_N10_stale_stamp_does_not_alias_after_a_full_wrap);
    RUN_TEST(test_N11_window_still_applies_across_the_wrap);

    printf("\n=== Group L: licensed identity and plaintext signing ===\n");
    RUN_TEST(test_L1_licensed_nodeinfo_publishes_public_key);
    RUN_TEST(test_L2_licensed_identity_key_is_generated_and_preserved);
    RUN_TEST(test_L3_factory_config_reset_preserves_valid_identity_private_key);
    RUN_TEST(test_L4_licensed_low_entropy_identity_is_regenerated);

    printf("\n=== Group D: encoding invariants ===\n");
    RUN_TEST(test_D1_signature_field_overhead_exact);

    printf("\n=== Group E: decoded-ingress policy ===\n");
    RUN_TEST(test_E1_decoded_unsigned_broadcast_from_signer_dropped);
    RUN_TEST(test_E2_decoded_unsigned_broadcast_from_nonsigner_accepted);
    RUN_TEST(test_E3_decoded_valid_signature_verified_and_learns_signer);
    RUN_TEST(test_E4_decoded_bad_signature_dropped);
    RUN_TEST(test_E5_decoded_unsigned_oversized_broadcast_from_signer_accepted);
    RUN_TEST(test_E6_decoded_unsigned_unicast_from_signer_accepted);
    RUN_TEST(test_E8_decoded_partial_signature_from_signer_dropped);
    RUN_TEST(test_E9_decoded_partial_signature_from_nonsigner_dropped);
    RUN_TEST(test_E10_decoded_unsigned_position_padded_inside_payload_dropped);
    RUN_TEST(test_E11_decoded_unsigned_oversized_telemetry_from_signer_accepted);
    RUN_TEST(test_E12_decoded_unsigned_waypoint_padded_inside_payload_dropped);
    RUN_TEST(test_E13_decoded_unsigned_nodeinfo_padded_inside_payload_dropped);

    const int result = UNITY_END();
    airTime = savedAirTime;
    nodeStatus = savedNodeStatus;
    exit(result);
}

void loop() {}

#else // XEdDSA or PKI excluded

void setUp(void) {}
void tearDown(void) {}
void setup()
{
    initializeTestEnvironment();
    UNITY_BEGIN();
    exit(UNITY_END());
}
void loop() {}

#endif
