| import { describe, it, expect, vi, beforeEach } from "vitest"; |
|
|
| const mocks = vi.hoisted(() => ({ |
| nextResponse: Symbol("next"), |
| jsonResponse: vi.fn((body, init) => ({ |
| status: init?.status || 200, |
| body, |
| })), |
| getSettings: vi.fn(), |
| validateApiKey: vi.fn(), |
| getConsistentMachineId: vi.fn(), |
| verifyDashboardAuthToken: vi.fn(), |
| })); |
|
|
| vi.mock("next/server", () => ({ |
| NextResponse: { |
| next: vi.fn(() => mocks.nextResponse), |
| json: mocks.jsonResponse, |
| redirect: vi.fn((url) => ({ status: 307, url })), |
| }, |
| })); |
|
|
| vi.mock("@/lib/localDb", () => ({ |
| getSettings: mocks.getSettings, |
| validateApiKey: mocks.validateApiKey, |
| })); |
|
|
| vi.mock("@/shared/utils/machineId", () => ({ |
| getConsistentMachineId: mocks.getConsistentMachineId, |
| })); |
|
|
| vi.mock("@/lib/auth/dashboardSession", () => ({ |
| verifyDashboardAuthToken: mocks.verifyDashboardAuthToken, |
| })); |
|
|
| const { proxy, __test__ } = await import("../../src/dashboardGuard.js"); |
|
|
| function request(pathname, headers = {}) { |
| const normalizedHeaders = new Headers(headers); |
| return { |
| nextUrl: { pathname }, |
| headers: normalizedHeaders, |
| cookies: { get: vi.fn(() => undefined) }, |
| url: `http://localhost${pathname}`, |
| }; |
| } |
|
|
| describe("dashboard guard public LLM API access", () => { |
| beforeEach(() => { |
| vi.clearAllMocks(); |
| mocks.getSettings.mockResolvedValue({ requireLogin: true }); |
| mocks.validateApiKey.mockResolvedValue(false); |
| mocks.getConsistentMachineId.mockResolvedValue("cli-token"); |
| mocks.verifyDashboardAuthToken.mockResolvedValue(false); |
| }); |
|
|
| it("allows loopback public LLM API without API key", async () => { |
| const response = await proxy(request("/v1/chat/completions", { host: "localhost:20128" })); |
|
|
| expect(response).toBe(mocks.nextResponse); |
| expect(mocks.validateApiKey).not.toHaveBeenCalled(); |
| }); |
|
|
| it("rejects remote rewritten public LLM API without API key", async () => { |
| const response = await proxy(request("/api/v1/chat/completions", { host: "router.example.com" })); |
|
|
| expect(response.status).toBe(401); |
| expect(response.body.error).toBe("API key required for remote API access"); |
| }); |
|
|
| it("allows loopback rewritten public LLM API without API key", async () => { |
| const response = await proxy(request("/api/v1/chat/completions", { host: "localhost:20128" })); |
|
|
| expect(response).toBe(mocks.nextResponse); |
| expect(mocks.validateApiKey).not.toHaveBeenCalled(); |
| }); |
|
|
| it("rejects remote beta public LLM API without API key", async () => { |
| const response = await proxy(request("/v1beta/models", { host: "router.example.com" })); |
|
|
| expect(response.status).toBe(401); |
| expect(response.body.error).toBe("API key required for remote API access"); |
| }); |
|
|
| it("rejects remote rewritten beta public LLM API without API key", async () => { |
| const response = await proxy(request("/api/v1beta/models", { host: "router.example.com" })); |
|
|
| expect(response.status).toBe(401); |
| expect(response.body.error).toBe("API key required for remote API access"); |
| }); |
|
|
| it("allows remote public LLM API with valid bearer API key", async () => { |
| mocks.validateApiKey.mockResolvedValue(true); |
|
|
| const response = await proxy(request("/api/v1/chat/completions", { |
| host: "router.example.com", |
| authorization: "Bearer sk-valid", |
| })); |
|
|
| expect(response).toBe(mocks.nextResponse); |
| expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid"); |
| }); |
|
|
| it("allows remote public LLM API with valid x-api-key", async () => { |
| mocks.validateApiKey.mockResolvedValue(true); |
|
|
| const response = await proxy(request("/v1/web/fetch", { |
| host: "router.example.com", |
| "x-api-key": "sk-valid", |
| })); |
|
|
| expect(response).toBe(mocks.nextResponse); |
| expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid"); |
| }); |
|
|
| it("allows remote rewritten beta public LLM API with valid API key", async () => { |
| mocks.validateApiKey.mockResolvedValue(true); |
|
|
| const response = await proxy(request("/api/v1beta/models", { |
| host: "router.example.com", |
| "x-api-key": "sk-valid", |
| })); |
|
|
| expect(response).toBe(mocks.nextResponse); |
| expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid"); |
| }); |
| }); |
|
|
| describe("dashboard guard local-only access", () => { |
| beforeEach(() => { |
| vi.clearAllMocks(); |
| mocks.getSettings.mockResolvedValue({ requireLogin: true }); |
| mocks.validateApiKey.mockResolvedValue(false); |
| mocks.getConsistentMachineId.mockResolvedValue("cli-token"); |
| mocks.verifyDashboardAuthToken.mockResolvedValue(false); |
| }); |
|
|
| it("rejects local-only route from non-loopback host without CLI token", async () => { |
| const response = await proxy(request("/api/mcp/filesystem/sse", { |
| host: "router.example.com", |
| })); |
|
|
| expect(response.status).toBe(403); |
| expect(response.body.error).toBe("Local only: CLI token required"); |
| }); |
|
|
| it("rejects local-only route on loopback when requireLogin=true and no JWT", async () => { |
| const response = await proxy(request("/api/mcp/filesystem/sse", { |
| host: "localhost:20128", |
| origin: "http://localhost:20128", |
| })); |
|
|
| expect(response.status).toBe(403); |
| expect(response.body.error).toBe("Local only: CLI token required"); |
| }); |
|
|
| it("allows local-only route on loopback when requireLogin=false", async () => { |
| mocks.getSettings.mockResolvedValue({ requireLogin: false }); |
|
|
| const response = await proxy(request("/api/cli-tools/antigravity-mitm", { |
| host: "localhost:20128", |
| origin: "http://localhost:20128", |
| })); |
|
|
| expect(response).toBe(mocks.nextResponse); |
| }); |
|
|
| it("rejects local-only route from tunnel host even when requireLogin=false", async () => { |
| mocks.getSettings.mockResolvedValue({ requireLogin: false }); |
|
|
| const response = await proxy(request("/api/cli-tools/antigravity-mitm", { |
| host: "router.example.com", |
| })); |
|
|
| expect(response.status).toBe(403); |
| }); |
|
|
| it("rejects local-only route when Origin is non-loopback (CSRF block)", async () => { |
| mocks.getSettings.mockResolvedValue({ requireLogin: false }); |
|
|
| const response = await proxy(request("/api/cli-tools/antigravity-mitm", { |
| host: "localhost:20128", |
| origin: "http://evil.example.com", |
| })); |
|
|
| expect(response.status).toBe(403); |
| }); |
|
|
| it("allows local-only route with valid CLI token", async () => { |
| const response = await proxy(request("/api/mcp/filesystem/sse", { |
| host: "router.example.com", |
| "x-9r-cli-token": "cli-token", |
| })); |
|
|
| expect(response).toBe(mocks.nextResponse); |
| }); |
| }); |
|
|
| describe("dashboard guard helpers", () => { |
| it("extracts bearer API keys before x-api-key", () => { |
| const apiRequest = request("/v1/chat/completions", { |
| authorization: "Bearer bearer-key", |
| "x-api-key": "header-key", |
| }); |
|
|
| expect(__test__.extractApiKey(apiRequest)).toBe("bearer-key"); |
| }); |
| }); |
|
|