Implements FED-010: Agent Spawn via Federation feature that enables spawning and managing Claude agents on remote federated Mosaic Stack instances via COMMAND message type. Features: - Federation agent command types (spawn, status, kill) - FederationAgentService for handling agent operations - Integration with orchestrator's agent spawner/lifecycle services - API endpoints for spawning, querying status, and killing agents - Full command routing through federation COMMAND infrastructure - Comprehensive test coverage (12/12 tests passing) Architecture: - Hub → Spoke: Spawn agents on remote instances - Command flow: FederationController → FederationAgentService → CommandService → Remote Orchestrator - Response handling: Remote orchestrator returns agent status/results - Security: Connection validation, signature verification Files created: - apps/api/src/federation/types/federation-agent.types.ts - apps/api/src/federation/federation-agent.service.ts - apps/api/src/federation/federation-agent.service.spec.ts Files modified: - apps/api/src/federation/command.service.ts (agent command routing) - apps/api/src/federation/federation.controller.ts (agent endpoints) - apps/api/src/federation/federation.module.ts (service registration) - apps/orchestrator/src/api/agents/agents.controller.ts (status endpoint) - apps/orchestrator/src/api/agents/agents.module.ts (lifecycle integration) Testing: - 12/12 tests passing for FederationAgentService - All command service tests passing - TypeScript compilation successful - Linting passed Refs #93 Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
132 lines
4.0 KiB
TypeScript
132 lines
4.0 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { UnauthorizedException } from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { StitcherController } from "./stitcher.controller";
|
|
import { StitcherService } from "./stitcher.service";
|
|
import { ApiKeyGuard } from "../common/guards/api-key.guard";
|
|
|
|
/**
|
|
* Security tests for StitcherController
|
|
*
|
|
* These tests verify that all stitcher endpoints require authentication
|
|
* and reject requests without valid API keys.
|
|
*/
|
|
describe("StitcherController - Security", () => {
|
|
let controller: StitcherController;
|
|
let guard: ApiKeyGuard;
|
|
|
|
const mockService = {
|
|
handleWebhook: vi.fn(),
|
|
dispatchJob: vi.fn(),
|
|
};
|
|
|
|
const mockConfigService = {
|
|
get: vi.fn().mockReturnValue("test-api-key-12345"),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
controllers: [StitcherController],
|
|
providers: [
|
|
{ provide: StitcherService, useValue: mockService },
|
|
{ provide: ConfigService, useValue: mockConfigService },
|
|
ApiKeyGuard,
|
|
],
|
|
}).compile();
|
|
|
|
controller = module.get<StitcherController>(StitcherController);
|
|
guard = module.get<ApiKeyGuard>(ApiKeyGuard);
|
|
});
|
|
|
|
describe("Authentication Requirements", () => {
|
|
it("should have ApiKeyGuard applied to controller", () => {
|
|
const guards = Reflect.getMetadata("__guards__", StitcherController);
|
|
expect(guards).toBeDefined();
|
|
expect(guards).toContain(ApiKeyGuard);
|
|
});
|
|
|
|
it("POST /stitcher/webhook should require authentication", async () => {
|
|
const mockContext = {
|
|
switchToHttp: () => ({
|
|
getRequest: () => ({ headers: {} }),
|
|
}),
|
|
};
|
|
|
|
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
|
});
|
|
|
|
it("POST /stitcher/dispatch should require authentication", async () => {
|
|
const mockContext = {
|
|
switchToHttp: () => ({
|
|
getRequest: () => ({ headers: {} }),
|
|
}),
|
|
};
|
|
|
|
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
|
});
|
|
});
|
|
|
|
describe("Valid Authentication", () => {
|
|
it("should allow requests with valid API key", async () => {
|
|
const mockContext = {
|
|
switchToHttp: () => ({
|
|
getRequest: () => ({
|
|
headers: { "x-api-key": "test-api-key-12345" },
|
|
}),
|
|
}),
|
|
};
|
|
|
|
const result = await guard.canActivate(mockContext as any);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should reject requests with invalid API key", async () => {
|
|
const mockContext = {
|
|
switchToHttp: () => ({
|
|
getRequest: () => ({
|
|
headers: { "x-api-key": "wrong-api-key" },
|
|
}),
|
|
}),
|
|
};
|
|
|
|
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
|
await expect(guard.canActivate(mockContext as any)).rejects.toThrow("Invalid API key");
|
|
});
|
|
|
|
it("should reject requests with empty API key", async () => {
|
|
const mockContext = {
|
|
switchToHttp: () => ({
|
|
getRequest: () => ({
|
|
headers: { "x-api-key": "" },
|
|
}),
|
|
}),
|
|
};
|
|
|
|
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
|
await expect(guard.canActivate(mockContext as any)).rejects.toThrow("No API key provided");
|
|
});
|
|
});
|
|
|
|
describe("Webhook Security", () => {
|
|
it("should prevent unauthorized webhook submissions", async () => {
|
|
const mockContext = {
|
|
switchToHttp: () => ({
|
|
getRequest: () => ({
|
|
headers: {},
|
|
body: {
|
|
issueNumber: "42",
|
|
repository: "malicious/repo",
|
|
action: "assigned",
|
|
},
|
|
}),
|
|
}),
|
|
};
|
|
|
|
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
|
});
|
|
});
|
|
});
|