mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat: limit quota for user
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user