feat: limit quota for user

This commit is contained in:
2026-07-17 18:08:50 +07:00
parent 2e595627d0
commit b700a070c0
15 changed files with 770 additions and 3 deletions
+3 -1
View File
@@ -34,13 +34,15 @@ describe("Schema migrations", () => {
const tables = db.all(`SELECT name FROM sqlite_master WHERE type='table'`).map(t => t.name);
expect(tables).toEqual(expect.arrayContaining([
"_meta", "settings", "providerConnections", "providerNodes",
"_meta", "settings", "users", "userTokenLimits", "providerConnections", "providerNodes",
"proxyPools", "apiKeys", "combos", "kv", "usageHistory", "usageDaily", "requestDetails",
]));
expect(db.all(`PRAGMA table_info(providerConnections)`).map((column) => column.name)).toContain("ownerId");
expect(db.all(`PRAGMA index_list(providerConnections)`).map((index) => index.name)).toContain("idx_pc_owner");
expect(db.all(`PRAGMA table_info(combos)`).map((column) => column.name)).toContain("ownerId");
expect(db.all(`PRAGMA index_list(combos)`).map((index) => index.name)).toContain("idx_combo_owner_name");
expect(db.all(`PRAGMA index_list(userTokenLimits)`).map((index) => index.name)).toContain("idx_user_token_limits_user");
expect(db.all(`PRAGMA index_list(usageHistory)`).map((index) => index.name)).toContain("idx_uh_user_provider_ts");
});
it("existing DB at older schemaVersion → re-applies pending migrations on restart", async () => {
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const getUserById = vi.fn();
const getUserProviderTokenUsageSince = vi.fn();
const getUserTokenLimits = vi.fn();
vi.mock("@/lib/db/index.js", () => ({
getUserById,
getUserProviderTokenUsageSince,
getUserTokenLimits,
}));
const {
checkUserTokenLimit,
getUserTokenLimitWindowStart,
} = await import("@/lib/tokenLimitEnforcer.js");
describe("user token limit enforcement", () => {
beforeEach(() => {
getUserById.mockReset();
getUserProviderTokenUsageSince.mockReset();
getUserTokenLimits.mockReset();
getUserById.mockResolvedValue({ id: "user-1", role: "user", isActive: true });
getUserTokenLimits.mockResolvedValue({
"orbit-provider": { session: 100, weekly: 1000 },
codex: { session: 200, weekly: 2000 },
});
});
it("calculates rolling session and Monday Vietnam weekly window starts", () => {
const now = new Date("2026-07-17T10:00:00.000Z");
expect(getUserTokenLimitWindowStart("session", now).toISOString())
.toBe("2026-07-17T05:00:00.000Z");
expect(getUserTokenLimitWindowStart("weekly", now).toISOString())
.toBe("2026-07-12T17:00:00.000Z");
});
it("blocks when the rolling session total reaches its limit", async () => {
getUserProviderTokenUsageSince.mockResolvedValueOnce(100);
const result = await checkUserTokenLimit(
"user-1",
"orbit-provider",
new Date("2026-07-17T10:00:00.000Z"),
);
expect(result).toMatchObject({
exceeded: true,
provider: "orbit-provider",
windowType: "session",
limit: 100,
used: 100,
});
expect(getUserProviderTokenUsageSince).toHaveBeenCalledTimes(1);
});
it("checks weekly usage after the session window still has headroom", async () => {
getUserProviderTokenUsageSince
.mockResolvedValueOnce(80)
.mockResolvedValueOnce(1200);
const result = await checkUserTokenLimit(
"user-1",
"orbit-provider",
new Date("2026-07-17T10:00:00.000Z"),
);
expect(result).toMatchObject({ windowType: "weekly", limit: 1000, used: 1200 });
expect(getUserProviderTokenUsageSince).toHaveBeenCalledTimes(2);
});
it("exempts administrators, unknown users, and unsupported providers", async () => {
getUserById.mockResolvedValueOnce({ id: "admin-1", role: "admin", isActive: true });
await expect(checkUserTokenLimit("admin-1", "codex")).resolves.toBeNull();
getUserById.mockResolvedValueOnce(null);
await expect(checkUserTokenLimit("missing", "codex")).resolves.toBeNull();
await expect(checkUserTokenLimit("user-1", "openai")).resolves.toBeNull();
expect(getUserTokenLimits).not.toHaveBeenCalled();
});
it("treats zero limits as unlimited", async () => {
getUserTokenLimits.mockResolvedValue({
"orbit-provider": { session: 0, weekly: 0 },
codex: { session: 0, weekly: 0 },
});
await expect(checkUserTokenLimit("user-1", "codex")).resolves.toBeNull();
expect(getUserProviderTokenUsageSince).not.toHaveBeenCalled();
});
});
+101
View File
@@ -0,0 +1,101 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let tempDir;
const originalDataDir = process.env.DATA_DIR;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-token-limits-"));
process.env.DATA_DIR = tempDir;
delete global._dbAdapter;
vi.resetModules();
});
afterEach(() => {
try { global._dbAdapter?.instance?.close?.(); } catch {}
delete global._dbAdapter;
fs.rmSync(tempDir, { recursive: true, force: true });
if (originalDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = originalDataDir;
});
describe("user token limit repository", () => {
it("replaces and normalizes all supported provider limits", async () => {
const db = await import("@/lib/db/index.js");
const user = await db.createUser({ username: "limited-user", password: "password", role: "user" });
await expect(db.getUserTokenLimits(user.id)).resolves.toEqual({
"orbit-provider": { session: 0, weekly: 0 },
codex: { session: 0, weekly: 0 },
});
await db.replaceUserTokenLimits(user.id, {
"orbit-provider": { session: 100, weekly: 1000 },
codex: { session: 0, weekly: 2000 },
});
await expect(db.getUserTokenLimits(user.id)).resolves.toEqual({
"orbit-provider": { session: 100, weekly: 1000 },
codex: { session: 0, weekly: 2000 },
});
});
it("sums prompt and completion tokens by user, provider, and timestamp", async () => {
const db = await import("@/lib/db/index.js");
const { getAdapter } = await import("@/lib/db/driver.js");
const user = await db.createUser({ username: "usage-user", password: "password", role: "user" });
const adapter = await getAdapter();
for (const [timestamp, provider, prompt, completion] of [
["2026-07-17T06:00:00.000Z", "codex", 30, 20],
["2026-07-17T09:00:00.000Z", "codex", 40, 10],
["2026-07-17T09:30:00.000Z", "orbit-provider", 500, 500],
]) {
adapter.run(
`INSERT INTO usageHistory(timestamp, provider, userId, promptTokens, completionTokens)
VALUES(?, ?, ?, ?, ?)`,
[timestamp, provider, user.id, prompt, completion],
);
}
await expect(db.getUserProviderTokenUsageSince(
user.id,
"codex",
new Date("2026-07-17T08:00:00.000Z"),
)).resolves.toBe(50);
});
it("rejects negative and non-integer limits without changing stored values", async () => {
const db = await import("@/lib/db/index.js");
const user = await db.createUser({ username: "invalid-limit", password: "password", role: "user" });
await db.replaceUserTokenLimits(user.id, {
"orbit-provider": { session: 100, weekly: 0 },
codex: { session: 0, weekly: 0 },
});
await expect(db.replaceUserTokenLimits(user.id, {
"orbit-provider": { session: -1, weekly: 0 },
codex: { session: 0, weekly: 0 },
})).rejects.toThrow("Token limit must be a non-negative integer");
await expect(db.getUserTokenLimits(user.id)).resolves.toMatchObject({
"orbit-provider": { session: 100 },
});
});
it("rejects unsupported providers and window names", async () => {
const db = await import("@/lib/db/index.js");
const user = await db.createUser({ username: "invalid-scope", password: "password", role: "user" });
await expect(db.replaceUserTokenLimits(user.id, {
openai: { session: 100 },
})).rejects.toThrow("Unsupported token limit provider");
await expect(db.replaceUserTokenLimits(user.id, {
codex: { monthly: 100 },
})).rejects.toThrow("Unsupported token limit window");
});
});
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const requireAdminUser = vi.fn();
const getUserById = vi.fn();
const getUserTokenLimits = vi.fn();
const replaceUserTokenLimits = vi.fn();
vi.mock("next/server", () => ({
NextResponse: {
json(body, init = {}) {
return new Response(JSON.stringify(body), {
status: init.status || 200,
headers: { "Content-Type": "application/json", ...(init.headers || {}) },
});
},
},
}));
vi.mock("@/lib/auth/currentUser.js", () => ({ requireAdminUser }));
vi.mock("@/lib/db/index.js", () => ({
getUserById,
getUserTokenLimits,
replaceUserTokenLimits,
}));
const { GET, PUT } = await import("@/app/api/users/[userId]/token-limits/route.js");
const context = (userId = "user-1") => ({ params: Promise.resolve({ userId }) });
const putRequest = (body) => new Request("https://9router.local/api/users/user-1/token-limits", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const limits = {
"orbit-provider": { session: 100, weekly: 1000 },
codex: { session: 200, weekly: 2000 },
};
describe("/api/users/[userId]/token-limits", () => {
beforeEach(() => {
requireAdminUser.mockReset();
getUserById.mockReset();
getUserTokenLimits.mockReset();
replaceUserTokenLimits.mockReset();
requireAdminUser.mockResolvedValue({ id: "admin-1", role: "admin" });
getUserById.mockResolvedValue({ id: "user-1", role: "user", isActive: true });
getUserTokenLimits.mockResolvedValue(limits);
replaceUserTokenLimits.mockResolvedValue(limits);
});
it("returns an administrator-only no-store response", async () => {
const response = await GET(new Request("https://9router.local"), context());
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe("no-store");
await expect(response.json()).resolves.toEqual({ limits });
expect(getUserTokenLimits).toHaveBeenCalledWith("user-1");
});
it("replaces all limits for a regular user", async () => {
const response = await PUT(putRequest({ limits }), context());
expect(response.status).toBe(200);
expect(replaceUserTokenLimits).toHaveBeenCalledWith("user-1", limits);
});
it("rejects non-admin access and administrator targets", async () => {
requireAdminUser.mockRejectedValueOnce(new Error("Forbidden"));
expect((await GET(new Request("https://9router.local"), context())).status).toBe(403);
getUserById.mockResolvedValueOnce({ id: "admin-2", role: "admin", isActive: true });
const response = await PUT(putRequest({ limits }), context("admin-2"));
expect(response.status).toBe(400);
expect(replaceUserTokenLimits).not.toHaveBeenCalled();
});
it("requires an explicit limits object", async () => {
const response = await PUT(putRequest({}), context());
expect(response.status).toBe(400);
expect(replaceUserTokenLimits).not.toHaveBeenCalled();
});
});