diff --git a/src/app/api/image-generation/export/route.test.ts b/src/app/api/image-generation/export/route.test.ts new file mode 100644 index 0000000..f85dc32 --- /dev/null +++ b/src/app/api/image-generation/export/route.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/image-generation/export/route.ts b/src/app/api/image-generation/export/route.ts new file mode 100644 index 0000000..050dd92 --- /dev/null +++ b/src/app/api/image-generation/export/route.ts @@ -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( + actor, + "/api/image-generation/export" + ); + return NextResponse.json(body, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} diff --git a/src/app/api/model-registry/export/route.test.ts b/src/app/api/model-registry/export/route.test.ts new file mode 100644 index 0000000..cd240a6 --- /dev/null +++ b/src/app/api/model-registry/export/route.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/model-registry/export/route.ts b/src/app/api/model-registry/export/route.ts new file mode 100644 index 0000000..ba43635 --- /dev/null +++ b/src/app/api/model-registry/export/route.ts @@ -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( + actor, + "/api/model-registry/export" + ); + return NextResponse.json(body, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} diff --git a/src/lib/server/delegation.ts b/src/lib/server/delegation.ts index 5379b3a..5c535f4 100644 --- a/src/lib/server/delegation.ts +++ b/src/lib/server/delegation.ts @@ -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;