fix: update the permission for viewing provider pages

This commit is contained in:
2026-07-15 17:56:14 +07:00
parent 4b0acbfc69
commit 5c8d9f80b0
45 changed files with 362 additions and 340 deletions
@@ -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);
});
});
+8 -8
View File
@@ -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);
+46
View File
@@ -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();
});
});