From 427929f0e9fd963d04637152ca1ecbe7ea66b587 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 20 Feb 2026 02:52:42 +0000 Subject: [PATCH] =?UTF-8?q?test(ansible):=20Phase=201=20Step=201.4=20?= =?UTF-8?q?=E2=80=94=20user/group=20&=20advanced=20module=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 69 new tests for user (7), group (7), cron (5), authorized_key (7), git (8), unarchive (8), uri (6), ufw (8), docker_compose (7), and dispatch (6) modules. 9 module shims added to mock infrastructure. Total ansible tests: 334, all passing. Co-Authored-By: Virgil --- ansible/mock_ssh_test.go | 448 ++++++++++++++ ansible/modules_adv_test.go | 1127 +++++++++++++++++++++++++++++++++++ 2 files changed, 1575 insertions(+) create mode 100644 ansible/modules_adv_test.go diff --git a/ansible/mock_ssh_test.go b/ansible/mock_ssh_test.go index d89471d..6452d74 100644 --- a/ansible/mock_ssh_test.go +++ b/ansible/mock_ssh_test.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "path/filepath" "regexp" "strconv" "strings" @@ -390,6 +391,40 @@ func executeModuleWithMock(e *Executor, mock *MockSSHClient, host string, task * case "ansible.builtin.pip": return modulePipWithClient(e, mock, args) + // User/group management + case "ansible.builtin.user": + return moduleUserWithClient(e, mock, args) + case "ansible.builtin.group": + return moduleGroupWithClient(e, mock, args) + + // Cron + case "ansible.builtin.cron": + return moduleCronWithClient(e, mock, args) + + // SSH keys + case "ansible.posix.authorized_key", "ansible.builtin.authorized_key": + return moduleAuthorizedKeyWithClient(e, mock, args) + + // Git + case "ansible.builtin.git": + return moduleGitWithClient(e, mock, args) + + // Archive + case "ansible.builtin.unarchive": + return moduleUnarchiveWithClient(e, mock, args) + + // HTTP + case "ansible.builtin.uri": + return moduleURIWithClient(e, mock, args) + + // Firewall + case "community.general.ufw", "ansible.builtin.ufw": + return moduleUFWWithClient(e, mock, args) + + // Docker + case "community.docker.docker_compose_v2", "ansible.builtin.docker_compose": + return moduleDockerComposeWithClient(e, mock, args) + default: return nil, fmt.Errorf("mock dispatch: unsupported module %s", module) } @@ -953,6 +988,419 @@ func modulePipWithClient(_ *Executor, client sshRunner, args map[string]any) (*T return &TaskResult{Changed: true}, nil } +// --- User/Group module shims --- + +func moduleUserWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) { + name := getStringArg(args, "name", "") + state := getStringArg(args, "state", "present") + + if name == "" { + return nil, fmt.Errorf("user: name required") + } + + if state == "absent" { + cmd := fmt.Sprintf("userdel -r %s 2>/dev/null || true", name) + _, _, _, _ = client.Run(context.Background(), cmd) + return &TaskResult{Changed: true}, nil + } + + // Build useradd/usermod command + var opts []string + + if uid := getStringArg(args, "uid", ""); uid != "" { + opts = append(opts, "-u", uid) + } + if group := getStringArg(args, "group", ""); group != "" { + opts = append(opts, "-g", group) + } + if groups := getStringArg(args, "groups", ""); groups != "" { + opts = append(opts, "-G", groups) + } + if home := getStringArg(args, "home", ""); home != "" { + opts = append(opts, "-d", home) + } + if shell := getStringArg(args, "shell", ""); shell != "" { + opts = append(opts, "-s", shell) + } + if getBoolArg(args, "system", false) { + opts = append(opts, "-r") + } + if getBoolArg(args, "create_home", true) { + opts = append(opts, "-m") + } + + // Try usermod first, then useradd + optsStr := strings.Join(opts, " ") + var cmd string + if optsStr == "" { + cmd = fmt.Sprintf("id %s >/dev/null 2>&1 || useradd %s", name, name) + } else { + cmd = fmt.Sprintf("id %s >/dev/null 2>&1 && usermod %s %s || useradd %s %s", + name, optsStr, name, optsStr, name) + } + + 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 + } + + return &TaskResult{Changed: true}, nil +} + +func moduleGroupWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) { + name := getStringArg(args, "name", "") + state := getStringArg(args, "state", "present") + + if name == "" { + return nil, fmt.Errorf("group: name required") + } + + if state == "absent" { + cmd := fmt.Sprintf("groupdel %s 2>/dev/null || true", name) + _, _, _, _ = client.Run(context.Background(), cmd) + return &TaskResult{Changed: true}, nil + } + + var opts []string + if gid := getStringArg(args, "gid", ""); gid != "" { + opts = append(opts, "-g", gid) + } + if getBoolArg(args, "system", false) { + opts = append(opts, "-r") + } + + cmd := fmt.Sprintf("getent group %s >/dev/null 2>&1 || groupadd %s %s", + name, strings.Join(opts, " "), name) + + 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 + } + + return &TaskResult{Changed: true}, nil +} + +// --- Cron module shim --- + +func moduleCronWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) { + name := getStringArg(args, "name", "") + job := getStringArg(args, "job", "") + state := getStringArg(args, "state", "present") + user := getStringArg(args, "user", "root") + + minute := getStringArg(args, "minute", "*") + hour := getStringArg(args, "hour", "*") + day := getStringArg(args, "day", "*") + month := getStringArg(args, "month", "*") + weekday := getStringArg(args, "weekday", "*") + + if state == "absent" { + if name != "" { + // Remove by name (comment marker) + cmd := fmt.Sprintf("crontab -u %s -l 2>/dev/null | grep -v '# %s' | grep -v '%s' | crontab -u %s -", + user, name, job, user) + _, _, _, _ = client.Run(context.Background(), cmd) + } + return &TaskResult{Changed: true}, nil + } + + // Build cron entry + schedule := fmt.Sprintf("%s %s %s %s %s", minute, hour, day, month, weekday) + entry := fmt.Sprintf("%s %s # %s", schedule, job, name) + + // Add to crontab + cmd := fmt.Sprintf("(crontab -u %s -l 2>/dev/null | grep -v '# %s' ; echo %q) | crontab -u %s -", + user, name, entry, user) + 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 + } + + return &TaskResult{Changed: true}, nil +} + +// --- Authorized key module shim --- + +func moduleAuthorizedKeyWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) { + user := getStringArg(args, "user", "") + key := getStringArg(args, "key", "") + state := getStringArg(args, "state", "present") + + if user == "" || key == "" { + return nil, fmt.Errorf("authorized_key: user and key required") + } + + // Get user's home directory + stdout, _, _, err := client.Run(context.Background(), fmt.Sprintf("getent passwd %s | cut -d: -f6", user)) + if err != nil { + return nil, fmt.Errorf("get home dir: %w", err) + } + home := strings.TrimSpace(stdout) + if home == "" { + home = "/root" + if user != "root" { + home = "/home/" + user + } + } + + authKeysPath := filepath.Join(home, ".ssh", "authorized_keys") + + if state == "absent" { + // Remove key + escapedKey := strings.ReplaceAll(key, "/", "\\/") + cmd := fmt.Sprintf("sed -i '/%s/d' %q 2>/dev/null || true", escapedKey[:40], authKeysPath) + _, _, _, _ = client.Run(context.Background(), cmd) + return &TaskResult{Changed: true}, nil + } + + // Ensure .ssh directory exists (best-effort) + _, _, _, _ = client.Run(context.Background(), fmt.Sprintf("mkdir -p %q && chmod 700 %q && chown %s:%s %q", + filepath.Dir(authKeysPath), filepath.Dir(authKeysPath), user, user, filepath.Dir(authKeysPath))) + + // Add key if not present + cmd := fmt.Sprintf("grep -qF %q %q 2>/dev/null || echo %q >> %q", + key[:40], authKeysPath, key, authKeysPath) + 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 + } + + // Fix permissions (best-effort) + _, _, _, _ = client.Run(context.Background(), fmt.Sprintf("chmod 600 %q && chown %s:%s %q", + authKeysPath, user, user, authKeysPath)) + + return &TaskResult{Changed: true}, nil +} + +// --- Git module shim --- + +func moduleGitWithClient(_ *Executor, client sshFileRunner, args map[string]any) (*TaskResult, error) { + repo := getStringArg(args, "repo", "") + dest := getStringArg(args, "dest", "") + version := getStringArg(args, "version", "HEAD") + + if repo == "" || dest == "" { + return nil, fmt.Errorf("git: repo and dest required") + } + + // Check if dest exists + exists, _ := client.FileExists(context.Background(), dest+"/.git") + + var cmd string + if exists { + cmd = fmt.Sprintf("cd %q && git fetch --all && git checkout --force %q", dest, version) + } else { + cmd = fmt.Sprintf("git clone %q %q && cd %q && git checkout %q", + repo, dest, dest, version) + } + + 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 + } + + return &TaskResult{Changed: true}, nil +} + +// --- Unarchive module shim --- + +func moduleUnarchiveWithClient(_ *Executor, client sshFileRunner, args map[string]any) (*TaskResult, error) { + src := getStringArg(args, "src", "") + dest := getStringArg(args, "dest", "") + remote := getBoolArg(args, "remote_src", false) + + if src == "" || dest == "" { + return nil, fmt.Errorf("unarchive: src and dest required") + } + + // Create dest directory (best-effort) + _, _, _, _ = client.Run(context.Background(), fmt.Sprintf("mkdir -p %q", dest)) + + var cmd string + if !remote { + // Upload local file first + content, err := os.ReadFile(src) + if err != nil { + return nil, fmt.Errorf("read src: %w", err) + } + tmpPath := "/tmp/ansible_unarchive_" + filepath.Base(src) + err = client.Upload(context.Background(), strings.NewReader(string(content)), tmpPath, 0644) + if err != nil { + return nil, err + } + src = tmpPath + defer func() { _, _, _, _ = client.Run(context.Background(), fmt.Sprintf("rm -f %q", tmpPath)) }() + } + + // Detect archive type and extract + if strings.HasSuffix(src, ".tar.gz") || strings.HasSuffix(src, ".tgz") { + cmd = fmt.Sprintf("tar -xzf %q -C %q", src, dest) + } else if strings.HasSuffix(src, ".tar.xz") { + cmd = fmt.Sprintf("tar -xJf %q -C %q", src, dest) + } else if strings.HasSuffix(src, ".tar.bz2") { + cmd = fmt.Sprintf("tar -xjf %q -C %q", src, dest) + } else if strings.HasSuffix(src, ".tar") { + cmd = fmt.Sprintf("tar -xf %q -C %q", src, dest) + } else if strings.HasSuffix(src, ".zip") { + cmd = fmt.Sprintf("unzip -o %q -d %q", src, dest) + } else { + cmd = fmt.Sprintf("tar -xf %q -C %q", src, dest) // Guess tar + } + + 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 + } + + return &TaskResult{Changed: true}, nil +} + +// --- URI module shim --- + +func moduleURIWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) { + url := getStringArg(args, "url", "") + method := getStringArg(args, "method", "GET") + + if url == "" { + return nil, fmt.Errorf("uri: url required") + } + + var curlOpts []string + curlOpts = append(curlOpts, "-s", "-S") + curlOpts = append(curlOpts, "-X", method) + + // Headers + if headers, ok := args["headers"].(map[string]any); ok { + for k, v := range headers { + curlOpts = append(curlOpts, "-H", fmt.Sprintf("%s: %v", k, v)) + } + } + + // Body + if body := getStringArg(args, "body", ""); body != "" { + curlOpts = append(curlOpts, "-d", body) + } + + // Status code + curlOpts = append(curlOpts, "-w", "\\n%{http_code}") + + cmd := fmt.Sprintf("curl %s %q", strings.Join(curlOpts, " "), url) + stdout, stderr, rc, err := client.Run(context.Background(), cmd) + if err != nil { + return &TaskResult{Failed: true, Msg: err.Error()}, nil + } + + // Parse status code from last line + lines := strings.Split(strings.TrimSpace(stdout), "\n") + statusCode := 0 + if len(lines) > 0 { + statusCode, _ = strconv.Atoi(lines[len(lines)-1]) + } + + // Check expected status + expectedStatus := 200 + if s, ok := args["status_code"].(int); ok { + expectedStatus = s + } + + failed := rc != 0 || statusCode != expectedStatus + + return &TaskResult{ + Changed: false, + Failed: failed, + Stdout: stdout, + Stderr: stderr, + RC: statusCode, + Data: map[string]any{"status": statusCode}, + }, nil +} + +// --- UFW module shim --- + +func moduleUFWWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) { + rule := getStringArg(args, "rule", "") + port := getStringArg(args, "port", "") + proto := getStringArg(args, "proto", "tcp") + state := getStringArg(args, "state", "") + + var cmd string + + // Handle state (enable/disable) + if state != "" { + switch state { + case "enabled": + cmd = "ufw --force enable" + case "disabled": + cmd = "ufw disable" + case "reloaded": + cmd = "ufw reload" + case "reset": + cmd = "ufw --force reset" + } + if cmd != "" { + 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 + } + return &TaskResult{Changed: true}, nil + } + } + + // Handle rule + if rule != "" && port != "" { + switch rule { + case "allow": + cmd = fmt.Sprintf("ufw allow %s/%s", port, proto) + case "deny": + cmd = fmt.Sprintf("ufw deny %s/%s", port, proto) + case "reject": + cmd = fmt.Sprintf("ufw reject %s/%s", port, proto) + case "limit": + cmd = fmt.Sprintf("ufw limit %s/%s", port, proto) + } + + 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 + } + } + + return &TaskResult{Changed: true}, nil +} + +// --- Docker Compose module shim --- + +func moduleDockerComposeWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) { + projectSrc := getStringArg(args, "project_src", "") + state := getStringArg(args, "state", "present") + + if projectSrc == "" { + return nil, fmt.Errorf("docker_compose: project_src required") + } + + var cmd string + switch state { + case "present": + cmd = fmt.Sprintf("cd %q && docker compose up -d", projectSrc) + case "absent": + cmd = fmt.Sprintf("cd %q && docker compose down", projectSrc) + case "restarted": + cmd = fmt.Sprintf("cd %q && docker compose restart", projectSrc) + default: + cmd = fmt.Sprintf("cd %q && docker compose up -d", projectSrc) + } + + 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 + } + + // Heuristic for changed + changed := !strings.Contains(stdout, "Up to date") && !strings.Contains(stderr, "Up to date") + + return &TaskResult{Changed: changed, Stdout: stdout}, nil +} + // --- String helpers for assertions --- // containsSubstring checks if any executed command contains the given substring. diff --git a/ansible/modules_adv_test.go b/ansible/modules_adv_test.go new file mode 100644 index 0000000..389c5dd --- /dev/null +++ b/ansible/modules_adv_test.go @@ -0,0 +1,1127 @@ +package ansible + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================ +// Step 1.4: user / group / cron / authorized_key / git / +// unarchive / uri / ufw / docker_compose / blockinfile +// advanced module tests +// ============================================================ + +// --- user module --- + +func TestModuleUser_Good_CreateNewUser(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`id deploy >/dev/null 2>&1`, "", "no such user", 1) + mock.expectCommand(`useradd`, "", "", 0) + + result, err := moduleUserWithClient(e, mock, map[string]any{ + "name": "deploy", + "uid": "1500", + "group": "www-data", + "groups": "docker,sudo", + "home": "/opt/deploy", + "shell": "/bin/bash", + "create_home": true, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.containsSubstring("useradd")) + assert.True(t, mock.containsSubstring("-u 1500")) + assert.True(t, mock.containsSubstring("-g www-data")) + assert.True(t, mock.containsSubstring("-G docker,sudo")) + assert.True(t, mock.containsSubstring("-d /opt/deploy")) + assert.True(t, mock.containsSubstring("-s /bin/bash")) + assert.True(t, mock.containsSubstring("-m")) +} + +func TestModuleUser_Good_ModifyExistingUser(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + // id returns success meaning user exists, so usermod branch is taken + mock.expectCommand(`id deploy >/dev/null 2>&1 && usermod`, "", "", 0) + + result, err := moduleUserWithClient(e, mock, map[string]any{ + "name": "deploy", + "shell": "/bin/zsh", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.containsSubstring("usermod")) + assert.True(t, mock.containsSubstring("-s /bin/zsh")) +} + +func TestModuleUser_Good_RemoveUser(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`userdel -r deploy`, "", "", 0) + + result, err := moduleUserWithClient(e, mock, map[string]any{ + "name": "deploy", + "state": "absent", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted(`userdel -r deploy`)) +} + +func TestModuleUser_Good_SystemUser(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`id|useradd`, "", "", 0) + + result, err := moduleUserWithClient(e, mock, map[string]any{ + "name": "prometheus", + "system": true, + "create_home": false, + "shell": "/usr/sbin/nologin", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + // system flag adds -r + assert.True(t, mock.containsSubstring("-r")) + assert.True(t, mock.containsSubstring("-s /usr/sbin/nologin")) + // create_home=false means -m should NOT be present + // Actually, looking at the production code: getBoolArg(args, "create_home", true) — default is true + // We set it to false explicitly, so -m should NOT appear + cmd := mock.lastCommand() + assert.NotContains(t, cmd.Cmd, " -m ") +} + +func TestModuleUser_Good_NoOptsUsesSimpleForm(t *testing.T) { + // When no options are provided, uses the simple "id || useradd" form + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`id testuser >/dev/null 2>&1 || useradd testuser`, "", "", 0) + + result, err := moduleUserWithClient(e, mock, map[string]any{ + "name": "testuser", + "create_home": false, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) +} + +func TestModuleUser_Bad_MissingName(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleUserWithClient(e, mock, map[string]any{ + "state": "present", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "name required") +} + +func TestModuleUser_Good_CommandFailure(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`id|useradd|usermod`, "", "useradd: Permission denied", 1) + + result, err := moduleUserWithClient(e, mock, map[string]any{ + "name": "deploy", + "shell": "/bin/bash", + }) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Contains(t, result.Msg, "Permission denied") +} + +// --- group module --- + +func TestModuleGroup_Good_CreateNewGroup(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + // getent fails → groupadd runs + mock.expectCommand(`getent group appgroup`, "", "", 1) + mock.expectCommand(`groupadd`, "", "", 0) + + result, err := moduleGroupWithClient(e, mock, map[string]any{ + "name": "appgroup", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.containsSubstring("groupadd")) + assert.True(t, mock.containsSubstring("appgroup")) +} + +func TestModuleGroup_Good_GroupAlreadyExists(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + // getent succeeds → groupadd skipped (|| short-circuits) + mock.expectCommand(`getent group docker >/dev/null 2>&1 || groupadd`, "", "", 0) + + result, err := moduleGroupWithClient(e, mock, map[string]any{ + "name": "docker", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) +} + +func TestModuleGroup_Good_RemoveGroup(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`groupdel oldgroup`, "", "", 0) + + result, err := moduleGroupWithClient(e, mock, map[string]any{ + "name": "oldgroup", + "state": "absent", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted(`groupdel oldgroup`)) +} + +func TestModuleGroup_Good_SystemGroup(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`getent group|groupadd`, "", "", 0) + + result, err := moduleGroupWithClient(e, mock, map[string]any{ + "name": "prometheus", + "system": true, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.containsSubstring("-r")) +} + +func TestModuleGroup_Good_CustomGID(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`getent group|groupadd`, "", "", 0) + + result, err := moduleGroupWithClient(e, mock, map[string]any{ + "name": "custom", + "gid": "5000", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.containsSubstring("-g 5000")) +} + +func TestModuleGroup_Bad_MissingName(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleGroupWithClient(e, mock, map[string]any{ + "state": "present", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "name required") +} + +func TestModuleGroup_Good_CommandFailure(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`getent group|groupadd`, "", "groupadd: Permission denied", 1) + + result, err := moduleGroupWithClient(e, mock, map[string]any{ + "name": "failgroup", + }) + + require.NoError(t, err) + assert.True(t, result.Failed) +} + +// --- cron module --- + +func TestModuleCron_Good_AddCronJob(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`crontab -u root`, "", "", 0) + + result, err := moduleCronWithClient(e, mock, map[string]any{ + "name": "backup", + "job": "/usr/local/bin/backup.sh", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + // Default schedule is * * * * * + assert.True(t, mock.containsSubstring("* * * * *")) + assert.True(t, mock.containsSubstring("/usr/local/bin/backup.sh")) + assert.True(t, mock.containsSubstring("# backup")) +} + +func TestModuleCron_Good_RemoveCronJob(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`crontab -u root -l`, "* * * * * /bin/backup # backup\n", "", 0) + + result, err := moduleCronWithClient(e, mock, map[string]any{ + "name": "backup", + "job": "/bin/backup", + "state": "absent", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.containsSubstring("grep -v")) +} + +func TestModuleCron_Good_CustomSchedule(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`crontab -u root`, "", "", 0) + + result, err := moduleCronWithClient(e, mock, map[string]any{ + "name": "nightly-backup", + "job": "/opt/scripts/backup.sh", + "minute": "30", + "hour": "2", + "day": "1", + "month": "6", + "weekday": "0", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.containsSubstring("30 2 1 6 0")) + assert.True(t, mock.containsSubstring("/opt/scripts/backup.sh")) +} + +func TestModuleCron_Good_CustomUser(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`crontab -u www-data`, "", "", 0) + + result, err := moduleCronWithClient(e, mock, map[string]any{ + "name": "cache-clear", + "job": "php artisan cache:clear", + "user": "www-data", + "minute": "0", + "hour": "*/4", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.containsSubstring("crontab -u www-data")) + assert.True(t, mock.containsSubstring("0 */4 * * *")) +} + +func TestModuleCron_Good_AbsentWithNoName(t *testing.T) { + // Absent with no name — changed but no grep command + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleCronWithClient(e, mock, map[string]any{ + "state": "absent", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + // No commands should have run since name is empty + assert.Equal(t, 0, mock.commandCount()) +} + +// --- authorized_key module --- + +func TestModuleAuthorizedKey_Good_AddKey(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + testKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDcT... user@host" + mock.expectCommand(`getent passwd deploy`, "/home/deploy", "", 0) + mock.expectCommand(`mkdir -p`, "", "", 0) + mock.expectCommand(`grep -qF`, "", "", 1) // key not found, will be appended + mock.expectCommand(`echo`, "", "", 0) + mock.expectCommand(`chmod 600`, "", "", 0) + + result, err := moduleAuthorizedKeyWithClient(e, mock, map[string]any{ + "user": "deploy", + "key": testKey, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.containsSubstring("mkdir -p")) + assert.True(t, mock.containsSubstring("chmod 700")) + assert.True(t, mock.containsSubstring("authorized_keys")) +} + +func TestModuleAuthorizedKey_Good_RemoveKey(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + testKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDcT... user@host" + mock.expectCommand(`getent passwd deploy`, "/home/deploy", "", 0) + mock.expectCommand(`sed -i`, "", "", 0) + + result, err := moduleAuthorizedKeyWithClient(e, mock, map[string]any{ + "user": "deploy", + "key": testKey, + "state": "absent", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted(`sed -i`)) + assert.True(t, mock.containsSubstring("authorized_keys")) +} + +func TestModuleAuthorizedKey_Good_KeyAlreadyExists(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + testKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDcT... user@host" + mock.expectCommand(`getent passwd deploy`, "/home/deploy", "", 0) + mock.expectCommand(`mkdir -p`, "", "", 0) + // grep succeeds: key already present, || short-circuits, echo not needed + mock.expectCommand(`grep -qF.*echo`, "", "", 0) + mock.expectCommand(`chmod 600`, "", "", 0) + + result, err := moduleAuthorizedKeyWithClient(e, mock, map[string]any{ + "user": "deploy", + "key": testKey, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) +} + +func TestModuleAuthorizedKey_Good_RootUserFallback(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + testKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDcT... admin@host" + // getent returns empty — falls back to /root for root user + mock.expectCommand(`getent passwd root`, "", "", 0) + mock.expectCommand(`mkdir -p`, "", "", 0) + mock.expectCommand(`grep -qF.*echo`, "", "", 0) + mock.expectCommand(`chmod 600`, "", "", 0) + + result, err := moduleAuthorizedKeyWithClient(e, mock, map[string]any{ + "user": "root", + "key": testKey, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + // Should use /root/.ssh/authorized_keys + assert.True(t, mock.containsSubstring("/root/.ssh")) +} + +func TestModuleAuthorizedKey_Bad_MissingUserAndKey(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleAuthorizedKeyWithClient(e, mock, map[string]any{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "user and key required") +} + +func TestModuleAuthorizedKey_Bad_MissingKey(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleAuthorizedKeyWithClient(e, mock, map[string]any{ + "user": "deploy", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "user and key required") +} + +func TestModuleAuthorizedKey_Bad_MissingUser(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleAuthorizedKeyWithClient(e, mock, map[string]any{ + "key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDcT...", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "user and key required") +} + +// --- git module --- + +func TestModuleGit_Good_FreshClone(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + // .git does not exist → fresh clone + mock.expectCommand(`git clone`, "", "", 0) + + result, err := moduleGitWithClient(e, mock, map[string]any{ + "repo": "https://github.com/example/app.git", + "dest": "/opt/app", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.hasExecuted(`git clone`)) + assert.True(t, mock.containsSubstring("https://github.com/example/app.git")) + assert.True(t, mock.containsSubstring("/opt/app")) + // Default version is HEAD + assert.True(t, mock.containsSubstring("git checkout")) +} + +func TestModuleGit_Good_UpdateExisting(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + // .git exists → fetch + checkout + mock.addFile("/opt/app/.git", []byte("gitdir")) + mock.expectCommand(`git fetch --all && git checkout`, "", "", 0) + + result, err := moduleGitWithClient(e, mock, map[string]any{ + "repo": "https://github.com/example/app.git", + "dest": "/opt/app", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.hasExecuted(`git fetch --all`)) + assert.True(t, mock.containsSubstring("git checkout --force")) + // Should NOT contain git clone + assert.False(t, mock.containsSubstring("git clone")) +} + +func TestModuleGit_Good_CustomVersion(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`git clone`, "", "", 0) + + result, err := moduleGitWithClient(e, mock, map[string]any{ + "repo": "https://github.com/example/app.git", + "dest": "/opt/app", + "version": "v2.1.0", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.containsSubstring("v2.1.0")) +} + +func TestModuleGit_Good_UpdateWithBranch(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.addFile("/srv/myapp/.git", []byte("gitdir")) + mock.expectCommand(`git fetch --all && git checkout`, "", "", 0) + + result, err := moduleGitWithClient(e, mock, map[string]any{ + "repo": "git@github.com:org/repo.git", + "dest": "/srv/myapp", + "version": "develop", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.containsSubstring("develop")) +} + +func TestModuleGit_Bad_MissingRepoAndDest(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleGitWithClient(e, mock, map[string]any{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "repo and dest required") +} + +func TestModuleGit_Bad_MissingRepo(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleGitWithClient(e, mock, map[string]any{ + "dest": "/opt/app", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "repo and dest required") +} + +func TestModuleGit_Bad_MissingDest(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleGitWithClient(e, mock, map[string]any{ + "repo": "https://github.com/example/app.git", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "repo and dest required") +} + +func TestModuleGit_Good_CloneFailure(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`git clone`, "", "fatal: repository not found", 128) + + result, err := moduleGitWithClient(e, mock, map[string]any{ + "repo": "https://github.com/example/nonexistent.git", + "dest": "/opt/app", + }) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Contains(t, result.Msg, "repository not found") +} + +// --- unarchive module --- + +func TestModuleUnarchive_Good_ExtractTarGzLocal(t *testing.T) { + // Create a temporary "archive" file + tmpDir := t.TempDir() + archivePath := filepath.Join(tmpDir, "package.tar.gz") + require.NoError(t, os.WriteFile(archivePath, []byte("fake-archive-content"), 0644)) + + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`mkdir -p`, "", "", 0) + mock.expectCommand(`tar -xzf`, "", "", 0) + mock.expectCommand(`rm -f`, "", "", 0) + + result, err := moduleUnarchiveWithClient(e, mock, map[string]any{ + "src": archivePath, + "dest": "/opt/app", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + // Should have uploaded the file + assert.Equal(t, 1, mock.uploadCount()) + assert.True(t, mock.containsSubstring("tar -xzf")) + assert.True(t, mock.containsSubstring("/opt/app")) +} + +func TestModuleUnarchive_Good_ExtractZipLocal(t *testing.T) { + tmpDir := t.TempDir() + archivePath := filepath.Join(tmpDir, "release.zip") + require.NoError(t, os.WriteFile(archivePath, []byte("fake-zip-content"), 0644)) + + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`mkdir -p`, "", "", 0) + mock.expectCommand(`unzip -o`, "", "", 0) + mock.expectCommand(`rm -f`, "", "", 0) + + result, err := moduleUnarchiveWithClient(e, mock, map[string]any{ + "src": archivePath, + "dest": "/opt/releases", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.Equal(t, 1, mock.uploadCount()) + assert.True(t, mock.containsSubstring("unzip -o")) +} + +func TestModuleUnarchive_Good_RemoteSource(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`mkdir -p`, "", "", 0) + mock.expectCommand(`tar -xzf`, "", "", 0) + + result, err := moduleUnarchiveWithClient(e, mock, map[string]any{ + "src": "/tmp/remote-archive.tar.gz", + "dest": "/opt/app", + "remote_src": true, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + // No upload should happen for remote sources + assert.Equal(t, 0, mock.uploadCount()) + assert.True(t, mock.containsSubstring("tar -xzf")) +} + +func TestModuleUnarchive_Good_TarXz(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`mkdir -p`, "", "", 0) + mock.expectCommand(`tar -xJf`, "", "", 0) + + result, err := moduleUnarchiveWithClient(e, mock, map[string]any{ + "src": "/tmp/archive.tar.xz", + "dest": "/opt/extract", + "remote_src": true, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.containsSubstring("tar -xJf")) +} + +func TestModuleUnarchive_Good_TarBz2(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`mkdir -p`, "", "", 0) + mock.expectCommand(`tar -xjf`, "", "", 0) + + result, err := moduleUnarchiveWithClient(e, mock, map[string]any{ + "src": "/tmp/archive.tar.bz2", + "dest": "/opt/extract", + "remote_src": true, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.containsSubstring("tar -xjf")) +} + +func TestModuleUnarchive_Bad_MissingSrcAndDest(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleUnarchiveWithClient(e, mock, map[string]any{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "src and dest required") +} + +func TestModuleUnarchive_Bad_MissingSrc(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleUnarchiveWithClient(e, mock, map[string]any{ + "dest": "/opt/app", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "src and dest required") +} + +func TestModuleUnarchive_Bad_LocalFileNotFound(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + mock.expectCommand(`mkdir -p`, "", "", 0) + + _, err := moduleUnarchiveWithClient(e, mock, map[string]any{ + "src": "/nonexistent/archive.tar.gz", + "dest": "/opt/app", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "read src") +} + +// --- uri module --- + +func TestModuleURI_Good_GetRequestDefault(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`curl.*https://example.com/api/health`, "OK\n200", "", 0) + + result, err := moduleURIWithClient(e, mock, map[string]any{ + "url": "https://example.com/api/health", + }) + + require.NoError(t, err) + assert.False(t, result.Failed) + assert.False(t, result.Changed) // URI module does not set changed + assert.Equal(t, 200, result.RC) + assert.Equal(t, 200, result.Data["status"]) +} + +func TestModuleURI_Good_PostWithBodyAndHeaders(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + // Use a broad pattern since header order in map iteration is non-deterministic + mock.expectCommand(`curl.*api\.example\.com`, "{\"id\":1}\n201", "", 0) + + result, err := moduleURIWithClient(e, mock, map[string]any{ + "url": "https://api.example.com/users", + "method": "POST", + "body": `{"name":"test"}`, + "status_code": 201, + "headers": map[string]any{ + "Content-Type": "application/json", + "Authorization": "Bearer token123", + }, + }) + + require.NoError(t, err) + assert.False(t, result.Failed) + assert.Equal(t, 201, result.RC) + assert.True(t, mock.containsSubstring("-X POST")) + assert.True(t, mock.containsSubstring("-d")) + assert.True(t, mock.containsSubstring("Content-Type")) + assert.True(t, mock.containsSubstring("Authorization")) +} + +func TestModuleURI_Good_WrongStatusCode(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`curl`, "Not Found\n404", "", 0) + + result, err := moduleURIWithClient(e, mock, map[string]any{ + "url": "https://example.com/missing", + }) + + require.NoError(t, err) + assert.True(t, result.Failed) // Expected 200, got 404 + assert.Equal(t, 404, result.RC) +} + +func TestModuleURI_Good_CurlCommandFailure(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommandError(`curl`, assert.AnError) + + result, err := moduleURIWithClient(e, mock, map[string]any{ + "url": "https://unreachable.example.com", + }) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Contains(t, result.Msg, assert.AnError.Error()) +} + +func TestModuleURI_Good_CustomExpectedStatus(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`curl`, "\n204", "", 0) + + result, err := moduleURIWithClient(e, mock, map[string]any{ + "url": "https://api.example.com/resource/1", + "method": "DELETE", + "status_code": 204, + }) + + require.NoError(t, err) + assert.False(t, result.Failed) + assert.Equal(t, 204, result.RC) +} + +func TestModuleURI_Bad_MissingURL(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleURIWithClient(e, mock, map[string]any{ + "method": "GET", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "url required") +} + +// --- ufw module --- + +func TestModuleUFW_Good_AllowRuleWithPort(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`ufw allow 443/tcp`, "Rule added", "", 0) + + result, err := moduleUFWWithClient(e, mock, map[string]any{ + "rule": "allow", + "port": "443", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.hasExecuted(`ufw allow 443/tcp`)) +} + +func TestModuleUFW_Good_EnableFirewall(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`ufw --force enable`, "Firewall is active", "", 0) + + result, err := moduleUFWWithClient(e, mock, map[string]any{ + "state": "enabled", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.hasExecuted(`ufw --force enable`)) +} + +func TestModuleUFW_Good_DenyRuleWithProto(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`ufw deny 53/udp`, "Rule added", "", 0) + + result, err := moduleUFWWithClient(e, mock, map[string]any{ + "rule": "deny", + "port": "53", + "proto": "udp", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.hasExecuted(`ufw deny 53/udp`)) +} + +func TestModuleUFW_Good_ResetFirewall(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`ufw --force reset`, "Resetting", "", 0) + + result, err := moduleUFWWithClient(e, mock, map[string]any{ + "state": "reset", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.hasExecuted(`ufw --force reset`)) +} + +func TestModuleUFW_Good_DisableFirewall(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`ufw disable`, "Firewall stopped", "", 0) + + result, err := moduleUFWWithClient(e, mock, map[string]any{ + "state": "disabled", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.hasExecuted(`ufw disable`)) +} + +func TestModuleUFW_Good_ReloadFirewall(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`ufw reload`, "Firewall reloaded", "", 0) + + result, err := moduleUFWWithClient(e, mock, map[string]any{ + "state": "reloaded", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.hasExecuted(`ufw reload`)) +} + +func TestModuleUFW_Good_LimitRule(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`ufw limit 22/tcp`, "Rule added", "", 0) + + result, err := moduleUFWWithClient(e, mock, map[string]any{ + "rule": "limit", + "port": "22", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.hasExecuted(`ufw limit 22/tcp`)) +} + +func TestModuleUFW_Good_StateCommandFailure(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`ufw --force enable`, "", "ERROR: problem running ufw", 1) + + result, err := moduleUFWWithClient(e, mock, map[string]any{ + "state": "enabled", + }) + + require.NoError(t, err) + assert.True(t, result.Failed) +} + +// --- docker_compose module --- + +func TestModuleDockerCompose_Good_StatePresent(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`docker compose up -d`, "Creating container_1\nCreating container_2\n", "", 0) + + result, err := moduleDockerComposeWithClient(e, mock, map[string]any{ + "project_src": "/opt/myapp", + "state": "present", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.hasExecuted(`docker compose up -d`)) + assert.True(t, mock.containsSubstring("/opt/myapp")) +} + +func TestModuleDockerCompose_Good_StateAbsent(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`docker compose down`, "Removing container_1\n", "", 0) + + result, err := moduleDockerComposeWithClient(e, mock, map[string]any{ + "project_src": "/opt/myapp", + "state": "absent", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.hasExecuted(`docker compose down`)) +} + +func TestModuleDockerCompose_Good_AlreadyUpToDate(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`docker compose up -d`, "Container myapp-web-1 Up to date\n", "", 0) + + result, err := moduleDockerComposeWithClient(e, mock, map[string]any{ + "project_src": "/opt/myapp", + "state": "present", + }) + + require.NoError(t, err) + assert.False(t, result.Changed) // "Up to date" in stdout → changed=false + assert.False(t, result.Failed) +} + +func TestModuleDockerCompose_Good_StateRestarted(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`docker compose restart`, "Restarting container_1\n", "", 0) + + result, err := moduleDockerComposeWithClient(e, mock, map[string]any{ + "project_src": "/opt/stack", + "state": "restarted", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.hasExecuted(`docker compose restart`)) +} + +func TestModuleDockerCompose_Bad_MissingProjectSrc(t *testing.T) { + e, _ := newTestExecutorWithMock("host1") + mock := NewMockSSHClient() + + _, err := moduleDockerComposeWithClient(e, mock, map[string]any{ + "state": "present", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "project_src required") +} + +func TestModuleDockerCompose_Good_CommandFailure(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`docker compose up -d`, "", "Error response from daemon", 1) + + result, err := moduleDockerComposeWithClient(e, mock, map[string]any{ + "project_src": "/opt/broken", + "state": "present", + }) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Contains(t, result.Msg, "Error response from daemon") +} + +func TestModuleDockerCompose_Good_DefaultStateIsPresent(t *testing.T) { + // When no state is specified, default is "present" + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`docker compose up -d`, "Starting\n", "", 0) + + result, err := moduleDockerComposeWithClient(e, mock, map[string]any{ + "project_src": "/opt/app", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.hasExecuted(`docker compose up -d`)) +} + +// --- Cross-module dispatch tests for advanced modules --- + +func TestExecuteModuleWithMock_Good_DispatchUser(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`id|useradd|usermod`, "", "", 0) + + task := &Task{ + Module: "user", + Args: map[string]any{ + "name": "appuser", + "shell": "/bin/bash", + }, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) +} + +func TestExecuteModuleWithMock_Good_DispatchGroup(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`getent group|groupadd`, "", "", 0) + + task := &Task{ + Module: "group", + Args: map[string]any{ + "name": "docker", + }, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) +} + +func TestExecuteModuleWithMock_Good_DispatchCron(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`crontab`, "", "", 0) + + task := &Task{ + Module: "cron", + Args: map[string]any{ + "name": "logrotate", + "job": "/usr/sbin/logrotate", + }, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) +} + +func TestExecuteModuleWithMock_Good_DispatchGit(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`git clone`, "", "", 0) + + task := &Task{ + Module: "git", + Args: map[string]any{ + "repo": "https://github.com/org/repo.git", + "dest": "/opt/repo", + }, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) +} + +func TestExecuteModuleWithMock_Good_DispatchURI(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`curl`, "OK\n200", "", 0) + + task := &Task{ + Module: "uri", + Args: map[string]any{ + "url": "https://example.com", + }, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.False(t, result.Failed) +} + +func TestExecuteModuleWithMock_Good_DispatchDockerCompose(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`docker compose up -d`, "Creating\n", "", 0) + + task := &Task{ + Module: "ansible.builtin.docker_compose", + Args: map[string]any{ + "project_src": "/opt/stack", + "state": "present", + }, + } + + result, err := executeModuleWithMock(e, mock, "host1", task) + + require.NoError(t, err) + assert.True(t, result.Changed) +}