package ansible import ( "context" "crypto/sha256" "crypto/sha512" "encoding/hex" "io" "io/fs" "regexp" "strings" "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type diffFileClient struct { mu sync.Mutex files map[string]string } func newDiffFileClient(initial map[string]string) *diffFileClient { files := make(map[string]string, len(initial)) for path, content := range initial { files[path] = content } return &diffFileClient{files: files} } func (c *diffFileClient) Run(_ context.Context, cmd string) (string, string, int, error) { c.mu.Lock() defer c.mu.Unlock() if strings.Contains(cmd, `|| echo `) && strings.Contains(cmd, `grep -qxF `) { re := regexp.MustCompile(`grep -qxF "([^"]*)" "([^"]*)" \|\| echo "([^"]*)" >> "([^"]*)"`) match := re.FindStringSubmatch(cmd) if len(match) == 5 { line := match[3] path := match[4] if c.files[path] == "" { c.files[path] = line + "\n" } else if !strings.Contains(c.files[path], line+"\n") && c.files[path] != line { if !strings.HasSuffix(c.files[path], "\n") { c.files[path] += "\n" } c.files[path] += line + "\n" } } } if strings.Contains(cmd, `sed -i '/`) && strings.Contains(cmd, `/d' `) { re := regexp.MustCompile(`sed -i '/([^']*)/d' "([^"]*)"`) match := re.FindStringSubmatch(cmd) if len(match) == 3 { pattern := match[1] path := match[2] lines := strings.Split(c.files[path], "\n") out := make([]string, 0, len(lines)) for _, line := range lines { if line == "" { continue } if strings.Contains(line, pattern) { continue } out = append(out, line) } if len(out) > 0 { c.files[path] = strings.Join(out, "\n") + "\n" } else { c.files[path] = "" } } } return "", "", 0, nil } func (c *diffFileClient) RunScript(_ context.Context, script string) (string, string, int, error) { c.mu.Lock() defer c.mu.Unlock() re := regexp.MustCompile("(?s)cat >> \"([^\"]+)\" << 'BLOCK_EOF'\\n(.*)\\nBLOCK_EOF") match := re.FindStringSubmatch(script) if len(match) == 3 { path := match[1] block := match[2] c.files[path] = block + "\n" } return "", "", 0, nil } func (c *diffFileClient) Upload(_ context.Context, local io.Reader, remote string, _ fs.FileMode) error { c.mu.Lock() defer c.mu.Unlock() content, err := io.ReadAll(local) if err != nil { return err } c.files[remote] = string(content) return nil } func (c *diffFileClient) Download(_ context.Context, remote string) ([]byte, error) { c.mu.Lock() defer c.mu.Unlock() content, ok := c.files[remote] if !ok { return nil, mockError("diffFileClient.Download", "file not found: "+remote) } return []byte(content), nil } func (c *diffFileClient) Stat(_ context.Context, path string) (map[string]any, error) { c.mu.Lock() defer c.mu.Unlock() if _, ok := c.files[path]; ok { return map[string]any{"exists": true}, nil } return map[string]any{"exists": false}, nil } func (c *diffFileClient) FileExists(_ context.Context, path string) (bool, error) { c.mu.Lock() defer c.mu.Unlock() _, ok := c.files[path] return ok, nil } func (c *diffFileClient) BecomeState() (bool, string, string) { return false, "", "" } func (c *diffFileClient) SetBecome(bool, string, string) {} func (c *diffFileClient) Close() error { return nil } // ============================================================ // Step 1.2: copy / template / file / lineinfile / blockinfile / stat module tests // ============================================================ // --- copy module --- func TestModulesFile_ModuleCopy_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, fs.FileMode(0644), up.Mode) } func TestModulesFile_ModuleCopy_Good_SrcFile(t *testing.T) { tmpDir := t.TempDir() srcPath := joinPath(tmpDir, "nginx.conf") require.NoError(t, writeTestFile(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 TestModulesFile_ModuleCopy_Good_RemoteSrc(t *testing.T) { e, mock := newTestExecutorWithMock("host1") mock.addFile("/tmp/remote-source.txt", []byte("remote payload")) result, err := e.moduleCopy(context.Background(), mock, map[string]any{ "src": "/tmp/remote-source.txt", "dest": "/etc/app/remote.txt", "remote_src": true, }, "host1", &Task{}) require.NoError(t, err) assert.True(t, result.Changed) up := mock.lastUpload() require.NotNil(t, up) assert.Equal(t, "/etc/app/remote.txt", up.Remote) assert.Equal(t, []byte("remote payload"), up.Content) } func TestModulesFile_ModuleCopy_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 TestModulesFile_ModuleCopy_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, fs.FileMode(0755), up.Mode) } func TestModulesFile_ModuleCopy_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 TestModulesFile_ModuleCopy_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 TestModulesFile_ModuleCopy_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 TestModulesFile_ModuleCopy_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) } func TestModulesFile_ModuleCopy_Good_SkipsUnchangedContent(t *testing.T) { e, mock := newTestExecutorWithMock("host1") e.Diff = true mock.addFile("/etc/app/config", []byte("server_name=web01")) result, err := moduleCopyWithClient(e, mock, map[string]any{ "content": "server_name=web01", "dest": "/etc/app/config", }, "host1", &Task{}) require.NoError(t, err) assert.False(t, result.Changed) assert.Equal(t, 0, mock.uploadCount()) assert.Contains(t, result.Msg, "already up to date") } func TestModulesFile_ModuleCopy_Good_ForceFalseSkipsExistingDest(t *testing.T) { e, mock := newTestExecutorWithMock("host1") mock.addFile("/etc/app/config", []byte("server_name=old")) result, err := moduleCopyWithClient(e, mock, map[string]any{ "content": "server_name=new", "dest": "/etc/app/config", "force": false, }, "host1", &Task{}) require.NoError(t, err) assert.False(t, result.Changed) assert.Equal(t, 0, mock.uploadCount()) assert.Contains(t, result.Msg, "skipped existing destination") } func TestModulesFile_ModuleCopy_Good_BackupExistingDest(t *testing.T) { e, mock := newTestExecutorWithMock("host1") mock.addFile("/etc/app/config", []byte("server_name=old")) result, err := moduleCopyWithClient(e, mock, map[string]any{ "content": "server_name=new", "dest": "/etc/app/config", "backup": true, }, "host1", &Task{}) require.NoError(t, err) assert.True(t, result.Changed) require.NotNil(t, result.Data) backupPath, ok := result.Data["backup_file"].(string) require.True(t, ok) assert.Contains(t, backupPath, "/etc/app/config.") assert.Equal(t, 2, mock.uploadCount()) backupContent, err := mock.Download(context.Background(), backupPath) require.NoError(t, err) assert.Equal(t, []byte("server_name=old"), backupContent) } // --- file module --- func TestModulesFile_ModuleFile_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 TestModulesFile_ModuleFile_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 TestModulesFile_ModuleFile_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 TestModulesFile_ModuleFile_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 TestModulesFile_ModuleFile_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 TestModulesFile_ModuleFile_Good_StateHard(t *testing.T) { e, mock := newTestExecutorWithMock("host1") result, err := moduleFileWithClient(e, mock, map[string]any{ "path": "/usr/local/bin/node", "state": "hard", "src": "/opt/node/bin/node", }) require.NoError(t, err) assert.True(t, result.Changed) assert.True(t, mock.hasExecuted(`ln -f "/opt/node/bin/node" "/usr/local/bin/node"`)) } func TestModulesFile_ModuleFile_Bad_StateHardMissingSrc(t *testing.T) { e, mock := newTestExecutorWithMock("host1") _, err := moduleFileWithClient(e, mock, map[string]any{ "path": "/usr/local/bin/node", "state": "hard", }) assert.Error(t, err) assert.Contains(t, err.Error(), "src required for hard state") } func TestModulesFile_ModuleFile_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 TestModulesFile_ModuleFile_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 TestModulesFile_ModuleFile_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 TestModulesFile_ModuleFile_Good_RecurseGroupAndMode(t *testing.T) { e, mock := newTestExecutorWithMock("host1") result, err := moduleFileWithClient(e, mock, map[string]any{ "path": "/srv/app", "state": "directory", "group": "appgroup", "mode": "0770", "recurse": true, }) require.NoError(t, err) assert.True(t, result.Changed) assert.True(t, mock.hasExecuted(`chgrp appgroup "/srv/app"`)) assert.True(t, mock.hasExecuted(`chgrp -R appgroup "/srv/app"`)) assert.True(t, mock.hasExecuted(`chmod -R 0770 "/srv/app"`)) } func TestModulesFile_ModuleFile_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 TestModulesFile_ModuleFile_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 TestModulesFile_ModuleFile_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 TestModulesFile_ModuleFile_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 TestModulesFile_ModuleLineinfile_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 TestModulesFile_ModuleLineinfile_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 TestModulesFile_ModuleLineinfile_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 TestModulesFile_ModuleLineinfile_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 TestModulesFile_ModuleLineinfile_Good_CreateFile(t *testing.T) { e, mock := newTestExecutorWithMock("host1") result, err := e.moduleLineinfile(context.Background(), mock, map[string]any{ "path": "/etc/example.conf", "regexp": "^setting=", "line": "setting=value", "create": true, }) require.NoError(t, err) assert.True(t, result.Changed) assert.True(t, mock.hasExecuted(`touch "/etc/example\.conf"`)) assert.True(t, mock.hasExecuted(`sed -i`)) } func TestModulesFile_ModuleLineinfile_Good_BackrefsReplaceMatchOnly(t *testing.T) { e, mock := newTestExecutorWithMock("host1") result, err := moduleLineinfileWithClient(e, mock, map[string]any{ "path": "/etc/example.conf", "regexp": "^(foo=).*$", "line": "\\1bar", "backrefs": true, }) require.NoError(t, err) assert.True(t, result.Changed) assert.True(t, mock.hasExecuted(`grep -Eq`)) cmd := mock.lastCommand() assert.Equal(t, "Run", cmd.Method) assert.Contains(t, cmd.Cmd, "sed -E -i") assert.Contains(t, cmd.Cmd, "s/^(foo=).*$") assert.Contains(t, cmd.Cmd, "\\1bar") assert.Contains(t, cmd.Cmd, `"/etc/example.conf"`) assert.False(t, mock.hasExecuted(`echo`)) } func TestModulesFile_ModuleLineinfile_Good_BackrefsNoMatchNoAppend(t *testing.T) { e, mock := newTestExecutorWithMock("host1") mock.expectCommand("grep -Eq", "", "", 1) result, err := moduleLineinfileWithClient(e, mock, map[string]any{ "path": "/etc/example.conf", "regexp": "^(foo=).*$", "line": "\\1bar", "backrefs": true, }) require.NoError(t, err) assert.False(t, result.Changed) assert.Equal(t, 1, mock.commandCount()) assert.Contains(t, mock.lastCommand().Cmd, "grep -Eq") assert.False(t, mock.hasExecuted(`echo`)) } func TestModulesFile_ModuleLineinfile_Good_InsertBeforeAnchor(t *testing.T) { e, mock := newTestExecutorWithMock("host1") result, err := e.moduleLineinfile(context.Background(), mock, map[string]any{ "path": "/etc/example.conf", "line": "setting=value", "insertbefore": "^# managed settings", "firstmatch": true, }) require.NoError(t, err) assert.True(t, result.Changed) assert.True(t, mock.hasExecuted(`grep -Eq`)) assert.True(t, mock.hasExecuted(regexp.QuoteMeta("print line; done=1 } print"))) } func TestModulesFile_ModuleLineinfile_Good_InsertAfterAnchor(t *testing.T) { e, mock := newTestExecutorWithMock("host1") result, err := e.moduleLineinfile(context.Background(), mock, map[string]any{ "path": "/etc/example.conf", "line": "setting=value", "insertafter": "^# managed settings", "firstmatch": true, }) require.NoError(t, err) assert.True(t, result.Changed) assert.True(t, mock.hasExecuted(`grep -Eq`)) assert.True(t, mock.hasExecuted(regexp.QuoteMeta("print; if (!done && $0 ~ re) { print line; done=1 }"))) } func TestModulesFile_ModuleLineinfile_Good_InsertAfterAnchor_DefaultUsesLastMatch(t *testing.T) { e, mock := newTestExecutorWithMock("host1") result, err := e.moduleLineinfile(context.Background(), mock, map[string]any{ "path": "/etc/example.conf", "line": "setting=value", "insertafter": "^# managed settings", }) require.NoError(t, err) assert.True(t, result.Changed) assert.True(t, mock.hasExecuted(`grep -Eq`)) assert.True(t, mock.hasExecuted("pos=NR")) assert.False(t, mock.hasExecuted("done=1")) } func TestModulesFile_ModuleLineinfile_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 TestModulesFile_ModuleLineinfile_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 TestModulesFile_ModuleLineinfile_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 TestModulesFile_ModuleLineinfile_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;`)) } func TestModulesFile_ModuleLineinfile_Good_ExactLineAlreadyPresentIsNoOp(t *testing.T) { e, mock := newTestExecutorWithMock("host1") mock.addFile("/etc/example.conf", []byte("setting=value\nother=1\n")) result, err := e.moduleLineinfile(context.Background(), mock, map[string]any{ "path": "/etc/example.conf", "line": "setting=value", }) require.NoError(t, err) assert.False(t, result.Changed) assert.Contains(t, result.Msg, "already up to date") assert.Equal(t, 0, mock.commandCount()) } func TestModulesFile_ModuleLineinfile_Good_SearchStringReplacesMatchingLine(t *testing.T) { e, mock := newTestExecutorWithMock("host1") mock.addFile("/etc/ssh/sshd_config", []byte("PermitRootLogin yes\nPasswordAuthentication yes\n")) result, err := moduleLineinfileWithClient(e, mock, map[string]any{ "path": "/etc/ssh/sshd_config", "search_string": "PermitRootLogin", "line": "PermitRootLogin no", }) require.NoError(t, err) assert.True(t, result.Changed) after, err := mock.Download(context.Background(), "/etc/ssh/sshd_config") require.NoError(t, err) assert.Equal(t, "PermitRootLogin no\nPasswordAuthentication yes\n", string(after)) } func TestModulesFile_ModuleLineinfile_Good_SearchStringRemovesMatchingLine(t *testing.T) { e, mock := newTestExecutorWithMock("host1") mock.addFile("/etc/ssh/sshd_config", []byte("PermitRootLogin yes\nPasswordAuthentication yes\n")) result, err := moduleLineinfileWithClient(e, mock, map[string]any{ "path": "/etc/ssh/sshd_config", "search_string": "PermitRootLogin", "state": "absent", }) require.NoError(t, err) assert.True(t, result.Changed) after, err := mock.Download(context.Background(), "/etc/ssh/sshd_config") require.NoError(t, err) assert.Equal(t, "PasswordAuthentication yes\n", string(after)) } func TestModulesFile_ModuleLineinfile_Good_BackupExistingFile(t *testing.T) { e, mock := newTestExecutorWithMock("host1") path := "/etc/example.conf" mock.addFile(path, []byte("setting=old\n")) result, err := e.moduleLineinfile(context.Background(), mock, map[string]any{ "path": path, "line": "setting=new", "backup": true, }) require.NoError(t, err) assert.True(t, result.Changed) require.NotNil(t, result.Data) backupPath, ok := result.Data["backup_file"].(string) require.True(t, ok) assert.Contains(t, backupPath, "/etc/example.conf.") backupContent, err := mock.Download(context.Background(), backupPath) require.NoError(t, err) assert.Equal(t, []byte("setting=old\n"), backupContent) } func TestModulesFile_ModuleLineinfile_Good_DiffData(t *testing.T) { e := NewExecutor("/tmp") e.Diff = true client := newDiffFileClient(map[string]string{ "/etc/example.conf": "setting=old\n", }) result, err := e.moduleLineinfile(context.Background(), client, map[string]any{ "path": "/etc/example.conf", "line": "setting=new", }) require.NoError(t, err) assert.True(t, result.Changed) require.NotNil(t, result.Data) diff, ok := result.Data["diff"].(map[string]any) require.True(t, ok) assert.Equal(t, "/etc/example.conf", diff["path"]) assert.Equal(t, "setting=old\n", diff["before"]) assert.Contains(t, diff["after"], "setting=new") } // --- replace module --- func TestModulesFile_ModuleReplace_Good_RegexpReplacementWithBackupAndDiff(t *testing.T) { e := NewExecutor("/tmp") e.Diff = true client := newDiffFileClient(map[string]string{ "/etc/app.conf": "port=8080\nmode=prod\n", }) result, err := e.moduleReplace(context.Background(), client, map[string]any{ "path": "/etc/app.conf", "regexp": `port=(\d+)`, "replace": "port=9090", "backup": true, }) require.NoError(t, err) assert.True(t, result.Changed) require.NotNil(t, result.Data) assert.Contains(t, result.Data, "backup_file") assert.Contains(t, result.Data, "diff") after, err := client.Download(context.Background(), "/etc/app.conf") require.NoError(t, err) assert.Equal(t, "port=9090\nmode=prod\n", string(after)) backupPath, _ := result.Data["backup_file"].(string) require.NotEmpty(t, backupPath) backup, err := client.Download(context.Background(), backupPath) require.NoError(t, err) assert.Equal(t, "port=8080\nmode=prod\n", string(backup)) } func TestModulesFile_ModuleReplace_Good_NoOpWhenPatternMissing(t *testing.T) { e := NewExecutor("/tmp") client := newDiffFileClient(map[string]string{ "/etc/app.conf": "port=8080\n", }) result, err := e.moduleReplace(context.Background(), client, map[string]any{ "path": "/etc/app.conf", "regexp": `mode=.+`, "replace": "mode=prod", }) require.NoError(t, err) assert.False(t, result.Changed) assert.Contains(t, result.Msg, "already up to date") } func TestModulesFile_ModuleReplace_Bad_MissingPath(t *testing.T) { e := NewExecutor("/tmp") client := newDiffFileClient(nil) _, err := e.moduleReplace(context.Background(), client, map[string]any{ "regexp": `mode=.+`, "replace": "mode=prod", }) assert.Error(t, err) assert.Contains(t, err.Error(), "path required") } // --- blockinfile module --- func TestModulesFile_ModuleBlockinfile_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 TestModulesFile_ModuleBlockinfile_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 TestModulesFile_ModuleBlockinfile_Good_NewlinePadding(t *testing.T) { e, mock := newTestExecutorWithMock("host1") result, err := moduleBlockinfileWithClient(e, mock, map[string]any{ "path": "/etc/hosts", "block": "10.0.0.5 db01", "prepend_newline": true, "append_newline": true, }) require.NoError(t, err) assert.True(t, result.Changed) cmd := mock.lastCommand().Cmd assert.Contains(t, cmd, "\n\n# BEGIN ANSIBLE MANAGED BLOCK\n10.0.0.5 db01\n# END ANSIBLE MANAGED BLOCK\n") } func TestModulesFile_ModuleBlockinfile_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 TestModulesFile_ModuleBlockinfile_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 TestModulesFile_ModuleBlockinfile_Good_BackupExistingDest(t *testing.T) { e, mock := newTestExecutorWithMock("host1") mock.addFile("/etc/config", []byte("old block contents")) result, err := moduleBlockinfileWithClient(e, mock, map[string]any{ "path": "/etc/config", "block": "new block contents", "backup": true, }) require.NoError(t, err) assert.True(t, result.Changed) require.NotNil(t, result.Data) backupPath, ok := result.Data["backup_file"].(string) require.True(t, ok) assert.Contains(t, backupPath, "/etc/config.") assert.Equal(t, 1, mock.uploadCount()) backupContent, err := mock.Download(context.Background(), backupPath) require.NoError(t, err) assert.Equal(t, []byte("old block contents"), backupContent) } func TestModulesFile_ModuleBlockinfile_Good_DiffData(t *testing.T) { e := NewExecutor("/tmp") e.Diff = true client := newDiffFileClient(map[string]string{ "/etc/config": "old block contents\n", }) result, err := e.moduleBlockinfile(context.Background(), client, map[string]any{ "path": "/etc/config", "block": "new block contents", }) require.NoError(t, err) assert.True(t, result.Changed) require.NotNil(t, result.Data) diff, ok := result.Data["diff"].(map[string]any) require.True(t, ok) assert.Equal(t, "/etc/config", diff["path"]) assert.Equal(t, "old block contents\n", diff["before"]) assert.Contains(t, diff["after"], "new block contents") } func TestModulesFile_ModuleBlockinfile_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 TestModulesFile_ModuleBlockinfile_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 TestModulesFile_ModuleBlockinfile_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 TestModulesFile_ModuleStat_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 TestModulesFile_ModuleStat_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 TestModulesFile_ModuleStat_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 TestModulesFile_ModuleStat_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 TestModulesFile_ModuleStat_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 TestModulesFile_ModuleTemplate_Good_BasicTemplate(t *testing.T) { tmpDir := t.TempDir() srcPath := joinPath(tmpDir, "app.conf.j2") require.NoError(t, writeTestFile(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 TestModulesFile_ModuleTemplate_Good_AnsibleFactsMapTemplate(t *testing.T) { tmpDir := t.TempDir() srcPath := joinPath(tmpDir, "facts.conf.j2") require.NoError(t, writeTestFile(srcPath, []byte("host={{ ansible_facts.ansible_hostname }}"), 0644)) e, mock := newTestExecutorWithMock("host1") e.facts["host1"] = &Facts{ Hostname: "web01", Distribution: "debian", } result, err := moduleTemplateWithClient(e, mock, map[string]any{ "src": srcPath, "dest": "/etc/app/facts.conf", }, "host1", &Task{}) require.NoError(t, err) assert.True(t, result.Changed) up := mock.lastUpload() require.NotNil(t, up) assert.Contains(t, string(up.Content), "host=web01") } func TestModulesFile_ModuleTemplate_Good_TaskVarsAndHostMagicVars(t *testing.T) { tmpDir := t.TempDir() srcPath := joinPath(tmpDir, "context.conf.j2") require.NoError(t, writeTestFile(srcPath, []byte("short={{ inventory_hostname_short }} local={{ local_value }}"), 0644)) e, mock := newTestExecutorWithMock("web01.example.com") task := &Task{ Vars: map[string]any{ "local_value": "from-task", }, } result, err := moduleTemplateWithClient(e, mock, map[string]any{ "src": srcPath, "dest": "/etc/app/context.conf", }, "web01.example.com", task) require.NoError(t, err) assert.True(t, result.Changed) up := mock.lastUpload() require.NotNil(t, up) assert.Contains(t, string(up.Content), "short=web01") assert.Contains(t, string(up.Content), "local=from-task") } func TestModulesFile_ModuleTemplate_Good_CustomMode(t *testing.T) { tmpDir := t.TempDir() srcPath := joinPath(tmpDir, "script.sh.j2") require.NoError(t, writeTestFile(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, fs.FileMode(0755), up.Mode) } func TestModulesFile_ModuleTemplate_Good_ForceFalseSkipsExistingDest(t *testing.T) { tmpDir := t.TempDir() srcPath := joinPath(tmpDir, "config.tmpl") require.NoError(t, writeTestFile(srcPath, []byte("server_name={{ inventory_hostname }}"), 0644)) e, mock := newTestExecutorWithMock("host1") mock.addFile("/etc/app/config", []byte("server_name=old")) result, err := moduleTemplateWithClient(e, mock, map[string]any{ "src": srcPath, "dest": "/etc/app/config", "force": false, }, "host1", &Task{}) require.NoError(t, err) assert.False(t, result.Changed) assert.Equal(t, 0, mock.uploadCount()) assert.Contains(t, result.Msg, "skipped existing destination") } func TestModulesFile_ModuleTemplate_Good_BackupExistingDest(t *testing.T) { tmpDir := t.TempDir() srcPath := joinPath(tmpDir, "config.tmpl") require.NoError(t, writeTestFile(srcPath, []byte("server_name={{ inventory_hostname }}"), 0644)) e, mock := newTestExecutorWithMock("host1") mock.addFile("/etc/app/config", []byte("server_name=old")) result, err := moduleTemplateWithClient(e, mock, map[string]any{ "src": srcPath, "dest": "/etc/app/config", "backup": true, }, "host1", &Task{}) require.NoError(t, err) assert.True(t, result.Changed) require.NotNil(t, result.Data) backupPath, ok := result.Data["backup_file"].(string) require.True(t, ok) assert.Contains(t, backupPath, "/etc/app/config.") assert.Equal(t, 2, mock.uploadCount()) backupContent, err := mock.Download(context.Background(), backupPath) require.NoError(t, err) assert.Equal(t, []byte("server_name=old"), backupContent) } func TestModulesFile_ModuleTemplate_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 TestModulesFile_ModuleTemplate_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 TestModulesFile_ModuleTemplate_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 TestModulesFile_ModuleTemplate_Good_PlainTextNoVars(t *testing.T) { tmpDir := t.TempDir() srcPath := joinPath(tmpDir, "static.conf") content := "listen 80;\nserver_name localhost;" require.NoError(t, writeTestFile(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)) } func TestModulesFile_ModuleTemplate_Good_DiffData(t *testing.T) { tmpDir := t.TempDir() srcPath := joinPath(tmpDir, "app.conf.j2") require.NoError(t, writeTestFile(srcPath, []byte("server_name={{ server_name }};"), 0644)) e, mock := newTestExecutorWithMock("host1") e.Diff = true e.SetVar("server_name", "web01.example.com") mock.addFile("/etc/nginx/conf.d/app.conf", []byte("server_name=old.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) require.NotNil(t, result.Data) diff, ok := result.Data["diff"].(map[string]any) require.True(t, ok) assert.Equal(t, "/etc/nginx/conf.d/app.conf", diff["path"]) assert.Equal(t, "server_name=old.example.com;", diff["before"]) assert.Contains(t, diff["after"], "web01.example.com") } // --- Cross-module dispatch tests for file modules --- func TestModulesFile_ExecuteModuleWithMock_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 TestModulesFile_ExecuteModuleWithMock_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 TestModulesFile_ExecuteModuleWithMock_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 TestModulesFile_ExecuteModuleWithMock_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 TestModulesFile_ExecuteModuleWithMock_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 TestModulesFile_ExecuteModuleWithMock_Good_DispatchTemplate(t *testing.T) { tmpDir := t.TempDir() srcPath := joinPath(tmpDir, "test.j2") require.NoError(t, writeTestFile(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 TestModulesFile_ModuleCopy_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 TestModulesFile_ModuleFile_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"`)) } func TestModulesFile_ModuleGetURL_Good_Checksum(t *testing.T) { e, mock := newTestExecutorWithMock("host1") payload := "downloaded artifact" mock.expectCommand(`curl.*https://downloads\.example\.com/app\.tgz`, payload, "", 0) sum := sha256.Sum256([]byte(payload)) result, err := e.moduleGetURL(context.Background(), mock, map[string]any{ "url": "https://downloads.example.com/app.tgz", "dest": "/tmp/app.tgz", "checksum": "sha256:" + hex.EncodeToString(sum[:]), "mode": "0600", }) require.NoError(t, err) assert.True(t, result.Changed) assert.Equal(t, 1, mock.uploadCount()) up := mock.lastUpload() require.NotNil(t, up) assert.Equal(t, "/tmp/app.tgz", up.Remote) assert.Equal(t, []byte(payload), up.Content) assert.Equal(t, fs.FileMode(0600), up.Mode) } func TestModulesFile_ModuleGetURL_Good_Sha512Checksum(t *testing.T) { e, mock := newTestExecutorWithMock("host1") payload := "downloaded artifact" mock.expectCommand(`curl.*https://downloads\.example\.com/app\.tgz`, payload, "", 0) sum := sha512.Sum512([]byte(payload)) result, err := e.moduleGetURL(context.Background(), mock, map[string]any{ "url": "https://downloads.example.com/app.tgz", "dest": "/tmp/app.tgz", "checksum": "sha512:" + hex.EncodeToString(sum[:]), }) require.NoError(t, err) assert.True(t, result.Changed) assert.Equal(t, 1, mock.uploadCount()) up := mock.lastUpload() require.NotNil(t, up) assert.Equal(t, "/tmp/app.tgz", up.Remote) assert.Equal(t, []byte(payload), up.Content) } func TestModulesFile_ModuleGetURL_Good_ChecksumFileURL(t *testing.T) { e, mock := newTestExecutorWithMock("host1") payload := "downloaded artifact" sum := sha256.Sum256([]byte(payload)) checksumURL := "https://downloads.example.com/app.tgz.sha256" mock.expectCommand(`curl.*https://downloads\.example\.com/app\.tgz\.sha256(?:["\s]|$)`, hex.EncodeToString(sum[:])+" app.tgz\n", "", 0) mock.expectCommand(`curl.*https://downloads\.example\.com/app\.tgz(?:["\s]|$)`, payload, "", 0) result, err := e.moduleGetURL(context.Background(), mock, map[string]any{ "url": "https://downloads.example.com/app.tgz", "dest": "/tmp/app.tgz", "checksum": "sha256:" + checksumURL, }) require.NoError(t, err) assert.True(t, result.Changed) assert.Equal(t, 1, mock.uploadCount()) up := mock.lastUpload() require.NotNil(t, up) assert.Equal(t, "/tmp/app.tgz", up.Remote) assert.Equal(t, []byte(payload), up.Content) } func TestModulesFile_ModuleGetURL_Bad_ChecksumMismatch(t *testing.T) { e, mock := newTestExecutorWithMock("host1") mock.expectCommand(`curl.*https://downloads\.example\.com/app\.tgz`, "downloaded artifact", "", 0) result, err := e.moduleGetURL(context.Background(), mock, map[string]any{ "url": "https://downloads.example.com/app.tgz", "dest": "/tmp/app.tgz", "checksum": "sha256:deadbeef", }) require.NoError(t, err) assert.True(t, result.Failed) assert.Contains(t, result.Msg, "checksum mismatch") assert.Equal(t, 0, mock.uploadCount()) }