feat(bff): proxy admin config export routes with config:export scope
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireActor: vi.fn(),
|
||||
requireAdmin: vi.fn(),
|
||||
configApiFetch: vi.fn(),
|
||||
isCrossOrigin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/server/actor", () => ({
|
||||
requireActor: mocks.requireActor,
|
||||
requireAdmin: mocks.requireAdmin,
|
||||
ActorError: class ActorError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: 401 | 403 = 401
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/server/evoscientistConfigClient", () => ({
|
||||
configApiFetch: mocks.configApiFetch,
|
||||
ConfigApiError: class ConfigApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code: string | null = null,
|
||||
readonly details: unknown[] = []
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/server/errorLogStore", () => ({
|
||||
appendErrorLog: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/server/workspace", () => ({
|
||||
isCrossOrigin: mocks.isCrossOrigin,
|
||||
}));
|
||||
|
||||
const routes = await import("./route");
|
||||
|
||||
function request(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/image-generation/export", {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
describe("image-generation export route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.isCrossOrigin.mockReturnValue(false);
|
||||
mocks.requireActor.mockReturnValue({ sub: "alice", role: "admin" });
|
||||
mocks.requireAdmin.mockReturnValue(undefined);
|
||||
mocks.configApiFetch.mockResolvedValue({
|
||||
body: { kind: "evoscientist.image-generation", format_version: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("GET proxies the backend export for an admin", async () => {
|
||||
const response = await routes.GET(request());
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.configApiFetch).toHaveBeenCalledWith(
|
||||
{ sub: "alice", role: "admin" },
|
||||
"/api/image-generation/export"
|
||||
);
|
||||
expect(await response.json()).toEqual({
|
||||
kind: "evoscientist.image-generation",
|
||||
format_version: 1,
|
||||
});
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("GET returns 403 for a non-admin", async () => {
|
||||
const { ActorError } = await import("@/lib/server/actor");
|
||||
mocks.requireAdmin.mockImplementation(() => {
|
||||
throw new ActorError("Admin role required.", 403);
|
||||
});
|
||||
const response = await routes.GET(request());
|
||||
expect(response.status).toBe(403);
|
||||
expect(mocks.configApiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("GET returns 401 when unauthenticated", async () => {
|
||||
const { ActorError } = await import("@/lib/server/actor");
|
||||
mocks.requireActor.mockImplementation(() => {
|
||||
throw new ActorError("Authentication required.", 401);
|
||||
});
|
||||
const response = await routes.GET(request());
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("GET returns 403 for cross-origin requests", async () => {
|
||||
mocks.isCrossOrigin.mockReturnValue(true);
|
||||
const response = await routes.GET(request());
|
||||
expect(response.status).toBe(403);
|
||||
expect(mocks.configApiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { requireActor, requireAdmin, type Actor } from "@/lib/server/actor";
|
||||
import { configApiFetch } from "@/lib/server/evoscientistConfigClient";
|
||||
import { isCrossOrigin } from "@/lib/server/workspace";
|
||||
import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
let actor: Actor | undefined;
|
||||
try {
|
||||
if (isCrossOrigin(request)) {
|
||||
return NextResponse.json(
|
||||
{ code: "FORBIDDEN", message: "Cross-origin access is not allowed." },
|
||||
{ status: 403, headers: NO_STORE }
|
||||
);
|
||||
}
|
||||
actor = requireActor(request);
|
||||
requireAdmin(actor);
|
||||
const { body } = await configApiFetch<unknown>(
|
||||
actor,
|
||||
"/api/image-generation/export"
|
||||
);
|
||||
return NextResponse.json(body, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return routeErrorResponse(error, actor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireActor: vi.fn(),
|
||||
requireAdmin: vi.fn(),
|
||||
configApiFetch: vi.fn(),
|
||||
isCrossOrigin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/server/actor", () => ({
|
||||
requireActor: mocks.requireActor,
|
||||
requireAdmin: mocks.requireAdmin,
|
||||
ActorError: class ActorError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: 401 | 403 = 401
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/server/evoscientistConfigClient", () => ({
|
||||
configApiFetch: mocks.configApiFetch,
|
||||
ConfigApiError: class ConfigApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code: string | null = null,
|
||||
readonly details: unknown[] = []
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/server/errorLogStore", () => ({
|
||||
appendErrorLog: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/server/workspace", () => ({
|
||||
isCrossOrigin: mocks.isCrossOrigin,
|
||||
}));
|
||||
|
||||
const routes = await import("./route");
|
||||
|
||||
function request(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/model-registry/export", {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
describe("model-registry export route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.isCrossOrigin.mockReturnValue(false);
|
||||
mocks.requireActor.mockReturnValue({ sub: "alice", role: "admin" });
|
||||
mocks.requireAdmin.mockReturnValue(undefined);
|
||||
mocks.configApiFetch.mockResolvedValue({
|
||||
body: { kind: "evoscientist.model-registry", format_version: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("GET proxies the backend export for an admin", async () => {
|
||||
const response = await routes.GET(request());
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.configApiFetch).toHaveBeenCalledWith(
|
||||
{ sub: "alice", role: "admin" },
|
||||
"/api/model-registry/export"
|
||||
);
|
||||
expect(await response.json()).toEqual({
|
||||
kind: "evoscientist.model-registry",
|
||||
format_version: 1,
|
||||
});
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("GET returns 403 for a non-admin", async () => {
|
||||
const { ActorError } = await import("@/lib/server/actor");
|
||||
mocks.requireAdmin.mockImplementation(() => {
|
||||
throw new ActorError("Admin role required.", 403);
|
||||
});
|
||||
const response = await routes.GET(request());
|
||||
expect(response.status).toBe(403);
|
||||
expect(mocks.configApiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("GET returns 401 when unauthenticated", async () => {
|
||||
const { ActorError } = await import("@/lib/server/actor");
|
||||
mocks.requireActor.mockImplementation(() => {
|
||||
throw new ActorError("Authentication required.", 401);
|
||||
});
|
||||
const response = await routes.GET(request());
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("GET returns 403 for cross-origin requests", async () => {
|
||||
mocks.isCrossOrigin.mockReturnValue(true);
|
||||
const response = await routes.GET(request());
|
||||
expect(response.status).toBe(403);
|
||||
expect(mocks.configApiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { requireActor, requireAdmin, type Actor } from "@/lib/server/actor";
|
||||
import { configApiFetch } from "@/lib/server/evoscientistConfigClient";
|
||||
import { isCrossOrigin } from "@/lib/server/workspace";
|
||||
import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
let actor: Actor | undefined;
|
||||
try {
|
||||
if (isCrossOrigin(request)) {
|
||||
return NextResponse.json(
|
||||
{ code: "FORBIDDEN", message: "Cross-origin access is not allowed." },
|
||||
{ status: 403, headers: NO_STORE }
|
||||
);
|
||||
}
|
||||
actor = requireActor(request);
|
||||
requireAdmin(actor);
|
||||
const { body } = await configApiFetch<unknown>(
|
||||
actor,
|
||||
"/api/model-registry/export"
|
||||
);
|
||||
return NextResponse.json(body, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return routeErrorResponse(error, actor);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export const SCOPE_MODEL_CONFIG_TEST = "model_config:test";
|
||||
export const SCOPE_MODEL_SELECT = "model:select";
|
||||
export const SCOPE_RUN_CREATE = "run:create";
|
||||
export const SCOPE_SYSTEM_READ = "system:read";
|
||||
export const SCOPE_CONFIG_EXPORT = "config:export";
|
||||
|
||||
const ADMIN_SCOPES = [
|
||||
SCOPE_MODEL_CONFIG_READ,
|
||||
@@ -34,6 +35,7 @@ const ADMIN_SCOPES = [
|
||||
SCOPE_MODEL_SELECT,
|
||||
SCOPE_RUN_CREATE,
|
||||
SCOPE_SYSTEM_READ,
|
||||
SCOPE_CONFIG_EXPORT,
|
||||
] as const;
|
||||
|
||||
const USER_SCOPES = [SCOPE_MODEL_SELECT, SCOPE_RUN_CREATE, SCOPE_SYSTEM_READ] as const;
|
||||
|
||||
Reference in New Issue
Block a user