Implement complete QUERY message protocol for federated queries between Mosaic Stack instances, building on existing connection infrastructure. Database Changes: - Add FederationMessageType enum (QUERY, COMMAND, EVENT) - Add FederationMessageStatus enum (PENDING, DELIVERED, FAILED, TIMEOUT) - Add FederationMessage model for tracking all federation messages - Add workspace and connection relations Types & DTOs: - QueryMessage: Signed query request payload - QueryResponse: Signed query response payload - QueryMessageDetails: API response type - SendQueryDto: Client request DTO - IncomingQueryDto: Validated incoming query DTO QueryService: - sendQuery: Send signed query to remote instance via ACTIVE connection - handleIncomingQuery: Process and validate incoming queries - processQueryResponse: Handle and verify query responses - getQueryMessages: List workspace queries with optional status filter - getQueryMessage: Get single query message details - Message deduplication via unique messageId - Signature verification using SignatureService - Timestamp validation (5-minute window) QueryController: - POST /api/v1/federation/query: Send query (authenticated) - POST /api/v1/federation/incoming/query: Receive query (public, signature-verified) - GET /api/v1/federation/queries: List queries (authenticated) - GET /api/v1/federation/queries/🆔 Get query details (authenticated) Security: - All messages signed with instance private key - All responses verified with remote public key - Timestamp validation prevents replay attacks - Connection status validation (must be ACTIVE) - Workspace isolation enforced via RLS Testing: - 15 QueryService tests (100% coverage) - 9 QueryController tests (100% coverage) - All tests passing with proper mocking - TypeScript strict mode compliance Refs #88 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
239 lines
7.0 KiB
TypeScript
239 lines
7.0 KiB
TypeScript
/**
|
|
* Query Controller Tests
|
|
*
|
|
* Tests for federated query API endpoints.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { FederationMessageType, FederationMessageStatus } from "@prisma/client";
|
|
import { QueryController } from "./query.controller";
|
|
import { QueryService } from "./query.service";
|
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
|
import type { AuthenticatedRequest } from "../common/types/user.types";
|
|
import type { SendQueryDto, IncomingQueryDto } from "./dto/query.dto";
|
|
|
|
describe("QueryController", () => {
|
|
let controller: QueryController;
|
|
let queryService: QueryService;
|
|
|
|
const mockQueryService = {
|
|
sendQuery: vi.fn(),
|
|
handleIncomingQuery: vi.fn(),
|
|
getQueryMessages: vi.fn(),
|
|
getQueryMessage: vi.fn(),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
controllers: [QueryController],
|
|
providers: [{ provide: QueryService, useValue: mockQueryService }],
|
|
})
|
|
.overrideGuard(AuthGuard)
|
|
.useValue({ canActivate: () => true })
|
|
.compile();
|
|
|
|
controller = module.get<QueryController>(QueryController);
|
|
queryService = module.get<QueryService>(QueryService);
|
|
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("sendQuery", () => {
|
|
it("should send query to remote instance", async () => {
|
|
const req = {
|
|
user: {
|
|
id: "user-1",
|
|
workspaceId: "workspace-1",
|
|
},
|
|
} as AuthenticatedRequest;
|
|
|
|
const dto: SendQueryDto = {
|
|
connectionId: "connection-1",
|
|
query: "SELECT * FROM tasks",
|
|
context: { userId: "user-1" },
|
|
};
|
|
|
|
const mockResult = {
|
|
id: "msg-1",
|
|
workspaceId: "workspace-1",
|
|
connectionId: "connection-1",
|
|
messageType: FederationMessageType.QUERY,
|
|
messageId: "unique-msg-1",
|
|
query: dto.query,
|
|
status: FederationMessageStatus.PENDING,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
mockQueryService.sendQuery.mockResolvedValue(mockResult);
|
|
|
|
const result = await controller.sendQuery(req, dto);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.messageType).toBe(FederationMessageType.QUERY);
|
|
expect(mockQueryService.sendQuery).toHaveBeenCalledWith(
|
|
"workspace-1",
|
|
dto.connectionId,
|
|
dto.query,
|
|
dto.context
|
|
);
|
|
});
|
|
|
|
it("should throw error if user not authenticated", async () => {
|
|
const req = {} as AuthenticatedRequest;
|
|
|
|
const dto: SendQueryDto = {
|
|
connectionId: "connection-1",
|
|
query: "SELECT * FROM tasks",
|
|
};
|
|
|
|
await expect(controller.sendQuery(req, dto)).rejects.toThrow(
|
|
"Workspace ID not found in request"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("handleIncomingQuery", () => {
|
|
it("should process incoming query", async () => {
|
|
const dto: IncomingQueryDto = {
|
|
messageId: "msg-1",
|
|
instanceId: "remote-instance-1",
|
|
query: "SELECT * FROM tasks",
|
|
timestamp: Date.now(),
|
|
signature: "valid-signature",
|
|
};
|
|
|
|
const mockResponse = {
|
|
messageId: "response-1",
|
|
correlationId: dto.messageId,
|
|
instanceId: "local-instance-1",
|
|
success: true,
|
|
data: { tasks: [] },
|
|
timestamp: Date.now(),
|
|
signature: "response-signature",
|
|
};
|
|
|
|
mockQueryService.handleIncomingQuery.mockResolvedValue(mockResponse);
|
|
|
|
const result = await controller.handleIncomingQuery(dto);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.correlationId).toBe(dto.messageId);
|
|
expect(mockQueryService.handleIncomingQuery).toHaveBeenCalledWith(dto);
|
|
});
|
|
|
|
it("should return error response for invalid query", async () => {
|
|
const dto: IncomingQueryDto = {
|
|
messageId: "msg-1",
|
|
instanceId: "remote-instance-1",
|
|
query: "SELECT * FROM tasks",
|
|
timestamp: Date.now(),
|
|
signature: "invalid-signature",
|
|
};
|
|
|
|
mockQueryService.handleIncomingQuery.mockRejectedValue(new Error("Invalid signature"));
|
|
|
|
await expect(controller.handleIncomingQuery(dto)).rejects.toThrow("Invalid signature");
|
|
});
|
|
});
|
|
|
|
describe("getQueries", () => {
|
|
it("should return query messages for workspace", async () => {
|
|
const req = {
|
|
user: {
|
|
id: "user-1",
|
|
workspaceId: "workspace-1",
|
|
},
|
|
} as AuthenticatedRequest;
|
|
|
|
const mockMessages = [
|
|
{
|
|
id: "msg-1",
|
|
workspaceId: "workspace-1",
|
|
connectionId: "connection-1",
|
|
messageType: FederationMessageType.QUERY,
|
|
messageId: "unique-msg-1",
|
|
query: "SELECT * FROM tasks",
|
|
status: FederationMessageStatus.DELIVERED,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
];
|
|
|
|
mockQueryService.getQueryMessages.mockResolvedValue(mockMessages);
|
|
|
|
const result = await controller.getQueries(req, undefined);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(mockQueryService.getQueryMessages).toHaveBeenCalledWith("workspace-1", undefined);
|
|
});
|
|
|
|
it("should filter by status when provided", async () => {
|
|
const req = {
|
|
user: {
|
|
id: "user-1",
|
|
workspaceId: "workspace-1",
|
|
},
|
|
} as AuthenticatedRequest;
|
|
|
|
const status = FederationMessageStatus.PENDING;
|
|
|
|
mockQueryService.getQueryMessages.mockResolvedValue([]);
|
|
|
|
await controller.getQueries(req, status);
|
|
|
|
expect(mockQueryService.getQueryMessages).toHaveBeenCalledWith("workspace-1", status);
|
|
});
|
|
|
|
it("should throw error if user not authenticated", async () => {
|
|
const req = {} as AuthenticatedRequest;
|
|
|
|
await expect(controller.getQueries(req, undefined)).rejects.toThrow(
|
|
"Workspace ID not found in request"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getQuery", () => {
|
|
it("should return query message by ID", async () => {
|
|
const req = {
|
|
user: {
|
|
id: "user-1",
|
|
workspaceId: "workspace-1",
|
|
},
|
|
} as AuthenticatedRequest;
|
|
|
|
const messageId = "msg-1";
|
|
|
|
const mockMessage = {
|
|
id: messageId,
|
|
workspaceId: "workspace-1",
|
|
connectionId: "connection-1",
|
|
messageType: FederationMessageType.QUERY,
|
|
messageId: "unique-msg-1",
|
|
query: "SELECT * FROM tasks",
|
|
status: FederationMessageStatus.DELIVERED,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
mockQueryService.getQueryMessage.mockResolvedValue(mockMessage);
|
|
|
|
const result = await controller.getQuery(req, messageId);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.id).toBe(messageId);
|
|
expect(mockQueryService.getQueryMessage).toHaveBeenCalledWith("workspace-1", messageId);
|
|
});
|
|
|
|
it("should throw error if user not authenticated", async () => {
|
|
const req = {} as AuthenticatedRequest;
|
|
|
|
await expect(controller.getQuery(req, "msg-1")).rejects.toThrow(
|
|
"Workspace ID not found in request"
|
|
);
|
|
});
|
|
});
|
|
});
|