Pulse/internal/ai/tools/tools_file_test.go
2026-03-18 16:06:30 +00:00

159 lines
5.2 KiB
Go

package tools
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/stretchr/testify/assert"
)
func TestFileTools_Registry(t *testing.T) {
exec := NewPulseToolExecutor(ExecutorConfig{})
exec.registerFileTools()
tools := exec.registry.ListTools(ControlLevelControlled)
found := false
for _, tool := range tools {
if tool.Name == "pulse_file_edit" {
found = true
break
}
}
assert.True(t, found)
}
func TestExecuteFileEdit_Validation(t *testing.T) {
exec := NewPulseToolExecutor(ExecutorConfig{})
tests := []struct {
name string
args map[string]interface{}
wantErr string
}{
{
name: "Missing Path",
args: map[string]interface{}{"action": "read", "target_host": "h1"},
wantErr: "path is required",
},
{
name: "Missing TargetHost",
args: map[string]interface{}{"action": "read", "path": "/etc/config"},
wantErr: "target_host is required",
},
{
name: "Relative Path",
args: map[string]interface{}{"action": "read", "path": "etc/config", "target_host": "h1"},
wantErr: "path must be absolute",
},
{
name: "Invalid Docker Container",
args: map[string]interface{}{"action": "read", "path": "/f", "target_host": "h1", "container": "bad name!"},
wantErr: "invalid character",
},
{
name: "Legacy AppContainer Rejected",
args: map[string]interface{}{"action": "read", "path": "/f", "target_host": "h1", "app_container": "nginx"},
wantErr: "app_container is no longer supported; use app-container",
},
{
name: "Unknown Action",
args: map[string]interface{}{"action": "dance", "path": "/f", "target_host": "h1"},
wantErr: "unknown action",
},
{
name: "Append Missing Content",
args: map[string]interface{}{"action": "append", "path": "/f", "target_host": "h1"},
wantErr: "content is required",
},
{
name: "Write Missing Content",
args: map[string]interface{}{"action": "write", "path": "/f", "target_host": "h1"},
wantErr: "content is required",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
res, err := exec.executeFileEdit(context.Background(), tc.args)
// Implementation returns error result (NewErrorResult), err is usually nil
assert.NoError(t, err)
assert.True(t, res.IsError)
assert.NotEmpty(t, res.Content)
assert.Contains(t, res.Content[0].Text, tc.wantErr)
})
}
}
func TestValidateWriteExecutionContext_Blocked(t *testing.T) {
// This tests the security invariant: don't write to host when target is LXC
state := models.StateSnapshot{
Containers: []models.Container{
{VMID: 100, Name: "my-lxc", Node: "pve1", Status: "running"},
},
Nodes: []models.Node{
{Name: "pve1"},
},
}
stateProvider := &mockStateProvider{state: state}
agentServer := &mockAgentServer{}
agentServer.agents = []agentexec.ConnectedAgent{
{AgentID: "agent-1", Hostname: "my-lxc"}, // Agent hostname matches LXC name! Risks confusion.
}
exec := NewPulseToolExecutor(ExecutorConfig{
AgentServer: agentServer,
StateProvider: stateProvider,
})
// We expect resolveTargetForCommandFull to map "my-lxc" to agent-1
// But since agent-1 matches hostname "my-lxc" directly, it might be resolved as "direct".
// Wait, resolveTargetForCommandFull logic logic isn't mocked, it's real code in executor.go using state.
// If ResolveResource says it's an LXC on pve1.
// And FindAgent says agent-1 (hostname=my-lxc) exists.
// The fallback logic in resolveTarget might pick agent-1 if it matches hostname directly?
// Actually, `resolveTargetForCommandFull` calls `ResolveResource`.
// Then checks if agent exists for that resource.
// If I want to trigger the BLOCK, I need:
// 1. ResolveResource returns LXC.
// 2. Routing returns "direct" transport on "agent" type.
// This happens if `findAgent` returns an agent that is NOT the node agent?
// Or if the node agent is found via hostname match of the LXC name?
// Let's manually invoke validateWriteExecutionContext to test logic in isolation.
err := exec.validateWriteExecutionContext("my-lxc", CommandRoutingResult{
Transport: "direct",
TargetType: "agent", // Agent matched directly as an agent target
AgentHostname: "my-lxc",
AgentID: "agent-1",
ResolvedKind: "system-container", // But resolving matched system container
ResolvedNode: "pve1",
})
assert.NotNil(t, err)
assert.Contains(t, err.Message, "write would execute on the host node instead of inside the system-container")
}
func TestFormatFileApprovalNeeded_JSONSafe(t *testing.T) {
formatted := formatFileApprovalNeeded(`/tmp/"cfg".json`, `host"name`, "write", 42, "approval-1")
assert.True(t, strings.HasPrefix(formatted, "APPROVAL_REQUIRED: "))
raw := strings.TrimPrefix(formatted, "APPROVAL_REQUIRED: ")
var payload map[string]interface{}
assert.NoError(t, json.Unmarshal([]byte(raw), &payload))
assert.Equal(t, "approval_required", payload["type"])
assert.Equal(t, "file_write", payload["action"])
assert.Equal(t, `/tmp/"cfg".json`, payload["path"])
assert.Equal(t, `host"name`, payload["host"])
assert.Equal(t, float64(42), payload["size"])
}