Files
ITWorx_MCP_Hub-Public/apps/web/tests/api.test.ts
T

103 lines
6.3 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError, createApiClient, logoutSession } from "../src/api";
afterEach(() => vi.unstubAllGlobals());
describe("management API safety contracts", () => {
it("uses same-origin server pagination and URL-addressable filters", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ kind: "tools", generatedAt: "2026-08-01T18:00:00Z", rows: [], page: { total: 0 } }) });
vi.stubGlobal("fetch", fetchMock);
await createApiClient().resource("tools", { filter: "risk:R1", limit: 25 });
expect(fetchMock).toHaveBeenCalledWith("/api/tools?q=risk%3AR1&limit=25", expect.objectContaining({ credentials: "same-origin", headers: { Accept: "application/json" } }));
});
it("projects only safe client profile fields from the generated configuration", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({
clientId: "client-1",
kind: "stdio-bridge",
protocolVersion: "2026-07-28",
endpoint: "https://mcp.example.test/mcp/ludarium/prod",
transport: "stdio",
scopes: ["tools:read"],
credentialReference: "authentik://mcp-client/private-reference",
environmentVariable: "ITWORX_MCP_ACCESS_TOKEN",
bridgeCommand: "itworx-mcp-bridge",
}) });
vi.stubGlobal("fetch", fetchMock);
const config = await createApiClient().generatedConfig("client-1");
expect(config).toEqual({
clientId: "client-1",
kind: "stdio-bridge",
protocolVersion: "2026-07-28",
endpoint: "https://mcp.example.test/mcp/ludarium/prod",
transport: "stdio",
scopes: ["tools:read"],
environmentVariable: "ITWORX_MCP_ACCESS_TOKEN",
bridgeCommand: "itworx-mcp-bridge",
});
expect(config).not.toHaveProperty("credentialReference");
expect(JSON.stringify(config)).not.toContain("authentik://mcp-client/private-reference");
expect(fetchMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ credentials: "same-origin" }));
});
it("fails closed without CSRF and sends the session-bound token on writes", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-fixture" }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ decision: "deny", matchedPolicies: [], reasons: [], traceId: "trace" }) });
vi.stubGlobal("fetch", fetchMock);
const api = createApiClient();
await expect(api.simulatePolicy({ project: "p", environment: "prod", tool: "t" })).rejects.toBeInstanceOf(ApiError);
await api.session();
await api.simulatePolicy({ project: "p", environment: "prod", tool: "t" });
expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ headers: expect.objectContaining({ "X-CSRF-Token": "csrf-fixture" }) }));
});
it("logs out through the same-origin CSRF-protected BFF route", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-logout" }) })
.mockResolvedValueOnce({ ok: true, status: 204 });
vi.stubGlobal("fetch", fetchMock);
await logoutSession();
expect(fetchMock.mock.calls[1]?.[0]).toBe("/auth/logout");
expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ method: "POST", headers: expect.objectContaining({ "X-CSRF-Token": "csrf-logout" }) }));
});
it("keeps resource listing separate from persistent client management routes", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-1" }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ status: "revoked" }) });
vi.stubGlobal("fetch", fetchMock);
const api = createApiClient();
await api.session();
expect(await api.clientAction("client-1", "revoke")).toEqual({ accepted: true, message: "Client revoke completed." });
expect(fetchMock.mock.calls[1]?.[0]).toBe("/api/client-management/client-1/revoke");
});
it("posts bounded tool readiness input to the dedicated endpoint", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-ready" }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ ready: false, checks: {} }) });
vi.stubGlobal("fetch", fetchMock);
const api = createApiClient();
await api.session();
await api.testTool({ project: "mobilityops", environment: "prod", tool: "mobilityops.vehicle.get", arguments: { vehicleRef: "V-42" } });
expect(fetchMock.mock.calls[1]?.[0]).toBe("/api/tools/test");
expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ method: "POST", body: JSON.stringify({ project: "mobilityops", environment: "prod", tool: "mobilityops.vehicle.get", arguments: { vehicleRef: "V-42" } }) }));
});
it("invokes tools and operations checks only through CSRF-protected routes", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-live" }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ ok: true, traceId: "trace-1" }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ healthy: true, connectors: {} }) });
vi.stubGlobal("fetch", fetchMock);
const api = createApiClient();
await api.session();
const payload = { project: "mobilityops", environment: "prod", tool: "mobilityops.operations.summary", arguments: {} };
await api.invokeTool(payload);
await api.runOperationsCheck();
expect(fetchMock.mock.calls[1]?.[0]).toBe("/api/tools/invoke");
expect(fetchMock.mock.calls[2]?.[0]).toBe("/api/operations/check");
expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ headers: expect.objectContaining({ "X-CSRF-Token": "csrf-live" }) }));
expect(fetchMock.mock.calls[2]?.[1]).toEqual(expect.objectContaining({ headers: expect.objectContaining({ "X-CSRF-Token": "csrf-live" }) }));
});
it("converts non-success responses to typed safe errors", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 403 }));
await expect(createApiClient().dashboard()).rejects.toBeInstanceOf(ApiError);
});
});