Files
buzz/desktop/src/shared/api/relayRateLimitGate.test.mjs
T

352 lines
11 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
// ── Fake-timer setup ──────────────────────────────────────────────────────────
// The gate uses window.setTimeout/clearTimeout. We need a controllable fake
// before the module is loaded. Set up globalThis.window with a fake timer
// implementation and then dynamically import the module under test.
let fakeNow = 0;
const pendingTimers = new Map(); // id → { fn, fireAt }
let nextTimerId = 1;
function fakeSetTimeout(fn, ms) {
const id = nextTimerId++;
pendingTimers.set(id, { fn, fireAt: fakeNow + ms });
return id;
}
function fakeClearTimeout(id) {
pendingTimers.delete(id);
}
function tickTo(ms) {
fakeNow = ms;
for (const [id, { fn, fireAt }] of Array.from(pendingTimers.entries())) {
if (fireAt <= fakeNow) {
pendingTimers.delete(id);
fn();
}
}
}
// Install window shim before any imports use it.
globalThis.window = {
setTimeout: fakeSetTimeout,
clearTimeout: fakeClearTimeout,
};
// Patch Date.now for the gate.
const origDateNow = Date.now;
function setFakeNow(ms) {
fakeNow = ms;
Date.now = () => fakeNow;
}
// Import after window is set up.
const {
activateRateLimit,
isRateLimited,
waitForRateLimit,
resetRateLimitGate,
rateLimitRemainingMs,
parseRateLimitHint,
MAX_HINT_SECONDS,
} = await import("./relayRateLimitGate.ts");
// Helper to reset between tests.
function reset(startMs = 0) {
pendingTimers.clear();
nextTimerId = 1;
setFakeNow(startMs);
resetRateLimitGate();
}
// ── parseRateLimitHint ────────────────────────────────────────────────────────
test("parseRateLimitHint extracts seconds from CLOSED message", () => {
assert.equal(
parseRateLimitHint("rate-limited: quota exceeded; retry in 4s"),
4,
);
});
test("parseRateLimitHint extracts seconds from HTTP 429 prefix", () => {
assert.equal(parseRateLimitHint("relay rate-limited: retry in 30s"), 30);
});
test("parseRateLimitHint returns null when no hint present", () => {
assert.equal(parseRateLimitHint("rate-limited: quota exceeded"), null);
assert.equal(parseRateLimitHint(""), null);
assert.equal(parseRateLimitHint("some other message"), null);
});
// ── isRateLimited / activate ──────────────────────────────────────────────────
test("not rate-limited before any activation", () => {
reset();
assert.equal(isRateLimited(), false);
});
test("rate-limited immediately after activation", () => {
reset(0);
activateRateLimit(10);
assert.equal(isRateLimited(), true);
});
test("rate-limit expires when timer fires", () => {
reset(0);
activateRateLimit(10);
tickTo(10_001);
assert.equal(isRateLimited(), false);
});
test("activation extends expiry when new hint is longer than existing", () => {
reset(0);
activateRateLimit(5); // expires at 5000
activateRateLimit(20); // should extend to 20000
tickTo(5_001);
// Gate should still be active because the longer window was applied.
assert.equal(isRateLimited(), true);
tickTo(20_001);
assert.equal(isRateLimited(), false);
});
test("shorter hint does not shrink an existing longer window", () => {
reset(0);
activateRateLimit(20); // expires at 20000
activateRateLimit(5); // shorter — should NOT shrink
tickTo(5_001);
assert.equal(isRateLimited(), true);
tickTo(20_001);
assert.equal(isRateLimited(), false);
});
test("null hint uses 10s default", () => {
reset(0);
activateRateLimit(null);
tickTo(9_999);
assert.equal(isRateLimited(), true);
tickTo(10_001);
assert.equal(isRateLimited(), false);
});
test("zero hint uses 10s default (0s gate would be swallowed)", () => {
reset(0);
activateRateLimit(0);
tickTo(9_999);
assert.equal(isRateLimited(), true);
tickTo(10_001);
assert.equal(isRateLimited(), false);
});
test("negative hint uses 10s default", () => {
reset(0);
activateRateLimit(-5);
tickTo(9_999);
assert.equal(isRateLimited(), true);
tickTo(10_001);
assert.equal(isRateLimited(), false);
});
// ── MAX_HINT_SECONDS cap ──────────────────────────────────────────────────────
test("oversized hint is clamped to MAX_HINT_SECONDS", () => {
reset(0);
// A relay sending 1 000 000s must not pin the gate beyond 300s.
activateRateLimit(1_000_000);
// Gate must still be active at MAX_HINT_SECONDS - 1 ms.
tickTo(MAX_HINT_SECONDS * 1_000 - 1);
assert.equal(
isRateLimited(),
true,
"gate must be active just before cap expiry",
);
// Gate must expire at MAX_HINT_SECONDS.
tickTo(MAX_HINT_SECONDS * 1_000 + 1);
assert.equal(isRateLimited(), false, "gate must expire at MAX_HINT_SECONDS");
});
test("hint exactly at MAX_HINT_SECONDS is honoured without clamping", () => {
reset(0);
activateRateLimit(MAX_HINT_SECONDS);
tickTo(MAX_HINT_SECONDS * 1_000 - 1);
assert.equal(isRateLimited(), true);
tickTo(MAX_HINT_SECONDS * 1_000 + 1);
assert.equal(isRateLimited(), false);
});
test("applyTauriRateLimitIfNeeded with oversized hint clamps to MAX_HINT_SECONDS", async () => {
// This test imports applyTauriRateLimitIfNeeded separately and verifies that
// the TS cap applies even when the message string contains a large hint value
// (the Rust layer clamps in practice, but TS must be independently safe).
reset(0);
const { applyTauriRateLimitIfNeeded } = await import("./tauri.ts");
// Simulate a message that somehow escaped the Rust cap (defence-in-depth).
applyTauriRateLimitIfNeeded("relay rate-limited: retry in 1000000s");
// Gate should cap at MAX_HINT_SECONDS * 1000 ms.
tickTo(MAX_HINT_SECONDS * 1_000 - 1);
assert.equal(
isRateLimited(),
true,
"gate must still be active just before cap",
);
tickTo(MAX_HINT_SECONDS * 1_000 + 1);
assert.equal(
isRateLimited(),
false,
"gate must expire at MAX_HINT_SECONDS, not 1 000 000s",
);
});
// ── waitForRateLimit ──────────────────────────────────────────────────────────
test("waitForRateLimit resolves immediately when not rate-limited", async () => {
reset();
let resolved = false;
const p = waitForRateLimit().then(() => {
resolved = true;
});
await p;
assert.equal(resolved, true);
});
test("waitForRateLimit resolves after timer fires", async () => {
reset(0);
activateRateLimit(5);
let resolved = false;
const p = waitForRateLimit().then(() => {
resolved = true;
});
// Should not have resolved yet.
await Promise.resolve();
assert.equal(resolved, false);
// Fire the timer.
tickTo(5_001);
await p;
assert.equal(resolved, true);
});
test("multiple waiters all resolve when gate expires", async () => {
reset(0);
activateRateLimit(5);
const results = [];
const promises = [1, 2, 3].map((id) =>
waitForRateLimit().then(() => results.push(id)),
);
await Promise.resolve();
assert.equal(results.length, 0);
tickTo(5_001);
await Promise.all(promises);
assert.deepEqual(results, [1, 2, 3]);
});
// ── resetRateLimitGate ────────────────────────────────────────────────────────
test("resetRateLimitGate clears an active gate immediately", () => {
reset(0);
activateRateLimit(30);
assert.equal(isRateLimited(), true);
resetRateLimitGate();
assert.equal(isRateLimited(), false);
});
test("resetRateLimitGate clears the timer so it does not fire later", () => {
reset(0);
activateRateLimit(10);
const timerCountBefore = pendingTimers.size;
assert.equal(timerCountBefore, 1);
resetRateLimitGate();
assert.equal(pendingTimers.size, 0);
});
test("in-flight waitForRateLimit resolves when resetRateLimitGate is called", async () => {
reset(0);
activateRateLimit(30);
let inFlightResolved = false;
const inFlight = waitForRateLimit().then(() => {
inFlightResolved = true;
});
// Not yet resolved before reset.
await Promise.resolve();
assert.equal(inFlightResolved, false);
// reset resolves the in-flight awaiter immediately.
resetRateLimitGate();
await inFlight;
assert.equal(inFlightResolved, true);
// Gate is now clear.
assert.equal(isRateLimited(), false);
// A new wait should also resolve immediately.
await waitForRateLimit();
});
// ── rateLimitRemainingMs ──────────────────────────────────────────────────────
test("rateLimitRemainingMs returns 0 when gate is inactive", () => {
reset(0);
assert.equal(rateLimitRemainingMs(), 0);
});
test("rateLimitRemainingMs returns remaining ms when gate is active", () => {
reset(0);
activateRateLimit(10); // expires at 10_000 ms
setFakeNow(3_000);
assert.equal(rateLimitRemainingMs(), 7_000);
});
test("rateLimitRemainingMs returns 0 after gate expires", () => {
reset(0);
activateRateLimit(5);
tickTo(5_001);
assert.equal(rateLimitRemainingMs(), 0);
});
// ── NOTICE → gate integration ─────────────────────────────────────────────────
// Verifies that the session NOTICE handler logic — a case-sensitive
// `startsWith("rate-limited:")` check followed by activateRateLimit — correctly
// arms the gate. The relay always emits lowercase prefixes.
test("rate-limited: NOTICE prefix (case-sensitive) activates the gate", () => {
reset(0);
const notice = "rate-limited: quota exceeded; retry in 8s";
if (notice.startsWith("rate-limited:")) {
activateRateLimit(parseRateLimitHint(notice));
}
assert.equal(isRateLimited(), true);
tickTo(8_001);
assert.equal(isRateLimited(), false);
});
test("Rate-limited: NOTICE with uppercase prefix does NOT activate the gate (relay always emits lowercase)", () => {
reset(0);
const notice = "Rate-limited: quota exceeded; retry in 8s";
if (notice.startsWith("rate-limited:")) {
activateRateLimit(parseRateLimitHint(notice));
}
// Uppercase is not emitted by the relay — gate should remain inactive.
assert.equal(isRateLimited(), false);
});
// ── Cleanup ───────────────────────────────────────────────────────────────────
// Restore Date.now after all tests to avoid polluting subsequent test files.
test("teardown — restore Date.now", () => {
Date.now = origDateNow;
assert.ok(true);
});