Improve test coverage
This commit is contained in:
parent
bacf6b5fc9
commit
0b717bfa44
14 changed files with 829 additions and 128 deletions
|
|
@ -2,23 +2,29 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func run(args []string, out io.Writer) int {
|
||||||
if len(os.Args) < 2 {
|
if len(args) < 2 {
|
||||||
fmt.Println("Usage: hashpw <password>")
|
fmt.Fprintln(out, "Usage: hashpw <password>")
|
||||||
os.Exit(1)
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
password := os.Args[1]
|
password := args[1]
|
||||||
hash, err := auth.HashPassword(password)
|
hash, err := auth.HashPassword(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error: %v\n", err)
|
fmt.Fprintf(out, "Error: %v\n", err)
|
||||||
os.Exit(1)
|
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
36
cmd/hashpw/main_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend-modern/package-lock.json
generated
BIN
frontend-modern/package-lock.json
generated
Binary file not shown.
|
|
@ -19,6 +19,8 @@
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"generate-types": "cd ../scripts && go run generate-types.go",
|
"generate-types": "cd ../scripts && go run generate-types.go",
|
||||||
"test": "vitest run",
|
"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",
|
"type-check": "tsc --noEmit",
|
||||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||||
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
||||||
|
|
@ -39,6 +41,7 @@
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||||
"@typescript-eslint/parser": "^8.0.0",
|
"@typescript-eslint/parser": "^8.0.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
|
|
||||||
130
frontend-modern/src/api/__tests__/ai.test.ts
Normal file
130
frontend-modern/src/api/__tests__/ai.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -141,7 +141,7 @@ export class AIAPI {
|
||||||
target_type: string;
|
target_type: string;
|
||||||
target_id: string;
|
target_id: string;
|
||||||
run_on_host: boolean;
|
run_on_host: boolean;
|
||||||
vmid?: string;
|
vmid?: string | number;
|
||||||
target_host?: string; // Explicit host for command routing
|
target_host?: string; // Explicit host for command routing
|
||||||
}): Promise<{ output: string; success: boolean; error?: string }> {
|
}): Promise<{ output: string; success: boolean; error?: string }> {
|
||||||
// Ensure run_on_host is explicitly a boolean (not undefined)
|
// Ensure run_on_host is explicitly a boolean (not undefined)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
import { Component, Show, createSignal, For, createEffect, createMemo, onMount, Switch, Match } from 'solid-js';
|
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 { AIAPI } from '@/api/ai';
|
||||||
import { notificationStore } from '@/stores/notifications';
|
import { notificationStore } from '@/stores/notifications';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { aiChatStore } from '@/stores/aiChat';
|
import { aiChatStore } from '@/stores/aiChat';
|
||||||
import { useWebSocket } from '@/App';
|
import { useWebSocket } from '@/App';
|
||||||
import { GuestNotes } from './GuestNotes';
|
import { GuestNotes } from './GuestNotes';
|
||||||
|
import {
|
||||||
|
PROVIDER_DISPLAY_NAMES,
|
||||||
|
getGuestName,
|
||||||
|
groupModelsByProvider,
|
||||||
|
renderMarkdown,
|
||||||
|
sanitizeThinking,
|
||||||
|
} from './aiChatUtils';
|
||||||
import type {
|
import type {
|
||||||
AIToolExecution,
|
AIToolExecution,
|
||||||
AIStreamEvent,
|
AIStreamEvent,
|
||||||
|
|
@ -17,115 +22,6 @@ import type {
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
} from '@/types/ai';
|
} 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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
||||||
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)
|
// In-progress tool execution (before completion)
|
||||||
interface PendingTool {
|
interface PendingTool {
|
||||||
|
|
@ -173,14 +69,6 @@ interface AIChatProps {
|
||||||
onClose: () => void;
|
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) => {
|
export const AIChat: Component<AIChatProps> = (props) => {
|
||||||
// Read all context from store for proper SolidJS reactivity
|
// Read all context from store for proper SolidJS reactivity
|
||||||
const isOpen = () => aiChatStore.isOpen;
|
const isOpen = () => aiChatStore.isOpen;
|
||||||
|
|
|
||||||
104
frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts
Normal file
104
frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts
Normal 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&b <c> "d" 'e'');
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
123
frontend-modern/src/components/AI/aiChatUtils.ts
Normal file
123
frontend-modern/src/components/AI/aiChatUtils.ts
Normal 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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||||
|
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;
|
||||||
|
};
|
||||||
98
frontend-modern/src/stores/__tests__/aiChat.test.ts
Normal file
98
frontend-modern/src/stores/__tests__/aiChat.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
48
internal/agentexec/policy_test.go
Normal file
48
internal/agentexec/policy_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
50
internal/agentexec/server_test.go
Normal file
50
internal/agentexec/server_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
114
internal/ceph/collector_test.go
Normal file
114
internal/ceph/collector_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
101
internal/metrics/store_test.go
Normal file
101
internal/metrics/store_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue