From 13935baafddae8977b3ce6fe52ac9ba19702eb9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Fri, 17 Jul 2026 11:43:10 +0200 Subject: [PATCH] Exempt home region from duty-cycle gating; restructure tests by scenario The home region is an explicit operator statement of which cluster this repeater serves, which the innermost-depth heuristic alone can't express (the home region may not be at the deepest level of the hierarchy). Never gate it, in addition to the always-protected innermost layer. Repeaters without a home region set keep the pure depth-based behaviour. Also restructure the gating tests into one-scenario-per-test cases with complete per-region assertions (review feedback), and add coverage for the home region exemption. Co-Authored-By: Claude Fable 5 --- src/helpers/RegionMap.cpp | 5 +- src/helpers/RegionMap.h | 3 +- .../test_region_gating/test_region_gating.cpp | 187 +++++++++++++----- 3 files changed, 138 insertions(+), 57 deletions(-) diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index 7da1209b..3db78854 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -350,11 +350,12 @@ void RegionMap::applyDutyGate(uint8_t level) { else wildcard.rt_flags &= (uint8_t)~REGION_DENY_FLOOD; // Named regions: level 2 gates depth 1, level 3 gates depth 2, ... The innermost - // layer (depth == maxDepth) is always protected. + // layer (depth == maxDepth) is always protected, as is the home region (an explicit + // operator signal of this repeater's own cluster, which depth alone can't express). for (int i = 0; i < num_regions; i++) { RegionEntry* r = ®ions[i]; int d = depthOf(r); - bool gate = (level >= 2) && (d <= (int)level - 1) && (d < maxDepth); + bool gate = (level >= 2) && (d <= (int)level - 1) && (d < maxDepth) && (r->id != home_id); if (gate) r->rt_flags |= REGION_DENY_FLOOD; else r->rt_flags &= (uint8_t)~REGION_DENY_FLOOD; } diff --git a/src/helpers/RegionMap.h b/src/helpers/RegionMap.h index ae33c214..5bfd1f0e 100644 --- a/src/helpers/RegionMap.h +++ b/src/helpers/RegionMap.h @@ -60,7 +60,8 @@ public: // --- duty-cycle region gating (transient, via rt_flags; see applyDutyGate) --- int getMaxDepth() const; // deepest named-region depth (wildcard=0, its children=1, ...) uint8_t getMaxGateLevel() const; // highest gate level that still protects the innermost cluster - void applyDutyGate(uint8_t level); // gate '*' first (level>=1), then broadest depth inward + void applyDutyGate(uint8_t level); // gate '*' first (level>=1), then broadest depth inward; + // the innermost layer and the home region are never gated int exportNamesTo(char *dest, int max_len, uint8_t mask, bool invert = false); int getTransportKeysFor(const RegionEntry& src, TransportKey dest[], int max_num); diff --git a/test/test_region_gating/test_region_gating.cpp b/test/test_region_gating/test_region_gating.cpp index e4b8d943..1d6703ce 100644 --- a/test/test_region_gating/test_region_gating.cpp +++ b/test/test_region_gating/test_region_gating.cpp @@ -3,120 +3,195 @@ // Unit tests for duty-cycle region gating (issue #2747). // These exercise the pure hierarchy/level logic; no crypto or filesystem is touched. +// Each test covers one gating scenario (one level, one transition, or one invariant) +// so a failure points directly at the behaviour that broke. static bool gated(RegionMap& rm, const char* name) { RegionEntry* r = rm.findByName(name); // findByName("*") returns the wildcard return r && (r->rt_flags & REGION_DENY_FLOOD); } -// Build: * > eu > nl > nl-ge > nl-ge-nij (a single deep chain) -static void buildNlChain(RegionMap& rm) { - RegionEntry* eu = rm.putRegion("eu", 0); - RegionEntry* nl = rm.putRegion("nl", eu->id); - RegionEntry* nlge = rm.putRegion("nl-ge", nl->id); - rm.putRegion("nl-ge-nij", nlge->id); -} +// Fixture: * > eu > nl > nl-ge > nl-ge-nij (a single deep chain) +class NlChainGating : public ::testing::Test { +protected: + TransportKeyStore ks; + RegionMap rm{ks}; + + void SetUp() override { + RegionEntry* eu = rm.putRegion("eu", 0); + RegionEntry* nl = rm.putRegion("nl", eu->id); + RegionEntry* nlge = rm.putRegion("nl-ge", nl->id); + rm.putRegion("nl-ge-nij", nlge->id); + } +}; + +// Fixture: * > cz > { cz-ulk, cz-stc, cz-lbk } (three regions on the same level) +class CzFlatGating : public ::testing::Test { +protected: + TransportKeyStore ks; + RegionMap rm{ks}; -// Build: * > cz > { cz-ulk, cz-stc, cz-lbk } (three regions on the same level) -static void buildCzFlat(RegionMap& rm) { - RegionEntry* cz = rm.putRegion("cz", 0); - rm.putRegion("cz-ulk", cz->id); - rm.putRegion("cz-stc", cz->id); - rm.putRegion("cz-lbk", cz->id); -} + void SetUp() override { + RegionEntry* cz = rm.putRegion("cz", 0); + rm.putRegion("cz-ulk", cz->id); + rm.putRegion("cz-stc", cz->id); + rm.putRegion("cz-lbk", cz->id); + } +}; -TEST(RegionGating, DepthAndMaxGateLevel) { - TransportKeyStore ks; - RegionMap rm(ks); - buildNlChain(rm); +// ---------- depth model ---------- + +TEST_F(NlChainGating, MaxDepthCountsChainDepth) { EXPECT_EQ(rm.getMaxDepth(), 4); +} + +TEST_F(NlChainGating, MaxGateLevelEqualsMaxDepth) { EXPECT_EQ(rm.getMaxGateLevel(), 4); } -TEST(RegionGating, GatesWildcardFirstThenOutermostInward) { - TransportKeyStore ks; - RegionMap rm(ks); - buildNlChain(rm); +// ---------- gating, level by level ---------- +TEST_F(NlChainGating, LevelZeroGatesNothing) { rm.applyDutyGate(0); EXPECT_FALSE(gated(rm, "*")); EXPECT_FALSE(gated(rm, "eu")); + EXPECT_FALSE(gated(rm, "nl")); + EXPECT_FALSE(gated(rm, "nl-ge")); + EXPECT_FALSE(gated(rm, "nl-ge-nij")); +} - rm.applyDutyGate(1); // wildcard first +TEST_F(NlChainGating, LevelOneGatesOnlyWildcard) { + rm.applyDutyGate(1); EXPECT_TRUE(gated(rm, "*")); EXPECT_FALSE(gated(rm, "eu")); + EXPECT_FALSE(gated(rm, "nl")); + EXPECT_FALSE(gated(rm, "nl-ge")); + EXPECT_FALSE(gated(rm, "nl-ge-nij")); +} - rm.applyDutyGate(2); // + broadest named region +TEST_F(NlChainGating, LevelTwoAddsBroadestNamedRegion) { + rm.applyDutyGate(2); EXPECT_TRUE(gated(rm, "*")); EXPECT_TRUE(gated(rm, "eu")); EXPECT_FALSE(gated(rm, "nl")); + EXPECT_FALSE(gated(rm, "nl-ge")); + EXPECT_FALSE(gated(rm, "nl-ge-nij")); +} +TEST_F(NlChainGating, LevelThreeGatesDownToSecondDeepest) { rm.applyDutyGate(3); + EXPECT_TRUE(gated(rm, "*")); + EXPECT_TRUE(gated(rm, "eu")); EXPECT_TRUE(gated(rm, "nl")); EXPECT_FALSE(gated(rm, "nl-ge")); + EXPECT_FALSE(gated(rm, "nl-ge-nij")); +} - rm.applyDutyGate(4); // max: everything except the innermost cluster +TEST_F(NlChainGating, MaxLevelProtectsOnlyInnermostCluster) { + rm.applyDutyGate(4); + EXPECT_TRUE(gated(rm, "*")); + EXPECT_TRUE(gated(rm, "eu")); + EXPECT_TRUE(gated(rm, "nl")); EXPECT_TRUE(gated(rm, "nl-ge")); - EXPECT_FALSE(gated(rm, "nl-ge-nij")); // innermost local cluster is always protected + EXPECT_FALSE(gated(rm, "nl-ge-nij")); } -TEST(RegionGating, InnermostClusterNeverGatedEvenAboveMax) { - TransportKeyStore ks; - RegionMap rm(ks); - buildNlChain(rm); - rm.applyDutyGate(99); // clamped behaviour +TEST_F(NlChainGating, LevelAboveMaxStillProtectsInnermostCluster) { + rm.applyDutyGate(99); EXPECT_TRUE(gated(rm, "nl-ge")); EXPECT_FALSE(gated(rm, "nl-ge-nij")); } -TEST(RegionGating, RecoveryReenablesInsideOut) { - TransportKeyStore ks; - RegionMap rm(ks); - buildNlChain(rm); +// ---------- recovery ---------- +TEST_F(NlChainGating, RecoveryStepDownReenablesInsideOut) { rm.applyDutyGate(4); - EXPECT_TRUE(gated(rm, "eu")); - EXPECT_TRUE(gated(rm, "nl-ge")); - rm.applyDutyGate(2); // step back down: nl and nl-ge come back first EXPECT_TRUE(gated(rm, "*")); EXPECT_TRUE(gated(rm, "eu")); EXPECT_FALSE(gated(rm, "nl")); EXPECT_FALSE(gated(rm, "nl-ge")); + EXPECT_FALSE(gated(rm, "nl-ge-nij")); +} - rm.applyDutyGate(0); // fully recovered +TEST_F(NlChainGating, RecoveryToZeroClearsAllGates) { + rm.applyDutyGate(4); + rm.applyDutyGate(0); EXPECT_FALSE(gated(rm, "*")); EXPECT_FALSE(gated(rm, "eu")); + EXPECT_FALSE(gated(rm, "nl")); + EXPECT_FALSE(gated(rm, "nl-ge")); + EXPECT_FALSE(gated(rm, "nl-ge-nij")); } -TEST(RegionGating, MultipleRegionsOnSameLevelGateTogetherAndLeavesProtected) { - TransportKeyStore ks; - RegionMap rm(ks); - buildCzFlat(rm); +// ---------- home region exemption ---------- + +TEST_F(NlChainGating, HomeRegionIsNeverGated) { + rm.setHomeRegion(rm.findByName("nl-ge")); // home NOT at the deepest level + rm.applyDutyGate(4); + EXPECT_TRUE(gated(rm, "*")); + EXPECT_TRUE(gated(rm, "eu")); + EXPECT_TRUE(gated(rm, "nl")); + EXPECT_FALSE(gated(rm, "nl-ge")); // exempt: it's the home region + EXPECT_FALSE(gated(rm, "nl-ge-nij")); // exempt: innermost layer +} + +TEST_F(NlChainGating, BroadHomeRegionIsAlsoExempt) { + rm.setHomeRegion(rm.findByName("eu")); // operator chose a broad home region + rm.applyDutyGate(4); + EXPECT_TRUE(gated(rm, "*")); + EXPECT_FALSE(gated(rm, "eu")); // exempt, even though it is depth 1 + EXPECT_TRUE(gated(rm, "nl")); + EXPECT_TRUE(gated(rm, "nl-ge")); + EXPECT_FALSE(gated(rm, "nl-ge-nij")); +} + +TEST_F(NlChainGating, ClearingHomeRegionRestoresDepthOnlyGating) { + rm.setHomeRegion(rm.findByName("nl-ge")); + rm.applyDutyGate(4); + rm.setHomeRegion(NULL); + rm.applyDutyGate(4); // gate state is recomputed on every application + EXPECT_TRUE(gated(rm, "nl-ge")); + EXPECT_FALSE(gated(rm, "nl-ge-nij")); +} + +// ---------- multiple regions on the same level ---------- + +TEST_F(CzFlatGating, MaxGateLevelForTwoLevelTree) { EXPECT_EQ(rm.getMaxDepth(), 2); EXPECT_EQ(rm.getMaxGateLevel(), 2); +} - rm.applyDutyGate(1); // wildcard only +TEST_F(CzFlatGating, LevelOneGatesOnlyWildcard) { + rm.applyDutyGate(1); EXPECT_TRUE(gated(rm, "*")); EXPECT_FALSE(gated(rm, "cz")); + EXPECT_FALSE(gated(rm, "cz-ulk")); + EXPECT_FALSE(gated(rm, "cz-stc")); + EXPECT_FALSE(gated(rm, "cz-lbk")); +} - rm.applyDutyGate(2); // wildcard + cz; the three leaf regions stay up +TEST_F(CzFlatGating, MaxLevelKeepsAllLeavesOnSameLevel) { + rm.applyDutyGate(2); + EXPECT_TRUE(gated(rm, "*")); EXPECT_TRUE(gated(rm, "cz")); EXPECT_FALSE(gated(rm, "cz-ulk")); EXPECT_FALSE(gated(rm, "cz-stc")); EXPECT_FALSE(gated(rm, "cz-lbk")); } -TEST(RegionGating, LoneWildcardIsNeverGated) { +// ---------- degenerate configurations ---------- + +TEST(RegionGatingEdgeCases, LoneWildcardIsNeverGated) { TransportKeyStore ks; - RegionMap rm(ks); // no named regions + RegionMap rm(ks); // no named regions: the wildcard IS the local cluster EXPECT_EQ(rm.getMaxDepth(), 0); EXPECT_EQ(rm.getMaxGateLevel(), 0); rm.applyDutyGate(1); - EXPECT_FALSE(gated(rm, "*")); // wildcard is the local cluster here, keep serving + EXPECT_FALSE(gated(rm, "*")); } -TEST(RegionGating, SingleNamedRegionGatesWildcardButKeepsRegion) { +TEST(RegionGatingEdgeCases, SingleNamedRegionGatesWildcardButKeepsRegion) { TransportKeyStore ks; RegionMap rm(ks); rm.putRegion("cz", 0); @@ -126,11 +201,9 @@ TEST(RegionGating, SingleNamedRegionGatesWildcardButKeepsRegion) { EXPECT_FALSE(gated(rm, "cz")); } -TEST(RegionGating, RuntimeGateIsSeparateFromConfigFlags) { - TransportKeyStore ks; - RegionMap rm(ks); - buildNlChain(rm); +// ---------- config flags vs runtime gate ---------- +TEST_F(NlChainGating, RuntimeGateDoesNotTouchConfigFlags) { RegionEntry* eu = rm.findByName("eu"); eu->flags &= (uint8_t)~REGION_DENY_FLOOD; // admin: this region allows flood @@ -138,8 +211,14 @@ TEST(RegionGating, RuntimeGateIsSeparateFromConfigFlags) { EXPECT_TRUE(eu->rt_flags & REGION_DENY_FLOOD); EXPECT_FALSE(eu->flags & REGION_DENY_FLOOD); // config untouched EXPECT_TRUE(eu->effectiveFlags() & REGION_DENY_FLOOD); +} - rm.applyDutyGate(0); // recover +TEST_F(NlChainGating, RecoveryRestoresConfiguredBehaviour) { + RegionEntry* eu = rm.findByName("eu"); + eu->flags &= (uint8_t)~REGION_DENY_FLOOD; + + rm.applyDutyGate(2); + rm.applyDutyGate(0); EXPECT_FALSE(eu->rt_flags & REGION_DENY_FLOOD); EXPECT_FALSE(eu->effectiveFlags() & REGION_DENY_FLOOD); // flood allowed again }