logos-delivery/tests/messaging/test_rate_limit_manager.nim
stubbsta ef06f67a2a
feat: source the rate limit manager's epoch and budget from RLN
Wires the quota seam to its producer, so enforcement tracks RLN rather
than only the wall clock.

- Waku.currentRlnEpochQuota (waku/api/publish) reads RLN's current epoch
  index and the epoch's user message limit together, returning none when
  RLN is not mounted (or its limit is unset).
- MessagingClient.new builds a QuotaProvider closure over that accessor
  and hands it to the RateLimitManager. The closure is late-binding: it
  queries the kernel on each admission, so a node whose RLN mounts after
  construction upgrades from the wall-clock fallback to RLN's epoch and
  limit automatically, with no reconstruction.

With this, admit() rolls its window on RLN's epoch and clamps the
configured cap to RLN's user message limit; without RLN it still falls
back to the absolute wall-clock window and the configured limit.

Also switches the quota seam from std/options to results `Opt`, matching
the kernel surface it now bridges (`groupManager.userMessageLimit` is
`Opt`) and the rest of the messaging layer post-Opt migration.

Tests: currentRlnEpochQuota is none unmounted and reports epoch + the
configured userMessageLimit when mounted (anvil-backed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:40:00 +02:00

87 lines
3.7 KiB
Nim

{.used.}
import results, chronos, testutils/unittests, stew/byteutils
import logos_delivery/messaging/rate_limit_manager/rate_limit_manager
proc fixedQuota(epochIndex, userMessageLimit: uint64): QuotaProvider =
## A quota source pinned to one epoch — the epoch never rolls on its own, so
## limit-boundary tests are deterministic without touching the wall clock.
return proc(): Opt[EpochQuota] {.gcsafe, raises: [].} =
Opt.some(EpochQuota(epochIndex: epochIndex, userMessageLimit: userMessageLimit))
suite "RateLimitManager - admission":
asyncTest "admit is a pass-through when disabled":
let rl = RateLimitManager.new(
RateLimitConfig(enabled: false, epochPeriodSec: 600, messagesPerEpoch: 1)
)
for _ in 0 ..< 10:
check (await rl.admit("payload".toBytes())).isOk()
asyncTest "zeroed limit or epoch period is treated as disabled":
let zeroLimit = RateLimitManager.new(
RateLimitConfig(enabled: true, epochPeriodSec: 600, messagesPerEpoch: 0)
)
check (await zeroLimit.admit("a".toBytes())).isOk()
let zeroEpoch = RateLimitManager.new(
RateLimitConfig(enabled: true, epochPeriodSec: 0, messagesPerEpoch: 1)
)
check (await zeroEpoch.admit("a".toBytes())).isOk()
check (await zeroEpoch.admit("b".toBytes())).isOk()
asyncTest "admits up to the message limit then rejects with OverBudget":
## Fixed-epoch quota so the window cannot roll mid-test.
let rl = RateLimitManager.new(
RateLimitConfig(enabled: true, epochPeriodSec: 600, messagesPerEpoch: 3),
fixedQuota(epochIndex = 42, userMessageLimit = 100),
)
for i in 0 ..< 3:
check (await rl.admit(("msg" & $i).toBytes())).isOk()
let res = await rl.admit("over".toBytes())
check:
res.isErr()
res.error == RateLimitError.OverBudget
asyncTest "allowance refills when the epoch rolls over":
## Drive the roll through the provider — no sleeps, no flake.
var epoch = 1'u64
let rl = RateLimitManager.new(
RateLimitConfig(enabled: true, epochPeriodSec: 600, messagesPerEpoch: 1),
proc(): Opt[EpochQuota] {.gcsafe, raises: [].} =
Opt.some(EpochQuota(epochIndex: epoch, userMessageLimit: 100)),
)
check (await rl.admit("first".toBytes())).isOk()
check (await rl.admit("second".toBytes())).isErr()
epoch = 2
check (await rl.admit("third".toBytes())).isOk()
check (await rl.admit("fourth".toBytes())).isErr()
asyncTest "RLN user message limit clamps a looser configured cap":
## config cap 5, RLN grants 2 — the third admission must reject, since
## exceeding RLN's limit would fail at proof generation anyway.
let rl = RateLimitManager.new(
RateLimitConfig(enabled: true, epochPeriodSec: 600, messagesPerEpoch: 5),
fixedQuota(epochIndex = 7, userMessageLimit = 2),
)
check (await rl.admit("a".toBytes())).isOk()
check (await rl.admit("b".toBytes())).isOk()
check (await rl.admit("c".toBytes())).isErr()
asyncTest "config cap can tighten below the RLN limit":
## config cap 1, RLN grants 100 — the config cap wins.
let rl = RateLimitManager.new(
RateLimitConfig(enabled: true, epochPeriodSec: 600, messagesPerEpoch: 1),
fixedQuota(epochIndex = 7, userMessageLimit = 100),
)
check (await rl.admit("a".toBytes())).isOk()
check (await rl.admit("b".toBytes())).isErr()
asyncTest "falls back to the wall-clock window when no quota source is set":
## No provider: rate limiting still enforces within a single wall-clock epoch.
let rl = RateLimitManager.new(
RateLimitConfig(enabled: true, epochPeriodSec: 600, messagesPerEpoch: 1)
)
check (await rl.admit("first".toBytes())).isOk()
check (await rl.admit("second".toBytes())).isErr()