From c7da9ad16cd26257e71b0b78c72fdc78730ef61c Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 20 Feb 2026 02:39:27 +0000 Subject: [PATCH] =?UTF-8?q?test(ansible):=20Phase=201=20Step=201.2=20?= =?UTF-8?q?=E2=80=94=20file=20operation=20module=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 54 new tests for copy/file/lineinfile/blockinfile/stat/template modules. Extended mock_ssh_test.go with sshFileRunner interface and 6 module shims. Fixed unsupported module test (copy→hostname now that copy is supported). Total ansible tests: 208. Co-Authored-By: Virgil --- ansible/mock_ssh_test.go | 277 +++++++++++ ansible/modules_cmd_test.go | 2 +- ansible/modules_file_test.go | 899 +++++++++++++++++++++++++++++++++++ 3 files changed, 1177 insertions(+), 1 deletion(-) create mode 100644 ansible/modules_file_test.go diff --git a/ansible/mock_ssh_test.go b/ansible/mock_ssh_test.go index a963092..7df9bda 100644 --- a/ansible/mock_ssh_test.go +++ b/ansible/mock_ssh_test.go @@ -6,6 +6,7 @@ import ( "io" "os" "regexp" + "strconv" "strings" "sync" ) @@ -359,6 +360,18 @@ func executeModuleWithMock(e *Executor, mock *MockSSHClient, host string, task * return moduleRawWithClient(e, mock, args) case "ansible.builtin.script": return moduleScriptWithClient(e, mock, args) + case "ansible.builtin.copy": + return moduleCopyWithClient(e, mock, args, host, task) + case "ansible.builtin.template": + return moduleTemplateWithClient(e, mock, args, host, task) + case "ansible.builtin.file": + return moduleFileWithClient(e, mock, args) + case "ansible.builtin.lineinfile": + return moduleLineinfileWithClient(e, mock, args) + case "ansible.builtin.blockinfile": + return moduleBlockinfileWithClient(e, mock, args) + case "ansible.builtin.stat": + return moduleStatWithClient(e, mock, args) default: return nil, fmt.Errorf("mock dispatch: unsupported module %s", module) } @@ -470,6 +483,270 @@ func moduleScriptWithClient(_ *Executor, client sshRunner, args map[string]any) }, nil } +// --- Extended interface for file operations --- +// File modules need Upload, Stat, FileExists in addition to Run/RunScript. + +type sshFileRunner interface { + sshRunner + Upload(ctx context.Context, local io.Reader, remote string, mode os.FileMode) error + Stat(ctx context.Context, path string) (map[string]any, error) + FileExists(ctx context.Context, path string) (bool, error) +} + +// --- File module shims --- + +func moduleCopyWithClient(e *Executor, client sshFileRunner, args map[string]any, host string, task *Task) (*TaskResult, error) { + dest := getStringArg(args, "dest", "") + if dest == "" { + return nil, fmt.Errorf("copy: dest required") + } + + var content []byte + var err error + + if src := getStringArg(args, "src", ""); src != "" { + content, err = os.ReadFile(src) + if err != nil { + return nil, fmt.Errorf("read src: %w", err) + } + } else if c := getStringArg(args, "content", ""); c != "" { + content = []byte(c) + } else { + return nil, fmt.Errorf("copy: src or content required") + } + + mode := os.FileMode(0644) + if m := getStringArg(args, "mode", ""); m != "" { + if parsed, parseErr := strconv.ParseInt(m, 8, 32); parseErr == nil { + mode = os.FileMode(parsed) + } + } + + err = client.Upload(context.Background(), strings.NewReader(string(content)), dest, mode) + if err != nil { + return nil, err + } + + // Handle owner/group (best-effort, errors ignored) + if owner := getStringArg(args, "owner", ""); owner != "" { + _, _, _, _ = client.Run(context.Background(), fmt.Sprintf("chown %s %q", owner, dest)) + } + if group := getStringArg(args, "group", ""); group != "" { + _, _, _, _ = client.Run(context.Background(), fmt.Sprintf("chgrp %s %q", group, dest)) + } + + return &TaskResult{Changed: true, Msg: fmt.Sprintf("copied to %s", dest)}, nil +} + +func moduleTemplateWithClient(e *Executor, client sshFileRunner, args map[string]any, host string, task *Task) (*TaskResult, error) { + src := getStringArg(args, "src", "") + dest := getStringArg(args, "dest", "") + if src == "" || dest == "" { + return nil, fmt.Errorf("template: src and dest required") + } + + // Process template + content, err := e.TemplateFile(src, host, task) + if err != nil { + return nil, fmt.Errorf("template: %w", err) + } + + mode := os.FileMode(0644) + if m := getStringArg(args, "mode", ""); m != "" { + if parsed, parseErr := strconv.ParseInt(m, 8, 32); parseErr == nil { + mode = os.FileMode(parsed) + } + } + + err = client.Upload(context.Background(), strings.NewReader(content), dest, mode) + if err != nil { + return nil, err + } + + return &TaskResult{Changed: true, Msg: fmt.Sprintf("templated to %s", dest)}, nil +} + +func moduleFileWithClient(_ *Executor, client sshFileRunner, args map[string]any) (*TaskResult, error) { + path := getStringArg(args, "path", "") + if path == "" { + path = getStringArg(args, "dest", "") + } + if path == "" { + return nil, fmt.Errorf("file: path required") + } + + state := getStringArg(args, "state", "file") + + switch state { + case "directory": + mode := getStringArg(args, "mode", "0755") + cmd := fmt.Sprintf("mkdir -p %q && chmod %s %q", path, mode, path) + stdout, stderr, rc, err := client.Run(context.Background(), cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + case "absent": + cmd := fmt.Sprintf("rm -rf %q", path) + _, stderr, rc, err := client.Run(context.Background(), cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil + } + + case "touch": + cmd := fmt.Sprintf("touch %q", path) + _, stderr, rc, err := client.Run(context.Background(), cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil + } + + case "link": + src := getStringArg(args, "src", "") + if src == "" { + return nil, fmt.Errorf("file: src required for link state") + } + cmd := fmt.Sprintf("ln -sf %q %q", src, path) + _, stderr, rc, err := client.Run(context.Background(), cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil + } + + case "file": + // Ensure file exists and set permissions + if mode := getStringArg(args, "mode", ""); mode != "" { + _, _, _, _ = client.Run(context.Background(), fmt.Sprintf("chmod %s %q", mode, path)) + } + } + + // Handle owner/group (best-effort, errors ignored) + if owner := getStringArg(args, "owner", ""); owner != "" { + _, _, _, _ = client.Run(context.Background(), fmt.Sprintf("chown %s %q", owner, path)) + } + if group := getStringArg(args, "group", ""); group != "" { + _, _, _, _ = client.Run(context.Background(), fmt.Sprintf("chgrp %s %q", group, path)) + } + if recurse := getBoolArg(args, "recurse", false); recurse { + if owner := getStringArg(args, "owner", ""); owner != "" { + _, _, _, _ = client.Run(context.Background(), fmt.Sprintf("chown -R %s %q", owner, path)) + } + } + + return &TaskResult{Changed: true}, nil +} + +func moduleLineinfileWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) { + path := getStringArg(args, "path", "") + if path == "" { + path = getStringArg(args, "dest", "") + } + if path == "" { + return nil, fmt.Errorf("lineinfile: path required") + } + + line := getStringArg(args, "line", "") + regexpArg := getStringArg(args, "regexp", "") + state := getStringArg(args, "state", "present") + + if state == "absent" { + if regexpArg != "" { + cmd := fmt.Sprintf("sed -i '/%s/d' %q", regexpArg, path) + _, stderr, rc, _ := client.Run(context.Background(), cmd) + if rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil + } + } + } else { + // state == present + if regexpArg != "" { + // Replace line matching regexp + escapedLine := strings.ReplaceAll(line, "/", "\\/") + cmd := fmt.Sprintf("sed -i 's/%s/%s/' %q", regexpArg, escapedLine, path) + _, _, rc, _ := client.Run(context.Background(), cmd) + if rc != 0 { + // Line not found, append + cmd = fmt.Sprintf("echo %q >> %q", line, path) + _, _, _, _ = client.Run(context.Background(), cmd) + } + } else if line != "" { + // Ensure line is present + cmd := fmt.Sprintf("grep -qxF %q %q || echo %q >> %q", line, path, line, path) + _, _, _, _ = client.Run(context.Background(), cmd) + } + } + + return &TaskResult{Changed: true}, nil +} + +func moduleBlockinfileWithClient(_ *Executor, client sshFileRunner, args map[string]any) (*TaskResult, error) { + path := getStringArg(args, "path", "") + if path == "" { + path = getStringArg(args, "dest", "") + } + if path == "" { + return nil, fmt.Errorf("blockinfile: path required") + } + + block := getStringArg(args, "block", "") + marker := getStringArg(args, "marker", "# {mark} ANSIBLE MANAGED BLOCK") + state := getStringArg(args, "state", "present") + create := getBoolArg(args, "create", false) + + beginMarker := strings.Replace(marker, "{mark}", "BEGIN", 1) + endMarker := strings.Replace(marker, "{mark}", "END", 1) + + if state == "absent" { + // Remove block + cmd := fmt.Sprintf("sed -i '/%s/,/%s/d' %q", + strings.ReplaceAll(beginMarker, "/", "\\/"), + strings.ReplaceAll(endMarker, "/", "\\/"), + path) + _, _, _, _ = client.Run(context.Background(), cmd) + return &TaskResult{Changed: true}, nil + } + + // Create file if needed (best-effort) + if create { + _, _, _, _ = client.Run(context.Background(), fmt.Sprintf("touch %q", path)) + } + + // Remove existing block and add new one + escapedBlock := strings.ReplaceAll(block, "'", "'\\''") + cmd := fmt.Sprintf(` +sed -i '/%s/,/%s/d' %q 2>/dev/null || true +cat >> %q << 'BLOCK_EOF' +%s +%s +%s +BLOCK_EOF +`, strings.ReplaceAll(beginMarker, "/", "\\/"), + strings.ReplaceAll(endMarker, "/", "\\/"), + path, path, beginMarker, escapedBlock, endMarker) + + stdout, stderr, rc, err := client.RunScript(context.Background(), cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + return &TaskResult{Changed: true}, nil +} + +func moduleStatWithClient(_ *Executor, client sshFileRunner, args map[string]any) (*TaskResult, error) { + path := getStringArg(args, "path", "") + if path == "" { + return nil, fmt.Errorf("stat: path required") + } + + stat, err := client.Stat(context.Background(), path) + if err != nil { + return nil, err + } + + return &TaskResult{ + Changed: false, + Data: map[string]any{"stat": stat}, + }, nil +} + // --- String helpers for assertions --- // containsSubstring checks if any executed command contains the given substring. diff --git a/ansible/modules_cmd_test.go b/ansible/modules_cmd_test.go index ee5301e..cf4f28e 100644 --- a/ansible/modules_cmd_test.go +++ b/ansible/modules_cmd_test.go @@ -690,7 +690,7 @@ func TestExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) { e, mock := newTestExecutorWithMock("host1") task := &Task{ - Module: "ansible.builtin.copy", + Module: "ansible.builtin.hostname", Args: map[string]any{}, } diff --git a/ansible/modules_file_test.go b/ansible/modules_file_test.go new file mode 100644 index 0000000..cf3cd33 --- /dev/null +++ b/ansible/modules_file_test.go @@ -0,0 +1,899 @@ +package ansible + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================ +// Step 1.2: copy / template / file / lineinfile / blockinfile / stat module tests +// ============================================================ + +// --- copy module --- + +func TestModuleCopy_Good_ContentUpload(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleCopyWithClient(e, mock, map[string]any{ + "content": "server_name=web01", + "dest": "/etc/app/config", + }, "host1", &Task{}) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.Contains(t, result.Msg, "copied to /etc/app/config") + + // Verify upload was performed + assert.Equal(t, 1, mock.uploadCount()) + up := mock.lastUpload() + require.NotNil(t, up) + assert.Equal(t, "/etc/app/config", up.Remote) + assert.Equal(t, []byte("server_name=web01"), up.Content) + assert.Equal(t, os.FileMode(0644), up.Mode) +} + +func TestModuleCopy_Good_SrcFile(t *testing.T) { + tmpDir := t.TempDir() + srcPath := filepath.Join(tmpDir, "nginx.conf") + require.NoError(t, os.WriteFile(srcPath, []byte("worker_processes auto;"), 0644)) + + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleCopyWithClient(e, mock, map[string]any{ + "src": srcPath, + "dest": "/etc/nginx/nginx.conf", + }, "host1", &Task{}) + + require.NoError(t, err) + assert.True(t, result.Changed) + + up := mock.lastUpload() + require.NotNil(t, up) + assert.Equal(t, "/etc/nginx/nginx.conf", up.Remote) + assert.Equal(t, []byte("worker_processes auto;"), up.Content) +} + +func TestModuleCopy_Good_OwnerGroup(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleCopyWithClient(e, mock, map[string]any{ + "content": "data", + "dest": "/opt/app/data.txt", + "owner": "appuser", + "group": "appgroup", + }, "host1", &Task{}) + + require.NoError(t, err) + assert.True(t, result.Changed) + + // Upload + chown + chgrp = 1 upload + 2 Run calls + assert.Equal(t, 1, mock.uploadCount()) + assert.True(t, mock.hasExecuted(`chown appuser "/opt/app/data.txt"`)) + assert.True(t, mock.hasExecuted(`chgrp appgroup "/opt/app/data.txt"`)) +} + +func TestModuleCopy_Good_CustomMode(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleCopyWithClient(e, mock, map[string]any{ + "content": "#!/bin/bash\necho hello", + "dest": "/usr/local/bin/hello.sh", + "mode": "0755", + }, "host1", &Task{}) + + require.NoError(t, err) + assert.True(t, result.Changed) + + up := mock.lastUpload() + require.NotNil(t, up) + assert.Equal(t, os.FileMode(0755), up.Mode) +} + +func TestModuleCopy_Bad_MissingDest(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + _, err := moduleCopyWithClient(e, mock, map[string]any{ + "content": "data", + }, "host1", &Task{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "dest required") +} + +func TestModuleCopy_Bad_MissingSrcAndContent(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + _, err := moduleCopyWithClient(e, mock, map[string]any{ + "dest": "/tmp/out", + }, "host1", &Task{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "src or content required") +} + +func TestModuleCopy_Bad_SrcFileNotFound(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + _, err := moduleCopyWithClient(e, mock, map[string]any{ + "src": "/nonexistent/file.txt", + "dest": "/tmp/out", + }, "host1", &Task{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "read src") +} + +func TestModuleCopy_Good_ContentTakesPrecedenceOverSrc(t *testing.T) { + // When both content and src are given, src is checked first in the implementation + // but if src is empty string, content is used + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleCopyWithClient(e, mock, map[string]any{ + "content": "from_content", + "dest": "/tmp/out", + }, "host1", &Task{}) + + require.NoError(t, err) + assert.True(t, result.Changed) + up := mock.lastUpload() + assert.Equal(t, []byte("from_content"), up.Content) +} + +// --- file module --- + +func TestModuleFile_Good_StateDirectory(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleFileWithClient(e, mock, map[string]any{ + "path": "/var/lib/app", + "state": "directory", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + // Should execute mkdir -p with default mode 0755 + assert.True(t, mock.hasExecuted(`mkdir -p "/var/lib/app"`)) + assert.True(t, mock.hasExecuted(`chmod 0755`)) +} + +func TestModuleFile_Good_StateDirectoryCustomMode(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleFileWithClient(e, mock, map[string]any{ + "path": "/opt/data", + "state": "directory", + "mode": "0700", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted(`mkdir -p "/opt/data" && chmod 0700 "/opt/data"`)) +} + +func TestModuleFile_Good_StateAbsent(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleFileWithClient(e, mock, map[string]any{ + "path": "/tmp/old-dir", + "state": "absent", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted(`rm -rf "/tmp/old-dir"`)) +} + +func TestModuleFile_Good_StateTouch(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleFileWithClient(e, mock, map[string]any{ + "path": "/var/log/app.log", + "state": "touch", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted(`touch "/var/log/app.log"`)) +} + +func TestModuleFile_Good_StateLink(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleFileWithClient(e, mock, map[string]any{ + "path": "/usr/local/bin/node", + "state": "link", + "src": "/opt/node/bin/node", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted(`ln -sf "/opt/node/bin/node" "/usr/local/bin/node"`)) +} + +func TestModuleFile_Bad_LinkMissingSrc(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + _, err := moduleFileWithClient(e, mock, map[string]any{ + "path": "/usr/local/bin/node", + "state": "link", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "src required for link state") +} + +func TestModuleFile_Good_OwnerGroupMode(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleFileWithClient(e, mock, map[string]any{ + "path": "/var/lib/app/data", + "state": "directory", + "owner": "www-data", + "group": "www-data", + "mode": "0775", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + // Should have mkdir, chmod in the directory command, then chown and chgrp + assert.True(t, mock.hasExecuted(`mkdir -p "/var/lib/app/data" && chmod 0775 "/var/lib/app/data"`)) + assert.True(t, mock.hasExecuted(`chown www-data "/var/lib/app/data"`)) + assert.True(t, mock.hasExecuted(`chgrp www-data "/var/lib/app/data"`)) +} + +func TestModuleFile_Good_RecurseOwner(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleFileWithClient(e, mock, map[string]any{ + "path": "/var/www", + "state": "directory", + "owner": "www-data", + "recurse": true, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + // Should have both regular chown and recursive chown + assert.True(t, mock.hasExecuted(`chown www-data "/var/www"`)) + assert.True(t, mock.hasExecuted(`chown -R www-data "/var/www"`)) +} + +func TestModuleFile_Bad_MissingPath(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + _, err := moduleFileWithClient(e, mock, map[string]any{ + "state": "directory", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "path required") +} + +func TestModuleFile_Good_DestAliasForPath(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleFileWithClient(e, mock, map[string]any{ + "dest": "/opt/myapp", + "state": "directory", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted(`mkdir -p "/opt/myapp"`)) +} + +func TestModuleFile_Good_StateFileWithMode(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleFileWithClient(e, mock, map[string]any{ + "path": "/etc/config.yml", + "state": "file", + "mode": "0600", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted(`chmod 0600 "/etc/config.yml"`)) +} + +func TestModuleFile_Good_DirectoryCommandFailure(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("mkdir", "", "permission denied", 1) + + result, err := moduleFileWithClient(e, mock, map[string]any{ + "path": "/root/protected", + "state": "directory", + }) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Contains(t, result.Msg, "permission denied") +} + +// --- lineinfile module --- + +func TestModuleLineinfile_Good_InsertLine(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleLineinfileWithClient(e, mock, map[string]any{ + "path": "/etc/hosts", + "line": "192.168.1.100 web01", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + // Should use grep -qxF to check and echo to append + assert.True(t, mock.hasExecuted(`grep -qxF`)) + assert.True(t, mock.hasExecuted(`192.168.1.100 web01`)) +} + +func TestModuleLineinfile_Good_ReplaceRegexp(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleLineinfileWithClient(e, mock, map[string]any{ + "path": "/etc/ssh/sshd_config", + "regexp": "^#?PermitRootLogin", + "line": "PermitRootLogin no", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + // Should use sed to replace + assert.True(t, mock.hasExecuted(`sed -i 's/\^#\?PermitRootLogin/PermitRootLogin no/'`)) +} + +func TestModuleLineinfile_Good_RemoveLine(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleLineinfileWithClient(e, mock, map[string]any{ + "path": "/etc/hosts", + "regexp": "^192\\.168\\.1\\.100", + "state": "absent", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + // Should use sed to delete matching lines + assert.True(t, mock.hasExecuted(`sed -i '/\^192`)) + assert.True(t, mock.hasExecuted(`/d'`)) +} + +func TestModuleLineinfile_Good_RegexpFallsBackToAppend(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + // Simulate sed returning non-zero (pattern not found) + mock.expectCommand("sed -i", "", "", 1) + + result, err := moduleLineinfileWithClient(e, mock, map[string]any{ + "path": "/etc/config", + "regexp": "^setting=", + "line": "setting=value", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + // Should have attempted sed, then fallen back to echo append + cmds := mock.executedCommands() + assert.GreaterOrEqual(t, len(cmds), 2) + assert.True(t, mock.hasExecuted(`echo`)) +} + +func TestModuleLineinfile_Bad_MissingPath(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + _, err := moduleLineinfileWithClient(e, mock, map[string]any{ + "line": "test", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "path required") +} + +func TestModuleLineinfile_Good_DestAliasForPath(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleLineinfileWithClient(e, mock, map[string]any{ + "dest": "/etc/config", + "line": "key=value", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted(`/etc/config`)) +} + +func TestModuleLineinfile_Good_AbsentWithNoRegexp(t *testing.T) { + // When state=absent but no regexp, nothing happens (no commands) + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleLineinfileWithClient(e, mock, map[string]any{ + "path": "/etc/config", + "state": "absent", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.Equal(t, 0, mock.commandCount()) +} + +func TestModuleLineinfile_Good_LineWithSlashes(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleLineinfileWithClient(e, mock, map[string]any{ + "path": "/etc/nginx/conf.d/default.conf", + "regexp": "^root /", + "line": "root /var/www/html;", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + // Slashes in the line should be escaped + assert.True(t, mock.hasExecuted(`root \\/var\\/www\\/html;`)) +} + +// --- blockinfile module --- + +func TestModuleBlockinfile_Good_InsertBlock(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleBlockinfileWithClient(e, mock, map[string]any{ + "path": "/etc/nginx/conf.d/upstream.conf", + "block": "server 10.0.0.1:8080;\nserver 10.0.0.2:8080;", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + // Should use RunScript for the heredoc approach + assert.True(t, mock.hasExecutedMethod("RunScript", "BEGIN ANSIBLE MANAGED BLOCK")) + assert.True(t, mock.hasExecutedMethod("RunScript", "END ANSIBLE MANAGED BLOCK")) + assert.True(t, mock.hasExecutedMethod("RunScript", "10\\.0\\.0\\.1")) +} + +func TestModuleBlockinfile_Good_CustomMarkers(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleBlockinfileWithClient(e, mock, map[string]any{ + "path": "/etc/hosts", + "block": "10.0.0.5 db01", + "marker": "# {mark} managed by devops", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + // Should use custom markers instead of default + assert.True(t, mock.hasExecutedMethod("RunScript", "# BEGIN managed by devops")) + assert.True(t, mock.hasExecutedMethod("RunScript", "# END managed by devops")) +} + +func TestModuleBlockinfile_Good_RemoveBlock(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleBlockinfileWithClient(e, mock, map[string]any{ + "path": "/etc/config", + "state": "absent", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + // Should use sed to remove the block between markers + assert.True(t, mock.hasExecuted(`sed -i '/.*BEGIN ANSIBLE MANAGED BLOCK/,/.*END ANSIBLE MANAGED BLOCK/d'`)) +} + +func TestModuleBlockinfile_Good_CreateFile(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleBlockinfileWithClient(e, mock, map[string]any{ + "path": "/etc/new-config", + "block": "setting=value", + "create": true, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + // Should touch the file first when create=true + assert.True(t, mock.hasExecuted(`touch "/etc/new-config"`)) +} + +func TestModuleBlockinfile_Bad_MissingPath(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + _, err := moduleBlockinfileWithClient(e, mock, map[string]any{ + "block": "content", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "path required") +} + +func TestModuleBlockinfile_Good_DestAliasForPath(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleBlockinfileWithClient(e, mock, map[string]any{ + "dest": "/etc/config", + "block": "data", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) +} + +func TestModuleBlockinfile_Good_ScriptFailure(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("BLOCK_EOF", "", "write error", 1) + + result, err := moduleBlockinfileWithClient(e, mock, map[string]any{ + "path": "/etc/config", + "block": "data", + }) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Contains(t, result.Msg, "write error") +} + +// --- stat module --- + +func TestModuleStat_Good_ExistingFile(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.addStat("/etc/nginx/nginx.conf", map[string]any{ + "exists": true, + "isdir": false, + "mode": "0644", + "size": 1234, + "uid": 0, + "gid": 0, + }) + + result, err := moduleStatWithClient(e, mock, map[string]any{ + "path": "/etc/nginx/nginx.conf", + }) + + require.NoError(t, err) + assert.False(t, result.Changed) // stat never changes anything + require.NotNil(t, result.Data) + + stat, ok := result.Data["stat"].(map[string]any) + require.True(t, ok) + assert.Equal(t, true, stat["exists"]) + assert.Equal(t, false, stat["isdir"]) + assert.Equal(t, "0644", stat["mode"]) + assert.Equal(t, 1234, stat["size"]) +} + +func TestModuleStat_Good_MissingFile(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleStatWithClient(e, mock, map[string]any{ + "path": "/nonexistent/file.txt", + }) + + require.NoError(t, err) + assert.False(t, result.Changed) + require.NotNil(t, result.Data) + + stat, ok := result.Data["stat"].(map[string]any) + require.True(t, ok) + assert.Equal(t, false, stat["exists"]) +} + +func TestModuleStat_Good_Directory(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.addStat("/var/log", map[string]any{ + "exists": true, + "isdir": true, + "mode": "0755", + }) + + result, err := moduleStatWithClient(e, mock, map[string]any{ + "path": "/var/log", + }) + + require.NoError(t, err) + stat := result.Data["stat"].(map[string]any) + assert.Equal(t, true, stat["exists"]) + assert.Equal(t, true, stat["isdir"]) +} + +func TestModuleStat_Good_FallbackFromFileSystem(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + // No explicit stat, but add a file — stat falls back to file existence + mock.addFile("/etc/hosts", []byte("127.0.0.1 localhost")) + + result, err := moduleStatWithClient(e, mock, map[string]any{ + "path": "/etc/hosts", + }) + + require.NoError(t, err) + stat := result.Data["stat"].(map[string]any) + assert.Equal(t, true, stat["exists"]) + assert.Equal(t, false, stat["isdir"]) +} + +func TestModuleStat_Bad_MissingPath(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + _, err := moduleStatWithClient(e, mock, map[string]any{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "path required") +} + +// --- template module --- + +func TestModuleTemplate_Good_BasicTemplate(t *testing.T) { + tmpDir := t.TempDir() + srcPath := filepath.Join(tmpDir, "app.conf.j2") + require.NoError(t, os.WriteFile(srcPath, []byte("server_name={{ server_name }};"), 0644)) + + e, mock := newTestExecutorWithMock("host1") + e.SetVar("server_name", "web01.example.com") + + result, err := moduleTemplateWithClient(e, mock, map[string]any{ + "src": srcPath, + "dest": "/etc/nginx/conf.d/app.conf", + }, "host1", &Task{}) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.Contains(t, result.Msg, "templated to /etc/nginx/conf.d/app.conf") + + // Verify upload was performed with templated content + assert.Equal(t, 1, mock.uploadCount()) + up := mock.lastUpload() + require.NotNil(t, up) + assert.Equal(t, "/etc/nginx/conf.d/app.conf", up.Remote) + // Template replaces {{ var }} — the TemplateFile does Jinja2 to Go conversion + assert.Contains(t, string(up.Content), "web01.example.com") +} + +func TestModuleTemplate_Good_CustomMode(t *testing.T) { + tmpDir := t.TempDir() + srcPath := filepath.Join(tmpDir, "script.sh.j2") + require.NoError(t, os.WriteFile(srcPath, []byte("#!/bin/bash\necho done"), 0644)) + + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleTemplateWithClient(e, mock, map[string]any{ + "src": srcPath, + "dest": "/usr/local/bin/run.sh", + "mode": "0755", + }, "host1", &Task{}) + + require.NoError(t, err) + assert.True(t, result.Changed) + + up := mock.lastUpload() + require.NotNil(t, up) + assert.Equal(t, os.FileMode(0755), up.Mode) +} + +func TestModuleTemplate_Bad_MissingSrc(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + _, err := moduleTemplateWithClient(e, mock, map[string]any{ + "dest": "/tmp/out", + }, "host1", &Task{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "src and dest required") +} + +func TestModuleTemplate_Bad_MissingDest(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + _, err := moduleTemplateWithClient(e, mock, map[string]any{ + "src": "/tmp/in.j2", + }, "host1", &Task{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "src and dest required") +} + +func TestModuleTemplate_Bad_SrcFileNotFound(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + _, err := moduleTemplateWithClient(e, mock, map[string]any{ + "src": "/nonexistent/template.j2", + "dest": "/tmp/out", + }, "host1", &Task{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "template") +} + +func TestModuleTemplate_Good_PlainTextNoVars(t *testing.T) { + tmpDir := t.TempDir() + srcPath := filepath.Join(tmpDir, "static.conf") + content := "listen 80;\nserver_name localhost;" + require.NoError(t, os.WriteFile(srcPath, []byte(content), 0644)) + + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleTemplateWithClient(e, mock, map[string]any{ + "src": srcPath, + "dest": "/etc/config", + }, "host1", &Task{}) + + require.NoError(t, err) + assert.True(t, result.Changed) + + up := mock.lastUpload() + require.NotNil(t, up) + assert.Equal(t, content, string(up.Content)) +} + +// --- Cross-module dispatch tests for file modules --- + +func TestExecuteModuleWithMock_Good_DispatchCopy(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + task := &Task{ + Module: "copy", + Args: map[string]any{ + "content": "hello world", + "dest": "/tmp/hello.txt", + }, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.Equal(t, 1, mock.uploadCount()) +} + +func TestExecuteModuleWithMock_Good_DispatchFile(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + task := &Task{ + Module: "file", + Args: map[string]any{ + "path": "/opt/data", + "state": "directory", + }, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted("mkdir")) +} + +func TestExecuteModuleWithMock_Good_DispatchStat(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.addStat("/etc/hosts", map[string]any{"exists": true, "isdir": false}) + + task := &Task{ + Module: "stat", + Args: map[string]any{ + "path": "/etc/hosts", + }, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.False(t, result.Changed) + stat := result.Data["stat"].(map[string]any) + assert.Equal(t, true, stat["exists"]) +} + +func TestExecuteModuleWithMock_Good_DispatchLineinfile(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + task := &Task{ + Module: "lineinfile", + Args: map[string]any{ + "path": "/etc/hosts", + "line": "10.0.0.1 dbhost", + }, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) +} + +func TestExecuteModuleWithMock_Good_DispatchBlockinfile(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + task := &Task{ + Module: "blockinfile", + Args: map[string]any{ + "path": "/etc/config", + "block": "key=value", + }, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) +} + +func TestExecuteModuleWithMock_Good_DispatchTemplate(t *testing.T) { + tmpDir := t.TempDir() + srcPath := filepath.Join(tmpDir, "test.j2") + require.NoError(t, os.WriteFile(srcPath, []byte("static content"), 0644)) + + e, mock := newTestExecutorWithMock("host1") + + task := &Task{ + Module: "template", + Args: map[string]any{ + "src": srcPath, + "dest": "/etc/out.conf", + }, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.Equal(t, 1, mock.uploadCount()) +} + +// --- Template variable resolution integration --- + +func TestModuleCopy_Good_TemplatedArgs(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + e.SetVar("deploy_path", "/opt/myapp") + + task := &Task{ + Module: "copy", + Args: map[string]any{ + "content": "deployed", + "dest": "{{ deploy_path }}/config.yml", + }, + } + + // Template the args as the executor does + args := e.templateArgs(task.Args, "host1", task) + result, err := moduleCopyWithClient(e, mock, args, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) + + up := mock.lastUpload() + require.NotNil(t, up) + assert.Equal(t, "/opt/myapp/config.yml", up.Remote) +} + +func TestModuleFile_Good_TemplatedPath(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + e.SetVar("app_dir", "/var/www/html") + + task := &Task{ + Module: "file", + Args: map[string]any{ + "path": "{{ app_dir }}/uploads", + "state": "directory", + "owner": "www-data", + }, + } + + args := e.templateArgs(task.Args, "host1", task) + result, err := moduleFileWithClient(e, mock, args) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted(`mkdir -p "/var/www/html/uploads"`)) + assert.True(t, mock.hasExecuted(`chown www-data "/var/www/html/uploads"`)) +}