package tools import ( "context" "encoding/json" "strings" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) type stubAppContainerReadProvider struct { calls []AppContainerReadRequest result *AppContainerReadResult err error } func (s *stubAppContainerReadProvider) ReadLogs(_ context.Context, req AppContainerReadRequest) (*AppContainerReadResult, error) { s.calls = append(s.calls, req) if s.err != nil { return nil, s.err } if s.result == nil { return &AppContainerReadResult{ ResourceID: req.ResourceID, ProviderUID: req.ProviderUID, Name: req.Name, Host: req.Host, Platform: req.Platform, Container: req.Container, Lines: req.Lines, Output: "ok", }, nil } result := *s.result return &result, nil } func newCurrentResourceSystemContainerExecutor(t *testing.T, agentSrv AgentServer) *PulseToolExecutor { t.Helper() registry := unifiedresources.NewRegistry(nil) registry.IngestRecords(unifiedresources.SourceProxmox, []unifiedresources.IngestRecord{{ SourceID: "delly:delly:101", Resource: unifiedresources.Resource{ ID: "system-container-homeassistant", Type: unifiedresources.ResourceTypeSystemContainer, Name: "homeassistant", Status: unifiedresources.StatusOnline, Proxmox: &unifiedresources.ProxmoxData{ SourceID: "delly:delly:101", NodeName: "delly", Instance: "delly", VMID: 101, }, }, }}) provider := ®istryUnifiedQueryProvider{ResourceRegistry: registry} reg, ok := CanonicalHandoffResourceRegistration(provider, "delly:delly:101", "homeassistant", "system-container", "delly") require.True(t, ok, "expected system-container handoff registration") resolved := &mockResolvedContext{ resources: make(map[string]ResolvedResourceInfo), aliases: make(map[string]ResolvedResourceInfo), lastAccessed: make(map[string]time.Time), } resolved.AddResolvedResource(reg) resource, ok := resolved.GetResolvedResourceByAlias("homeassistant") require.True(t, ok, "expected homeassistant alias in resolved context") resolved.MarkExplicitAccess(resource.GetResourceID()) exec := NewPulseToolExecutor(ExecutorConfig{ AgentServer: agentSrv, ReadState: registry, }) exec.SetResolvedContext(resolved) return exec } func TestExecuteReadExec_CurrentResourceHandleRoutesToAttachedSystemContainer(t *testing.T) { t.Setenv("PULSE_STRICT_RESOLUTION", "true") agentSrv := &mockAgentServer{} agentSrv.On("GetConnectedAgents").Return([]agentexec.ConnectedAgent{ {AgentID: "agent-delly", Hostname: "delly"}, }).Maybe() agentSrv.On("ExecuteCommand", mock.Anything, "agent-delly", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { return payload.TargetType == "container" && payload.TargetID == "101" && payload.Command == "cat /etc/hostname" })).Return(&agentexec.CommandResultPayload{ Stdout: "homeassistant\n", ExitCode: 0, }, nil).Once() exec := newCurrentResourceSystemContainerExecutor(t, agentSrv) result, err := exec.executeReadExec(context.Background(), map[string]interface{}{ "action": "exec", "target_host": "current_resource", "command": "cat /etc/hostname", }) require.NoError(t, err) assert.False(t, result.IsError) require.NotEmpty(t, result.Content) assert.Contains(t, result.Content[0].Text, "homeassistant") agentSrv.AssertExpectations(t) } func TestExecuteReadExec_RedactedPolicyTargetUsesAttachedCurrentResource(t *testing.T) { t.Setenv("PULSE_STRICT_RESOLUTION", "true") agentSrv := &mockAgentServer{} agentSrv.On("GetConnectedAgents").Return([]agentexec.ConnectedAgent{ {AgentID: "agent-delly", Hostname: "delly"}, }).Maybe() agentSrv.On("ExecuteCommand", mock.Anything, "agent-delly", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { return payload.TargetType == "container" && payload.TargetID == "101" && payload.Command == "cat /etc/hostname" })).Return(&agentexec.CommandResultPayload{ Stdout: "homeassistant\n", ExitCode: 0, }, nil).Once() exec := newCurrentResourceSystemContainerExecutor(t, agentSrv) result, err := exec.executeReadExec(context.Background(), map[string]interface{}{ "action": "exec", "target_host": "redacted by policy", "command": "cat /etc/hostname", }) require.NoError(t, err) assert.False(t, result.IsError) require.NotEmpty(t, result.Content) assert.Contains(t, result.Content[0].Text, "homeassistant") agentSrv.AssertExpectations(t) } func TestExecuteGetResource_CurrentResourceHandleUsesAttachedResource(t *testing.T) { exec := newCurrentResourceSystemContainerExecutor(t, nil) result, err := exec.executeGetResource(context.Background(), map[string]interface{}{ "resource_id": "current_resource", }) require.NoError(t, err) assert.False(t, result.IsError) require.NotEmpty(t, result.Content) assert.Contains(t, result.Content[0].Text, "homeassistant") assert.NotContains(t, result.Content[0].Text, "not_found") } func TestPulseToolExecutor_ExecuteReadLogs_Fallbacks(t *testing.T) { ctx := context.Background() t.Run("DockerSourceWithoutContainerFallsBackToDockerPs", func(t *testing.T) { t.Setenv("PULSE_STRICT_RESOLUTION", "false") agentSrv := &mockAgentServer{} agentSrv.On("GetConnectedAgents").Return([]agentexec.ConnectedAgent{ {AgentID: "agent1", Hostname: "node1"}, }) agentSrv.On("ExecuteCommand", mock.Anything, "agent1", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { return payload.TargetType == "agent" && payload.TargetID == "" && strings.Contains(payload.Command, "docker ps --format") && strings.Contains(payload.Command, "head -20") })).Return(&agentexec.CommandResultPayload{ Stdout: "container-a\tUp 5h", ExitCode: 0, }, nil).Once() exec := NewPulseToolExecutor(ExecutorConfig{AgentServer: agentSrv}) result, err := exec.executeReadLogs(ctx, map[string]interface{}{ "action": "logs", "source": "docker", "target_host": "node1", }) require.NoError(t, err) assert.False(t, result.IsError) require.NotEmpty(t, result.Content) assert.Contains(t, result.Content[0].Text, "container-a") agentSrv.AssertExpectations(t) }) t.Run("JournalSourceWithoutUnitFallsBackToGlobalJournal", func(t *testing.T) { t.Setenv("PULSE_STRICT_RESOLUTION", "false") agentSrv := &mockAgentServer{} agentSrv.On("GetConnectedAgents").Return([]agentexec.ConnectedAgent{ {AgentID: "agent1", Hostname: "node1"}, }) agentSrv.On("ExecuteCommand", mock.Anything, "agent1", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { return payload.TargetType == "agent" && payload.TargetID == "" && payload.Command == "journalctl --since '1h' -n 50 --no-pager" })).Return(&agentexec.CommandResultPayload{ Stdout: "journal output", ExitCode: 0, }, nil).Once() exec := NewPulseToolExecutor(ExecutorConfig{AgentServer: agentSrv}) result, err := exec.executeReadLogs(ctx, map[string]interface{}{ "action": "logs", "source": "journal", "target_host": "node1", "since": "1h", "lines": 50, }) require.NoError(t, err) assert.False(t, result.IsError) require.NotEmpty(t, result.Content) assert.Contains(t, result.Content[0].Text, "journal output") agentSrv.AssertExpectations(t) }) t.Run("MissingSourceInfersDockerAndUnknownSourceFallsBackToJournal", func(t *testing.T) { t.Setenv("PULSE_STRICT_RESOLUTION", "false") agentSrv := &mockAgentServer{} agentSrv.On("GetConnectedAgents").Return([]agentexec.ConnectedAgent{ {AgentID: "agent1", Hostname: "node1"}, }) agentSrv.On("ExecuteCommand", mock.Anything, "agent1", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { return payload.Command == "docker logs --tail 100 'homepage'" && payload.TargetType == "agent" })).Return(&agentexec.CommandResultPayload{ Stdout: "docker log line", ExitCode: 0, }, nil).Once() agentSrv.On("ExecuteCommand", mock.Anything, "agent1", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { return payload.Command == "journalctl -n 30 --no-pager" && payload.TargetType == "agent" })).Return(&agentexec.CommandResultPayload{ Stdout: "journal fallback line", ExitCode: 0, }, nil).Once() exec := NewPulseToolExecutor(ExecutorConfig{AgentServer: agentSrv}) result, err := exec.executeReadLogs(ctx, map[string]interface{}{ "action": "logs", "target_host": "node1", "container": "homepage", }) require.NoError(t, err) assert.False(t, result.IsError) require.NotEmpty(t, result.Content) assert.Contains(t, result.Content[0].Text, "docker log line") result, err = exec.executeReadLogs(ctx, map[string]interface{}{ "action": "logs", "source": "syslog", "target_host": "node1", "lines": 30, }) require.NoError(t, err) assert.False(t, result.IsError) require.NotEmpty(t, result.Content) assert.Contains(t, result.Content[0].Text, "journal fallback line") agentSrv.AssertExpectations(t) }) } func TestPulseToolExecutor_ExecuteReadRejectsLegacyAppContainerArg(t *testing.T) { exec := NewPulseToolExecutor(ExecutorConfig{}) result, err := exec.executeReadExec(context.Background(), map[string]interface{}{ "action": "exec", "command": "uptime", "target_host": "node1", "app_container": "homepage", }) require.NoError(t, err) assert.True(t, result.IsError) require.NotEmpty(t, result.Content) assert.Contains(t, result.Content[0].Text, "app_container is no longer supported; use app-container") } func TestPulseToolExecutor_ListTools_IncludesPulseReadForNativeAppReadProvider(t *testing.T) { provider := newTrueNASUnifiedQueryProvider(t) exec := NewPulseToolExecutor(ExecutorConfig{ UnifiedResourceProvider: provider, ReadState: provider.ResourceRegistry, AppContainerReadProvider: &stubAppContainerReadProvider{}, }) tools := exec.ListTools() assert.True(t, containsTool(tools, "pulse_read")) } func TestExecuteReadLogs_TrueNASAppUsesNativeReadProvider(t *testing.T) { provider := newTrueNASUnifiedQueryProvider(t) resolved := &mockResolvedContext{ resources: make(map[string]ResolvedResourceInfo), aliases: make(map[string]ResolvedResourceInfo), } readProvider := &stubAppContainerReadProvider{ result: &AppContainerReadResult{ ResourceID: "app-container:truenas-main:nextcloud", ProviderUID: "nextcloud", Name: "Nextcloud", Host: "truenas-main", Platform: "truenas", Container: "nextcloud", Lines: 25, Output: "2026-03-29T18:00:00Z ready\n2026-03-29T18:01:00Z serving", }, } exec := NewPulseToolExecutor(ExecutorConfig{ UnifiedResourceProvider: provider, ReadState: provider.ResourceRegistry, AppContainerReadProvider: readProvider, }) exec.SetResolvedContext(resolved) if _, err := exec.executeGetResource(context.Background(), map[string]interface{}{ "resource_type": "app-container", "resource_id": "nextcloud", }); err != nil { t.Fatalf("seed resolved context: unexpected error: %v", err) } result, err := exec.executeReadLogs(context.Background(), map[string]interface{}{ "action": "logs", "resource_id": "Nextcloud", "container": "nextcloud", "lines": 25, }) require.NoError(t, err) assert.False(t, result.IsError) require.NotEmpty(t, result.Content) assert.Contains(t, result.Content[0].Text, "Logs from app 'Nextcloud' (container 'nextcloud') (last 25 lines):") assert.Contains(t, result.Content[0].Text, "serving") if len(readProvider.calls) != 1 { t.Fatalf("expected one native app read call, got %+v", readProvider.calls) } call := readProvider.calls[0] if call.OrgID != "default" || call.ProviderUID != "nextcloud" || call.Host != "truenas-main" || call.Container != "nextcloud" || call.Lines != 25 { t.Fatalf("unexpected native app read request: %+v", call) } } func TestExecuteReadLogs_VMwareResourcesReturnStructuredQueryGuidance(t *testing.T) { tests := []struct { name string resourceType string resourceID string resourceRef string wantKind string }{ {name: "vm", resourceType: "vm", resourceID: "app-01", resourceRef: "app-01", wantKind: "vm"}, {name: "agent", resourceType: "agent", resourceID: "esxi-01.lab.local", resourceRef: "esxi-01.lab.local", wantKind: "agent"}, {name: "storage", resourceType: "storage", resourceID: "nvme-primary", resourceRef: "nvme-primary", wantKind: "storage"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { provider := newVMwareUnifiedQueryProvider(t) resolved := &mockResolvedContext{ resources: make(map[string]ResolvedResourceInfo), aliases: make(map[string]ResolvedResourceInfo), } exec := NewPulseToolExecutor(ExecutorConfig{ UnifiedResourceProvider: provider, ReadState: provider.ResourceRegistry, }) exec.SetResolvedContext(resolved) _, err := exec.executeGetResource(context.Background(), map[string]interface{}{ "resource_type": tc.resourceType, "resource_id": tc.resourceID, }) require.NoError(t, err) result, err := exec.executeReadLogs(context.Background(), map[string]interface{}{ "action": "logs", "resource_id": tc.resourceRef, }) require.NoError(t, err) assert.True(t, result.IsError) payload := decodeToolResponse(t, result) require.NotNil(t, payload.Error) assert.Equal(t, ErrCodeActionNotAllowed, payload.Error.Code) assert.True(t, payload.Error.Blocked) assert.Contains(t, payload.Error.Message, "does not expose native logs through pulse_read") assert.Equal(t, tc.wantKind, payload.Error.Details["resource_type"]) assert.Equal(t, "vmware-vsphere", payload.Error.Details["adapter"]) assert.NotContains(t, payload.Error.Details, "auto_recoverable") assert.NotContains(t, payload.Error.Details, "suggested_tool") assert.NotContains(t, payload.Error.Details, "suggested_arguments") policy, ok := payload.Error.Details["policy_boundary"].(string) require.True(t, ok) assert.Contains(t, policy, `Status, alerts, activity, and metrics are addressed`) assert.Contains(t, policy, `resource_type="`+tc.wantKind+`"`) assert.Equal(t, tc.wantKind, payload.Error.Details["query_resource_type"]) }) } } func TestExecuteReadLogs_NonNativeAppContainersReturnStructuredTargetHostGuidance(t *testing.T) { resolved := &mockResolvedContext{ resources: make(map[string]ResolvedResourceInfo), aliases: make(map[string]ResolvedResourceInfo), } resource := &mockResource{ resourceID: "app-container:docker-host:homepage", resourceType: "app-container", targetHost: "docker-host", adapter: "docker", allowedActions: []string{"query", "get", "start", "stop", "restart"}, providerUID: "homepage", kind: "app-container", aliases: []string{"homepage"}, } resolved.resources[resource.resourceID] = resource resolved.aliases["homepage"] = resource exec := NewPulseToolExecutor(ExecutorConfig{}) exec.SetResolvedContext(resolved) result, err := exec.executeReadLogs(context.Background(), map[string]interface{}{ "action": "logs", "resource_id": "homepage", }) require.NoError(t, err) assert.True(t, result.IsError) payload := decodeToolResponse(t, result) require.NotNil(t, payload.Error) assert.Equal(t, ErrCodeActionNotAllowed, payload.Error.Code) assert.True(t, payload.Error.Blocked) assert.Contains(t, payload.Error.Message, "does not expose native app logs through pulse_read resource_id") assert.Equal(t, "app-container", payload.Error.Details["resource_type"]) assert.Equal(t, "docker-host", payload.Error.Details["target_host"]) assert.Equal(t, "homepage", payload.Error.Details["container"]) assert.NotContains(t, payload.Error.Details, "auto_recoverable") assert.NotContains(t, payload.Error.Details, "suggested_tool") assert.NotContains(t, payload.Error.Details, "suggested_arguments") policy, ok := payload.Error.Details["policy_boundary"].(string) require.True(t, ok) assert.Contains(t, policy, `target_host="docker-host"`) assert.Contains(t, policy, `container="homepage"`) } func decodeToolResponse(t *testing.T, result CallToolResult) ToolResponse { t.Helper() require.NotEmpty(t, result.Content) var payload ToolResponse require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &payload)) return payload }