Files
AgentHub/server/__tests__/user-quota.test.js
T
nntrivi2001 fa27b299e7 feat(server-mode): spend quota per user — no new session, prompts refused once used up
An admin sets a default spend quota on the Users page and overrides it per
user (Edit user → Spend quota: default / no limit / their own), in dollars
per local day or per sliding 5 hours. Spend is the users page's own
hourlyTokenRows + calculateCost over the sessions a user owns.

Over the quota, until it opens again:
- spawnRun refuses every start/resume/home prompt with EQUOTA (429);
- a minute sweep writes each pane's AGENTHUB_QUOTA_FILE (host: data dir,
  member: handoff dir via write-as) and hook-handler.js exits 2 on
  UserPromptSubmit from that file alone (Claude Code, Codex with trusted
  hooks) — fail-open, never asks the dashboard, never for Antigravity;
- a member's omp on the shared login loses its gateway key (clientsFor);
- panes no hook can stop (Antigravity, Cursor, Codex without trusted
  hooks, omp on its own login) are ended.

GET /api/me/quota feeds a "Quota N%" chip in the status bar.
New: server/lib/user-quota.js, user_quotas table, PUT /api/users/quota-default,
PATCH /api/users/:id {quota}, docs in SERVER-MODE.md § Spend quotas.
2026-10-28 14:31:11 +07:00

344 lines
14 KiB
JavaScript

/**
* @file user-quota.test.js
* @description Spend quotas per user (server/lib/user-quota.js): the default
* and a user's own setting, spend over a day and a sliding 5 h window, the
* admin routes (password again), `/api/me/quota`, the EQUOTA refusal of a new
* pane, the sweep's flag files and the panes it ends, the omp gateway keys it
* leaves out, and the hook handler refusing a prompt from the flag alone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const { spawnSync } = require("node:child_process");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "user-quota-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
process.env.CODEX_HOME = path.join(TMP, ".codex");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.DASHBOARD_QUOTA_SWEEP_MS = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
const quota = require("../lib/user-quota");
const runs = require("../lib/pty-run");
let server;
let BASE;
const BOSS_PASS = "admin-pass-1";
const ids = {};
let laneId;
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return res.headers.get("set-cookie").split(";")[0];
}
const HOUR = 3_600_000;
const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13);
/** `tokens` output tokens of Sonnet 4.5 ($15 / M) booked to `sid` in the hour of `at`. */
function spend(sid, tokens, at = Date.now()) {
db.prepare(
`INSERT INTO token_usage (session_id, model, output_tokens)
VALUES (?, 'claude-sonnet-4-5-20250929', ?)
ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens`
).run(sid, tokens);
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
).run(sid, hourOf(at), tokens);
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
ids.lou = (await auth.createUser("lou", "member-pass-2", "developer")).id;
laneId = Number(
db
.prepare(
"INSERT INTO lanes (cwd, title, tool, owner_id) VALUES ('/w/mia', 'Mia', 'claude', ?)"
)
.run(ids.mia).lastInsertRowid
);
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('sm', 'mia work', 'active', '/w/mia', ?, ?)"
).run(laneId, new Date().toISOString());
spend("sm", 1_000_000); // $15 now
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("settings", () => {
it("a user without a row of their own gets the default; unlimited and custom override it", () => {
assert.equal(quota.effective(ids.mia), null);
quota.setDefault({ limit_usd: 10, period: "day", tz_offset: -420 });
assert.deepEqual(quota.effective(ids.mia), {
source: "default",
limit_usd: 10,
period: "day",
tz_offset: -420,
});
quota.setUserQuota(ids.mia, { mode: "unlimited" });
assert.equal(quota.effective(ids.mia), null);
assert.deepEqual(quota.getOverride(ids.mia), { mode: "unlimited" });
quota.setUserQuota(ids.mia, { mode: "custom", limit_usd: 50, period: "5h" });
assert.equal(quota.effective(ids.mia).source, "user");
assert.equal(quota.effective(ids.mia).limit_usd, 50);
quota.setUserQuota(ids.mia, { mode: "default" });
assert.equal(quota.effective(ids.mia).source, "default");
quota.setDefault(null);
assert.equal(quota.effective(ids.mia), null);
});
it("refuses a bad limit, period or mode", () => {
for (const bad of [
{ mode: "custom", limit_usd: 0, period: "day" },
{ mode: "custom", limit_usd: 5, period: "week" },
{ mode: "custom", limit_usd: 5, period: "day", tz_offset: 9999 },
{ mode: "nope" },
]) {
assert.throws(() => quota.setUserQuota(ids.mia, bad), { code: "EBADQUOTA" });
}
});
});
describe("spend windows", () => {
it("a day starts at the zone's local midnight and resets at the next one", () => {
const now = Date.parse("2026-10-28T03:30:00Z"); // 10:30 at UTC+7
const w = quota.windowOf({ period: "day", tz_offset: -420 }, now);
assert.equal(w.since, "2026-10-27T17");
assert.equal(new Date(w.nextReset).toISOString(), "2026-10-28T17:00:00.000Z");
});
it("5 h is the current hour and the four before it; it opens as the oldest hours leave", () => {
const now = Date.parse("2026-10-28T10:20:00Z");
assert.equal(quota.windowOf({ period: "5h" }, now).since, "2026-10-28T06");
const byHour = new Map([
["2026-10-28T06", 4],
["2026-10-28T08", 3],
["2026-10-28T10", 2],
]);
// 9 spent, limit 6: 06's $4 leaving brings it to 5 < 6 → at 06 + 5 h.
assert.equal(
new Date(quota.slidingReset(byHour, 9, 6)).toISOString(),
"2026-10-28T11:00:00.000Z"
);
// limit 3: everything up to 08 must go → 08 + 5 h.
assert.equal(
new Date(quota.slidingReset(byHour, 9, 3)).toISOString(),
"2026-10-28T13:00:00.000Z"
);
});
it("counts the spend of the sessions a user owns, inside the window only", () => {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('old', 'old', 'completed', '/w/mia', ?, ?)"
).run(laneId, new Date().toISOString());
spend("old", 1_000_000, Date.now() - 8 * HOUR); // outside 5 h
quota.setUserQuota(ids.mia, { mode: "custom", limit_usd: 100, period: "5h" });
const s = quota.statusFor(ids.mia);
assert.equal(s.spend, 15);
assert.equal(s.exceeded, false);
assert.equal(s.resetsAt, null);
assert.equal(quota.statusFor(ids.lou), null);
quota.setUserQuota(ids.mia, { mode: "default" });
});
});
describe("routes", () => {
it("an admin sets the default with their password; the list shows each user's quota", async () => {
const boss = await login("boss", BOSS_PASS);
const noPass = await call("PUT", "/api/users/quota-default", {
cookie: boss,
body: { quota: { limit_usd: 10, period: "day" } },
});
assert.equal(noPass.status, 403);
const bad = await call("PUT", "/api/users/quota-default", {
cookie: boss,
body: { quota: { limit_usd: -1, period: "day" }, currentPassword: BOSS_PASS },
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADQUOTA");
const ok = await call("PUT", "/api/users/quota-default", {
cookie: boss,
body: { quota: { limit_usd: 10, period: "day" }, currentPassword: BOSS_PASS },
});
assert.equal(ok.status, 200, JSON.stringify(ok.body));
assert.equal(ok.body.quotaDefault.limit_usd, 10);
const list = await call("GET", "/api/users", { cookie: boss });
assert.equal(list.body.quotaDefault.limit_usd, 10);
const mia = list.body.users.find((u) => u.id === ids.mia);
assert.deepEqual(mia.quotaSetting, { mode: "default" });
assert.equal(mia.quota.spend, 15);
assert.equal(mia.quota.exceeded, true);
assert.ok(mia.quota.resetsAt);
});
it("a developer reads their own quota and may not change any", async () => {
const me = await login("mia", "member-pass-1");
const r = await call("GET", "/api/me/quota", { cookie: me });
assert.equal(r.status, 200);
assert.equal(r.body.quota.exceeded, true);
const put = await call("PUT", "/api/users/quota-default", {
cookie: me,
body: { quota: null, currentPassword: "member-pass-1" },
});
assert.equal(put.status, 403);
});
it("PATCH /api/users/:id {quota} overrides one user's, audited", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("PATCH", `/api/users/${ids.mia}`, {
cookie: boss,
body: { quota: { mode: "custom", limit_usd: 40, period: "day" }, currentPassword: BOSS_PASS },
});
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.equal(quota.statusFor(ids.mia).exceeded, false);
const audit = db
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
.get(ids.mia);
assert.match(audit.path, /spend quota \$40\/day/);
const bad = await call("PATCH", `/api/users/${ids.mia}`, {
cookie: boss,
body: { quota: { mode: "lots" }, currentPassword: BOSS_PASS },
});
assert.equal(bad.status, 400);
quota.setUserQuota(ids.mia, { mode: "default" }); // back to the $10 default: over
});
});
describe("enforcement", () => {
it("refuses a new pane of an owner over quota with EQUOTA and a reason", () => {
assert.throws(
() => quota.assertSpawnAllowed(ids.mia),
(err) => {
assert.equal(err.code, "EQUOTA");
assert.match(err.message, /\$15\.00 of \$10\.00 per day/);
return true;
}
);
quota.assertSpawnAllowed(ids.lou); // under the default: no throw
quota.assertSpawnAllowed(null); // a workspace with no owner
});
it("gives a pane its owner's flag file in server mode only", () => {
const env = quota.flagEnv(ids.mia, null);
assert.match(env.AGENTHUB_QUOTA_FILE, /user-quota[/\\]u\d+\.json$/);
delete process.env.AGENTHUB_MODE;
try {
assert.deepEqual(quota.flagEnv(ids.mia, null), {});
quota.assertSpawnAllowed(ids.mia); // local mode never refuses
assert.equal(quota.statusAll().size, 0);
} finally {
process.env.AGENTHUB_MODE = "server";
}
});
it("sweep: writes the flag, ends the panes no hook can stop, keeps Claude's, clears on raise", () => {
const realList = runs.listRuns;
const realKill = runs.killRun;
const killed = [];
runs.listRuns = () => [
{ id: `agenthub-lane-${laneId}`, laneId, status: "running", tool: "claude" },
{ id: `agenthub-lane-${laneId}-1`, laneId, status: "running", tool: "cursor" },
];
runs.killRun = (id) => killed.push(id) > 0;
try {
quota.__reset();
const r = quota.sweep();
assert.deepEqual(r.blocked, [ids.mia]);
assert.deepEqual(killed, [`agenthub-lane-${laneId}-1`]);
const file = quota.flagEnv(ids.mia, null).AGENTHUB_QUOTA_FILE;
const flag = JSON.parse(fs.readFileSync(file, "utf8"));
assert.equal(flag.blocked, true);
assert.match(flag.message, /quota is used up/);
assert.ok(Date.parse(flag.until) > Date.now());
// Lou never went over: no file is written for them.
assert.equal(fs.existsSync(quota.flagEnv(ids.lou, null).AGENTHUB_QUOTA_FILE), false);
assert.equal(quota.isRunBlocked(`agenthub-lane-${laneId}-3`), true);
quota.setUserQuota(ids.mia, { mode: "unlimited" });
killed.length = 0;
quota.sweep();
assert.deepEqual(killed, []);
assert.equal(JSON.parse(fs.readFileSync(file, "utf8")).blocked, false);
assert.equal(quota.isRunBlocked(`agenthub-lane-${laneId}`), false);
} finally {
runs.listRuns = realList;
runs.killRun = realKill;
quota.setUserQuota(ids.mia, { mode: "default" });
quota.__reset();
}
});
it("the omp gateway loses a blocked owner's key, never the queue's", () => {
const { clientsFor } = require("../lib/omp-share");
const t = { standby: ["s1"], used: { a: { hash: "ha" }, b: { hash: "hb" } } };
const out = clientsFor(t, (id) => id === "a");
assert.equal(out.a, undefined);
assert.deepEqual(out.b, { tokenSha256: "hb" });
assert.equal(Object.keys(out).filter((k) => k.startsWith("standby-")).length, 1);
});
});
describe("hook handler", () => {
const HANDLER = path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js");
const flag = path.join(TMP, "flag.json");
const run = (args, file) =>
spawnSync(process.execPath, [HANDLER, ...args], {
input: "{}",
env: { ...process.env, AGENTHUB_QUOTA_FILE: file, CLAUDE_DASHBOARD_PORT: "1" },
encoding: "utf8",
});
it("refuses a Claude Code or Codex prompt while the flag says blocked", () => {
fs.writeFileSync(
flag,
JSON.stringify({ blocked: true, until: "2999-01-01T00:00:00Z", message: "quota used up" })
);
for (const args of [["UserPromptSubmit"], ["UserPromptSubmit", "codex"]]) {
const r = run(args, flag);
assert.equal(r.status, 2);
assert.match(r.stderr, /quota used up/);
}
});
it("lets everything else through: other events, Antigravity, an expired or missing flag", () => {
assert.equal(run(["PreToolUse"], flag).status, 0);
assert.equal(run(["UserPromptSubmit", "antigravity"], flag).status, 0);
assert.equal(run(["UserPromptSubmit"], path.join(TMP, "none.json")).status, 0);
fs.writeFileSync(flag, JSON.stringify({ blocked: true, until: "2000-01-01T00:00:00Z" }));
assert.equal(run(["UserPromptSubmit"], flag).status, 0);
fs.writeFileSync(flag, "{not json");
assert.equal(run(["UserPromptSubmit"], flag).status, 0);
});
});