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:
422
apps/api/src/federation/connection.service.spec.ts
Normal file
422
apps/api/src/federation/connection.service.spec.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Connection Service Tests
|
||||
*
|
||||
* Tests for federation connection management.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { ConnectionService } from "./connection.service";
|
||||
import { FederationService } from "./federation.service";
|
||||
import { SignatureService } from "./signature.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { FederationConnectionStatus } from "@prisma/client";
|
||||
import { FederationConnection } from "@prisma/client";
|
||||
import { of, throwError } from "rxjs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
describe("ConnectionService", () => {
|
||||
let service: ConnectionService;
|
||||
let prismaService: PrismaService;
|
||||
let federationService: FederationService;
|
||||
let signatureService: SignatureService;
|
||||
let httpService: HttpService;
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockRemoteUrl = "https://remote.example.com";
|
||||
const mockInstanceIdentity = {
|
||||
id: "local-id",
|
||||
instanceId: "local-instance-123",
|
||||
name: "Local Instance",
|
||||
url: "https://local.example.com",
|
||||
publicKey: "-----BEGIN PUBLIC KEY-----\nLOCAL\n-----END PUBLIC KEY-----",
|
||||
privateKey: "-----BEGIN PRIVATE KEY-----\nLOCAL\n-----END PRIVATE KEY-----",
|
||||
capabilities: {
|
||||
supportsQuery: true,
|
||||
supportsCommand: true,
|
||||
protocolVersion: "1.0",
|
||||
},
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockRemoteIdentity = {
|
||||
id: "remote-id",
|
||||
instanceId: "remote-instance-456",
|
||||
name: "Remote Instance",
|
||||
url: mockRemoteUrl,
|
||||
publicKey: "-----BEGIN PUBLIC KEY-----\nREMOTE\n-----END PUBLIC KEY-----",
|
||||
capabilities: {
|
||||
supportsQuery: true,
|
||||
protocolVersion: "1.0",
|
||||
},
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockConnection: FederationConnection = {
|
||||
id: "conn-123",
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteInstanceId: mockRemoteIdentity.instanceId,
|
||||
remoteUrl: mockRemoteUrl,
|
||||
remotePublicKey: mockRemoteIdentity.publicKey,
|
||||
remoteCapabilities: mockRemoteIdentity.capabilities,
|
||||
status: FederationConnectionStatus.PENDING,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
connectedAt: null,
|
||||
disconnectedAt: null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ConnectionService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: {
|
||||
federationConnection: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: FederationService,
|
||||
useValue: {
|
||||
getInstanceIdentity: vi.fn().mockResolvedValue(mockInstanceIdentity),
|
||||
getPublicIdentity: vi.fn().mockResolvedValue(mockInstanceIdentity),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SignatureService,
|
||||
useValue: {
|
||||
signMessage: vi.fn().mockResolvedValue("mock-signature"),
|
||||
verifyConnectionRequest: vi.fn().mockReturnValue({ valid: true }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: HttpService,
|
||||
useValue: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ConnectionService>(ConnectionService);
|
||||
prismaService = module.get<PrismaService>(PrismaService);
|
||||
federationService = module.get<FederationService>(FederationService);
|
||||
signatureService = module.get<SignatureService>(SignatureService);
|
||||
httpService = module.get<HttpService>(HttpService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("initiateConnection", () => {
|
||||
it("should create a pending connection", async () => {
|
||||
const mockAxiosResponse: AxiosResponse = {
|
||||
data: mockRemoteIdentity,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: {} as never,
|
||||
};
|
||||
|
||||
vi.spyOn(httpService, "get").mockReturnValue(of(mockAxiosResponse));
|
||||
vi.spyOn(httpService, "post").mockReturnValue(
|
||||
of({ data: { accepted: true } } as AxiosResponse)
|
||||
);
|
||||
vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection);
|
||||
|
||||
const result = await service.initiateConnection(mockWorkspaceId, mockRemoteUrl);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.status).toBe(FederationConnectionStatus.PENDING);
|
||||
expect(result.remoteUrl).toBe(mockRemoteUrl);
|
||||
expect(prismaService.federationConnection.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteUrl: mockRemoteUrl,
|
||||
status: FederationConnectionStatus.PENDING,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should fetch remote instance identity", async () => {
|
||||
const mockAxiosResponse: AxiosResponse = {
|
||||
data: mockRemoteIdentity,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: {} as never,
|
||||
};
|
||||
|
||||
vi.spyOn(httpService, "get").mockReturnValue(of(mockAxiosResponse));
|
||||
vi.spyOn(httpService, "post").mockReturnValue(
|
||||
of({ data: { accepted: true } } as AxiosResponse)
|
||||
);
|
||||
vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection);
|
||||
|
||||
await service.initiateConnection(mockWorkspaceId, mockRemoteUrl);
|
||||
|
||||
expect(httpService.get).toHaveBeenCalledWith(`${mockRemoteUrl}/api/v1/federation/instance`);
|
||||
});
|
||||
|
||||
it("should throw error if remote instance not reachable", async () => {
|
||||
vi.spyOn(httpService, "get").mockReturnValue(throwError(() => new Error("Network error")));
|
||||
|
||||
await expect(service.initiateConnection(mockWorkspaceId, mockRemoteUrl)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should send signed connection request", async () => {
|
||||
const mockAxiosResponse: AxiosResponse = {
|
||||
data: mockRemoteIdentity,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: {} as never,
|
||||
};
|
||||
|
||||
const postSpy = vi
|
||||
.spyOn(httpService, "post")
|
||||
.mockReturnValue(of({ data: { accepted: true } } as AxiosResponse));
|
||||
vi.spyOn(httpService, "get").mockReturnValue(of(mockAxiosResponse));
|
||||
vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection);
|
||||
|
||||
await service.initiateConnection(mockWorkspaceId, mockRemoteUrl);
|
||||
|
||||
expect(postSpy).toHaveBeenCalledWith(
|
||||
`${mockRemoteUrl}/api/v1/federation/incoming/connect`,
|
||||
expect.objectContaining({
|
||||
instanceId: mockInstanceIdentity.instanceId,
|
||||
instanceUrl: mockInstanceIdentity.url,
|
||||
publicKey: mockInstanceIdentity.publicKey,
|
||||
signature: "mock-signature",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("acceptConnection", () => {
|
||||
it("should update connection status to ACTIVE", async () => {
|
||||
vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(mockConnection);
|
||||
vi.spyOn(prismaService.federationConnection, "update").mockReturnValue({
|
||||
...mockConnection,
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
connectedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.acceptConnection(mockWorkspaceId, mockConnection.id);
|
||||
|
||||
expect(result.status).toBe(FederationConnectionStatus.ACTIVE);
|
||||
expect(result.connectedAt).toBeDefined();
|
||||
expect(prismaService.federationConnection.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
id: mockConnection.id,
|
||||
}),
|
||||
data: expect.objectContaining({
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
connectedAt: expect.any(Date),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error if connection not found", async () => {
|
||||
vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(null);
|
||||
|
||||
await expect(service.acceptConnection(mockWorkspaceId, "non-existent-id")).rejects.toThrow(
|
||||
"Connection not found"
|
||||
);
|
||||
});
|
||||
|
||||
it("should enforce workspace isolation", async () => {
|
||||
vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.acceptConnection("different-workspace", mockConnection.id)
|
||||
).rejects.toThrow("Connection not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejectConnection", () => {
|
||||
it("should update connection status to DISCONNECTED", async () => {
|
||||
vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(mockConnection);
|
||||
vi.spyOn(prismaService.federationConnection, "update").mockReturnValue({
|
||||
...mockConnection,
|
||||
status: FederationConnectionStatus.DISCONNECTED,
|
||||
metadata: { rejectionReason: "Not approved" },
|
||||
});
|
||||
|
||||
const result = await service.rejectConnection(
|
||||
mockWorkspaceId,
|
||||
mockConnection.id,
|
||||
"Not approved"
|
||||
);
|
||||
|
||||
expect(result.status).toBe(FederationConnectionStatus.DISCONNECTED);
|
||||
expect(result.metadata).toHaveProperty("rejectionReason", "Not approved");
|
||||
});
|
||||
|
||||
it("should throw error if connection not found", async () => {
|
||||
vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.rejectConnection(mockWorkspaceId, "non-existent-id", "Reason")
|
||||
).rejects.toThrow("Connection not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("disconnect", () => {
|
||||
const activeConnection: FederationConnection = {
|
||||
...mockConnection,
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
connectedAt: new Date(),
|
||||
};
|
||||
|
||||
it("should disconnect active connection", async () => {
|
||||
vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(activeConnection);
|
||||
vi.spyOn(prismaService.federationConnection, "update").mockReturnValue({
|
||||
...activeConnection,
|
||||
status: FederationConnectionStatus.DISCONNECTED,
|
||||
disconnectedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.disconnect(
|
||||
mockWorkspaceId,
|
||||
mockConnection.id,
|
||||
"Manual disconnect"
|
||||
);
|
||||
|
||||
expect(result.status).toBe(FederationConnectionStatus.DISCONNECTED);
|
||||
expect(result.disconnectedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("should store disconnection reason in metadata", async () => {
|
||||
vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(activeConnection);
|
||||
vi.spyOn(prismaService.federationConnection, "update").mockReturnValue({
|
||||
...activeConnection,
|
||||
status: FederationConnectionStatus.DISCONNECTED,
|
||||
disconnectedAt: new Date(),
|
||||
metadata: { disconnectReason: "Test reason" },
|
||||
});
|
||||
|
||||
const result = await service.disconnect(mockWorkspaceId, mockConnection.id, "Test reason");
|
||||
|
||||
expect(result.metadata).toHaveProperty("disconnectReason", "Test reason");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConnections", () => {
|
||||
it("should list all connections for workspace", async () => {
|
||||
const connections = [mockConnection];
|
||||
vi.spyOn(prismaService.federationConnection, "findMany").mockResolvedValue(connections);
|
||||
|
||||
const result = await service.getConnections(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(connections);
|
||||
expect(prismaService.federationConnection.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { workspaceId: mockWorkspaceId },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter by status if provided", async () => {
|
||||
const connections = [mockConnection];
|
||||
vi.spyOn(prismaService.federationConnection, "findMany").mockResolvedValue(connections);
|
||||
|
||||
await service.getConnections(mockWorkspaceId, FederationConnectionStatus.ACTIVE);
|
||||
|
||||
expect(prismaService.federationConnection.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConnection", () => {
|
||||
it("should return connection details", async () => {
|
||||
vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(mockConnection);
|
||||
|
||||
const result = await service.getConnection(mockWorkspaceId, mockConnection.id);
|
||||
|
||||
expect(result).toEqual(mockConnection);
|
||||
});
|
||||
|
||||
it("should throw error if connection not found", async () => {
|
||||
vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(null);
|
||||
|
||||
await expect(service.getConnection(mockWorkspaceId, "non-existent-id")).rejects.toThrow(
|
||||
"Connection not found"
|
||||
);
|
||||
});
|
||||
|
||||
it("should enforce workspace isolation", async () => {
|
||||
vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(null);
|
||||
|
||||
await expect(service.getConnection("different-workspace", mockConnection.id)).rejects.toThrow(
|
||||
"Connection not found"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleIncomingConnectionRequest", () => {
|
||||
const mockRequest = {
|
||||
instanceId: mockRemoteIdentity.instanceId,
|
||||
instanceUrl: mockRemoteIdentity.url,
|
||||
publicKey: mockRemoteIdentity.publicKey,
|
||||
capabilities: mockRemoteIdentity.capabilities,
|
||||
timestamp: Date.now(),
|
||||
signature: "valid-signature",
|
||||
};
|
||||
|
||||
it("should validate connection request signature", async () => {
|
||||
const verifySpy = vi.spyOn(signatureService, "verifyConnectionRequest");
|
||||
vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection);
|
||||
|
||||
await service.handleIncomingConnectionRequest(mockWorkspaceId, mockRequest);
|
||||
|
||||
expect(verifySpy).toHaveBeenCalledWith(mockRequest);
|
||||
});
|
||||
|
||||
it("should create pending connection for valid request", async () => {
|
||||
vi.spyOn(signatureService, "verifyConnectionRequest").mockReturnValue({ valid: true });
|
||||
vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection);
|
||||
|
||||
const result = await service.handleIncomingConnectionRequest(mockWorkspaceId, mockRequest);
|
||||
|
||||
expect(result.status).toBe(FederationConnectionStatus.PENDING);
|
||||
expect(prismaService.federationConnection.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reject request with invalid signature", async () => {
|
||||
vi.spyOn(signatureService, "verifyConnectionRequest").mockReturnValue({
|
||||
valid: false,
|
||||
error: "Invalid signature",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.handleIncomingConnectionRequest(mockWorkspaceId, mockRequest)
|
||||
).rejects.toThrow("Invalid connection request signature");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user