Files
stack/apps/api/src/federation/federation.controller.spec.ts
T
Jason WoltjeandClaude Sonnet 4.5 0495f979a7 feat(#94): implement spoke configuration UI
Implements the final piece of M7-Federation - the spoke configuration UI
that allows administrators to configure their local instance's federation
capabilities and settings.

Backend Changes:
- Add UpdateInstanceDto with validation for name, capabilities, and metadata
- Implement FederationService.updateInstanceConfiguration() method
- Add PATCH /api/v1/federation/instance endpoint to FederationController
- Add audit logging for configuration updates
- Add tests for updateInstanceConfiguration (5 new tests, all passing)

Frontend Changes:
- Create SpokeConfigurationForm component with PDA-friendly design
- Create /federation/settings page with configuration management
- Add regenerate keypair functionality with confirmation dialog
- Extend federation API client with updateInstanceConfiguration and regenerateInstanceKeys
- Add comprehensive tests (10 tests, all passing)

Design Decisions:
- Admin-only access via AdminGuard
- Never expose private key in API responses (security)
- PDA-friendly language throughout (no demanding terms)
- Clear visual hierarchy with read-only and editable fields
- Truncated public key with copy button for usability
- Confirmation dialog for destructive key regeneration

All tests passing:
- Backend: 13/13 federation service tests passing
- Frontend: 10/10 SpokeConfigurationForm tests passing
- TypeScript compilation: passing
- Linting: passing
- PDA-friendliness: verified

This completes M7-Federation. All federation features are now implemented.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-02-03 14:51:59 -06:00

344 lines
11 KiB
TypeScript

/**
* Federation Controller Tests
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { FederationController } from "./federation.controller";
import { FederationService } from "./federation.service";
import { FederationAuditService } from "./audit.service";
import { ConnectionService } from "./connection.service";
import { FederationAgentService } from "./federation-agent.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AdminGuard } from "../auth/guards/admin.guard";
import { FederationConnectionStatus } from "@prisma/client";
import type { PublicInstanceIdentity } from "./types/instance.types";
import type { ConnectionDetails } from "./types/connection.types";
describe("FederationController", () => {
let controller: FederationController;
let service: FederationService;
let auditService: FederationAuditService;
let connectionService: ConnectionService;
const mockPublicIdentity: PublicInstanceIdentity = {
id: "123e4567-e89b-12d3-a456-426614174000",
instanceId: "test-instance-id",
name: "Test Instance",
url: "https://test.example.com",
publicKey: "-----BEGIN PUBLIC KEY-----\nMOCK\n-----END PUBLIC KEY-----",
capabilities: {
supportsQuery: true,
supportsCommand: true,
supportsEvent: true,
protocolVersion: "1.0",
},
metadata: {},
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
};
const mockUser = {
id: "user-123",
email: "[email protected]",
name: "Admin User",
workspaceId: "workspace-123",
};
const mockConnection: ConnectionDetails = {
id: "conn-123",
workspaceId: "workspace-123",
remoteInstanceId: "remote-instance-456",
remoteUrl: "https://remote.example.com",
remotePublicKey: "-----BEGIN PUBLIC KEY-----\nREMOTE\n-----END PUBLIC KEY-----",
remoteCapabilities: { supportsQuery: true },
status: FederationConnectionStatus.PENDING,
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
connectedAt: null,
disconnectedAt: null,
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FederationController],
providers: [
{
provide: FederationService,
useValue: {
getPublicIdentity: vi.fn(),
regenerateKeypair: vi.fn(),
},
},
{
provide: FederationAuditService,
useValue: {
logKeypairRegeneration: vi.fn(),
},
},
{
provide: ConnectionService,
useValue: {
initiateConnection: vi.fn(),
acceptConnection: vi.fn(),
rejectConnection: vi.fn(),
disconnect: vi.fn(),
getConnections: vi.fn(),
getConnection: vi.fn(),
handleIncomingConnectionRequest: vi.fn(),
},
},
{
provide: FederationAgentService,
useValue: {
spawnAgentOnRemote: vi.fn(),
getAgentStatus: vi.fn(),
killAgentOnRemote: vi.fn(),
},
},
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(AdminGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<FederationController>(FederationController);
service = module.get<FederationService>(FederationService);
auditService = module.get<FederationAuditService>(FederationAuditService);
connectionService = module.get<ConnectionService>(ConnectionService);
});
describe("GET /instance", () => {
it("should return public instance identity", async () => {
// Arrange
vi.spyOn(service, "getPublicIdentity").mockResolvedValue(mockPublicIdentity);
// Act
const result = await controller.getInstance();
// Assert
expect(result).toEqual(mockPublicIdentity);
expect(service.getPublicIdentity).toHaveBeenCalledTimes(1);
});
it("should not expose private key", async () => {
// Arrange
vi.spyOn(service, "getPublicIdentity").mockResolvedValue(mockPublicIdentity);
// Act
const result = await controller.getInstance();
// Assert
expect(result).not.toHaveProperty("privateKey");
});
it("should return consistent identity across multiple calls", async () => {
// Arrange
vi.spyOn(service, "getPublicIdentity").mockResolvedValue(mockPublicIdentity);
// Act
const result1 = await controller.getInstance();
const result2 = await controller.getInstance();
// Assert
expect(result1).toEqual(result2);
expect(result1.instanceId).toEqual(result2.instanceId);
});
});
describe("POST /instance/regenerate-keys", () => {
it("should regenerate keypair and return public identity only", async () => {
// Arrange
const updatedIdentity = {
...mockPublicIdentity,
publicKey: "NEW_PUBLIC_KEY",
};
vi.spyOn(service, "regenerateKeypair").mockResolvedValue(updatedIdentity);
const mockRequest = {
user: mockUser,
} as any;
// Act
const result = await controller.regenerateKeys(mockRequest);
// Assert
expect(result).toEqual(updatedIdentity);
expect(service.regenerateKeypair).toHaveBeenCalledTimes(1);
// SECURITY FIX: Verify audit logging
expect(auditService.logKeypairRegeneration).toHaveBeenCalledWith(
mockUser.id,
updatedIdentity.instanceId
);
});
it("should NOT expose private key in response", async () => {
// Arrange
const updatedIdentity = {
...mockPublicIdentity,
publicKey: "NEW_PUBLIC_KEY",
};
vi.spyOn(service, "regenerateKeypair").mockResolvedValue(updatedIdentity);
const mockRequest = {
user: mockUser,
} as any;
// Act
const result = await controller.regenerateKeys(mockRequest);
// Assert - CRITICAL SECURITY TEST
expect(result).not.toHaveProperty("privateKey");
expect(result).toHaveProperty("publicKey");
expect(result).toHaveProperty("instanceId");
});
});
describe("POST /connections/initiate", () => {
it("should initiate connection to remote instance", async () => {
const dto = { remoteUrl: "https://remote.example.com" };
vi.spyOn(connectionService, "initiateConnection").mockResolvedValue(mockConnection);
const mockRequest = { user: mockUser } as never;
const result = await controller.initiateConnection(mockRequest, dto);
expect(result).toEqual(mockConnection);
expect(connectionService.initiateConnection).toHaveBeenCalledWith(
mockUser.workspaceId,
dto.remoteUrl
);
});
});
describe("POST /connections/:id/accept", () => {
it("should accept pending connection", async () => {
const activeConnection = { ...mockConnection, status: FederationConnectionStatus.ACTIVE };
vi.spyOn(connectionService, "acceptConnection").mockResolvedValue(activeConnection);
const mockRequest = { user: mockUser } as never;
const result = await controller.acceptConnection(mockRequest, "conn-123", {});
expect(result.status).toBe(FederationConnectionStatus.ACTIVE);
expect(connectionService.acceptConnection).toHaveBeenCalledWith(
mockUser.workspaceId,
"conn-123",
undefined
);
});
});
describe("POST /connections/:id/reject", () => {
it("should reject pending connection", async () => {
const rejectedConnection = {
...mockConnection,
status: FederationConnectionStatus.DISCONNECTED,
};
vi.spyOn(connectionService, "rejectConnection").mockResolvedValue(rejectedConnection);
const mockRequest = { user: mockUser } as never;
const result = await controller.rejectConnection(mockRequest, "conn-123", {
reason: "Not approved",
});
expect(result.status).toBe(FederationConnectionStatus.DISCONNECTED);
expect(connectionService.rejectConnection).toHaveBeenCalledWith(
mockUser.workspaceId,
"conn-123",
"Not approved"
);
});
});
describe("POST /connections/:id/disconnect", () => {
it("should disconnect active connection", async () => {
const disconnectedConnection = {
...mockConnection,
status: FederationConnectionStatus.DISCONNECTED,
};
vi.spyOn(connectionService, "disconnect").mockResolvedValue(disconnectedConnection);
const mockRequest = { user: mockUser } as never;
const result = await controller.disconnectConnection(mockRequest, "conn-123", {
reason: "Manual disconnect",
});
expect(result.status).toBe(FederationConnectionStatus.DISCONNECTED);
expect(connectionService.disconnect).toHaveBeenCalledWith(
mockUser.workspaceId,
"conn-123",
"Manual disconnect"
);
});
});
describe("GET /connections", () => {
it("should list all connections for workspace", async () => {
vi.spyOn(connectionService, "getConnections").mockResolvedValue([mockConnection]);
const mockRequest = { user: mockUser } as never;
const result = await controller.getConnections(mockRequest);
expect(result).toEqual([mockConnection]);
expect(connectionService.getConnections).toHaveBeenCalledWith(
mockUser.workspaceId,
undefined
);
});
it("should filter connections by status", async () => {
vi.spyOn(connectionService, "getConnections").mockResolvedValue([mockConnection]);
const mockRequest = { user: mockUser } as never;
await controller.getConnections(mockRequest, FederationConnectionStatus.ACTIVE);
expect(connectionService.getConnections).toHaveBeenCalledWith(
mockUser.workspaceId,
FederationConnectionStatus.ACTIVE
);
});
});
describe("GET /connections/:id", () => {
it("should return connection details", async () => {
vi.spyOn(connectionService, "getConnection").mockResolvedValue(mockConnection);
const mockRequest = { user: mockUser } as never;
const result = await controller.getConnection(mockRequest, "conn-123");
expect(result).toEqual(mockConnection);
expect(connectionService.getConnection).toHaveBeenCalledWith(
mockUser.workspaceId,
"conn-123"
);
});
});
describe("POST /incoming/connect", () => {
it("should handle incoming connection request", async () => {
const dto = {
instanceId: "remote-instance-456",
instanceUrl: "https://remote.example.com",
publicKey: "PUBLIC_KEY",
capabilities: { supportsQuery: true },
timestamp: Date.now(),
signature: "valid-signature",
};
vi.spyOn(connectionService, "handleIncomingConnectionRequest").mockResolvedValue(
mockConnection
);
const result = await controller.handleIncomingConnection(dto);
expect(result).toEqual({
status: "pending",
connectionId: mockConnection.id,
});
expect(connectionService.handleIncomingConnectionRequest).toHaveBeenCalled();
});
});
});