Improve test coverage

This commit is contained in:
rcourtman 2025-12-17 12:00:59 +00:00
parent bacf6b5fc9
commit 0b717bfa44
14 changed files with 829 additions and 128 deletions

View file

@ -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 <password>")
os.Exit(1)
func run(args []string, out io.Writer) int {
if len(args) < 2 {
fmt.Fprintln(out, "Usage: hashpw <password>")
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))
}

36
cmd/hashpw/main_test.go Normal file
View file

@ -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 <password>") {
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")
}
}

Binary file not shown.

View file

@ -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",

View file

@ -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');
});
});

View file

@ -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)

View file

@ -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<string, string> = {
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<string, ModelInfo[]> {
const grouped = new Map<string, ModelInfo[]>();
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 <br>
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<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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: <raw error>" 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, unknown>): 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<AIChatProps> = (props) => {
// Read all context from store for proper SolidJS reactivity
const isOpen = () => aiChatStore.isOpen;

View file

@ -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)',
'',
'<script>alert("xss")</script>',
].join('\n')
);
expect(output).toContain('Hello');
expect(output).toContain('<a');
expect(output).toContain('href="https://example.com"');
expect(output).toContain('target="_blank"');
expect(output).toContain('rel="noopener noreferrer"');
expect(output).not.toContain('<script');
expect(output).not.toContain('alert("xss")');
});
it('escapes HTML entities if markdown parsing fails', () => {
const spy = vi.spyOn(marked, 'parse').mockImplementation(() => {
throw new Error('boom');
});
const output = utils.renderMarkdown(`a&b <c> "d" 'e'`);
expect(output).toBe('a&amp;b &lt;c&gt; &quot;d&quot; &#39;e&#39;');
spy.mockRestore();
});
});
});

View file

@ -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<string, string> = {
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<string, ModelInfo[]> {
const grouped = new Map<string, ModelInfo[]>();
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 <br>
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<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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: <raw error>" 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, unknown>): string | undefined => {
if (!context) return undefined;
if (typeof context.guestName === 'string') return context.guestName;
if (typeof context.name === 'string') return context.name;
return undefined;
};

View file

@ -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();
});
});

View file

@ -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")
}
}

View file

@ -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))
}
}

View file

@ -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")
}
}

View file

@ -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)
}
}