Implements federated command messages following TDD principles and mirroring the QueryService pattern for consistency. ## Implementation ### Schema Changes - Added commandType and payload fields to FederationMessage model - Supports COMMAND message type (already defined in enum) - Applied schema changes with prisma db push ### Type Definitions - CommandMessage: Request structure with commandType and payload - CommandResponse: Response structure with correlation - CommandMessageDetails: Full message details for API responses ### CommandService - sendCommand(): Send command to remote instance with signature - handleIncomingCommand(): Process incoming commands with verification - processCommandResponse(): Handle command responses - getCommandMessages(): List commands for workspace - getCommandMessage(): Get single command details - Full signature verification and timestamp validation - Error handling and status tracking ### CommandController - POST /api/v1/federation/command - Send command (authenticated) - POST /api/v1/federation/incoming/command - Handle incoming (public) - GET /api/v1/federation/commands - List commands (authenticated) - GET /api/v1/federation/commands/:id - Get command (authenticated) ## Testing - CommandService: 15 tests, 90.21% coverage - CommandController: 8 tests, 100% coverage - All 23 tests passing - Exceeds 85% coverage requirement - Total 47 tests passing (includes command tests) ## Security - RSA signature verification for all incoming commands - Timestamp validation to prevent replay attacks - Connection status validation - Authorization checks on command types ## Quality Checks - TypeScript compilation: PASSED - All tests: 47 PASSED - Code coverage: >85% (90.21% for CommandService, 100% for CommandController) - Linting: PASSED Fixes #89 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
575 lines
18 KiB
TypeScript
575 lines
18 KiB
TypeScript
/**
|
|
* Command Service Tests
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { HttpService } from "@nestjs/axios";
|
|
import { CommandService } from "./command.service";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { FederationService } from "./federation.service";
|
|
import { SignatureService } from "./signature.service";
|
|
import {
|
|
FederationConnectionStatus,
|
|
FederationMessageType,
|
|
FederationMessageStatus,
|
|
} from "@prisma/client";
|
|
import { of } from "rxjs";
|
|
import type { CommandMessage, CommandResponse } from "./types/message.types";
|
|
|
|
describe("CommandService", () => {
|
|
let service: CommandService;
|
|
let prisma: PrismaService;
|
|
let federationService: FederationService;
|
|
let signatureService: SignatureService;
|
|
let httpService: HttpService;
|
|
|
|
const mockWorkspaceId = "workspace-123";
|
|
const mockConnectionId = "connection-123";
|
|
const mockInstanceId = "instance-456";
|
|
const mockRemoteUrl = "https://remote.example.com";
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
CommandService,
|
|
{
|
|
provide: PrismaService,
|
|
useValue: {
|
|
federationConnection: {
|
|
findUnique: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
},
|
|
federationMessage: {
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
findMany: vi.fn(),
|
|
findUnique: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
provide: FederationService,
|
|
useValue: {
|
|
getInstanceIdentity: vi.fn(),
|
|
},
|
|
},
|
|
{
|
|
provide: SignatureService,
|
|
useValue: {
|
|
signMessage: vi.fn(),
|
|
verifyMessage: vi.fn(),
|
|
validateTimestamp: vi.fn(),
|
|
},
|
|
},
|
|
{
|
|
provide: HttpService,
|
|
useValue: {
|
|
post: vi.fn(),
|
|
},
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<CommandService>(CommandService);
|
|
prisma = module.get<PrismaService>(PrismaService);
|
|
federationService = module.get<FederationService>(FederationService);
|
|
signatureService = module.get<SignatureService>(SignatureService);
|
|
httpService = module.get<HttpService>(HttpService);
|
|
});
|
|
|
|
describe("sendCommand", () => {
|
|
it("should send a command to a remote instance", async () => {
|
|
const commandType = "spawn_agent";
|
|
const payload = { agentType: "task_executor" };
|
|
|
|
const mockConnection = {
|
|
id: mockConnectionId,
|
|
workspaceId: mockWorkspaceId,
|
|
status: FederationConnectionStatus.ACTIVE,
|
|
remoteUrl: mockRemoteUrl,
|
|
remoteInstanceId: mockInstanceId,
|
|
};
|
|
|
|
const mockIdentity = {
|
|
instanceId: "local-instance",
|
|
displayName: "Local Instance",
|
|
};
|
|
|
|
const mockMessage = {
|
|
id: "msg-123",
|
|
workspaceId: mockWorkspaceId,
|
|
connectionId: mockConnectionId,
|
|
messageType: FederationMessageType.COMMAND,
|
|
messageId: expect.any(String),
|
|
correlationId: null,
|
|
query: null,
|
|
commandType,
|
|
payload,
|
|
response: {},
|
|
status: FederationMessageStatus.PENDING,
|
|
error: null,
|
|
signature: "signature-123",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
deliveredAt: null,
|
|
};
|
|
|
|
vi.spyOn(prisma.federationConnection, "findUnique").mockResolvedValue(
|
|
mockConnection as never
|
|
);
|
|
vi.spyOn(federationService, "getInstanceIdentity").mockResolvedValue(mockIdentity as never);
|
|
vi.spyOn(signatureService, "signMessage").mockResolvedValue("signature-123");
|
|
vi.spyOn(prisma.federationMessage, "create").mockResolvedValue(mockMessage as never);
|
|
vi.spyOn(httpService, "post").mockReturnValue(of({} as never));
|
|
|
|
const result = await service.sendCommand(
|
|
mockWorkspaceId,
|
|
mockConnectionId,
|
|
commandType,
|
|
payload
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
workspaceId: mockWorkspaceId,
|
|
connectionId: mockConnectionId,
|
|
messageType: FederationMessageType.COMMAND,
|
|
commandType,
|
|
status: FederationMessageStatus.PENDING,
|
|
});
|
|
|
|
expect(httpService.post).toHaveBeenCalledWith(
|
|
`${mockRemoteUrl}/api/v1/federation/incoming/command`,
|
|
expect.objectContaining({
|
|
messageId: expect.any(String),
|
|
instanceId: "local-instance",
|
|
commandType,
|
|
payload,
|
|
timestamp: expect.any(Number),
|
|
signature: "signature-123",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should throw error if connection not found", async () => {
|
|
vi.spyOn(prisma.federationConnection, "findUnique").mockResolvedValue(null);
|
|
|
|
await expect(
|
|
service.sendCommand(mockWorkspaceId, mockConnectionId, "test", {})
|
|
).rejects.toThrow("Connection not found");
|
|
});
|
|
|
|
it("should throw error if connection is not active", async () => {
|
|
const mockConnection = {
|
|
id: mockConnectionId,
|
|
workspaceId: mockWorkspaceId,
|
|
status: FederationConnectionStatus.SUSPENDED,
|
|
};
|
|
|
|
vi.spyOn(prisma.federationConnection, "findUnique").mockResolvedValue(
|
|
mockConnection as never
|
|
);
|
|
|
|
await expect(
|
|
service.sendCommand(mockWorkspaceId, mockConnectionId, "test", {})
|
|
).rejects.toThrow("Connection is not active");
|
|
});
|
|
|
|
it("should mark command as failed if sending fails", async () => {
|
|
const mockConnection = {
|
|
id: mockConnectionId,
|
|
workspaceId: mockWorkspaceId,
|
|
status: FederationConnectionStatus.ACTIVE,
|
|
remoteUrl: mockRemoteUrl,
|
|
};
|
|
|
|
const mockIdentity = {
|
|
instanceId: "local-instance",
|
|
displayName: "Local Instance",
|
|
};
|
|
|
|
const mockMessage = {
|
|
id: "msg-123",
|
|
workspaceId: mockWorkspaceId,
|
|
connectionId: mockConnectionId,
|
|
messageType: FederationMessageType.COMMAND,
|
|
messageId: "test-msg-id",
|
|
correlationId: null,
|
|
query: null,
|
|
commandType: "test",
|
|
payload: {},
|
|
response: {},
|
|
status: FederationMessageStatus.PENDING,
|
|
error: null,
|
|
signature: "signature-123",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
deliveredAt: null,
|
|
};
|
|
|
|
vi.spyOn(prisma.federationConnection, "findUnique").mockResolvedValue(
|
|
mockConnection as never
|
|
);
|
|
vi.spyOn(federationService, "getInstanceIdentity").mockResolvedValue(mockIdentity as never);
|
|
vi.spyOn(signatureService, "signMessage").mockResolvedValue("signature-123");
|
|
vi.spyOn(prisma.federationMessage, "create").mockResolvedValue(mockMessage as never);
|
|
vi.spyOn(httpService, "post").mockReturnValue(
|
|
new (class {
|
|
subscribe(handlers: { error: (err: Error) => void }) {
|
|
handlers.error(new Error("Network error"));
|
|
}
|
|
})() as never
|
|
);
|
|
vi.spyOn(prisma.federationMessage, "update").mockResolvedValue(mockMessage as never);
|
|
|
|
await expect(
|
|
service.sendCommand(mockWorkspaceId, mockConnectionId, "test", {})
|
|
).rejects.toThrow("Failed to send command");
|
|
|
|
expect(prisma.federationMessage.update).toHaveBeenCalledWith({
|
|
where: { id: "msg-123" },
|
|
data: {
|
|
status: FederationMessageStatus.FAILED,
|
|
error: "Network error",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("handleIncomingCommand", () => {
|
|
it("should process a valid incoming command", async () => {
|
|
const commandMessage: CommandMessage = {
|
|
messageId: "cmd-123",
|
|
instanceId: mockInstanceId,
|
|
commandType: "spawn_agent",
|
|
payload: { agentType: "task_executor" },
|
|
timestamp: Date.now(),
|
|
signature: "signature-123",
|
|
};
|
|
|
|
const mockConnection = {
|
|
id: mockConnectionId,
|
|
remoteInstanceId: mockInstanceId,
|
|
status: FederationConnectionStatus.ACTIVE,
|
|
};
|
|
|
|
const mockIdentity = {
|
|
instanceId: "local-instance",
|
|
displayName: "Local Instance",
|
|
};
|
|
|
|
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(true);
|
|
vi.spyOn(prisma.federationConnection, "findFirst").mockResolvedValue(mockConnection as never);
|
|
vi.spyOn(signatureService, "verifyMessage").mockResolvedValue({
|
|
valid: true,
|
|
error: null,
|
|
} as never);
|
|
vi.spyOn(federationService, "getInstanceIdentity").mockResolvedValue(mockIdentity as never);
|
|
vi.spyOn(signatureService, "signMessage").mockResolvedValue("response-signature");
|
|
|
|
const response = await service.handleIncomingCommand(commandMessage);
|
|
|
|
expect(response).toMatchObject({
|
|
correlationId: "cmd-123",
|
|
instanceId: "local-instance",
|
|
success: true,
|
|
});
|
|
|
|
expect(signatureService.validateTimestamp).toHaveBeenCalledWith(commandMessage.timestamp);
|
|
expect(signatureService.verifyMessage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
messageId: "cmd-123",
|
|
instanceId: mockInstanceId,
|
|
commandType: "spawn_agent",
|
|
}),
|
|
"signature-123",
|
|
mockInstanceId
|
|
);
|
|
});
|
|
|
|
it("should reject command with invalid timestamp", async () => {
|
|
const commandMessage: CommandMessage = {
|
|
messageId: "cmd-123",
|
|
instanceId: mockInstanceId,
|
|
commandType: "test",
|
|
payload: {},
|
|
timestamp: Date.now() - 1000000,
|
|
signature: "signature-123",
|
|
};
|
|
|
|
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(false);
|
|
|
|
await expect(service.handleIncomingCommand(commandMessage)).rejects.toThrow(
|
|
"Command timestamp is outside acceptable range"
|
|
);
|
|
});
|
|
|
|
it("should reject command if no connection found", async () => {
|
|
const commandMessage: CommandMessage = {
|
|
messageId: "cmd-123",
|
|
instanceId: mockInstanceId,
|
|
commandType: "test",
|
|
payload: {},
|
|
timestamp: Date.now(),
|
|
signature: "signature-123",
|
|
};
|
|
|
|
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(true);
|
|
vi.spyOn(prisma.federationConnection, "findFirst").mockResolvedValue(null);
|
|
|
|
await expect(service.handleIncomingCommand(commandMessage)).rejects.toThrow(
|
|
"No connection found for remote instance"
|
|
);
|
|
});
|
|
|
|
it("should reject command with invalid signature", async () => {
|
|
const commandMessage: CommandMessage = {
|
|
messageId: "cmd-123",
|
|
instanceId: mockInstanceId,
|
|
commandType: "test",
|
|
payload: {},
|
|
timestamp: Date.now(),
|
|
signature: "invalid-signature",
|
|
};
|
|
|
|
const mockConnection = {
|
|
id: mockConnectionId,
|
|
remoteInstanceId: mockInstanceId,
|
|
status: FederationConnectionStatus.ACTIVE,
|
|
};
|
|
|
|
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(true);
|
|
vi.spyOn(prisma.federationConnection, "findFirst").mockResolvedValue(mockConnection as never);
|
|
vi.spyOn(signatureService, "verifyMessage").mockResolvedValue({
|
|
valid: false,
|
|
error: "Invalid signature",
|
|
} as never);
|
|
|
|
await expect(service.handleIncomingCommand(commandMessage)).rejects.toThrow(
|
|
"Invalid signature"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("processCommandResponse", () => {
|
|
it("should process a successful command response", async () => {
|
|
const response: CommandResponse = {
|
|
messageId: "resp-123",
|
|
correlationId: "cmd-123",
|
|
instanceId: mockInstanceId,
|
|
success: true,
|
|
data: { result: "success" },
|
|
timestamp: Date.now(),
|
|
signature: "signature-123",
|
|
};
|
|
|
|
const mockMessage = {
|
|
id: "msg-123",
|
|
workspaceId: mockWorkspaceId,
|
|
connectionId: mockConnectionId,
|
|
messageType: FederationMessageType.COMMAND,
|
|
messageId: "cmd-123",
|
|
correlationId: null,
|
|
query: null,
|
|
commandType: "test",
|
|
payload: {},
|
|
response: {},
|
|
status: FederationMessageStatus.PENDING,
|
|
error: null,
|
|
signature: "signature-123",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
deliveredAt: null,
|
|
};
|
|
|
|
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(true);
|
|
vi.spyOn(prisma.federationMessage, "findFirst").mockResolvedValue(mockMessage as never);
|
|
vi.spyOn(signatureService, "verifyMessage").mockResolvedValue({
|
|
valid: true,
|
|
error: null,
|
|
} as never);
|
|
vi.spyOn(prisma.federationMessage, "update").mockResolvedValue(mockMessage as never);
|
|
|
|
await service.processCommandResponse(response);
|
|
|
|
expect(prisma.federationMessage.update).toHaveBeenCalledWith({
|
|
where: { id: "msg-123" },
|
|
data: {
|
|
status: FederationMessageStatus.DELIVERED,
|
|
deliveredAt: expect.any(Date),
|
|
response: { result: "success" },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should handle failed command response", async () => {
|
|
const response: CommandResponse = {
|
|
messageId: "resp-123",
|
|
correlationId: "cmd-123",
|
|
instanceId: mockInstanceId,
|
|
success: false,
|
|
error: "Command execution failed",
|
|
timestamp: Date.now(),
|
|
signature: "signature-123",
|
|
};
|
|
|
|
const mockMessage = {
|
|
id: "msg-123",
|
|
messageType: FederationMessageType.COMMAND,
|
|
messageId: "cmd-123",
|
|
};
|
|
|
|
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(true);
|
|
vi.spyOn(prisma.federationMessage, "findFirst").mockResolvedValue(mockMessage as never);
|
|
vi.spyOn(signatureService, "verifyMessage").mockResolvedValue({
|
|
valid: true,
|
|
error: null,
|
|
} as never);
|
|
vi.spyOn(prisma.federationMessage, "update").mockResolvedValue(mockMessage as never);
|
|
|
|
await service.processCommandResponse(response);
|
|
|
|
expect(prisma.federationMessage.update).toHaveBeenCalledWith({
|
|
where: { id: "msg-123" },
|
|
data: {
|
|
status: FederationMessageStatus.FAILED,
|
|
deliveredAt: expect.any(Date),
|
|
error: "Command execution failed",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should reject response with invalid timestamp", async () => {
|
|
const response: CommandResponse = {
|
|
messageId: "resp-123",
|
|
correlationId: "cmd-123",
|
|
instanceId: mockInstanceId,
|
|
success: true,
|
|
timestamp: Date.now() - 1000000,
|
|
signature: "signature-123",
|
|
};
|
|
|
|
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(false);
|
|
|
|
await expect(service.processCommandResponse(response)).rejects.toThrow(
|
|
"Response timestamp is outside acceptable range"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getCommandMessages", () => {
|
|
it("should return all command messages for a workspace", async () => {
|
|
const mockMessages = [
|
|
{
|
|
id: "msg-1",
|
|
workspaceId: mockWorkspaceId,
|
|
connectionId: mockConnectionId,
|
|
messageType: FederationMessageType.COMMAND,
|
|
messageId: "cmd-1",
|
|
correlationId: null,
|
|
query: null,
|
|
commandType: "test",
|
|
payload: {},
|
|
response: {},
|
|
status: FederationMessageStatus.DELIVERED,
|
|
error: null,
|
|
signature: "sig-1",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
deliveredAt: new Date(),
|
|
},
|
|
];
|
|
|
|
vi.spyOn(prisma.federationMessage, "findMany").mockResolvedValue(mockMessages as never);
|
|
|
|
const result = await service.getCommandMessages(mockWorkspaceId);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toMatchObject({
|
|
workspaceId: mockWorkspaceId,
|
|
messageType: FederationMessageType.COMMAND,
|
|
commandType: "test",
|
|
});
|
|
});
|
|
|
|
it("should filter command messages by status", async () => {
|
|
const mockMessages = [
|
|
{
|
|
id: "msg-1",
|
|
workspaceId: mockWorkspaceId,
|
|
connectionId: mockConnectionId,
|
|
messageType: FederationMessageType.COMMAND,
|
|
messageId: "cmd-1",
|
|
correlationId: null,
|
|
query: null,
|
|
commandType: "test",
|
|
payload: {},
|
|
response: {},
|
|
status: FederationMessageStatus.PENDING,
|
|
error: null,
|
|
signature: "sig-1",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
deliveredAt: null,
|
|
},
|
|
];
|
|
|
|
vi.spyOn(prisma.federationMessage, "findMany").mockResolvedValue(mockMessages as never);
|
|
|
|
await service.getCommandMessages(mockWorkspaceId, FederationMessageStatus.PENDING);
|
|
|
|
expect(prisma.federationMessage.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
workspaceId: mockWorkspaceId,
|
|
messageType: FederationMessageType.COMMAND,
|
|
status: FederationMessageStatus.PENDING,
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getCommandMessage", () => {
|
|
it("should return a single command message", async () => {
|
|
const mockMessage = {
|
|
id: "msg-1",
|
|
workspaceId: mockWorkspaceId,
|
|
connectionId: mockConnectionId,
|
|
messageType: FederationMessageType.COMMAND,
|
|
messageId: "cmd-1",
|
|
correlationId: null,
|
|
query: null,
|
|
commandType: "test",
|
|
payload: { key: "value" },
|
|
response: {},
|
|
status: FederationMessageStatus.DELIVERED,
|
|
error: null,
|
|
signature: "sig-1",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
deliveredAt: new Date(),
|
|
};
|
|
|
|
vi.spyOn(prisma.federationMessage, "findUnique").mockResolvedValue(mockMessage as never);
|
|
|
|
const result = await service.getCommandMessage(mockWorkspaceId, "msg-1");
|
|
|
|
expect(result).toMatchObject({
|
|
id: "msg-1",
|
|
workspaceId: mockWorkspaceId,
|
|
commandType: "test",
|
|
payload: { key: "value" },
|
|
});
|
|
});
|
|
|
|
it("should throw error if command message not found", async () => {
|
|
vi.spyOn(prisma.federationMessage, "findUnique").mockResolvedValue(null);
|
|
|
|
await expect(service.getCommandMessage(mockWorkspaceId, "invalid-id")).rejects.toThrow(
|
|
"Command message not found"
|
|
);
|
|
});
|
|
});
|
|
});
|