From 1005b7969c91982d91a02838b074a5b2fe658d72 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 6 Feb 2026 18:22:12 -0600 Subject: [PATCH] fix(SEC-WEB-37): Gate federation mock data behind NODE_ENV check Replace exported const mockConnections with getMockConnections() function that returns mock data only when NODE_ENV === "development". In production and test environments, returns an empty array as defense-in-depth alongside the existing ComingSoon page gate (SEC-WEB-4). Co-Authored-By: Claude Opus 4.6 --- .../federation/connections/page.tsx | 4 +- apps/web/src/lib/api/federation.test.ts | 194 ++++++++++++++++++ apps/web/src/lib/api/federation.ts | 147 ++++++------- 3 files changed, 274 insertions(+), 71 deletions(-) create mode 100644 apps/web/src/lib/api/federation.test.ts diff --git a/apps/web/src/app/(authenticated)/federation/connections/page.tsx b/apps/web/src/app/(authenticated)/federation/connections/page.tsx index e2027ff..486e55c 100644 --- a/apps/web/src/app/(authenticated)/federation/connections/page.tsx +++ b/apps/web/src/app/(authenticated)/federation/connections/page.tsx @@ -10,7 +10,7 @@ import { ConnectionList } from "@/components/federation/ConnectionList"; import { InitiateConnectionDialog } from "@/components/federation/InitiateConnectionDialog"; import { ComingSoon } from "@/components/ui/ComingSoon"; import { - mockConnections, + getMockConnections, FederationConnectionStatus, type ConnectionDetails, } from "@/lib/api/federation"; @@ -54,7 +54,7 @@ function ConnectionsPageContent(): React.JSX.Element { // Using mock data for now (development only) await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate network delay - setConnections(mockConnections); + setConnections(getMockConnections()); } catch (err) { setError( err instanceof Error ? err.message : "Unable to load connections. Please try again." diff --git a/apps/web/src/lib/api/federation.test.ts b/apps/web/src/lib/api/federation.test.ts new file mode 100644 index 0000000..5e84f90 --- /dev/null +++ b/apps/web/src/lib/api/federation.test.ts @@ -0,0 +1,194 @@ +/** + * Federation API Client Tests + * Tests for mock data NODE_ENV gating (SEC-WEB-37) + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as client from "./client"; +import { + getMockConnections, + fetchConnections, + fetchConnection, + fetchInstanceIdentity, + updateInstanceConfiguration, + regenerateInstanceKeys, + FederationConnectionStatus, +} from "./federation"; + +// Mock the API client +vi.mock("./client", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + apiPatch: vi.fn(), +})); + +describe("Federation API", () => { + describe("getMockConnections", () => { + it("should return mock connections in development mode", () => { + vi.stubEnv("NODE_ENV", "development"); + + const connections = getMockConnections(); + + expect(connections).toHaveLength(3); + expect(connections[0]?.id).toBe("conn-1"); + expect(connections[0]?.remoteUrl).toBe("https://mosaic.work.example.com"); + expect(connections[1]?.id).toBe("conn-2"); + expect(connections[2]?.id).toBe("conn-3"); + }); + + it("should return empty array in production mode", () => { + vi.stubEnv("NODE_ENV", "production"); + + const connections = getMockConnections(); + + expect(connections).toEqual([]); + expect(connections).toHaveLength(0); + }); + + it("should return empty array in test mode", () => { + vi.stubEnv("NODE_ENV", "test"); + + const connections = getMockConnections(); + + expect(connections).toEqual([]); + expect(connections).toHaveLength(0); + }); + + it("should include expected connection statuses in development", () => { + vi.stubEnv("NODE_ENV", "development"); + + const connections = getMockConnections(); + + expect(connections[0]?.status).toBe(FederationConnectionStatus.ACTIVE); + expect(connections[1]?.status).toBe(FederationConnectionStatus.PENDING); + expect(connections[2]?.status).toBe(FederationConnectionStatus.DISCONNECTED); + }); + + it("should include capabilities in development mock data", () => { + vi.stubEnv("NODE_ENV", "development"); + + const connections = getMockConnections(); + + expect(connections[0]?.remoteCapabilities).toEqual({ + supportsQuery: true, + supportsCommand: true, + supportsEvent: true, + supportsAgentSpawn: true, + protocolVersion: "1.0", + }); + }); + + it("should not expose mock public keys in production", () => { + vi.stubEnv("NODE_ENV", "production"); + + const connections = getMockConnections(); + + // In production, no connections should be returned at all + expect(connections).toHaveLength(0); + // Verify no public key data is accessible + const hasPublicKeys = connections.some((c) => c.remotePublicKey); + expect(hasPublicKeys).toBe(false); + }); + }); + + describe("fetchConnections", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should call the connections endpoint without filters", async () => { + const mockResponse = [{ id: "conn-1" }]; + + vi.mocked(client.apiGet).mockResolvedValue(mockResponse); + + const result = await fetchConnections(); + + expect(client.apiGet).toHaveBeenCalledWith("/api/v1/federation/connections"); + expect(result).toEqual(mockResponse); + }); + + it("should include status filter in query string", async () => { + const mockResponse = [{ id: "conn-1" }]; + + vi.mocked(client.apiGet).mockResolvedValue(mockResponse); + + const result = await fetchConnections(FederationConnectionStatus.ACTIVE); + + expect(client.apiGet).toHaveBeenCalledWith("/api/v1/federation/connections?status=ACTIVE"); + expect(result).toEqual(mockResponse); + }); + }); + + describe("fetchConnection", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should fetch a single connection by ID", async () => { + const mockResponse = { id: "conn-1", remoteUrl: "https://example.com" }; + + vi.mocked(client.apiGet).mockResolvedValue(mockResponse); + + const result = await fetchConnection("conn-1"); + + expect(client.apiGet).toHaveBeenCalledWith("/api/v1/federation/connections/conn-1"); + expect(result).toEqual(mockResponse); + }); + }); + + describe("fetchInstanceIdentity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should fetch the instance identity", async () => { + const mockResponse = { id: "inst-1", name: "Test Instance" }; + + vi.mocked(client.apiGet).mockResolvedValue(mockResponse); + + const result = await fetchInstanceIdentity(); + + expect(client.apiGet).toHaveBeenCalledWith("/api/v1/federation/instance"); + expect(result).toEqual(mockResponse); + }); + }); + + describe("updateInstanceConfiguration", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update instance configuration", async () => { + const mockResponse = { id: "inst-1", name: "Updated Instance" }; + + vi.mocked(client.apiPatch).mockResolvedValue(mockResponse); + + const result = await updateInstanceConfiguration({ name: "Updated Instance" }); + + expect(client.apiPatch).toHaveBeenCalledWith("/api/v1/federation/instance", { + name: "Updated Instance", + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe("regenerateInstanceKeys", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should regenerate instance keys", async () => { + const mockResponse = { id: "inst-1", publicKey: "new-key" }; + + vi.mocked(client.apiPost).mockResolvedValue(mockResponse); + + const result = await regenerateInstanceKeys(); + + expect(client.apiPost).toHaveBeenCalledWith( + "/api/v1/federation/instance/regenerate-keys", + {} + ); + expect(result).toEqual(mockResponse); + }); + }); +}); diff --git a/apps/web/src/lib/api/federation.ts b/apps/web/src/lib/api/federation.ts index 76f2fec..b46b18d 100644 --- a/apps/web/src/lib/api/federation.ts +++ b/apps/web/src/lib/api/federation.ts @@ -197,76 +197,85 @@ export async function regenerateInstanceKeys(): Promise } /** - * Mock connections for development + * Get mock connections for development only. + * Returns an empty array in production as defense-in-depth. + * The federation pages are also gated behind a ComingSoon component + * in production (SEC-WEB-4), but this provides an additional layer. */ -export const mockConnections: ConnectionDetails[] = [ - { - id: "conn-1", - workspaceId: "workspace-1", - remoteInstanceId: "instance-work-001", - remoteUrl: "https://mosaic.work.example.com", - remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----", - remoteCapabilities: { - supportsQuery: true, - supportsCommand: true, - supportsEvent: true, - supportsAgentSpawn: true, - protocolVersion: "1.0", +export function getMockConnections(): ConnectionDetails[] { + if (process.env.NODE_ENV !== "development") { + return []; + } + + return [ + { + id: "conn-1", + workspaceId: "workspace-1", + remoteInstanceId: "instance-work-001", + remoteUrl: "https://mosaic.work.example.com", + remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----", + remoteCapabilities: { + supportsQuery: true, + supportsCommand: true, + supportsEvent: true, + supportsAgentSpawn: true, + protocolVersion: "1.0", + }, + status: FederationConnectionStatus.ACTIVE, + metadata: { + name: "Work Instance", + description: "Corporate Mosaic instance", + }, + createdAt: new Date("2026-02-01").toISOString(), + updatedAt: new Date("2026-02-01").toISOString(), + connectedAt: new Date("2026-02-01").toISOString(), + disconnectedAt: null, }, - status: FederationConnectionStatus.ACTIVE, - metadata: { - name: "Work Instance", - description: "Corporate Mosaic instance", + { + id: "conn-2", + workspaceId: "workspace-1", + remoteInstanceId: "instance-partner-001", + remoteUrl: "https://mosaic.partner.example.com", + remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----", + remoteCapabilities: { + supportsQuery: true, + supportsCommand: false, + supportsEvent: true, + supportsAgentSpawn: false, + protocolVersion: "1.0", + }, + status: FederationConnectionStatus.PENDING, + metadata: { + name: "Partner Instance", + description: "Shared project collaboration", + }, + createdAt: new Date("2026-02-02").toISOString(), + updatedAt: new Date("2026-02-02").toISOString(), + connectedAt: null, + disconnectedAt: null, }, - createdAt: new Date("2026-02-01").toISOString(), - updatedAt: new Date("2026-02-01").toISOString(), - connectedAt: new Date("2026-02-01").toISOString(), - disconnectedAt: null, - }, - { - id: "conn-2", - workspaceId: "workspace-1", - remoteInstanceId: "instance-partner-001", - remoteUrl: "https://mosaic.partner.example.com", - remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----", - remoteCapabilities: { - supportsQuery: true, - supportsCommand: false, - supportsEvent: true, - supportsAgentSpawn: false, - protocolVersion: "1.0", + { + id: "conn-3", + workspaceId: "workspace-1", + remoteInstanceId: "instance-old-001", + remoteUrl: "https://mosaic.old.example.com", + remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----", + remoteCapabilities: { + supportsQuery: true, + supportsCommand: true, + supportsEvent: false, + supportsAgentSpawn: false, + protocolVersion: "1.0", + }, + status: FederationConnectionStatus.DISCONNECTED, + metadata: { + name: "Previous Instance", + description: "No longer in use", + }, + createdAt: new Date("2026-01-15").toISOString(), + updatedAt: new Date("2026-01-30").toISOString(), + connectedAt: new Date("2026-01-15").toISOString(), + disconnectedAt: new Date("2026-01-30").toISOString(), }, - status: FederationConnectionStatus.PENDING, - metadata: { - name: "Partner Instance", - description: "Shared project collaboration", - }, - createdAt: new Date("2026-02-02").toISOString(), - updatedAt: new Date("2026-02-02").toISOString(), - connectedAt: null, - disconnectedAt: null, - }, - { - id: "conn-3", - workspaceId: "workspace-1", - remoteInstanceId: "instance-old-001", - remoteUrl: "https://mosaic.old.example.com", - remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----", - remoteCapabilities: { - supportsQuery: true, - supportsCommand: true, - supportsEvent: false, - supportsAgentSpawn: false, - protocolVersion: "1.0", - }, - status: FederationConnectionStatus.DISCONNECTED, - metadata: { - name: "Previous Instance", - description: "No longer in use", - }, - createdAt: new Date("2026-01-15").toISOString(), - updatedAt: new Date("2026-01-30").toISOString(), - connectedAt: new Date("2026-01-15").toISOString(), - disconnectedAt: new Date("2026-01-30").toISOString(), - }, -]; + ]; +}