mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix: update the permission for viewing provider pages
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
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-admin-providers-"));
|
||||
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("administrator provider connections", () => {
|
||||
it("rejects provider credentials owned by a regular user", async () => {
|
||||
const db = await import("@/lib/db/index.js");
|
||||
const user = await db.createUser({ username: "provider-member", password: "password", role: "user" });
|
||||
|
||||
await expect(db.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "Member key",
|
||||
apiKey: "secret",
|
||||
ownerId: user.id,
|
||||
})).rejects.toMatchObject({
|
||||
message: "Provider connections require an administrator owner",
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it("removes user and orphan credentials while retaining legacy credentials for an administrator", async () => {
|
||||
const db = await import("@/lib/db/index.js");
|
||||
const { getAdapter } = await import("@/lib/db/driver.js");
|
||||
const migration = (await import("@/lib/db/migrations/007-admin-provider-connections.js")).default;
|
||||
const admin = await db.createUser({ username: "migration-admin", password: "password", role: "admin" });
|
||||
const member = await db.createUser({ username: "migration-member", password: "password", role: "user" });
|
||||
const adapter = await getAdapter();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
for (const [id, ownerId] of [["legacy", null], ["member", member.id], ["orphan", "missing-user"], ["admin", admin.id]]) {
|
||||
adapter.run(
|
||||
`INSERT INTO providerConnections(id, provider, authType, ownerId, isActive, data, createdAt, updatedAt)
|
||||
VALUES(?, 'openai', 'apikey', ?, 1, '{}', ?, ?)`,
|
||||
[id, ownerId, now, now],
|
||||
);
|
||||
}
|
||||
|
||||
adapter.transaction(() => migration.up(adapter));
|
||||
|
||||
const remaining = await db.getProviderConnections();
|
||||
expect(remaining.map((connection) => connection.id).sort()).toEqual(["admin", "legacy"]);
|
||||
const firstAdmin = (await db.getUsers()).find((user) => user.role === "admin");
|
||||
expect(remaining.find((connection) => connection.id === "legacy")?.ownerId).toBe(firstAdmin.id);
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,6 @@ describe("API-key credential access", () => {
|
||||
const db = await import("@/lib/db/index.js");
|
||||
const { getProviderCredentials } = await import("@/sse/services/auth.js");
|
||||
const admin = await db.createUser({ username: "credential-admin", password: "password", role: "admin" });
|
||||
const userA = await db.createUser({ username: "credential-user-a", password: "password", role: "user" });
|
||||
const userB = await db.createUser({ username: "credential-user-b", password: "password", role: "user" });
|
||||
const userC = await db.createUser({ username: "credential-user-c", password: "password", role: "user" });
|
||||
const adminConnection = await db.createProviderConnection({
|
||||
provider: "antigravity",
|
||||
@@ -36,19 +34,21 @@ describe("API-key credential access", () => {
|
||||
accessToken: "admin-token",
|
||||
ownerId: admin.id,
|
||||
});
|
||||
const secondAdmin = await db.createUser({ username: "credential-admin-two", password: "password", role: "admin" });
|
||||
const thirdAdmin = await db.createUser({ username: "credential-admin-three", password: "password", role: "admin" });
|
||||
const userAConnection = await db.createProviderConnection({
|
||||
provider: "antigravity",
|
||||
authType: "oauth",
|
||||
name: "user-a-antigravity",
|
||||
accessToken: "user-a-token",
|
||||
ownerId: userA.id,
|
||||
name: "second-admin-antigravity",
|
||||
accessToken: "second-admin-token",
|
||||
ownerId: secondAdmin.id,
|
||||
});
|
||||
const userBConnection = await db.createProviderConnection({
|
||||
provider: "antigravity",
|
||||
authType: "oauth",
|
||||
name: "user-b-antigravity",
|
||||
accessToken: "user-b-token",
|
||||
ownerId: userB.id,
|
||||
name: "third-admin-antigravity",
|
||||
accessToken: "third-admin-token",
|
||||
ownerId: thirdAdmin.id,
|
||||
});
|
||||
|
||||
const firstCredentials = await getProviderCredentials("antigravity", new Set(), "gemini-2.5-pro", {
|
||||
|
||||
@@ -8,6 +8,8 @@ const originalDataDir = process.env.DATA_DIR;
|
||||
async function setupTestContext(nodeData) {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-compatible-provider-"));
|
||||
process.env.DATA_DIR = tempDir;
|
||||
try { global._dbAdapter?.instance?.close?.(); } catch {}
|
||||
delete global._dbAdapter;
|
||||
vi.resetModules();
|
||||
vi.doMock("next/server", () => ({
|
||||
NextResponse: {
|
||||
@@ -19,12 +21,16 @@ async function setupTestContext(nodeData) {
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { POST } = await import("@/app/api/providers/route.js");
|
||||
const {
|
||||
createProviderNode,
|
||||
getProviderConnections,
|
||||
} = await import("@/models/index.js");
|
||||
const { createUser } = await import("@/lib/db/index.js");
|
||||
const admin = await createUser({ username: "provider-admin", password: "password", role: "admin" });
|
||||
vi.doMock("@/lib/providers/connectionAccess", () => ({
|
||||
getProviderConnectionAccess: vi.fn().mockResolvedValue({ user: admin, ownerId: null }),
|
||||
}));
|
||||
const { POST } = await import("@/app/api/providers/route.js");
|
||||
|
||||
const node = await createProviderNode(nodeData);
|
||||
|
||||
@@ -38,13 +44,13 @@ async function setupTestContext(nodeData) {
|
||||
};
|
||||
}
|
||||
|
||||
function makeRequest(provider, name = "Test Connection") {
|
||||
function makeRequest(provider, name = "Test Connection", apiKey = "test-key") {
|
||||
return new Request("https://9router.local/api/providers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
apiKey: "test-key",
|
||||
apiKey,
|
||||
name,
|
||||
defaultModel: "test-model",
|
||||
}),
|
||||
@@ -74,6 +80,8 @@ describe("compatible provider connections API", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { global._dbAdapter?.instance?.close?.(); } catch {}
|
||||
delete global._dbAdapter;
|
||||
vi.doUnmock("next/server");
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
@@ -157,7 +165,7 @@ describe("compatible provider connections API", () => {
|
||||
cleanup = ctx.cleanup;
|
||||
|
||||
const firstResponse = await ctx.POST(makeRequest(ctx.node.id, "Key A"));
|
||||
const secondResponse = await ctx.POST(makeRequest(ctx.node.id, "Key B"));
|
||||
const secondResponse = await ctx.POST(makeRequest(ctx.node.id, "Key B", "test-key-b"));
|
||||
const storedConnections = await ctx.getProviderConnections({ provider: ctx.node.id });
|
||||
|
||||
expect(firstResponse.status).toBe(201);
|
||||
|
||||
@@ -340,6 +340,52 @@ describe("dashboard guard token saver administration access", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard guard provider administration access", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getSettings.mockResolvedValue({});
|
||||
mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "user" });
|
||||
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
||||
mocks.getDashboardAuthSession.mockResolvedValue({ userId: "user-1" });
|
||||
mocks.verifyDashboardAuthToken.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("rejects normal users from provider pages and management APIs", async () => {
|
||||
for (const pathname of [
|
||||
"/api/providers",
|
||||
"/api/providers/connection-id/test",
|
||||
"/api/provider-nodes",
|
||||
"/api/oauth/codex/authorize",
|
||||
]) {
|
||||
const response = await proxy(request(pathname, { host: "localhost:20128" }, "user-token"));
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toBe("Administrator access required");
|
||||
}
|
||||
|
||||
for (const pathname of ["/dashboard/providers", "/dashboard/providers/openai"]) {
|
||||
const response = await proxy(request(pathname, { host: "localhost:20128" }, "user-token"));
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.url.href).toBe("http://localhost/dashboard");
|
||||
}
|
||||
});
|
||||
|
||||
it("allows administrators to access provider pages and APIs", async () => {
|
||||
mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "admin" });
|
||||
|
||||
for (const pathname of [
|
||||
"/dashboard/providers",
|
||||
"/dashboard/providers/openai",
|
||||
"/api/providers",
|
||||
"/api/provider-nodes",
|
||||
"/api/oauth/codex/authorize",
|
||||
]) {
|
||||
expect(await proxy(request(pathname, { host: "localhost:20128" }, "admin-token"))).toBe(mocks.nextResponse);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard guard helpers", () => {
|
||||
it("extracts bearer API keys before x-api-key", () => {
|
||||
const apiRequest = request("/v1/chat/completions", {
|
||||
|
||||
@@ -30,7 +30,7 @@ const adminAccess = {
|
||||
ownerId: null,
|
||||
};
|
||||
|
||||
describe("provider connection administrator-managed access", () => {
|
||||
describe("provider connection administrator-only access", () => {
|
||||
beforeEach(() => {
|
||||
getProviderConnectionById.mockReset();
|
||||
getProxyPoolById.mockReset();
|
||||
@@ -39,7 +39,7 @@ describe("provider connection administrator-managed access", () => {
|
||||
getProviderConnectionAccess.mockReset();
|
||||
});
|
||||
|
||||
it("prevents a member from updating their legacy compatible connection", async () => {
|
||||
it("prevents a member from updating a provider connection", async () => {
|
||||
getProviderConnectionAccess.mockResolvedValue(memberAccess);
|
||||
getProviderConnectionById.mockResolvedValue({
|
||||
id: "legacy-compatible",
|
||||
@@ -56,7 +56,7 @@ describe("provider connection administrator-managed access", () => {
|
||||
expect(updateProviderConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prevents a member from deleting their legacy compatible connection", async () => {
|
||||
it("prevents a member from deleting a provider connection", async () => {
|
||||
getProviderConnectionAccess.mockResolvedValue(memberAccess);
|
||||
getProviderConnectionById.mockResolvedValue({
|
||||
id: "legacy-compatible",
|
||||
@@ -89,7 +89,7 @@ describe("provider connection administrator-managed access", () => {
|
||||
expect(deleteProviderConnection).toHaveBeenCalledWith("compatible");
|
||||
});
|
||||
|
||||
it("preserves member control over their non-compatible connection", async () => {
|
||||
it("prevents a member from updating a non-compatible connection", async () => {
|
||||
getProviderConnectionAccess.mockResolvedValue(memberAccess);
|
||||
getProviderConnectionById.mockResolvedValue({
|
||||
id: "openai-connection",
|
||||
@@ -98,21 +98,12 @@ describe("provider connection administrator-managed access", () => {
|
||||
providerSpecificData: {},
|
||||
authType: "apikey",
|
||||
});
|
||||
updateProviderConnection.mockResolvedValue({
|
||||
id: "openai-connection",
|
||||
provider: "openai",
|
||||
name: "Changed",
|
||||
});
|
||||
|
||||
const response = await PUT(new Request("http://localhost/api/providers/openai-connection", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ name: "Changed" }),
|
||||
}), { params: Promise.resolve({ id: "openai-connection" }) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(updateProviderConnection).toHaveBeenCalledWith("openai-connection", {
|
||||
name: "Changed",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(updateProviderConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user