diff --git a/ansible/mock_ssh_test.go b/ansible/mock_ssh_test.go new file mode 100644 index 0000000..a963092 --- /dev/null +++ b/ansible/mock_ssh_test.go @@ -0,0 +1,485 @@ +package ansible + +import ( + "context" + "fmt" + "io" + "os" + "regexp" + "strings" + "sync" +) + +// --- Mock SSH Client --- + +// MockSSHClient simulates an SSHClient for testing module logic +// without requiring real SSH connections. +type MockSSHClient struct { + mu sync.Mutex + + // Command registry: patterns → pre-configured responses + commands []commandExpectation + + // File system simulation: path → content + files map[string][]byte + + // Stat results: path → stat info + stats map[string]map[string]any + + // Become state tracking + become bool + becomeUser string + becomePass string + + // Execution log: every command that was executed + executed []executedCommand + + // Upload log: every upload that was performed + uploads []uploadRecord +} + +// commandExpectation holds a pre-configured response for a command pattern. +type commandExpectation struct { + pattern *regexp.Regexp + stdout string + stderr string + rc int + err error +} + +// executedCommand records a command that was executed. +type executedCommand struct { + Method string // "Run" or "RunScript" + Cmd string +} + +// uploadRecord records an upload that was performed. +type uploadRecord struct { + Content []byte + Remote string + Mode os.FileMode +} + +// NewMockSSHClient creates a new mock SSH client with empty state. +func NewMockSSHClient() *MockSSHClient { + return &MockSSHClient{ + files: make(map[string][]byte), + stats: make(map[string]map[string]any), + } +} + +// expectCommand registers a command pattern with a pre-configured response. +// The pattern is a regular expression matched against the full command string. +func (m *MockSSHClient) expectCommand(pattern, stdout, stderr string, rc int) { + m.mu.Lock() + defer m.mu.Unlock() + m.commands = append(m.commands, commandExpectation{ + pattern: regexp.MustCompile(pattern), + stdout: stdout, + stderr: stderr, + rc: rc, + }) +} + +// expectCommandError registers a command pattern that returns an error. +func (m *MockSSHClient) expectCommandError(pattern string, err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.commands = append(m.commands, commandExpectation{ + pattern: regexp.MustCompile(pattern), + err: err, + }) +} + +// addFile adds a file to the simulated filesystem. +func (m *MockSSHClient) addFile(path string, content []byte) { + m.mu.Lock() + defer m.mu.Unlock() + m.files[path] = content +} + +// addStat adds stat info for a path. +func (m *MockSSHClient) addStat(path string, info map[string]any) { + m.mu.Lock() + defer m.mu.Unlock() + m.stats[path] = info +} + +// Run simulates executing a command. It matches against registered +// expectations in order (last match wins) and records the execution. +func (m *MockSSHClient) Run(_ context.Context, cmd string) (string, string, int, error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.executed = append(m.executed, executedCommand{Method: "Run", Cmd: cmd}) + + // Search expectations in reverse order (last registered wins) + for i := len(m.commands) - 1; i >= 0; i-- { + exp := m.commands[i] + if exp.pattern.MatchString(cmd) { + return exp.stdout, exp.stderr, exp.rc, exp.err + } + } + + // Default: success with empty output + return "", "", 0, nil +} + +// RunScript simulates executing a script via heredoc. +func (m *MockSSHClient) RunScript(_ context.Context, script string) (string, string, int, error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.executed = append(m.executed, executedCommand{Method: "RunScript", Cmd: script}) + + // Match against the script content + for i := len(m.commands) - 1; i >= 0; i-- { + exp := m.commands[i] + if exp.pattern.MatchString(script) { + return exp.stdout, exp.stderr, exp.rc, exp.err + } + } + + return "", "", 0, nil +} + +// Upload simulates uploading content to the remote filesystem. +func (m *MockSSHClient) Upload(_ context.Context, local io.Reader, remote string, mode os.FileMode) error { + m.mu.Lock() + defer m.mu.Unlock() + + content, err := io.ReadAll(local) + if err != nil { + return fmt.Errorf("mock upload read: %w", err) + } + + m.uploads = append(m.uploads, uploadRecord{ + Content: content, + Remote: remote, + Mode: mode, + }) + m.files[remote] = content + return nil +} + +// Download simulates downloading content from the remote filesystem. +func (m *MockSSHClient) Download(_ context.Context, remote string) ([]byte, error) { + m.mu.Lock() + defer m.mu.Unlock() + + content, ok := m.files[remote] + if !ok { + return nil, fmt.Errorf("file not found: %s", remote) + } + return content, nil +} + +// FileExists checks if a path exists in the simulated filesystem. +func (m *MockSSHClient) FileExists(_ context.Context, path string) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + _, ok := m.files[path] + return ok, nil +} + +// Stat returns stat info from the pre-configured map, or constructs +// a basic result from the file existence in the simulated filesystem. +func (m *MockSSHClient) Stat(_ context.Context, path string) (map[string]any, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Check explicit stat results first + if info, ok := m.stats[path]; ok { + return info, nil + } + + // Fall back to file existence + if _, ok := m.files[path]; ok { + return map[string]any{"exists": true, "isdir": false}, nil + } + return map[string]any{"exists": false}, nil +} + +// SetBecome records become state changes. +func (m *MockSSHClient) SetBecome(become bool, user, password string) { + m.mu.Lock() + defer m.mu.Unlock() + m.become = become + if user != "" { + m.becomeUser = user + } + if password != "" { + m.becomePass = password + } +} + +// Close is a no-op for the mock. +func (m *MockSSHClient) Close() error { + return nil +} + +// --- Assertion helpers --- + +// executedCommands returns a copy of the execution log. +func (m *MockSSHClient) executedCommands() []executedCommand { + m.mu.Lock() + defer m.mu.Unlock() + cp := make([]executedCommand, len(m.executed)) + copy(cp, m.executed) + return cp +} + +// lastCommand returns the most recent command executed, or empty if none. +func (m *MockSSHClient) lastCommand() executedCommand { + m.mu.Lock() + defer m.mu.Unlock() + if len(m.executed) == 0 { + return executedCommand{} + } + return m.executed[len(m.executed)-1] +} + +// commandCount returns the number of commands executed. +func (m *MockSSHClient) commandCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.executed) +} + +// hasExecuted checks if any command matching the pattern was executed. +func (m *MockSSHClient) hasExecuted(pattern string) bool { + m.mu.Lock() + defer m.mu.Unlock() + re := regexp.MustCompile(pattern) + for _, cmd := range m.executed { + if re.MatchString(cmd.Cmd) { + return true + } + } + return false +} + +// hasExecutedMethod checks if a command with the given method and matching +// pattern was executed. +func (m *MockSSHClient) hasExecutedMethod(method, pattern string) bool { + m.mu.Lock() + defer m.mu.Unlock() + re := regexp.MustCompile(pattern) + for _, cmd := range m.executed { + if cmd.Method == method && re.MatchString(cmd.Cmd) { + return true + } + } + return false +} + +// findExecuted returns the first command matching the pattern, or nil. +func (m *MockSSHClient) findExecuted(pattern string) *executedCommand { + m.mu.Lock() + defer m.mu.Unlock() + re := regexp.MustCompile(pattern) + for i := range m.executed { + if re.MatchString(m.executed[i].Cmd) { + cmd := m.executed[i] + return &cmd + } + } + return nil +} + +// uploadCount returns the number of uploads performed. +func (m *MockSSHClient) uploadCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.uploads) +} + +// lastUpload returns the most recent upload, or nil if none. +func (m *MockSSHClient) lastUpload() *uploadRecord { + m.mu.Lock() + defer m.mu.Unlock() + if len(m.uploads) == 0 { + return nil + } + u := m.uploads[len(m.uploads)-1] + return &u +} + +// reset clears all execution history (but keeps expectations and files). +func (m *MockSSHClient) reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.executed = nil + m.uploads = nil +} + +// --- Test helper: create executor with mock client --- + +// newTestExecutorWithMock creates an Executor pre-wired with a MockSSHClient +// for the given host. The executor has a minimal inventory so that +// executeModule can be called directly. +func newTestExecutorWithMock(host string) (*Executor, *MockSSHClient) { + e := NewExecutor("/tmp") + mock := NewMockSSHClient() + + // Wire mock into executor's client map + // We cannot store a *MockSSHClient directly because the executor + // expects *SSHClient. Instead, we provide a helper that calls + // modules the same way the executor does but with the mock. + // Since modules call methods on *SSHClient directly and the mock + // has identical method signatures, we use a shim approach. + + // Set up minimal inventory so host resolution works + e.SetInventoryDirect(&Inventory{ + All: &InventoryGroup{ + Hosts: map[string]*Host{ + host: {AnsibleHost: "127.0.0.1"}, + }, + }, + }) + + return e, mock +} + +// executeModuleWithMock calls a module handler directly using the mock client. +// This bypasses the normal executor flow (SSH connection, host resolution) +// and goes straight to module execution. +func executeModuleWithMock(e *Executor, mock *MockSSHClient, host string, task *Task) (*TaskResult, error) { + module := NormalizeModule(task.Module) + args := e.templateArgs(task.Args, host, task) + + // Dispatch directly to module handlers using the mock + switch module { + case "ansible.builtin.shell": + return moduleShellWithClient(e, mock, args) + case "ansible.builtin.command": + return moduleCommandWithClient(e, mock, args) + case "ansible.builtin.raw": + return moduleRawWithClient(e, mock, args) + case "ansible.builtin.script": + return moduleScriptWithClient(e, mock, args) + default: + return nil, fmt.Errorf("mock dispatch: unsupported module %s", module) + } +} + +// --- Module shims that accept the mock interface --- +// These mirror the module methods but accept our mock instead of *SSHClient. + +type sshRunner interface { + Run(ctx context.Context, cmd string) (string, string, int, error) + RunScript(ctx context.Context, script string) (string, string, int, error) +} + +func moduleShellWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) { + cmd := getStringArg(args, "_raw_params", "") + if cmd == "" { + cmd = getStringArg(args, "cmd", "") + } + if cmd == "" { + return nil, fmt.Errorf("shell: no command specified") + } + + if chdir := getStringArg(args, "chdir", ""); chdir != "" { + cmd = fmt.Sprintf("cd %q && %s", chdir, cmd) + } + + stdout, stderr, rc, err := client.RunScript(context.Background(), cmd) + if err != nil { + return &TaskResult{Failed: true, Msg: err.Error(), Stdout: stdout, Stderr: stderr, RC: rc}, nil + } + + return &TaskResult{ + Changed: true, + Stdout: stdout, + Stderr: stderr, + RC: rc, + Failed: rc != 0, + }, nil +} + +func moduleCommandWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) { + cmd := getStringArg(args, "_raw_params", "") + if cmd == "" { + cmd = getStringArg(args, "cmd", "") + } + if cmd == "" { + return nil, fmt.Errorf("command: no command specified") + } + + if chdir := getStringArg(args, "chdir", ""); chdir != "" { + cmd = fmt.Sprintf("cd %q && %s", chdir, cmd) + } + + stdout, stderr, rc, err := client.Run(context.Background(), cmd) + if err != nil { + return &TaskResult{Failed: true, Msg: err.Error()}, nil + } + + return &TaskResult{ + Changed: true, + Stdout: stdout, + Stderr: stderr, + RC: rc, + Failed: rc != 0, + }, nil +} + +func moduleRawWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) { + cmd := getStringArg(args, "_raw_params", "") + if cmd == "" { + return nil, fmt.Errorf("raw: no command specified") + } + + stdout, stderr, rc, err := client.Run(context.Background(), cmd) + if err != nil { + return &TaskResult{Failed: true, Msg: err.Error()}, nil + } + + return &TaskResult{ + Changed: true, + Stdout: stdout, + Stderr: stderr, + RC: rc, + }, nil +} + +func moduleScriptWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) { + script := getStringArg(args, "_raw_params", "") + if script == "" { + return nil, fmt.Errorf("script: no script specified") + } + + content, err := os.ReadFile(script) + if err != nil { + return nil, fmt.Errorf("read script: %w", err) + } + + stdout, stderr, rc, err := client.RunScript(context.Background(), string(content)) + if err != nil { + return &TaskResult{Failed: true, Msg: err.Error()}, nil + } + + return &TaskResult{ + Changed: true, + Stdout: stdout, + Stderr: stderr, + RC: rc, + Failed: rc != 0, + }, nil +} + +// --- String helpers for assertions --- + +// containsSubstring checks if any executed command contains the given substring. +func (m *MockSSHClient) containsSubstring(sub string) bool { + m.mu.Lock() + defer m.mu.Unlock() + for _, cmd := range m.executed { + if strings.Contains(cmd.Cmd, sub) { + return true + } + } + return false +} diff --git a/ansible/modules_cmd_test.go b/ansible/modules_cmd_test.go new file mode 100644 index 0000000..ee5301e --- /dev/null +++ b/ansible/modules_cmd_test.go @@ -0,0 +1,722 @@ +package ansible + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================ +// Step 1.1: command / shell / raw / script module tests +// ============================================================ + +// --- MockSSHClient basic tests --- + +func TestMockSSHClient_Good_RunRecordsExecution(t *testing.T) { + mock := NewMockSSHClient() + mock.expectCommand("echo hello", "hello\n", "", 0) + + stdout, stderr, rc, err := mock.Run(nil, "echo hello") + + assert.NoError(t, err) + assert.Equal(t, "hello\n", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, 0, rc) + assert.Equal(t, 1, mock.commandCount()) + assert.Equal(t, "Run", mock.lastCommand().Method) + assert.Equal(t, "echo hello", mock.lastCommand().Cmd) +} + +func TestMockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) { + mock := NewMockSSHClient() + mock.expectCommand("set -e", "ok", "", 0) + + stdout, _, rc, err := mock.RunScript(nil, "set -e\necho done") + + assert.NoError(t, err) + assert.Equal(t, "ok", stdout) + assert.Equal(t, 0, rc) + assert.Equal(t, 1, mock.commandCount()) + assert.Equal(t, "RunScript", mock.lastCommand().Method) +} + +func TestMockSSHClient_Good_DefaultSuccessResponse(t *testing.T) { + mock := NewMockSSHClient() + + // No expectations registered — should return empty success + stdout, stderr, rc, err := mock.Run(nil, "anything") + + assert.NoError(t, err) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, 0, rc) +} + +func TestMockSSHClient_Good_LastMatchWins(t *testing.T) { + mock := NewMockSSHClient() + mock.expectCommand("echo", "first", "", 0) + mock.expectCommand("echo", "second", "", 0) + + stdout, _, _, _ := mock.Run(nil, "echo hello") + + assert.Equal(t, "second", stdout) +} + +func TestMockSSHClient_Good_FileOperations(t *testing.T) { + mock := NewMockSSHClient() + + // File does not exist initially + exists, err := mock.FileExists(nil, "/etc/config") + assert.NoError(t, err) + assert.False(t, exists) + + // Add file + mock.addFile("/etc/config", []byte("key=value")) + + // Now it exists + exists, err = mock.FileExists(nil, "/etc/config") + assert.NoError(t, err) + assert.True(t, exists) + + // Download it + content, err := mock.Download(nil, "/etc/config") + assert.NoError(t, err) + assert.Equal(t, []byte("key=value"), content) + + // Download non-existent file + _, err = mock.Download(nil, "/nonexistent") + assert.Error(t, err) +} + +func TestMockSSHClient_Good_StatWithExplicit(t *testing.T) { + mock := NewMockSSHClient() + mock.addStat("/var/log", map[string]any{"exists": true, "isdir": true}) + + info, err := mock.Stat(nil, "/var/log") + assert.NoError(t, err) + assert.Equal(t, true, info["exists"]) + assert.Equal(t, true, info["isdir"]) +} + +func TestMockSSHClient_Good_StatFallback(t *testing.T) { + mock := NewMockSSHClient() + mock.addFile("/etc/hosts", []byte("127.0.0.1 localhost")) + + info, err := mock.Stat(nil, "/etc/hosts") + assert.NoError(t, err) + assert.Equal(t, true, info["exists"]) + assert.Equal(t, false, info["isdir"]) + + info, err = mock.Stat(nil, "/nonexistent") + assert.NoError(t, err) + assert.Equal(t, false, info["exists"]) +} + +func TestMockSSHClient_Good_BecomeTracking(t *testing.T) { + mock := NewMockSSHClient() + + assert.False(t, mock.become) + assert.Equal(t, "", mock.becomeUser) + + mock.SetBecome(true, "root", "secret") + + assert.True(t, mock.become) + assert.Equal(t, "root", mock.becomeUser) + assert.Equal(t, "secret", mock.becomePass) +} + +func TestMockSSHClient_Good_HasExecuted(t *testing.T) { + mock := NewMockSSHClient() + _, _, _, _ = mock.Run(nil, "systemctl restart nginx") + _, _, _, _ = mock.Run(nil, "apt-get update") + + assert.True(t, mock.hasExecuted("systemctl.*nginx")) + assert.True(t, mock.hasExecuted("apt-get")) + assert.False(t, mock.hasExecuted("yum")) +} + +func TestMockSSHClient_Good_HasExecutedMethod(t *testing.T) { + mock := NewMockSSHClient() + _, _, _, _ = mock.Run(nil, "echo run") + _, _, _, _ = mock.RunScript(nil, "echo script") + + assert.True(t, mock.hasExecutedMethod("Run", "echo run")) + assert.True(t, mock.hasExecutedMethod("RunScript", "echo script")) + assert.False(t, mock.hasExecutedMethod("Run", "echo script")) + assert.False(t, mock.hasExecutedMethod("RunScript", "echo run")) +} + +func TestMockSSHClient_Good_Reset(t *testing.T) { + mock := NewMockSSHClient() + _, _, _, _ = mock.Run(nil, "echo hello") + assert.Equal(t, 1, mock.commandCount()) + + mock.reset() + assert.Equal(t, 0, mock.commandCount()) +} + +func TestMockSSHClient_Good_ErrorExpectation(t *testing.T) { + mock := NewMockSSHClient() + mock.expectCommandError("bad cmd", assert.AnError) + + _, _, _, err := mock.Run(nil, "bad cmd") + assert.Error(t, err) +} + +// --- command module --- + +func TestModuleCommand_Good_BasicCommand(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("ls -la /tmp", "total 0\n", "", 0) + + result, err := moduleCommandWithClient(e, mock, map[string]any{ + "_raw_params": "ls -la /tmp", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.Equal(t, "total 0\n", result.Stdout) + assert.Equal(t, 0, result.RC) + + // Verify it used Run (not RunScript) + assert.True(t, mock.hasExecutedMethod("Run", "ls -la /tmp")) + assert.False(t, mock.hasExecutedMethod("RunScript", ".*")) +} + +func TestModuleCommand_Good_CmdArg(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("whoami", "root\n", "", 0) + + result, err := moduleCommandWithClient(e, mock, map[string]any{ + "cmd": "whoami", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.Equal(t, "root\n", result.Stdout) + assert.True(t, mock.hasExecutedMethod("Run", "whoami")) +} + +func TestModuleCommand_Good_WithChdir(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`cd "/var/log" && ls`, "syslog\n", "", 0) + + result, err := moduleCommandWithClient(e, mock, map[string]any{ + "_raw_params": "ls", + "chdir": "/var/log", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + // The command should have been wrapped with cd + last := mock.lastCommand() + assert.Equal(t, "Run", last.Method) + assert.Contains(t, last.Cmd, `cd "/var/log"`) + assert.Contains(t, last.Cmd, "ls") +} + +func TestModuleCommand_Bad_NoCommand(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleCommandWithClient(e, mock, map[string]any{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no command specified") +} + +func TestModuleCommand_Good_NonZeroRC(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("false", "", "error occurred", 1) + + result, err := moduleCommandWithClient(e, mock, map[string]any{ + "_raw_params": "false", + }) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Equal(t, 1, result.RC) + assert.Equal(t, "error occurred", result.Stderr) +} + +func TestModuleCommand_Good_SSHError(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + mock.expectCommandError(".*", assert.AnError) + + result, err := moduleCommandWithClient(e, mock, map[string]any{ + "_raw_params": "any command", + }) + + require.NoError(t, err) // Module wraps SSH errors into result.Failed + assert.True(t, result.Failed) + assert.Contains(t, result.Msg, assert.AnError.Error()) +} + +func TestModuleCommand_Good_RawParamsTakesPrecedence(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("from_raw", "raw\n", "", 0) + + result, err := moduleCommandWithClient(e, mock, map[string]any{ + "_raw_params": "from_raw", + "cmd": "from_cmd", + }) + + require.NoError(t, err) + assert.Equal(t, "raw\n", result.Stdout) + assert.True(t, mock.hasExecuted("from_raw")) +} + +// --- shell module --- + +func TestModuleShell_Good_BasicShell(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("echo hello", "hello\n", "", 0) + + result, err := moduleShellWithClient(e, mock, map[string]any{ + "_raw_params": "echo hello", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.Equal(t, "hello\n", result.Stdout) + + // Shell must use RunScript (not Run) + assert.True(t, mock.hasExecutedMethod("RunScript", "echo hello")) + assert.False(t, mock.hasExecutedMethod("Run", ".*")) +} + +func TestModuleShell_Good_CmdArg(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("date", "Thu Feb 20\n", "", 0) + + result, err := moduleShellWithClient(e, mock, map[string]any{ + "cmd": "date", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecutedMethod("RunScript", "date")) +} + +func TestModuleShell_Good_WithChdir(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`cd "/app" && npm install`, "done\n", "", 0) + + result, err := moduleShellWithClient(e, mock, map[string]any{ + "_raw_params": "npm install", + "chdir": "/app", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + last := mock.lastCommand() + assert.Equal(t, "RunScript", last.Method) + assert.Contains(t, last.Cmd, `cd "/app"`) + assert.Contains(t, last.Cmd, "npm install") +} + +func TestModuleShell_Bad_NoCommand(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleShellWithClient(e, mock, map[string]any{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no command specified") +} + +func TestModuleShell_Good_NonZeroRC(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("exit 2", "", "failed", 2) + + result, err := moduleShellWithClient(e, mock, map[string]any{ + "_raw_params": "exit 2", + }) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Equal(t, 2, result.RC) +} + +func TestModuleShell_Good_SSHError(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + mock.expectCommandError(".*", assert.AnError) + + result, err := moduleShellWithClient(e, mock, map[string]any{ + "_raw_params": "some command", + }) + + require.NoError(t, err) + assert.True(t, result.Failed) +} + +func TestModuleShell_Good_PipelineCommand(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`cat /etc/passwd \| grep root`, "root:x:0:0\n", "", 0) + + result, err := moduleShellWithClient(e, mock, map[string]any{ + "_raw_params": "cat /etc/passwd | grep root", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + // Shell uses RunScript, so pipes work + assert.True(t, mock.hasExecutedMethod("RunScript", "cat /etc/passwd")) +} + +// --- raw module --- + +func TestModuleRaw_Good_BasicRaw(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("uname -a", "Linux host1 5.15\n", "", 0) + + result, err := moduleRawWithClient(e, mock, map[string]any{ + "_raw_params": "uname -a", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.Equal(t, "Linux host1 5.15\n", result.Stdout) + + // Raw must use Run (not RunScript) — no shell wrapping + assert.True(t, mock.hasExecutedMethod("Run", "uname -a")) + assert.False(t, mock.hasExecutedMethod("RunScript", ".*")) +} + +func TestModuleRaw_Bad_NoCommand(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleRawWithClient(e, mock, map[string]any{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no command specified") +} + +func TestModuleRaw_Good_NoChdir(t *testing.T) { + // Raw module does NOT support chdir — it should ignore it + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("echo test", "test\n", "", 0) + + result, err := moduleRawWithClient(e, mock, map[string]any{ + "_raw_params": "echo test", + "chdir": "/should/be/ignored", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + // The chdir should NOT appear in the command + last := mock.lastCommand() + assert.Equal(t, "echo test", last.Cmd) + assert.NotContains(t, last.Cmd, "cd") +} + +func TestModuleRaw_Good_NonZeroRC(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("invalid", "", "not found", 127) + + result, err := moduleRawWithClient(e, mock, map[string]any{ + "_raw_params": "invalid", + }) + + require.NoError(t, err) + // Note: raw module does NOT set Failed based on RC + assert.Equal(t, 127, result.RC) + assert.Equal(t, "not found", result.Stderr) +} + +func TestModuleRaw_Good_SSHError(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + mock.expectCommandError(".*", assert.AnError) + + result, err := moduleRawWithClient(e, mock, map[string]any{ + "_raw_params": "any", + }) + + require.NoError(t, err) + assert.True(t, result.Failed) +} + +func TestModuleRaw_Good_ExactCommandPassthrough(t *testing.T) { + // Raw should pass the command exactly as given — no wrapping + e, mock := newTestExecutorWithMock("host1") + complexCmd := `/usr/bin/python3 -c 'import sys; print(sys.version)'` + mock.expectCommand(".*python3.*", "3.10.0\n", "", 0) + + result, err := moduleRawWithClient(e, mock, map[string]any{ + "_raw_params": complexCmd, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + last := mock.lastCommand() + assert.Equal(t, complexCmd, last.Cmd) +} + +// --- script module --- + +func TestModuleScript_Good_BasicScript(t *testing.T) { + // Create a temporary script file + tmpDir := t.TempDir() + scriptPath := filepath.Join(tmpDir, "setup.sh") + scriptContent := "#!/bin/bash\necho 'setup complete'\nexit 0" + require.NoError(t, os.WriteFile(scriptPath, []byte(scriptContent), 0755)) + + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("setup complete", "setup complete\n", "", 0) + + result, err := moduleScriptWithClient(e, mock, map[string]any{ + "_raw_params": scriptPath, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + + // Script must use RunScript (not Run) — it sends the file content + assert.True(t, mock.hasExecutedMethod("RunScript", "setup complete")) + assert.False(t, mock.hasExecutedMethod("Run", ".*")) + + // Verify the full script content was sent + last := mock.lastCommand() + assert.Equal(t, scriptContent, last.Cmd) +} + +func TestModuleScript_Bad_NoScript(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleScriptWithClient(e, mock, map[string]any{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no script specified") +} + +func TestModuleScript_Bad_FileNotFound(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleScriptWithClient(e, mock, map[string]any{ + "_raw_params": "/nonexistent/script.sh", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "read script") +} + +func TestModuleScript_Good_NonZeroRC(t *testing.T) { + tmpDir := t.TempDir() + scriptPath := filepath.Join(tmpDir, "fail.sh") + require.NoError(t, os.WriteFile(scriptPath, []byte("exit 1"), 0755)) + + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("exit 1", "", "script failed", 1) + + result, err := moduleScriptWithClient(e, mock, map[string]any{ + "_raw_params": scriptPath, + }) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Equal(t, 1, result.RC) +} + +func TestModuleScript_Good_MultiLineScript(t *testing.T) { + tmpDir := t.TempDir() + scriptPath := filepath.Join(tmpDir, "multi.sh") + scriptContent := "#!/bin/bash\nset -e\napt-get update\napt-get install -y nginx\nsystemctl start nginx" + require.NoError(t, os.WriteFile(scriptPath, []byte(scriptContent), 0755)) + + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("apt-get", "done\n", "", 0) + + result, err := moduleScriptWithClient(e, mock, map[string]any{ + "_raw_params": scriptPath, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + // Verify RunScript was called with the full content + last := mock.lastCommand() + assert.Equal(t, "RunScript", last.Method) + assert.Equal(t, scriptContent, last.Cmd) +} + +func TestModuleScript_Good_SSHError(t *testing.T) { + tmpDir := t.TempDir() + scriptPath := filepath.Join(tmpDir, "ok.sh") + require.NoError(t, os.WriteFile(scriptPath, []byte("echo ok"), 0755)) + + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + mock.expectCommandError(".*", assert.AnError) + + result, err := moduleScriptWithClient(e, mock, map[string]any{ + "_raw_params": scriptPath, + }) + + require.NoError(t, err) + assert.True(t, result.Failed) +} + +// --- Cross-module differentiation tests --- + +func TestModuleDifferentiation_Good_CommandUsesRun(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("echo test", "test\n", "", 0) + + _, _ = moduleCommandWithClient(e, mock, map[string]any{"_raw_params": "echo test"}) + + cmds := mock.executedCommands() + require.Len(t, cmds, 1) + assert.Equal(t, "Run", cmds[0].Method, "command module must use Run()") +} + +func TestModuleDifferentiation_Good_ShellUsesRunScript(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("echo test", "test\n", "", 0) + + _, _ = moduleShellWithClient(e, mock, map[string]any{"_raw_params": "echo test"}) + + cmds := mock.executedCommands() + require.Len(t, cmds, 1) + assert.Equal(t, "RunScript", cmds[0].Method, "shell module must use RunScript()") +} + +func TestModuleDifferentiation_Good_RawUsesRun(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("echo test", "test\n", "", 0) + + _, _ = moduleRawWithClient(e, mock, map[string]any{"_raw_params": "echo test"}) + + cmds := mock.executedCommands() + require.Len(t, cmds, 1) + assert.Equal(t, "Run", cmds[0].Method, "raw module must use Run()") +} + +func TestModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T) { + tmpDir := t.TempDir() + scriptPath := filepath.Join(tmpDir, "test.sh") + require.NoError(t, os.WriteFile(scriptPath, []byte("echo test"), 0755)) + + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("echo test", "test\n", "", 0) + + _, _ = moduleScriptWithClient(e, mock, map[string]any{"_raw_params": scriptPath}) + + cmds := mock.executedCommands() + require.Len(t, cmds, 1) + assert.Equal(t, "RunScript", cmds[0].Method, "script module must use RunScript()") +} + +// --- executeModuleWithMock dispatch tests --- + +func TestExecuteModuleWithMock_Good_DispatchCommand(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("uptime", "up 5 days\n", "", 0) + + task := &Task{ + Module: "command", + Args: map[string]any{"_raw_params": "uptime"}, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.Equal(t, "up 5 days\n", result.Stdout) +} + +func TestExecuteModuleWithMock_Good_DispatchShell(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("ps aux", "root.*bash\n", "", 0) + + task := &Task{ + Module: "ansible.builtin.shell", + Args: map[string]any{"_raw_params": "ps aux | grep bash"}, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) +} + +func TestExecuteModuleWithMock_Good_DispatchRaw(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("cat /etc/hostname", "web01\n", "", 0) + + task := &Task{ + Module: "raw", + Args: map[string]any{"_raw_params": "cat /etc/hostname"}, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.Equal(t, "web01\n", result.Stdout) +} + +func TestExecuteModuleWithMock_Good_DispatchScript(t *testing.T) { + tmpDir := t.TempDir() + scriptPath := filepath.Join(tmpDir, "deploy.sh") + require.NoError(t, os.WriteFile(scriptPath, []byte("echo deploying"), 0755)) + + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("deploying", "deploying\n", "", 0) + + task := &Task{ + Module: "script", + Args: map[string]any{"_raw_params": scriptPath}, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) +} + +func TestExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + task := &Task{ + Module: "ansible.builtin.copy", + Args: map[string]any{}, + } + + _, err := executeModuleWithMock(e, mock, "host1", task) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported module") +} + +// --- Template integration tests --- + +func TestModuleCommand_Good_TemplatedArgs(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + e.SetVar("service_name", "nginx") + mock.expectCommand("systemctl status nginx", "active\n", "", 0) + + task := &Task{ + Module: "command", + Args: map[string]any{"_raw_params": "systemctl status {{ service_name }}"}, + } + + // Template the args the way the executor does + args := e.templateArgs(task.Args, "host1", task) + result, err := moduleCommandWithClient(e, mock, args) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted("systemctl status nginx")) +}