diff --git a/cmd/hashpw/main.go b/cmd/hashpw/main.go index 45557de..660566f 100644 --- a/cmd/hashpw/main.go +++ b/cmd/hashpw/main.go @@ -2,23 +2,29 @@ package main import ( "fmt" + "io" "os" "github.com/rcourtman/pulse-go-rewrite/internal/auth" ) -func main() { - if len(os.Args) < 2 { - fmt.Println("Usage: hashpw ") - os.Exit(1) +func run(args []string, out io.Writer) int { + if len(args) < 2 { + fmt.Fprintln(out, "Usage: hashpw ") + return 1 } - password := os.Args[1] + password := args[1] hash, err := auth.HashPassword(password) if err != nil { - fmt.Printf("Error: %v\n", err) - os.Exit(1) + fmt.Fprintf(out, "Error: %v\n", err) + return 1 } - fmt.Println(hash) + fmt.Fprintln(out, hash) + return 0 +} + +func main() { + os.Exit(run(os.Args, os.Stdout)) } diff --git a/cmd/hashpw/main_test.go b/cmd/hashpw/main_test.go new file mode 100644 index 0000000..d2b9c85 --- /dev/null +++ b/cmd/hashpw/main_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/auth" +) + +func TestRunUsage(t *testing.T) { + var out bytes.Buffer + code := run([]string{"hashpw"}, &out) + if code != 1 { + t.Fatalf("expected exit code 1, got %d", code) + } + if !strings.Contains(out.String(), "Usage: hashpw ") { + t.Fatalf("expected usage output, got %q", out.String()) + } +} + +func TestRunHashesPassword(t *testing.T) { + var out bytes.Buffer + code := run([]string{"hashpw", "this-is-a-test-password"}, &out) + if code != 0 { + t.Fatalf("expected exit code 0, got %d (output: %q)", code, out.String()) + } + + hash := strings.TrimSpace(out.String()) + if hash == "" { + t.Fatalf("expected hash output, got empty string") + } + if !auth.CheckPasswordHash("this-is-a-test-password", hash) { + t.Fatalf("expected hash to validate against original password") + } +} diff --git a/frontend-modern/package-lock.json b/frontend-modern/package-lock.json index 1813282..e4e4d31 100644 Binary files a/frontend-modern/package-lock.json and b/frontend-modern/package-lock.json differ diff --git a/frontend-modern/package.json b/frontend-modern/package.json index 9c94d57..979fb74 100644 --- a/frontend-modern/package.json +++ b/frontend-modern/package.json @@ -19,6 +19,8 @@ "preview": "vite preview", "generate-types": "cd ../scripts && go run generate-types.go", "test": "vitest run", + "test:coverage": "vitest run --coverage --coverage.provider=v8 --coverage.include=src/**/*.ts --coverage.include=src/**/*.tsx --coverage.exclude=src/index.tsx", + "test:coverage:ai": "vitest run --coverage --coverage.provider=v8 --coverage.thresholds.100 --coverage.thresholds.perFile --coverage.include=src/components/AI/aiChatUtils.ts", "type-check": "tsc --noEmit", "lint": "eslint \"src/**/*.{ts,tsx}\"", "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", @@ -39,6 +41,7 @@ "@types/node": "^20.10.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", + "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.0.0", diff --git a/frontend-modern/src/api/__tests__/ai.test.ts b/frontend-modern/src/api/__tests__/ai.test.ts new file mode 100644 index 0000000..103e930 --- /dev/null +++ b/frontend-modern/src/api/__tests__/ai.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; + +vi.mock('@/utils/apiClient', () => ({ + apiFetchJSON: vi.fn(), + apiFetch: vi.fn(), +})); + +vi.mock('@/utils/logger', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { AIAPI } from '@/api/ai'; +import { apiFetch, apiFetchJSON } from '@/utils/apiClient'; + +describe('AIAPI', () => { + const apiFetchJSONMock = vi.mocked(apiFetchJSON); + const apiFetchMock = vi.mocked(apiFetch); + + beforeEach(() => { + apiFetchJSONMock.mockReset(); + apiFetchMock.mockReset(); + }); + + it('calls the expected endpoints for settings and models', async () => { + apiFetchJSONMock.mockResolvedValueOnce({ configured: true } as any); + await AIAPI.getSettings(); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/settings/ai'); + + apiFetchJSONMock.mockResolvedValueOnce({ configured: true } as any); + await AIAPI.updateSettings({ enabled: true }); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/settings/ai/update', { + method: 'PUT', + body: JSON.stringify({ enabled: true }), + }); + + apiFetchJSONMock.mockResolvedValueOnce({ models: [] } as any); + await AIAPI.getModels(); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/models'); + }); + + it('includes query parameters for cost and intelligence endpoints', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as any); + await AIAPI.getCostSummary(); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/cost/summary?days=30'); + + apiFetchJSONMock.mockResolvedValueOnce({} as any); + await AIAPI.getCostSummary(7); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/cost/summary?days=7'); + + apiFetchJSONMock.mockResolvedValueOnce({} as any); + await AIAPI.getPatterns('vm:101'); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/intelligence/patterns?resource_id=vm%3A101'); + + apiFetchJSONMock.mockResolvedValueOnce({} as any); + await AIAPI.getRecentChanges(12); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/intelligence/changes?hours=12'); + }); + + it('sanitizes runCommand payload consistently', async () => { + apiFetchJSONMock.mockResolvedValueOnce({ output: 'ok', success: true } as any); + await AIAPI.runCommand({ + command: 'echo hi', + target_type: 'vm', + target_id: 'vm-101', + run_on_host: undefined as any, + vmid: 101, + target_host: 'delly', + }); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/run-command', { + method: 'POST', + body: JSON.stringify({ + command: 'echo hi', + target_type: 'vm', + target_id: 'vm-101', + run_on_host: false, + vmid: '101', + target_host: 'delly', + }), + }); + }); + + it('throws useful errors for investigateAlert failures', async () => { + apiFetchMock.mockResolvedValueOnce(new Response('backend error', { status: 500 })); + await expect( + AIAPI.investigateAlert( + { + alert_id: 'a1', + resource_id: 'r1', + resource_name: 'res', + resource_type: 'vm', + alert_type: 'cpu', + level: 'warning', + value: 1, + threshold: 2, + message: 'msg', + duration: '1m', + }, + () => undefined + ) + ).rejects.toThrow('backend error'); + }); + + it('throws when investigateAlert has no streaming body', async () => { + apiFetchMock.mockResolvedValueOnce(new Response(null, { status: 200 })); + await expect( + AIAPI.investigateAlert( + { + alert_id: 'a1', + resource_id: 'r1', + resource_name: 'res', + resource_type: 'vm', + alert_type: 'cpu', + level: 'warning', + value: 1, + threshold: 2, + message: 'msg', + duration: '1m', + }, + () => undefined + ) + ).rejects.toThrow('No response body'); + }); +}); + diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index 5c3e690..9424161 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -141,7 +141,7 @@ export class AIAPI { target_type: string; target_id: string; run_on_host: boolean; - vmid?: string; + vmid?: string | number; target_host?: string; // Explicit host for command routing }): Promise<{ output: string; success: boolean; error?: string }> { // Ensure run_on_host is explicitly a boolean (not undefined) diff --git a/frontend-modern/src/components/AI/AIChat.tsx b/frontend-modern/src/components/AI/AIChat.tsx index a9e90d2..d85b3f7 100644 --- a/frontend-modern/src/components/AI/AIChat.tsx +++ b/frontend-modern/src/components/AI/AIChat.tsx @@ -1,12 +1,17 @@ import { Component, Show, createSignal, For, createEffect, createMemo, onMount, Switch, Match } from 'solid-js'; -import { marked } from 'marked'; -import DOMPurify from 'dompurify'; import { AIAPI } from '@/api/ai'; import { notificationStore } from '@/stores/notifications'; import { logger } from '@/utils/logger'; import { aiChatStore } from '@/stores/aiChat'; import { useWebSocket } from '@/App'; import { GuestNotes } from './GuestNotes'; +import { + PROVIDER_DISPLAY_NAMES, + getGuestName, + groupModelsByProvider, + renderMarkdown, + sanitizeThinking, +} from './aiChatUtils'; import type { AIToolExecution, AIStreamEvent, @@ -17,115 +22,6 @@ import type { ModelInfo, } from '@/types/ai'; -// Provider display names for grouped model selection -const PROVIDER_DISPLAY_NAMES: Record = { - anthropic: 'Anthropic', - openai: 'OpenAI', - deepseek: 'DeepSeek', - ollama: 'Ollama', -}; - -// Parse provider from model ID (format: "provider:model-name") -function getProviderFromModelId(modelId: string): string { - const colonIndex = modelId.indexOf(':'); - if (colonIndex > 0) { - return modelId.substring(0, colonIndex); - } - // Default detection for models without prefix - if (modelId.includes('claude') || modelId.includes('opus') || modelId.includes('sonnet') || modelId.includes('haiku')) { - return 'anthropic'; - } - if (modelId.includes('gpt') || modelId.includes('o1') || modelId.includes('o3')) { - return 'openai'; - } - if (modelId.includes('deepseek')) { - return 'deepseek'; - } - return 'ollama'; -} - -// Group models by provider for grouped rendering -function groupModelsByProvider(models: ModelInfo[]): Map { - const grouped = new Map(); - - for (const model of models) { - const provider = getProviderFromModelId(model.id); - const existing = grouped.get(provider) || []; - existing.push(model); - grouped.set(provider, existing); - } - - return grouped; -} - -// Configure marked for safe rendering -marked.setOptions({ - breaks: true, // Convert \n to
- gfm: true, // GitHub Flavored Markdown -}); - -let domPurifyConfigured = false; -const configureDOMPurify = () => { - if (domPurifyConfigured) return; - domPurifyConfigured = true; - - DOMPurify.addHook('afterSanitizeAttributes', (node) => { - const element = node as Element | null; - if (!element || element.tagName !== 'A') return; - element.setAttribute('target', '_blank'); - element.setAttribute('rel', 'noopener noreferrer'); - }); -}; - -// Helper to render markdown safely with XSS protection -// LLM output should NEVER be trusted - always sanitize before rendering as HTML -const renderMarkdown = (content: string): string => { - try { - configureDOMPurify(); - const rawHtml = marked.parse(content) as string; - // Sanitize to prevent XSS from malicious LLM output or injected content - return DOMPurify.sanitize(rawHtml, { - // Allow common formatting tags but block scripts, iframes, etc. - ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'b', 'i', 'u', 'code', 'pre', 'blockquote', - 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'hr', 'table', - 'thead', 'tbody', 'tr', 'th', 'td', 'span', 'div'], - ALLOWED_ATTR: ['href', 'target', 'rel', 'class'], - // Force all links to open in new tab and prevent opener attacks - ADD_ATTR: ['target', 'rel'], - }); - } catch { - // If parsing fails, escape HTML entities as fallback - return content.replace(/[&<>"']/g, (char) => { - const entities: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; - return entities[char] || char; - }); - } -}; - -// Helper to sanitize thinking/reasoning content for display -// Removes raw network errors with IP addresses that are not user-friendly -const sanitizeThinking = (content: string): string => { - // Replace raw TCP connection details like "write tcp 192.168.0.123:7655->192.168.0.134:58004: i/o timeout" - // with friendlier messages - let sanitized = content.replace( - /write tcp [\d.:]+->[\d.:]+: i\/o timeout/g, - 'connection timed out' - ); - sanitized = sanitized.replace( - /read tcp [\d.:]+: i\/o timeout/g, - 'connection timed out' - ); - sanitized = sanitized.replace( - /dial tcp [\d.:]+: connection refused/g, - 'connection refused' - ); - // Replace "failed to send command: " patterns - sanitized = sanitized.replace( - /failed to send command: write tcp [\d.:->\s]+/g, - 'failed to send command: connection error' - ); - return sanitized; -}; // In-progress tool execution (before completion) interface PendingTool { @@ -173,14 +69,6 @@ interface AIChatProps { onClose: () => void; } -// Extract guest name from context if available -const getGuestName = (context?: Record): string | undefined => { - if (!context) return undefined; - if (typeof context.guestName === 'string') return context.guestName; - if (typeof context.name === 'string') return context.name; - return undefined; -}; - export const AIChat: Component = (props) => { // Read all context from store for proper SolidJS reactivity const isOpen = () => aiChatStore.isOpen; diff --git a/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts b/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts new file mode 100644 index 0000000..7eed9c4 --- /dev/null +++ b/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest'; + +import * as utils from '@/components/AI/aiChatUtils'; +import { marked } from 'marked'; +import type { ModelInfo } from '@/types/ai'; + +describe('aiChatUtils', () => { + describe('getProviderFromModelId', () => { + it('uses explicit provider prefix when present', () => { + expect(utils.getProviderFromModelId('openai:gpt-4o')).toBe('openai'); + expect(utils.getProviderFromModelId('anthropic:claude-3-5-sonnet')).toBe('anthropic'); + }); + + it('detects provider from known model naming', () => { + expect(utils.getProviderFromModelId('claude-3-5-sonnet')).toBe('anthropic'); + expect(utils.getProviderFromModelId('o3-mini')).toBe('openai'); + expect(utils.getProviderFromModelId('deepseek-r1')).toBe('deepseek'); + expect(utils.getProviderFromModelId('llama3.1')).toBe('ollama'); + }); + + it('handles odd strings without a provider prefix', () => { + // colon at index 0 should not be treated as a provider prefix + expect(utils.getProviderFromModelId(':gpt-4o')).toBe('openai'); + expect(utils.getProviderFromModelId(':unknown-model')).toBe('ollama'); + }); + }); + + describe('groupModelsByProvider', () => { + it('groups models by detected provider', () => { + const models: ModelInfo[] = [ + { id: 'openai:gpt-4o', name: 'GPT-4o' }, + { id: 'claude-3-5-sonnet', name: 'Claude 3.5 Sonnet' }, + { id: 'deepseek-r1', name: 'DeepSeek R1' }, + { id: 'ollama:llama3.1', name: 'Llama 3.1' }, + ]; + + const grouped = utils.groupModelsByProvider(models); + expect(Array.from(grouped.keys()).sort()).toEqual(['anthropic', 'deepseek', 'ollama', 'openai']); + expect(grouped.get('openai')?.map((m) => m.id)).toEqual(['openai:gpt-4o']); + expect(grouped.get('anthropic')?.map((m) => m.id)).toEqual(['claude-3-5-sonnet']); + }); + }); + + describe('sanitizeThinking', () => { + it('replaces raw tcp timeout and connection errors', () => { + const input = [ + 'write tcp 192.168.0.123:7655->192.168.0.134:58004: i/o timeout', + 'read tcp 10.0.0.1: i/o timeout', + 'dial tcp 127.0.0.1: connection refused', + 'failed to send command: write tcp 192.168.0.123:7655->192.168.0.134:58004: i/o timeout', + ].join('\n'); + + const output = utils.sanitizeThinking(input); + expect(output).toContain('connection timed out'); + expect(output).toContain('connection refused'); + expect(output).toContain('failed to send command: connection error'); + expect(output).not.toContain('192.168.0.123'); + expect(output).not.toContain('10.0.0.1'); + expect(output).not.toContain('127.0.0.1'); + }); + }); + + describe('getGuestName', () => { + it('prefers guestName then falls back to name', () => { + expect(utils.getGuestName(undefined)).toBeUndefined(); + expect(utils.getGuestName({})).toBeUndefined(); + expect(utils.getGuestName({ name: 'vm-101' })).toBe('vm-101'); + expect(utils.getGuestName({ guestName: 'container-202', name: 'ignored' })).toBe('container-202'); + }); + }); + + describe('renderMarkdown', () => { + it('sanitizes HTML and forces safe link attributes', () => { + const output = utils.renderMarkdown( + [ + 'Hello', + '', + '[link](https://example.com)', + '', + '', + ].join('\n') + ); + + expect(output).toContain('Hello'); + expect(output).toContain(' { + const spy = vi.spyOn(marked, 'parse').mockImplementation(() => { + throw new Error('boom'); + }); + + const output = utils.renderMarkdown(`a&b "d" 'e'`); + expect(output).toBe('a&b <c> "d" 'e''); + + spy.mockRestore(); + }); + }); +}); diff --git a/frontend-modern/src/components/AI/aiChatUtils.ts b/frontend-modern/src/components/AI/aiChatUtils.ts new file mode 100644 index 0000000..b6fb3b0 --- /dev/null +++ b/frontend-modern/src/components/AI/aiChatUtils.ts @@ -0,0 +1,123 @@ +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; +import type { ModelInfo } from '@/types/ai'; + +// Provider display names for grouped model selection +export const PROVIDER_DISPLAY_NAMES: Record = { + anthropic: 'Anthropic', + openai: 'OpenAI', + deepseek: 'DeepSeek', + ollama: 'Ollama', +}; + +// Parse provider from model ID (format: "provider:model-name") +export function getProviderFromModelId(modelId: string): string { + const colonIndex = modelId.indexOf(':'); + if (colonIndex > 0) { + return modelId.substring(0, colonIndex); + } + // Default detection for models without prefix + if (modelId.includes('claude') || modelId.includes('opus') || modelId.includes('sonnet') || modelId.includes('haiku')) { + return 'anthropic'; + } + if (modelId.includes('gpt') || modelId.includes('o1') || modelId.includes('o3')) { + return 'openai'; + } + if (modelId.includes('deepseek')) { + return 'deepseek'; + } + return 'ollama'; +} + +// Group models by provider for grouped rendering +export function groupModelsByProvider(models: ModelInfo[]): Map { + const grouped = new Map(); + + for (const model of models) { + const provider = getProviderFromModelId(model.id); + const existing = grouped.get(provider) || []; + existing.push(model); + grouped.set(provider, existing); + } + + return grouped; +} + +// Configure marked for safe rendering +marked.setOptions({ + breaks: true, // Convert \n to
+ gfm: true, // GitHub Flavored Markdown +}); + +let domPurifyConfigured = false; +const configureDOMPurify = () => { + if (domPurifyConfigured) return; + domPurifyConfigured = true; + + DOMPurify.addHook('afterSanitizeAttributes', (node) => { + const element = node as Element | null; + if (!element || element.tagName !== 'A') return; + element.setAttribute('target', '_blank'); + element.setAttribute('rel', 'noopener noreferrer'); + }); +}; + +// Helper to render markdown safely with XSS protection +// LLM output should NEVER be trusted - always sanitize before rendering as HTML +export const renderMarkdown = (content: string): string => { + try { + configureDOMPurify(); + const rawHtml = marked.parse(content) as string; + // Sanitize to prevent XSS from malicious LLM output or injected content + return DOMPurify.sanitize(rawHtml, { + // Allow common formatting tags but block scripts, iframes, etc. + ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'b', 'i', 'u', 'code', 'pre', 'blockquote', + 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'hr', 'table', + 'thead', 'tbody', 'tr', 'th', 'td', 'span', 'div'], + ALLOWED_ATTR: ['href', 'target', 'rel', 'class'], + // Force all links to open in new tab and prevent opener attacks + ADD_ATTR: ['target', 'rel'], + }); + } catch { + // If parsing fails, escape HTML entities as fallback + return content.replace(/[&<>"']/g, (char) => { + const entities: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + return entities[char]; + }); + } +}; + +// Helper to sanitize thinking/reasoning content for display +// Removes raw network errors with IP addresses that are not user-friendly +export const sanitizeThinking = (content: string): string => { + // Replace raw TCP connection details like "write tcp 192.168.0.123:7655->192.168.0.134:58004: i/o timeout" + // with friendlier messages + // Replace "failed to send command: " patterns first so follow-on replacements + // don't partially sanitize the error and prevent the higher-level pattern from matching. + let sanitized = content.replace( + /failed to send command: write tcp [\d.:->\s]+/g, + 'failed to send command: connection error' + ); + + sanitized = sanitized.replace( + /write tcp [\d.:]+->[\d.:]+: i\/o timeout/g, + 'connection timed out' + ); + sanitized = sanitized.replace( + /read tcp [\d.:]+: i\/o timeout/g, + 'connection timed out' + ); + sanitized = sanitized.replace( + /dial tcp [\d.:]+: connection refused/g, + 'connection refused' + ); + return sanitized; +}; + +// Extract guest name from context if available +export const getGuestName = (context?: Record): string | undefined => { + if (!context) return undefined; + if (typeof context.guestName === 'string') return context.guestName; + if (typeof context.name === 'string') return context.name; + return undefined; +}; diff --git a/frontend-modern/src/stores/__tests__/aiChat.test.ts b/frontend-modern/src/stores/__tests__/aiChat.test.ts new file mode 100644 index 0000000..4bc1da7 --- /dev/null +++ b/frontend-modern/src/stores/__tests__/aiChat.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { aiChatStore } from '@/stores/aiChat'; + +describe('aiChatStore', () => { + beforeEach(() => { + aiChatStore.close(); + aiChatStore.clearContext(); + aiChatStore.clearAllContext(); + aiChatStore.clearConversation(); + aiChatStore.setMessages([]); + aiChatStore.registerInput(null); + aiChatStore.setEnabled(false); + }); + + it('opens, closes, and toggles', () => { + expect(aiChatStore.isOpen).toBe(false); + aiChatStore.open(); + expect(aiChatStore.isOpen).toBe(true); + aiChatStore.toggle(); + expect(aiChatStore.isOpen).toBe(false); + aiChatStore.toggle(); + expect(aiChatStore.isOpen).toBe(true); + aiChatStore.close(); + expect(aiChatStore.isOpen).toBe(false); + }); + + it('sets legacy context and clears it', () => { + aiChatStore.setContext({ targetType: 'vm', targetId: 'vm-101', context: { name: 'vm-101' } }); + expect(aiChatStore.context.targetType).toBe('vm'); + expect(aiChatStore.context.targetId).toBe('vm-101'); + aiChatStore.clearContext(); + expect(aiChatStore.context.targetType).toBeUndefined(); + }); + + it('adds context items without duplicates and updates legacy context', () => { + expect(aiChatStore.contextItems).toHaveLength(0); + expect(aiChatStore.hasContextItem('vm-101')).toBe(false); + + aiChatStore.addContextItem('vm', 'vm-101', 'vm-101', { guestName: 'vm-101', a: 1 }); + expect(aiChatStore.contextItems).toHaveLength(1); + expect(aiChatStore.hasContextItem('vm-101')).toBe(true); + expect(aiChatStore.context.targetId).toBe('vm-101'); + expect(aiChatStore.context.context).toMatchObject({ a: 1 }); + + aiChatStore.addContextItem('vm', 'vm-101', 'vm-101', { guestName: 'vm-101', a: 2 }); + expect(aiChatStore.contextItems).toHaveLength(1); + expect(aiChatStore.contextItems[0].data).toMatchObject({ a: 2 }); + expect(aiChatStore.context.targetId).toBe('vm-101'); + expect(aiChatStore.context.context).toMatchObject({ a: 2 }); + }); + + it('removes context items and keeps legacy context consistent', () => { + aiChatStore.addContextItem('vm', 'vm-101', 'vm-101', { name: 'vm-101' }); + aiChatStore.addContextItem('node', 'node-1', 'node-1', { name: 'node-1' }); + expect(aiChatStore.contextItems).toHaveLength(2); + expect(aiChatStore.context.targetId).toBe('node-1'); + + aiChatStore.removeContextItem('node-1'); + expect(aiChatStore.contextItems).toHaveLength(1); + expect(aiChatStore.context.targetId).toBe('vm-101'); + + aiChatStore.removeContextItem('vm-101'); + expect(aiChatStore.contextItems).toHaveLength(0); + expect(aiChatStore.context.targetId).toBeUndefined(); + }); + + it('setTargetContext and openForTarget derive a sensible name', () => { + aiChatStore.setTargetContext('vm', 'vm-101', { guestName: 'my-guest' }); + expect(aiChatStore.contextItems).toHaveLength(1); + expect(aiChatStore.contextItems[0].name).toBe('my-guest'); + expect(aiChatStore.context.targetId).toBe('vm-101'); + + aiChatStore.openForTarget('node', 'node-1', { name: 'delly' }); + expect(aiChatStore.isOpen).toBe(true); + expect(aiChatStore.contextItems).toHaveLength(2); + expect(aiChatStore.contextItems[1].name).toBe('delly'); + }); + + it('opens with a pre-filled prompt', () => { + aiChatStore.openWithPrompt('hello', { targetType: 'vm', targetId: 'vm-101' }); + expect(aiChatStore.isOpen).toBe(true); + expect(aiChatStore.context.initialPrompt).toBe('hello'); + expect(aiChatStore.context.targetId).toBe('vm-101'); + }); + + it('focusInput returns false when closed and true when open with a registered element', () => { + const textarea = document.createElement('textarea'); + document.body.appendChild(textarea); + aiChatStore.registerInput(textarea); + + expect(aiChatStore.focusInput()).toBe(false); + aiChatStore.open(); + expect(aiChatStore.focusInput()).toBe(true); + expect(document.activeElement).toBe(textarea); + + textarea.remove(); + }); +}); diff --git a/internal/agentexec/policy_test.go b/internal/agentexec/policy_test.go new file mode 100644 index 0000000..45d3975 --- /dev/null +++ b/internal/agentexec/policy_test.go @@ -0,0 +1,48 @@ +package agentexec + +import "testing" + +func TestCompilePatternsIgnoresInvalidRegex(t *testing.T) { + res := compilePatterns([]string{"^df(\\s|$)", "["}) + if len(res) != 1 { + t.Fatalf("expected 1 compiled regex, got %d", len(res)) + } +} + +func TestDefaultPolicyEvaluate(t *testing.T) { + p := DefaultPolicy() + + cases := []struct { + name string + command string + want PolicyDecision + }{ + {"blocked", "rm -rf /", PolicyBlock}, + {"blocked sudo", "sudo rm -rf /", PolicyBlock}, + {"auto approve", "df -h", PolicyAllow}, + {"require approval", "systemctl restart nginx", PolicyRequireApproval}, + {"unknown defaults to approval", "echo hello", PolicyRequireApproval}, + {"sudo with flags remains conservative", "sudo -u root df -h", PolicyRequireApproval}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := p.Evaluate(tc.command); got != tc.want { + t.Fatalf("Evaluate(%q) = %q, want %q", tc.command, got, tc.want) + } + }) + } +} + +func TestPolicyHelpers(t *testing.T) { + p := DefaultPolicy() + if !p.IsBlocked("rm -rf /") { + t.Fatalf("expected rm -rf / to be blocked") + } + if !p.NeedsApproval("echo hello") { + t.Fatalf("expected echo hello to require approval by default") + } + if !p.IsAutoApproved("df -h") { + t.Fatalf("expected df -h to be auto approved") + } +} diff --git a/internal/agentexec/server_test.go b/internal/agentexec/server_test.go new file mode 100644 index 0000000..17ceb5e --- /dev/null +++ b/internal/agentexec/server_test.go @@ -0,0 +1,50 @@ +package agentexec + +import ( + "context" + "testing" + "time" +) + +func TestExecuteCommandAgentNotConnected(t *testing.T) { + s := NewServer(nil) + _, err := s.ExecuteCommand(context.Background(), "missing", ExecuteCommandPayload{RequestID: "r1", Timeout: 1}) + if err == nil { + t.Fatalf("expected error when agent not connected") + } +} + +func TestReadFileAgentNotConnected(t *testing.T) { + s := NewServer(nil) + _, err := s.ReadFile(context.Background(), "missing", ReadFilePayload{RequestID: "r1"}) + if err == nil { + t.Fatalf("expected error when agent not connected") + } +} + +func TestConnectedAgentLookups(t *testing.T) { + s := NewServer(nil) + now := time.Now().Add(-1 * time.Minute) + + s.mu.Lock() + s.agents["a1"] = &agentConn{agent: ConnectedAgent{AgentID: "a1", Hostname: "host1", ConnectedAt: now}} + s.agents["a2"] = &agentConn{agent: ConnectedAgent{AgentID: "a2", Hostname: "host2", ConnectedAt: now}} + s.mu.Unlock() + + if !s.IsAgentConnected("a1") { + t.Fatalf("expected a1 to be connected") + } + if s.IsAgentConnected("missing") { + t.Fatalf("expected missing to not be connected") + } + + agentID, ok := s.GetAgentForHost("host2") + if !ok || agentID != "a2" { + t.Fatalf("expected GetAgentForHost(host2) = (a2, true), got (%q, %v)", agentID, ok) + } + + agents := s.GetConnectedAgents() + if len(agents) != 2 { + t.Fatalf("expected 2 connected agents, got %d", len(agents)) + } +} diff --git a/internal/ceph/collector_test.go b/internal/ceph/collector_test.go new file mode 100644 index 0000000..9c9485e --- /dev/null +++ b/internal/ceph/collector_test.go @@ -0,0 +1,114 @@ +package ceph + +import ( + "testing" +) + +func TestParseStatus(t *testing.T) { + data := []byte(`{ + "fsid":"fsid-123", + "health":{ + "status":"HEALTH_WARN", + "checks":{ + "OSD_DOWN":{ + "severity":"HEALTH_WARN", + "summary":{"message":"1 osd down"}, + "detail":[{"message":"osd.1 is down"}] + } + } + }, + "monmap":{"epoch":7,"mons":[{"name":"a","rank":0,"addr":"10.0.0.1"}]}, + "mgrmap":{"available":true,"active_name":"mgr-a","standbys":[{"name":"mgr-b"}]}, + "osdmap":{"epoch":3,"num_osds":3,"num_up_osds":2,"num_in_osds":1}, + "pgmap":{ + "num_pgs":64, + "bytes_total":1000, + "bytes_used":250, + "bytes_avail":750, + "data_bytes":200, + "degraded_ratio":0.1, + "misplaced_ratio":0.2, + "read_bytes_sec":1, + "write_bytes_sec":2, + "read_op_per_sec":3, + "write_op_per_sec":4 + } + }`) + + status, err := parseStatus(data) + if err != nil { + t.Fatalf("parseStatus returned error: %v", err) + } + + if status.FSID != "fsid-123" { + t.Fatalf("expected FSID fsid-123, got %q", status.FSID) + } + if status.Health.Status != "HEALTH_WARN" { + t.Fatalf("expected HEALTH_WARN, got %q", status.Health.Status) + } + check, ok := status.Health.Checks["OSD_DOWN"] + if !ok || check.Severity != "HEALTH_WARN" || check.Message != "1 osd down" || len(check.Detail) != 1 { + t.Fatalf("unexpected parsed health checks: %+v", status.Health.Checks) + } + + if status.MonMap.NumMons != 1 || len(status.MonMap.Monitors) != 1 { + t.Fatalf("expected 1 monitor, got %+v", status.MonMap) + } + if status.OSDMap.NumOSDs != 3 || status.OSDMap.NumUp != 2 || status.OSDMap.NumIn != 1 { + t.Fatalf("unexpected OSD map: %+v", status.OSDMap) + } + if status.OSDMap.NumDown != 1 || status.OSDMap.NumOut != 2 { + t.Fatalf("expected computed down/out counts, got %+v", status.OSDMap) + } + + if status.PGMap.UsagePercent != 25.0 { + t.Fatalf("expected usage percent 25.0, got %v", status.PGMap.UsagePercent) + } + + if len(status.Services) != 3 { + t.Fatalf("expected 3 service summaries, got %d", len(status.Services)) + } +} + +func TestParseStatusInvalidJSON(t *testing.T) { + _, err := parseStatus([]byte(`{not-json}`)) + if err == nil { + t.Fatalf("expected error for invalid JSON") + } +} + +func TestParseDF(t *testing.T) { + data := []byte(`{ + "stats":{"total_bytes":1000,"total_used_bytes":123,"percent_used":0.1234}, + "pools":[ + {"id":1,"name":"pool-a","stats":{"bytes_used":10,"max_avail":90,"objects":7,"percent_used":0.2}} + ] + }`) + + pools, usagePercent, err := parseDF(data) + if err != nil { + t.Fatalf("parseDF returned error: %v", err) + } + if usagePercent != 12.34 { + t.Fatalf("expected percent_used 12.34, got %v", usagePercent) + } + if len(pools) != 1 || pools[0].Name != "pool-a" || pools[0].PercentUsed != 20.0 { + t.Fatalf("unexpected pools parsed: %+v", pools) + } +} + +func TestParseDFInvalidJSON(t *testing.T) { + _, _, err := parseDF([]byte(`{not-json}`)) + if err == nil { + t.Fatalf("expected error for invalid JSON") + } +} + +func TestBoolToInt(t *testing.T) { + if boolToInt(true) != 1 { + t.Fatalf("expected boolToInt(true)=1") + } + if boolToInt(false) != 0 { + t.Fatalf("expected boolToInt(false)=0") + } +} diff --git a/internal/metrics/store_test.go b/internal/metrics/store_test.go new file mode 100644 index 0000000..4869442 --- /dev/null +++ b/internal/metrics/store_test.go @@ -0,0 +1,101 @@ +package metrics + +import ( + "path/filepath" + "testing" + "time" +) + +func TestStoreWriteBatchAndQuery(t *testing.T) { + dir := t.TempDir() + cfg := DefaultConfig(dir) + cfg.DBPath = filepath.Join(dir, "metrics-test.db") + cfg.FlushInterval = time.Hour + cfg.RetentionRaw = 10 * time.Second + cfg.RetentionMinute = 20 * time.Second + cfg.RetentionHourly = 30 * time.Second + cfg.RetentionDaily = 40 * time.Second + + store, err := NewStore(cfg) + if err != nil { + t.Fatalf("NewStore returned error: %v", err) + } + defer store.Close() + + ts := time.Unix(1000, 0) + store.writeBatch([]bufferedMetric{ + {resourceType: "vm", resourceID: "vm-101", metricType: "cpu", value: 1.5, timestamp: ts}, + {resourceType: "vm", resourceID: "vm-101", metricType: "cpu", value: 2.5, timestamp: ts.Add(1 * time.Second)}, + }) + + points, err := store.Query("vm", "vm-101", "cpu", ts.Add(-1*time.Second), ts.Add(2*time.Second)) + if err != nil { + t.Fatalf("Query returned error: %v", err) + } + if len(points) != 2 { + t.Fatalf("expected 2 points, got %d", len(points)) + } + if points[0].Value != 1.5 || points[1].Value != 2.5 { + t.Fatalf("unexpected query values: %+v", points) + } + + all, err := store.QueryAll("vm", "vm-101", ts.Add(-1*time.Second), ts.Add(2*time.Second)) + if err != nil { + t.Fatalf("QueryAll returned error: %v", err) + } + if len(all["cpu"]) != 2 { + t.Fatalf("expected QueryAll to return 2 cpu points, got %+v", all) + } +} + +func TestStoreSelectTierAndStats(t *testing.T) { + dir := t.TempDir() + cfg := DefaultConfig(dir) + cfg.DBPath = filepath.Join(dir, "metrics-test.db") + cfg.FlushInterval = time.Hour + cfg.RetentionRaw = 10 * time.Second + cfg.RetentionMinute = 20 * time.Second + cfg.RetentionHourly = 30 * time.Second + cfg.RetentionDaily = 40 * time.Second + + store, err := NewStore(cfg) + if err != nil { + t.Fatalf("NewStore returned error: %v", err) + } + defer store.Close() + + if store.selectTier(5*time.Second) != TierRaw { + t.Fatalf("expected raw tier") + } + if store.selectTier(15*time.Second) != TierMinute { + t.Fatalf("expected minute tier") + } + if store.selectTier(25*time.Second) != TierHourly { + t.Fatalf("expected hourly tier") + } + if store.selectTier(35*time.Second) != TierDaily { + t.Fatalf("expected daily tier") + } + + // Insert one point for each tier to verify stats aggregation. + ts := int64(1000) + _, err = store.db.Exec( + `INSERT INTO metrics (resource_type, resource_id, metric_type, value, timestamp, tier) VALUES + ('vm','vm-101','cpu',1.0,?, 'raw'), + ('vm','vm-101','cpu',2.0,?, 'minute'), + ('vm','vm-101','cpu',3.0,?, 'hourly'), + ('vm','vm-101','cpu',4.0,?, 'daily')`, + ts, ts, ts, ts, + ) + if err != nil { + t.Fatalf("insert metrics returned error: %v", err) + } + + stats := store.GetStats() + if stats.RawCount != 1 || stats.MinuteCount != 1 || stats.HourlyCount != 1 || stats.DailyCount != 1 { + t.Fatalf("unexpected tier counts: %+v", stats) + } + if stats.DBPath == "" || stats.DBSize <= 0 { + t.Fatalf("expected stats DB info to be populated: %+v", stats) + } +}