feat(#89): implement COMMAND message type for federation
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>
This commit is contained in:
236
apps/api/src/federation/command.controller.spec.ts
Normal file
236
apps/api/src/federation/command.controller.spec.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Command Controller Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { CommandController } from "./command.controller";
|
||||
import { CommandService } from "./command.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { FederationMessageType, FederationMessageStatus } from "@prisma/client";
|
||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
import type { CommandMessage, CommandResponse } from "./types/message.types";
|
||||
|
||||
describe("CommandController", () => {
|
||||
let controller: CommandController;
|
||||
let commandService: CommandService;
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockUserId = "user-123";
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [CommandController],
|
||||
providers: [
|
||||
{
|
||||
provide: CommandService,
|
||||
useValue: {
|
||||
sendCommand: vi.fn(),
|
||||
handleIncomingCommand: vi.fn(),
|
||||
getCommandMessages: vi.fn(),
|
||||
getCommandMessage: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<CommandController>(CommandController);
|
||||
commandService = module.get<CommandService>(CommandService);
|
||||
});
|
||||
|
||||
describe("sendCommand", () => {
|
||||
it("should send a command", async () => {
|
||||
const req = {
|
||||
user: { id: mockUserId, workspaceId: mockWorkspaceId },
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
const dto = {
|
||||
connectionId: "conn-123",
|
||||
commandType: "spawn_agent",
|
||||
payload: { agentType: "task_executor" },
|
||||
};
|
||||
|
||||
const mockResult = {
|
||||
id: "msg-123",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: "conn-123",
|
||||
messageType: FederationMessageType.COMMAND,
|
||||
messageId: "cmd-123",
|
||||
commandType: "spawn_agent",
|
||||
payload: { agentType: "task_executor" },
|
||||
status: FederationMessageStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
vi.spyOn(commandService, "sendCommand").mockResolvedValue(mockResult as never);
|
||||
|
||||
const result = await controller.sendCommand(req, dto);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(commandService.sendCommand).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
"conn-123",
|
||||
"spawn_agent",
|
||||
{ agentType: "task_executor" }
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error if workspace ID not found", async () => {
|
||||
const req = {
|
||||
user: { id: mockUserId },
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
const dto = {
|
||||
connectionId: "conn-123",
|
||||
commandType: "test",
|
||||
payload: {},
|
||||
};
|
||||
|
||||
await expect(controller.sendCommand(req, dto)).rejects.toThrow(
|
||||
"Workspace ID not found in request"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleIncomingCommand", () => {
|
||||
it("should handle an incoming command", async () => {
|
||||
const dto: CommandMessage = {
|
||||
messageId: "cmd-123",
|
||||
instanceId: "remote-instance",
|
||||
commandType: "spawn_agent",
|
||||
payload: { agentType: "task_executor" },
|
||||
timestamp: Date.now(),
|
||||
signature: "signature-123",
|
||||
};
|
||||
|
||||
const mockResponse: CommandResponse = {
|
||||
messageId: "resp-123",
|
||||
correlationId: "cmd-123",
|
||||
instanceId: "local-instance",
|
||||
success: true,
|
||||
data: { result: "success" },
|
||||
timestamp: Date.now(),
|
||||
signature: "response-signature",
|
||||
};
|
||||
|
||||
vi.spyOn(commandService, "handleIncomingCommand").mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.handleIncomingCommand(dto);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(commandService.handleIncomingCommand).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCommands", () => {
|
||||
it("should return all commands for workspace", async () => {
|
||||
const req = {
|
||||
user: { id: mockUserId, workspaceId: mockWorkspaceId },
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
const mockCommands = [
|
||||
{
|
||||
id: "msg-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: "conn-123",
|
||||
messageType: FederationMessageType.COMMAND,
|
||||
messageId: "cmd-1",
|
||||
commandType: "test",
|
||||
payload: {},
|
||||
status: FederationMessageStatus.DELIVERED,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
vi.spyOn(commandService, "getCommandMessages").mockResolvedValue(mockCommands as never);
|
||||
|
||||
const result = await controller.getCommands(req);
|
||||
|
||||
expect(result).toEqual(mockCommands);
|
||||
expect(commandService.getCommandMessages).toHaveBeenCalledWith(mockWorkspaceId, undefined);
|
||||
});
|
||||
|
||||
it("should filter commands by status", async () => {
|
||||
const req = {
|
||||
user: { id: mockUserId, workspaceId: mockWorkspaceId },
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
const mockCommands = [
|
||||
{
|
||||
id: "msg-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: "conn-123",
|
||||
messageType: FederationMessageType.COMMAND,
|
||||
messageId: "cmd-1",
|
||||
commandType: "test",
|
||||
payload: {},
|
||||
status: FederationMessageStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
vi.spyOn(commandService, "getCommandMessages").mockResolvedValue(mockCommands as never);
|
||||
|
||||
await controller.getCommands(req, FederationMessageStatus.PENDING);
|
||||
|
||||
expect(commandService.getCommandMessages).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
FederationMessageStatus.PENDING
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error if workspace ID not found", async () => {
|
||||
const req = {
|
||||
user: { id: mockUserId },
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
await expect(controller.getCommands(req)).rejects.toThrow(
|
||||
"Workspace ID not found in request"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCommand", () => {
|
||||
it("should return a single command", async () => {
|
||||
const req = {
|
||||
user: { id: mockUserId, workspaceId: mockWorkspaceId },
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
const mockCommand = {
|
||||
id: "msg-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
connectionId: "conn-123",
|
||||
messageType: FederationMessageType.COMMAND,
|
||||
messageId: "cmd-1",
|
||||
commandType: "test",
|
||||
payload: { key: "value" },
|
||||
status: FederationMessageStatus.DELIVERED,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
vi.spyOn(commandService, "getCommandMessage").mockResolvedValue(mockCommand as never);
|
||||
|
||||
const result = await controller.getCommand(req, "msg-1");
|
||||
|
||||
expect(result).toEqual(mockCommand);
|
||||
expect(commandService.getCommandMessage).toHaveBeenCalledWith(mockWorkspaceId, "msg-1");
|
||||
});
|
||||
|
||||
it("should throw error if workspace ID not found", async () => {
|
||||
const req = {
|
||||
user: { id: mockUserId },
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
await expect(controller.getCommand(req, "msg-1")).rejects.toThrow(
|
||||
"Workspace ID not found in request"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user