fix(#329): Harden BudgetService against security review findings
- Fix CRITICAL: Unbounded memory growth via daily record purging - Fix CRITICAL: Negative/NaN/Infinity token bypass via input clamping - Fix HIGH: TOCTOU race via atomic trySpawnAgent() method - Fix HIGH: Phantom agent leak via Set<string> ID tracking (not counter) - Fix HIGH: isAgentOverBudget now scoped to today only - Fix HIGH: Config validation clamps invalid values to safe defaults - Fix MEDIUM: Wire BudgetModule into AppModule - Fix MEDIUM: Sanitize agentId in log output to prevent log injection - Fix MEDIUM: Use Date objects for timezone-safe comparisons - Fix MEDIUM: Reject empty agentId/taskId in recordUsage - Add tests for negative tokens, NaN, Infinity, empty IDs, config edge cases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* BudgetService Unit Tests
|
||||
*
|
||||
* Tests usage budget tracking, enforcement, and reporting.
|
||||
* Covers issue #329 (ORCH-135)
|
||||
* Covers issue #329 (ORCH-135) including security hardening.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { BudgetService } from "./budget.service";
|
||||
@@ -12,7 +12,7 @@ describe("BudgetService", () => {
|
||||
let service: BudgetService;
|
||||
|
||||
const mockConfigService = {
|
||||
get: vi.fn((_key: string, defaultValue?: unknown) => defaultValue),
|
||||
get: vi.fn((_key: string, _defaultValue?: unknown) => undefined),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -53,6 +53,46 @@ describe("BudgetService", () => {
|
||||
expect(budget.maxTaskDurationMinutes).toBe(60);
|
||||
expect(budget.enforceHardLimits).toBe(true);
|
||||
});
|
||||
|
||||
it("should clamp negative config values to defaults", () => {
|
||||
const badConfig = {
|
||||
get: vi.fn((key: string) => {
|
||||
const config: Record<string, unknown> = {
|
||||
"orchestrator.budget.dailyTokenLimit": -100,
|
||||
"orchestrator.budget.perAgentTokenLimit": -50,
|
||||
"orchestrator.budget.maxConcurrentAgents": -1,
|
||||
"orchestrator.budget.maxTaskDurationMinutes": 0,
|
||||
};
|
||||
return config[key];
|
||||
}),
|
||||
};
|
||||
|
||||
const badService = new BudgetService(badConfig as unknown as ConfigService);
|
||||
const budget = badService.getBudget();
|
||||
|
||||
expect(budget.dailyTokenLimit).toBe(10_000_000);
|
||||
expect(budget.perAgentTokenLimit).toBe(2_000_000);
|
||||
expect(budget.maxConcurrentAgents).toBe(10);
|
||||
expect(budget.maxTaskDurationMinutes).toBe(120);
|
||||
});
|
||||
|
||||
it("should clamp NaN config values to defaults", () => {
|
||||
const nanConfig = {
|
||||
get: vi.fn((key: string) => {
|
||||
const config: Record<string, unknown> = {
|
||||
"orchestrator.budget.dailyTokenLimit": NaN,
|
||||
"orchestrator.budget.maxConcurrentAgents": Infinity,
|
||||
};
|
||||
return config[key];
|
||||
}),
|
||||
};
|
||||
|
||||
const nanService = new BudgetService(nanConfig as unknown as ConfigService);
|
||||
const budget = nanService.getBudget();
|
||||
|
||||
expect(budget.dailyTokenLimit).toBe(10_000_000);
|
||||
expect(budget.maxConcurrentAgents).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordUsage", () => {
|
||||
@@ -84,6 +124,42 @@ describe("BudgetService", () => {
|
||||
expect(agent1?.totalTokens).toBe(1500);
|
||||
expect(agent2?.totalTokens).toBe(4500);
|
||||
});
|
||||
|
||||
it("should clamp negative token values to 0", () => {
|
||||
service.recordUsage("agent-1", "task-1", -500, -200);
|
||||
|
||||
const summary = service.getUsageSummary();
|
||||
expect(summary.dailyTokensUsed).toBe(0);
|
||||
});
|
||||
|
||||
it("should clamp NaN token values to 0", () => {
|
||||
service.recordUsage("agent-1", "task-1", NaN, NaN);
|
||||
|
||||
const summary = service.getUsageSummary();
|
||||
expect(summary.dailyTokensUsed).toBe(0);
|
||||
});
|
||||
|
||||
it("should clamp Infinity token values to 0", () => {
|
||||
service.recordUsage("agent-1", "task-1", Infinity, -Infinity);
|
||||
|
||||
const summary = service.getUsageSummary();
|
||||
expect(summary.dailyTokensUsed).toBe(0);
|
||||
});
|
||||
|
||||
it("should skip recording when agentId is empty", () => {
|
||||
service.recordUsage("", "task-1", 1000, 500);
|
||||
|
||||
const summary = service.getUsageSummary();
|
||||
expect(summary.dailyTokensUsed).toBe(0);
|
||||
expect(summary.agentUsage).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should skip recording when taskId is empty", () => {
|
||||
service.recordUsage("agent-1", "", 1000, 500);
|
||||
|
||||
const summary = service.getUsageSummary();
|
||||
expect(summary.dailyTokensUsed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canSpawnAgent", () => {
|
||||
@@ -94,7 +170,7 @@ describe("BudgetService", () => {
|
||||
|
||||
it("should block spawning when at max concurrent agents", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
service.agentStarted();
|
||||
service.agentStarted(`agent-${String(i)}`);
|
||||
}
|
||||
|
||||
const result = service.canSpawnAgent();
|
||||
@@ -104,12 +180,12 @@ describe("BudgetService", () => {
|
||||
|
||||
it("should allow spawning after agent stops", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
service.agentStarted();
|
||||
service.agentStarted(`agent-${String(i)}`);
|
||||
}
|
||||
|
||||
expect(service.canSpawnAgent().allowed).toBe(false);
|
||||
|
||||
service.agentStopped();
|
||||
service.agentStopped("agent-0");
|
||||
|
||||
expect(service.canSpawnAgent().allowed).toBe(true);
|
||||
});
|
||||
@@ -141,6 +217,43 @@ describe("BudgetService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("trySpawnAgent", () => {
|
||||
it("should atomically check and reserve slot", () => {
|
||||
const result = service.trySpawnAgent("agent-1");
|
||||
expect(result.allowed).toBe(true);
|
||||
|
||||
const summary = service.getUsageSummary();
|
||||
expect(summary.activeAgents).toBe(1);
|
||||
});
|
||||
|
||||
it("should block when at max concurrent and not reserve slot", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
service.trySpawnAgent(`agent-${String(i)}`);
|
||||
}
|
||||
|
||||
const result = service.trySpawnAgent("agent-11");
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain("Maximum concurrent agents");
|
||||
|
||||
const summary = service.getUsageSummary();
|
||||
expect(summary.activeAgents).toBe(10);
|
||||
});
|
||||
|
||||
it("should reject empty agent ID", () => {
|
||||
const result = service.trySpawnAgent("");
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain("Agent ID is required");
|
||||
});
|
||||
|
||||
it("should not double-count same agent ID", () => {
|
||||
service.trySpawnAgent("agent-1");
|
||||
service.trySpawnAgent("agent-1");
|
||||
|
||||
const summary = service.getUsageSummary();
|
||||
expect(summary.activeAgents).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAgentOverBudget", () => {
|
||||
it("should return false when agent is within budget", () => {
|
||||
service.recordUsage("agent-1", "task-1", 100_000, 50_000);
|
||||
@@ -166,31 +279,38 @@ describe("BudgetService", () => {
|
||||
});
|
||||
|
||||
describe("agentStarted / agentStopped", () => {
|
||||
it("should track active agent count", () => {
|
||||
service.agentStarted();
|
||||
service.agentStarted();
|
||||
service.agentStarted();
|
||||
it("should track active agent count by ID", () => {
|
||||
service.agentStarted("agent-1");
|
||||
service.agentStarted("agent-2");
|
||||
service.agentStarted("agent-3");
|
||||
|
||||
const summary = service.getUsageSummary();
|
||||
expect(summary.activeAgents).toBe(3);
|
||||
});
|
||||
|
||||
it("should decrement active count on stop", () => {
|
||||
service.agentStarted();
|
||||
service.agentStarted();
|
||||
service.agentStopped();
|
||||
service.agentStarted("agent-1");
|
||||
service.agentStarted("agent-2");
|
||||
service.agentStopped("agent-1");
|
||||
|
||||
const summary = service.getUsageSummary();
|
||||
expect(summary.activeAgents).toBe(1);
|
||||
});
|
||||
|
||||
it("should not go below zero", () => {
|
||||
service.agentStopped();
|
||||
service.agentStopped();
|
||||
it("should handle stopping non-existent agent gracefully", () => {
|
||||
service.agentStopped("non-existent");
|
||||
|
||||
const summary = service.getUsageSummary();
|
||||
expect(summary.activeAgents).toBe(0);
|
||||
});
|
||||
|
||||
it("should not double-count same agent ID on start", () => {
|
||||
service.agentStarted("agent-1");
|
||||
service.agentStarted("agent-1");
|
||||
|
||||
const summary = service.getUsageSummary();
|
||||
expect(summary.activeAgents).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsageSummary", () => {
|
||||
@@ -290,7 +410,7 @@ describe("BudgetService", () => {
|
||||
const budget2 = service.getBudget();
|
||||
|
||||
expect(budget1).toEqual(budget2);
|
||||
expect(budget1).not.toBe(budget2); // Different reference
|
||||
expect(budget1).not.toBe(budget2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user