Four features to complete the open-source governance tier: 1. Policy Presets (presets.ts): Built-in rule sets (permissive, safety, strict, audit-only) users can reference by name. Merge order: preset defaults → top-level overrides → additional rules appended. 2. Rate Limiting (rate-limiter.ts): Sliding window counters per key (tool or category). RateLimiter integrated into PolicyEngine — rate limit rules only match when threshold exceeded within window. Memory-only, resets on session restart. 3. Audit Query (audit.ts filter()): Filter audit records by tool, category, status, target pattern (glob), time range (ISO or relative like "1h"), and limit. CLI: moltbot audit query. 4. Audit Reporter (reporter.ts): Aggregates audit data into tool stats, category breakdown, policy decisions, error summaries, and per-minute timeline. Text and JSON output formats. All wired into index.ts with CLI commands (policy presets, audit query, audit report) and plugin.json schema updated with preset field. 151 tests pass across 9 test files. Co-authored-by: dp-web4 <dp@metalinxx.io> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
173 lines
4.8 KiB
TypeScript
173 lines
4.8 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
import { RateLimiter } from "./rate-limiter.js";
|
|
|
|
describe("RateLimiter", () => {
|
|
let limiter: RateLimiter;
|
|
|
|
beforeEach(() => {
|
|
limiter = new RateLimiter();
|
|
});
|
|
|
|
describe("check", () => {
|
|
it("should allow when no actions recorded", () => {
|
|
const result = limiter.check("key1", 5, 60_000);
|
|
expect(result.allowed).toBe(true);
|
|
expect(result.current).toBe(0);
|
|
expect(result.limit).toBe(5);
|
|
});
|
|
|
|
it("should allow when under limit", () => {
|
|
limiter.record("key1");
|
|
limiter.record("key1");
|
|
const result = limiter.check("key1", 5, 60_000);
|
|
expect(result.allowed).toBe(true);
|
|
expect(result.current).toBe(2);
|
|
});
|
|
|
|
it("should deny when at limit", () => {
|
|
for (let i = 0; i < 5; i++) limiter.record("key1");
|
|
const result = limiter.check("key1", 5, 60_000);
|
|
expect(result.allowed).toBe(false);
|
|
expect(result.current).toBe(5);
|
|
});
|
|
|
|
it("should deny when over limit", () => {
|
|
for (let i = 0; i < 10; i++) limiter.record("key1");
|
|
const result = limiter.check("key1", 5, 60_000);
|
|
expect(result.allowed).toBe(false);
|
|
expect(result.current).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe("window expiry", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("should expire old entries", () => {
|
|
vi.setSystemTime(1000);
|
|
limiter.record("key1");
|
|
limiter.record("key1");
|
|
limiter.record("key1");
|
|
|
|
// Move time forward past window
|
|
vi.setSystemTime(62_000);
|
|
const result = limiter.check("key1", 3, 60_000);
|
|
expect(result.allowed).toBe(true);
|
|
expect(result.current).toBe(0);
|
|
});
|
|
|
|
it("should keep recent entries within window", () => {
|
|
vi.setSystemTime(1000);
|
|
limiter.record("key1");
|
|
|
|
vi.setSystemTime(30_000);
|
|
limiter.record("key1");
|
|
|
|
vi.setSystemTime(59_000);
|
|
// First entry is within 60s window (59000 - 1000 = 58000 < 60000)
|
|
const result = limiter.check("key1", 3, 60_000);
|
|
expect(result.allowed).toBe(true);
|
|
expect(result.current).toBe(2);
|
|
});
|
|
|
|
it("should partially expire entries", () => {
|
|
vi.setSystemTime(1000);
|
|
limiter.record("key1"); // will expire
|
|
|
|
vi.setSystemTime(50_000);
|
|
limiter.record("key1"); // will survive
|
|
|
|
vi.setSystemTime(70_000);
|
|
// cutoff = 70000 - 60000 = 10000, so t=1000 expires, t=50000 survives
|
|
const result = limiter.check("key1", 2, 60_000);
|
|
expect(result.allowed).toBe(true);
|
|
expect(result.current).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("concurrent keys", () => {
|
|
it("should track keys independently", () => {
|
|
for (let i = 0; i < 5; i++) limiter.record("bash");
|
|
limiter.record("read");
|
|
|
|
expect(limiter.check("bash", 5, 60_000).allowed).toBe(false);
|
|
expect(limiter.check("read", 5, 60_000).allowed).toBe(true);
|
|
});
|
|
|
|
it("should report correct keyCount", () => {
|
|
expect(limiter.keyCount).toBe(0);
|
|
limiter.record("a");
|
|
limiter.record("b");
|
|
limiter.record("c");
|
|
expect(limiter.keyCount).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("record", () => {
|
|
it("should create key on first record", () => {
|
|
expect(limiter.count("new")).toBe(0);
|
|
limiter.record("new");
|
|
expect(limiter.count("new")).toBe(1);
|
|
});
|
|
|
|
it("should increment count", () => {
|
|
limiter.record("key1");
|
|
limiter.record("key1");
|
|
limiter.record("key1");
|
|
expect(limiter.count("key1")).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("prune", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("should remove expired entries across all keys", () => {
|
|
vi.setSystemTime(1000);
|
|
limiter.record("a");
|
|
limiter.record("b");
|
|
|
|
vi.setSystemTime(70_000);
|
|
const pruned = limiter.prune(60_000);
|
|
expect(pruned).toBe(2);
|
|
expect(limiter.keyCount).toBe(0);
|
|
});
|
|
|
|
it("should keep recent entries during prune", () => {
|
|
vi.setSystemTime(1000);
|
|
limiter.record("a"); // will expire
|
|
|
|
vi.setSystemTime(50_000);
|
|
limiter.record("a"); // will survive
|
|
|
|
vi.setSystemTime(70_000);
|
|
const pruned = limiter.prune(60_000);
|
|
expect(pruned).toBe(1);
|
|
expect(limiter.count("a")).toBe(1);
|
|
});
|
|
|
|
it("should return 0 when nothing to prune", () => {
|
|
limiter.record("a");
|
|
const pruned = limiter.prune(60_000);
|
|
expect(pruned).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("static key", () => {
|
|
it("should build namespaced key", () => {
|
|
expect(RateLimiter.key("rule1", "Bash")).toBe("ratelimit:rule1:Bash");
|
|
expect(RateLimiter.key("deny-net", "network")).toBe("ratelimit:deny-net:network");
|
|
});
|
|
});
|
|
});
|