test(ansible): Phase 1 Step 1.2 — file operation module tests

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 <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-20 02:39:27 +00:00
parent fd46e82297
commit c7da9ad16c
3 changed files with 1177 additions and 1 deletions

View file

@ -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.

View file

@ -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{},
}

View file

@ -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"`))
}