feat(#88): implement QUERY message type for federation

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>
This commit is contained in:
Jason Woltje
2026-02-03 13:12:12 -06:00
parent 70a6bc82e0
commit 1159ca42a7
10 changed files with 1672 additions and 2 deletions

View File

@@ -0,0 +1,493 @@
/**
* Query Service Tests
*
* Tests for federated query message handling.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { ConfigService } from "@nestjs/config";
import {
FederationConnectionStatus,
FederationMessageType,
FederationMessageStatus,
} from "@prisma/client";
import { QueryService } from "./query.service";
import { PrismaService } from "../prisma/prisma.service";
import { FederationService } from "./federation.service";
import { SignatureService } from "./signature.service";
import { HttpService } from "@nestjs/axios";
import { of, throwError } from "rxjs";
import type { AxiosResponse } from "axios";
describe("QueryService", () => {
let service: QueryService;
let prisma: PrismaService;
let federationService: FederationService;
let signatureService: SignatureService;
let httpService: HttpService;
const mockPrisma = {
federationConnection: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
federationMessage: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
};
const mockFederationService = {
getInstanceIdentity: vi.fn(),
getPublicIdentity: vi.fn(),
};
const mockSignatureService = {
signMessage: vi.fn(),
verifyMessage: vi.fn(),
validateTimestamp: vi.fn(),
};
const mockHttpService = {
post: vi.fn(),
};
const mockConfig = {
get: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
QueryService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: FederationService, useValue: mockFederationService },
{ provide: SignatureService, useValue: mockSignatureService },
{ provide: HttpService, useValue: mockHttpService },
{ provide: ConfigService, useValue: mockConfig },
],
}).compile();
service = module.get<QueryService>(QueryService);
prisma = module.get<PrismaService>(PrismaService);
federationService = module.get<FederationService>(FederationService);
signatureService = module.get<SignatureService>(SignatureService);
httpService = module.get<HttpService>(HttpService);
vi.clearAllMocks();
});
describe("sendQuery", () => {
it("should send query to remote instance with signed message", async () => {
const workspaceId = "workspace-1";
const connectionId = "connection-1";
const query = "SELECT * FROM tasks";
const context = { userId: "user-1" };
const mockConnection = {
id: connectionId,
workspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
remotePublicKey: "mock-public-key",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
id: "identity-1",
instanceId: "local-instance-1",
name: "Local Instance",
url: "https://local.example.com",
publicKey: "local-public-key",
privateKey: "local-private-key",
};
const mockMessage = {
id: "message-1",
workspaceId,
connectionId,
messageType: FederationMessageType.QUERY,
messageId: expect.any(String),
correlationId: null,
query,
response: null,
status: FederationMessageStatus.PENDING,
error: null,
signature: "mock-signature",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: null,
};
const mockResponse: AxiosResponse = {
data: { success: true },
status: 200,
statusText: "OK",
headers: {},
config: { headers: {} as never },
};
mockPrisma.federationConnection.findUnique.mockResolvedValue(mockConnection);
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("mock-signature");
mockPrisma.federationMessage.create.mockResolvedValue(mockMessage);
mockHttpService.post.mockReturnValue(of(mockResponse));
const result = await service.sendQuery(workspaceId, connectionId, query, context);
expect(result).toBeDefined();
expect(result.messageType).toBe(FederationMessageType.QUERY);
expect(result.query).toBe(query);
expect(mockPrisma.federationConnection.findUnique).toHaveBeenCalledWith({
where: { id: connectionId, workspaceId },
});
expect(mockPrisma.federationMessage.create).toHaveBeenCalled();
expect(mockHttpService.post).toHaveBeenCalledWith(
`${mockConnection.remoteUrl}/api/v1/federation/incoming/query`,
expect.objectContaining({
messageId: expect.any(String),
instanceId: mockIdentity.instanceId,
query,
context,
timestamp: expect.any(Number),
signature: "mock-signature",
})
);
});
it("should throw error if connection not found", async () => {
mockPrisma.federationConnection.findUnique.mockResolvedValue(null);
await expect(
service.sendQuery("workspace-1", "connection-1", "SELECT * FROM tasks")
).rejects.toThrow("Connection not found");
});
it("should throw error if connection not active", async () => {
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
status: FederationConnectionStatus.PENDING,
};
mockPrisma.federationConnection.findUnique.mockResolvedValue(mockConnection);
await expect(
service.sendQuery("workspace-1", "connection-1", "SELECT * FROM tasks")
).rejects.toThrow("Connection is not active");
});
it("should handle network errors gracefully", async () => {
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
instanceId: "local-instance-1",
};
mockPrisma.federationConnection.findUnique.mockResolvedValue(mockConnection);
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("mock-signature");
mockPrisma.federationMessage.create.mockResolvedValue({
id: "message-1",
messageId: "msg-1",
});
mockHttpService.post.mockReturnValue(throwError(() => new Error("Network error")));
await expect(
service.sendQuery("workspace-1", "connection-1", "SELECT * FROM tasks")
).rejects.toThrow("Failed to send query");
});
});
describe("handleIncomingQuery", () => {
it("should process valid incoming query", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "SELECT * FROM tasks",
context: {},
timestamp: Date.now(),
signature: "valid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
instanceId: "local-instance-1",
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({ valid: true });
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("response-signature");
const result = await service.handleIncomingQuery(queryMessage);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
expect(result.correlationId).toBe(queryMessage.messageId);
expect(result.instanceId).toBe(mockIdentity.instanceId);
expect(result.signature).toBe("response-signature");
});
it("should reject query with invalid signature", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "SELECT * FROM tasks",
timestamp: Date.now(),
signature: "invalid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({
valid: false,
error: "Invalid signature",
});
await expect(service.handleIncomingQuery(queryMessage)).rejects.toThrow("Invalid signature");
});
it("should reject query with expired timestamp", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "SELECT * FROM tasks",
timestamp: Date.now() - 10 * 60 * 1000, // 10 minutes ago
signature: "valid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(false);
await expect(service.handleIncomingQuery(queryMessage)).rejects.toThrow(
"Query timestamp is outside acceptable range"
);
});
it("should reject query from inactive connection", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "SELECT * FROM tasks",
timestamp: Date.now(),
signature: "valid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.PENDING,
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
await expect(service.handleIncomingQuery(queryMessage)).rejects.toThrow(
"Connection is not active"
);
});
it("should reject query from unknown instance", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "unknown-instance",
query: "SELECT * FROM tasks",
timestamp: Date.now(),
signature: "valid-signature",
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(null);
mockSignatureService.validateTimestamp.mockReturnValue(true);
await expect(service.handleIncomingQuery(queryMessage)).rejects.toThrow(
"No connection found for remote instance"
);
});
});
describe("getQueryMessages", () => {
it("should return query messages for workspace", async () => {
const workspaceId = "workspace-1";
const mockMessages = [
{
id: "msg-1",
workspaceId,
connectionId: "connection-1",
messageType: FederationMessageType.QUERY,
messageId: "unique-msg-1",
query: "SELECT * FROM tasks",
status: FederationMessageStatus.DELIVERED,
createdAt: new Date(),
updatedAt: new Date(),
},
];
mockPrisma.federationMessage.findMany.mockResolvedValue(mockMessages);
const result = await service.getQueryMessages(workspaceId);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("msg-1");
expect(mockPrisma.federationMessage.findMany).toHaveBeenCalledWith({
where: {
workspaceId,
messageType: FederationMessageType.QUERY,
},
orderBy: { createdAt: "desc" },
});
});
it("should filter by status when provided", async () => {
const workspaceId = "workspace-1";
const status = FederationMessageStatus.PENDING;
mockPrisma.federationMessage.findMany.mockResolvedValue([]);
await service.getQueryMessages(workspaceId, status);
expect(mockPrisma.federationMessage.findMany).toHaveBeenCalledWith({
where: {
workspaceId,
messageType: FederationMessageType.QUERY,
status,
},
orderBy: { createdAt: "desc" },
});
});
});
describe("getQueryMessage", () => {
it("should return query message by ID", async () => {
const workspaceId = "workspace-1";
const messageId = "msg-1";
const mockMessage = {
id: "msg-1",
workspaceId,
messageType: FederationMessageType.QUERY,
messageId: "unique-msg-1",
query: "SELECT * FROM tasks",
status: FederationMessageStatus.DELIVERED,
createdAt: new Date(),
updatedAt: new Date(),
};
mockPrisma.federationMessage.findUnique.mockResolvedValue(mockMessage);
const result = await service.getQueryMessage(workspaceId, messageId);
expect(result).toBeDefined();
expect(result.id).toBe(messageId);
expect(mockPrisma.federationMessage.findUnique).toHaveBeenCalledWith({
where: { id: messageId, workspaceId },
});
});
it("should throw error if message not found", async () => {
mockPrisma.federationMessage.findUnique.mockResolvedValue(null);
await expect(service.getQueryMessage("workspace-1", "msg-1")).rejects.toThrow(
"Query message not found"
);
});
});
describe("processQueryResponse", () => {
it("should update message with response", async () => {
const response = {
messageId: "response-1",
correlationId: "original-msg-1",
instanceId: "remote-instance-1",
success: true,
data: { tasks: [] },
timestamp: Date.now(),
signature: "valid-signature",
};
const mockMessage = {
id: "msg-1",
messageId: "original-msg-1",
workspaceId: "workspace-1",
status: FederationMessageStatus.PENDING,
};
mockPrisma.federationMessage.findFirst.mockResolvedValue(mockMessage);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({ valid: true });
mockPrisma.federationMessage.update.mockResolvedValue({
...mockMessage,
status: FederationMessageStatus.DELIVERED,
response: response.data,
});
await service.processQueryResponse(response);
expect(mockPrisma.federationMessage.update).toHaveBeenCalledWith({
where: { id: mockMessage.id },
data: {
status: FederationMessageStatus.DELIVERED,
response: response.data,
deliveredAt: expect.any(Date),
},
});
});
it("should reject response with invalid signature", async () => {
const response = {
messageId: "response-1",
correlationId: "original-msg-1",
instanceId: "remote-instance-1",
success: true,
timestamp: Date.now(),
signature: "invalid-signature",
};
const mockMessage = {
id: "msg-1",
messageId: "original-msg-1",
workspaceId: "workspace-1",
};
mockPrisma.federationMessage.findFirst.mockResolvedValue(mockMessage);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({
valid: false,
error: "Invalid signature",
});
await expect(service.processQueryResponse(response)).rejects.toThrow("Invalid signature");
});
});
});