feat(#85): implement CONNECT/DISCONNECT protocol
Implemented connection handshake protocol for federation building on the Instance Identity Model from issue #84. **Services:** - SignatureService: Message signing/verification with RSA-SHA256 - ConnectionService: Federation connection management **API Endpoints:** - POST /api/v1/federation/connections/initiate - POST /api/v1/federation/connections/:id/accept - POST /api/v1/federation/connections/:id/reject - POST /api/v1/federation/connections/:id/disconnect - GET /api/v1/federation/connections - GET /api/v1/federation/connections/:id - POST /api/v1/federation/incoming/connect **Tests:** 70 tests pass (18 Signature + 20 Connection + 13 Controller + 19 existing) **Coverage:** 100% on new code **TDD Approach:** Tests written before implementation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,14 +7,18 @@ 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 { 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",
|
||||
@@ -37,6 +41,22 @@ describe("FederationController", () => {
|
||||
id: "user-123",
|
||||
email: "admin@example.com",
|
||||
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 () => {
|
||||
@@ -56,6 +76,18 @@ describe("FederationController", () => {
|
||||
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(),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
@@ -67,6 +99,7 @@ describe("FederationController", () => {
|
||||
controller = module.get<FederationController>(FederationController);
|
||||
service = module.get<FederationService>(FederationService);
|
||||
auditService = module.get<FederationAuditService>(FederationAuditService);
|
||||
connectionService = module.get<ConnectionService>(ConnectionService);
|
||||
});
|
||||
|
||||
describe("GET /instance", () => {
|
||||
@@ -155,4 +188,147 @@ describe("FederationController", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user