feat(#94): implement spoke configuration UI
Implements the final piece of M7-Federation - the spoke configuration UI that allows administrators to configure their local instance's federation capabilities and settings. Backend Changes: - Add UpdateInstanceDto with validation for name, capabilities, and metadata - Implement FederationService.updateInstanceConfiguration() method - Add PATCH /api/v1/federation/instance endpoint to FederationController - Add audit logging for configuration updates - Add tests for updateInstanceConfiguration (5 new tests, all passing) Frontend Changes: - Create SpokeConfigurationForm component with PDA-friendly design - Create /federation/settings page with configuration management - Add regenerate keypair functionality with confirmation dialog - Extend federation API client with updateInstanceConfiguration and regenerateInstanceKeys - Add comprehensive tests (10 tests, all passing) Design Decisions: - Admin-only access via AdminGuard - Never expose private key in API responses (security) - PDA-friendly language throughout (no demanding terms) - Clear visual hierarchy with read-only and editable fields - Truncated public key with copy button for usability - Confirmation dialog for destructive key regeneration All tests passing: - Backend: 13/13 federation service tests passing - Frontend: 10/10 SpokeConfigurationForm tests passing - TypeScript compilation: passing - Linting: passing - PDA-friendliness: verified This completes M7-Federation. All federation features are now implemented. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Tests for SpokeConfigurationForm Component
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { SpokeConfigurationForm } from "./SpokeConfigurationForm";
|
||||
import type { PublicInstanceIdentity } from "@/lib/api/federation";
|
||||
|
||||
describe("SpokeConfigurationForm", () => {
|
||||
const mockInstance: PublicInstanceIdentity = {
|
||||
id: "instance-123",
|
||||
instanceId: "test-instance-001",
|
||||
name: "Test Instance",
|
||||
url: "https://test.example.com",
|
||||
publicKey: "-----BEGIN PUBLIC KEY-----\nMOCKPUBLICKEY\n-----END PUBLIC KEY-----",
|
||||
capabilities: {
|
||||
supportsQuery: true,
|
||||
supportsCommand: true,
|
||||
supportsEvent: true,
|
||||
supportsAgentSpawn: false,
|
||||
protocolVersion: "1.0",
|
||||
},
|
||||
metadata: {
|
||||
description: "Test instance description",
|
||||
},
|
||||
createdAt: "2026-02-01T00:00:00Z",
|
||||
updatedAt: "2026-02-01T00:00:00Z",
|
||||
};
|
||||
|
||||
it("should render instance identity information", () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
|
||||
|
||||
expect(screen.getByDisplayValue("Test Instance")).toBeInTheDocument();
|
||||
expect(screen.getByText("test-instance-001")).toBeInTheDocument();
|
||||
expect(screen.getByText("https://test.example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render capability toggles with correct initial state", () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
|
||||
|
||||
const queryToggle = screen.getByLabelText(/Query Support/i);
|
||||
const commandToggle = screen.getByLabelText(/Command Support/i);
|
||||
const eventToggle = screen.getByLabelText(/Event Support/i);
|
||||
const agentToggle = screen.getByLabelText(/Agent Spawn Support/i);
|
||||
|
||||
expect(queryToggle).toBeChecked();
|
||||
expect(commandToggle).toBeChecked();
|
||||
expect(eventToggle).toBeChecked();
|
||||
expect(agentToggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should allow editing instance name", async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
|
||||
|
||||
const nameInput = screen.getByDisplayValue("Test Instance");
|
||||
fireEvent.change(nameInput, { target: { value: "Updated Instance" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("Updated Instance")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle capabilities", async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
|
||||
|
||||
const agentToggle = screen.getByLabelText(/Agent Spawn Support/i);
|
||||
expect(agentToggle).not.toBeChecked();
|
||||
|
||||
fireEvent.click(agentToggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(agentToggle).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onSave with updated configuration", async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
|
||||
|
||||
// Change name
|
||||
const nameInput = screen.getByDisplayValue("Test Instance");
|
||||
fireEvent.change(nameInput, { target: { value: "Updated Instance" } });
|
||||
|
||||
// Toggle agent spawn
|
||||
const agentToggle = screen.getByLabelText(/Agent Spawn Support/i);
|
||||
fireEvent.click(agentToggle);
|
||||
|
||||
// Click save
|
||||
const saveButton = screen.getByText("Save Configuration");
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
name: "Updated Instance",
|
||||
capabilities: {
|
||||
supportsQuery: true,
|
||||
supportsCommand: true,
|
||||
supportsEvent: true,
|
||||
supportsAgentSpawn: true,
|
||||
protocolVersion: "1.0",
|
||||
},
|
||||
metadata: {
|
||||
description: "Test instance description",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should display loading state when saving", () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} isLoading={true} />);
|
||||
|
||||
const saveButton = screen.getByText("Saving...");
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should display error message when provided", () => {
|
||||
const onSave = vi.fn();
|
||||
render(
|
||||
<SpokeConfigurationForm
|
||||
instance={mockInstance}
|
||||
onSave={onSave}
|
||||
error="Unable to save configuration"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Unable to save configuration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should use PDA-friendly language in help text", () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
|
||||
|
||||
// Should NOT use demanding language
|
||||
expect(screen.queryByText(/must/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/required/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/critical/i)).not.toBeInTheDocument();
|
||||
|
||||
// Should use friendly language (multiple instances expected)
|
||||
const friendlyText = screen.getAllByText(/Allows connected instances/i);
|
||||
expect(friendlyText.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should truncate public key and show copy button", () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
|
||||
|
||||
// Public key should be truncated
|
||||
expect(screen.getByText(/-----BEGIN PUBLIC KEY-----/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Copy/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle cancel action", async () => {
|
||||
const onSave = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} onCancel={onCancel} />);
|
||||
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user