diff --git a/ansible/modules_infra_test.go b/ansible/modules_infra_test.go new file mode 100644 index 0000000..14b6ea3 --- /dev/null +++ b/ansible/modules_infra_test.go @@ -0,0 +1,1261 @@ +package ansible + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// =========================================================================== +// 1. Error Propagation — getHosts +// =========================================================================== + +func TestGetHosts_Infra_Good_AllPattern(t *testing.T) { + e := NewExecutor("/tmp") + e.SetInventoryDirect(&Inventory{ + All: &InventoryGroup{ + Hosts: map[string]*Host{ + "web1": {AnsibleHost: "10.0.0.1"}, + "web2": {AnsibleHost: "10.0.0.2"}, + "db1": {AnsibleHost: "10.0.1.1"}, + }, + }, + }) + + hosts := e.getHosts("all") + assert.Len(t, hosts, 3) + assert.Contains(t, hosts, "web1") + assert.Contains(t, hosts, "web2") + assert.Contains(t, hosts, "db1") +} + +func TestGetHosts_Infra_Good_SpecificHost(t *testing.T) { + e := NewExecutor("/tmp") + e.SetInventoryDirect(&Inventory{ + All: &InventoryGroup{ + Hosts: map[string]*Host{ + "web1": {AnsibleHost: "10.0.0.1"}, + "web2": {AnsibleHost: "10.0.0.2"}, + }, + }, + }) + + hosts := e.getHosts("web1") + assert.Equal(t, []string{"web1"}, hosts) +} + +func TestGetHosts_Infra_Good_GroupName(t *testing.T) { + e := NewExecutor("/tmp") + e.SetInventoryDirect(&Inventory{ + All: &InventoryGroup{ + Children: map[string]*InventoryGroup{ + "webservers": { + Hosts: map[string]*Host{ + "web1": {AnsibleHost: "10.0.0.1"}, + "web2": {AnsibleHost: "10.0.0.2"}, + }, + }, + "dbservers": { + Hosts: map[string]*Host{ + "db1": {AnsibleHost: "10.0.1.1"}, + }, + }, + }, + }, + }) + + hosts := e.getHosts("webservers") + assert.Len(t, hosts, 2) + assert.Contains(t, hosts, "web1") + assert.Contains(t, hosts, "web2") +} + +func TestGetHosts_Infra_Good_Localhost(t *testing.T) { + e := NewExecutor("/tmp") + // No inventory at all + hosts := e.getHosts("localhost") + assert.Equal(t, []string{"localhost"}, hosts) +} + +func TestGetHosts_Infra_Bad_NilInventory(t *testing.T) { + e := NewExecutor("/tmp") + // inventory is nil, non-localhost pattern + hosts := e.getHosts("webservers") + assert.Nil(t, hosts) +} + +func TestGetHosts_Infra_Bad_NonexistentHost(t *testing.T) { + e := NewExecutor("/tmp") + e.SetInventoryDirect(&Inventory{ + All: &InventoryGroup{ + Hosts: map[string]*Host{ + "web1": {}, + }, + }, + }) + + hosts := e.getHosts("nonexistent") + assert.Empty(t, hosts) +} + +func TestGetHosts_Infra_Good_LimitFiltering(t *testing.T) { + e := NewExecutor("/tmp") + e.SetInventoryDirect(&Inventory{ + All: &InventoryGroup{ + Hosts: map[string]*Host{ + "web1": {}, + "web2": {}, + "db1": {}, + }, + }, + }) + e.Limit = "web1" + + hosts := e.getHosts("all") + assert.Len(t, hosts, 1) + assert.Contains(t, hosts, "web1") +} + +func TestGetHosts_Infra_Good_LimitSubstringMatch(t *testing.T) { + e := NewExecutor("/tmp") + e.SetInventoryDirect(&Inventory{ + All: &InventoryGroup{ + Hosts: map[string]*Host{ + "prod-web-01": {}, + "prod-web-02": {}, + "staging-web-01": {}, + }, + }, + }) + e.Limit = "prod" + + hosts := e.getHosts("all") + // Limit uses substring matching as fallback + assert.Len(t, hosts, 2) +} + +// =========================================================================== +// 1. Error Propagation — matchesTags +// =========================================================================== + +func TestMatchesTags_Infra_Good_NoFiltersNoTags(t *testing.T) { + e := NewExecutor("/tmp") + // No Tags, no SkipTags set + assert.True(t, e.matchesTags(nil)) +} + +func TestMatchesTags_Infra_Good_NoFiltersWithTaskTags(t *testing.T) { + e := NewExecutor("/tmp") + assert.True(t, e.matchesTags([]string{"deploy", "config"})) +} + +func TestMatchesTags_Infra_Good_IncludeMatchesOneOfMultiple(t *testing.T) { + e := NewExecutor("/tmp") + e.Tags = []string{"deploy"} + + // Task has deploy among its tags + assert.True(t, e.matchesTags([]string{"setup", "deploy", "config"})) +} + +func TestMatchesTags_Infra_Bad_IncludeNoMatch(t *testing.T) { + e := NewExecutor("/tmp") + e.Tags = []string{"deploy"} + + assert.False(t, e.matchesTags([]string{"build", "test"})) +} + +func TestMatchesTags_Infra_Good_SkipOverridesInclude(t *testing.T) { + e := NewExecutor("/tmp") + e.SkipTags = []string{"slow"} + + // Even with no include tags, skip tags filter out matching tasks + assert.False(t, e.matchesTags([]string{"deploy", "slow"})) + assert.True(t, e.matchesTags([]string{"deploy", "fast"})) +} + +func TestMatchesTags_Infra_Bad_IncludeFilterNoTaskTags(t *testing.T) { + e := NewExecutor("/tmp") + e.Tags = []string{"deploy"} + + // Tasks with no tags should not run when include tags are active + assert.False(t, e.matchesTags(nil)) + assert.False(t, e.matchesTags([]string{})) +} + +func TestMatchesTags_Infra_Good_AllTagMatchesEverything(t *testing.T) { + e := NewExecutor("/tmp") + e.Tags = []string{"all"} + + assert.True(t, e.matchesTags([]string{"deploy"})) + assert.True(t, e.matchesTags([]string{"config", "slow"})) +} + +// =========================================================================== +// 1. Error Propagation — evaluateWhen +// =========================================================================== + +func TestEvaluateWhen_Infra_Good_DefinedCheck(t *testing.T) { + e := NewExecutor("/tmp") + e.results["host1"] = map[string]*TaskResult{ + "myresult": {Changed: true}, + } + + assert.True(t, e.evaluateWhen("myresult is defined", "host1", nil)) +} + +func TestEvaluateWhen_Infra_Good_NotDefinedCheck(t *testing.T) { + e := NewExecutor("/tmp") + // No results registered for host1 + assert.True(t, e.evaluateWhen("missing_var is not defined", "host1", nil)) +} + +func TestEvaluateWhen_Infra_Good_UndefinedAlias(t *testing.T) { + e := NewExecutor("/tmp") + assert.True(t, e.evaluateWhen("some_var is undefined", "host1", nil)) +} + +func TestEvaluateWhen_Infra_Good_SucceededCheck(t *testing.T) { + e := NewExecutor("/tmp") + e.results["host1"] = map[string]*TaskResult{ + "result": {Failed: false, Changed: true}, + } + + assert.True(t, e.evaluateWhen("result is success", "host1", nil)) + assert.True(t, e.evaluateWhen("result is succeeded", "host1", nil)) +} + +func TestEvaluateWhen_Infra_Good_FailedCheck(t *testing.T) { + e := NewExecutor("/tmp") + e.results["host1"] = map[string]*TaskResult{ + "result": {Failed: true}, + } + + assert.True(t, e.evaluateWhen("result is failed", "host1", nil)) +} + +func TestEvaluateWhen_Infra_Good_ChangedCheck(t *testing.T) { + e := NewExecutor("/tmp") + e.results["host1"] = map[string]*TaskResult{ + "result": {Changed: true}, + } + + assert.True(t, e.evaluateWhen("result is changed", "host1", nil)) +} + +func TestEvaluateWhen_Infra_Good_SkippedCheck(t *testing.T) { + e := NewExecutor("/tmp") + e.results["host1"] = map[string]*TaskResult{ + "result": {Skipped: true}, + } + + assert.True(t, e.evaluateWhen("result is skipped", "host1", nil)) +} + +func TestEvaluateWhen_Infra_Good_BoolVarTruthy(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["my_flag"] = true + + assert.True(t, e.evalCondition("my_flag", "host1")) +} + +func TestEvaluateWhen_Infra_Good_BoolVarFalsy(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["my_flag"] = false + + assert.False(t, e.evalCondition("my_flag", "host1")) +} + +func TestEvaluateWhen_Infra_Good_StringVarTruthy(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["my_str"] = "hello" + assert.True(t, e.evalCondition("my_str", "host1")) +} + +func TestEvaluateWhen_Infra_Good_StringVarEmptyFalsy(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["my_str"] = "" + assert.False(t, e.evalCondition("my_str", "host1")) +} + +func TestEvaluateWhen_Infra_Good_StringVarFalseLiteral(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["my_str"] = "false" + assert.False(t, e.evalCondition("my_str", "host1")) + + e.vars["my_str2"] = "False" + assert.False(t, e.evalCondition("my_str2", "host1")) +} + +func TestEvaluateWhen_Infra_Good_IntVarNonZero(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["count"] = 42 + assert.True(t, e.evalCondition("count", "host1")) +} + +func TestEvaluateWhen_Infra_Good_IntVarZero(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["count"] = 0 + assert.False(t, e.evalCondition("count", "host1")) +} + +func TestEvaluateWhen_Infra_Good_Negation(t *testing.T) { + e := NewExecutor("/tmp") + assert.False(t, e.evalCondition("not true", "host1")) + assert.True(t, e.evalCondition("not false", "host1")) +} + +func TestEvaluateWhen_Infra_Good_MultipleConditionsAllTrue(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["enabled"] = true + e.results["host1"] = map[string]*TaskResult{ + "prev": {Failed: false}, + } + + // Both conditions must be true (AND semantics) + assert.True(t, e.evaluateWhen([]any{"enabled", "prev is success"}, "host1", nil)) +} + +func TestEvaluateWhen_Infra_Bad_MultipleConditionsOneFails(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["enabled"] = true + + // "false" literal fails + assert.False(t, e.evaluateWhen([]any{"enabled", "false"}, "host1", nil)) +} + +func TestEvaluateWhen_Infra_Good_DefaultFilterInCondition(t *testing.T) { + e := NewExecutor("/tmp") + // Condition with default filter should be satisfied + assert.True(t, e.evalCondition("my_var | default(true)", "host1")) +} + +func TestEvaluateWhen_Infra_Good_RegisteredVarTruthy(t *testing.T) { + e := NewExecutor("/tmp") + e.results["host1"] = map[string]*TaskResult{ + "check_result": {Failed: false, Skipped: false}, + } + + // Just referencing a registered var name evaluates truthy if not failed/skipped + assert.True(t, e.evalCondition("check_result", "host1")) +} + +func TestEvaluateWhen_Infra_Bad_RegisteredVarFailedFalsy(t *testing.T) { + e := NewExecutor("/tmp") + e.results["host1"] = map[string]*TaskResult{ + "check_result": {Failed: true}, + } + + // A failed registered var should be falsy + assert.False(t, e.evalCondition("check_result", "host1")) +} + +// =========================================================================== +// 1. Error Propagation — templateString +// =========================================================================== + +func TestTemplateString_Infra_Good_SimpleSubstitution(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["app_name"] = "myapp" + + result := e.templateString("Deploying {{ app_name }}", "", nil) + assert.Equal(t, "Deploying myapp", result) +} + +func TestTemplateString_Infra_Good_MultipleVars(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["host"] = "db.example.com" + e.vars["port"] = 5432 + + result := e.templateString("postgresql://{{ host }}:{{ port }}/mydb", "", nil) + assert.Equal(t, "postgresql://db.example.com:5432/mydb", result) +} + +func TestTemplateString_Infra_Good_Unresolved(t *testing.T) { + e := NewExecutor("/tmp") + result := e.templateString("{{ missing_var }}", "", nil) + assert.Equal(t, "{{ missing_var }}", result) +} + +func TestTemplateString_Infra_Good_NoTemplateMarkup(t *testing.T) { + e := NewExecutor("/tmp") + result := e.templateString("just a plain string", "", nil) + assert.Equal(t, "just a plain string", result) +} + +func TestTemplateString_Infra_Good_RegisteredVarStdout(t *testing.T) { + e := NewExecutor("/tmp") + e.results["host1"] = map[string]*TaskResult{ + "cmd_result": {Stdout: "42"}, + } + + result := e.templateString("{{ cmd_result.stdout }}", "host1", nil) + assert.Equal(t, "42", result) +} + +func TestTemplateString_Infra_Good_RegisteredVarRC(t *testing.T) { + e := NewExecutor("/tmp") + e.results["host1"] = map[string]*TaskResult{ + "cmd_result": {RC: 0}, + } + + result := e.templateString("{{ cmd_result.rc }}", "host1", nil) + assert.Equal(t, "0", result) +} + +func TestTemplateString_Infra_Good_RegisteredVarChanged(t *testing.T) { + e := NewExecutor("/tmp") + e.results["host1"] = map[string]*TaskResult{ + "cmd_result": {Changed: true}, + } + + result := e.templateString("{{ cmd_result.changed }}", "host1", nil) + assert.Equal(t, "true", result) +} + +func TestTemplateString_Infra_Good_RegisteredVarFailed(t *testing.T) { + e := NewExecutor("/tmp") + e.results["host1"] = map[string]*TaskResult{ + "cmd_result": {Failed: true}, + } + + result := e.templateString("{{ cmd_result.failed }}", "host1", nil) + assert.Equal(t, "true", result) +} + +func TestTemplateString_Infra_Good_TaskVars(t *testing.T) { + e := NewExecutor("/tmp") + task := &Task{ + Vars: map[string]any{ + "task_var": "task_value", + }, + } + + result := e.templateString("{{ task_var }}", "host1", task) + assert.Equal(t, "task_value", result) +} + +func TestTemplateString_Infra_Good_FactsResolution(t *testing.T) { + e := NewExecutor("/tmp") + e.facts["host1"] = &Facts{ + Hostname: "web1", + FQDN: "web1.example.com", + Distribution: "ubuntu", + Version: "24.04", + Architecture: "x86_64", + Kernel: "6.5.0", + } + + assert.Equal(t, "web1", e.templateString("{{ ansible_hostname }}", "host1", nil)) + assert.Equal(t, "web1.example.com", e.templateString("{{ ansible_fqdn }}", "host1", nil)) + assert.Equal(t, "ubuntu", e.templateString("{{ ansible_distribution }}", "host1", nil)) + assert.Equal(t, "24.04", e.templateString("{{ ansible_distribution_version }}", "host1", nil)) + assert.Equal(t, "x86_64", e.templateString("{{ ansible_architecture }}", "host1", nil)) + assert.Equal(t, "6.5.0", e.templateString("{{ ansible_kernel }}", "host1", nil)) +} + +// =========================================================================== +// 1. Error Propagation — applyFilter +// =========================================================================== + +func TestApplyFilter_Infra_Good_DefaultWithValue(t *testing.T) { + e := NewExecutor("/tmp") + // When value is non-empty, default is not applied + assert.Equal(t, "hello", e.applyFilter("hello", "default('fallback')")) +} + +func TestApplyFilter_Infra_Good_DefaultWithEmpty(t *testing.T) { + e := NewExecutor("/tmp") + // When value is empty, default IS applied + assert.Equal(t, "fallback", e.applyFilter("", "default('fallback')")) +} + +func TestApplyFilter_Infra_Good_DefaultWithDoubleQuotes(t *testing.T) { + e := NewExecutor("/tmp") + assert.Equal(t, "fallback", e.applyFilter("", `default("fallback")`)) +} + +func TestApplyFilter_Infra_Good_BoolFilterTrue(t *testing.T) { + e := NewExecutor("/tmp") + assert.Equal(t, "true", e.applyFilter("true", "bool")) + assert.Equal(t, "true", e.applyFilter("True", "bool")) + assert.Equal(t, "true", e.applyFilter("yes", "bool")) + assert.Equal(t, "true", e.applyFilter("Yes", "bool")) + assert.Equal(t, "true", e.applyFilter("1", "bool")) +} + +func TestApplyFilter_Infra_Good_BoolFilterFalse(t *testing.T) { + e := NewExecutor("/tmp") + assert.Equal(t, "false", e.applyFilter("false", "bool")) + assert.Equal(t, "false", e.applyFilter("no", "bool")) + assert.Equal(t, "false", e.applyFilter("0", "bool")) + assert.Equal(t, "false", e.applyFilter("random", "bool")) +} + +func TestApplyFilter_Infra_Good_TrimFilter(t *testing.T) { + e := NewExecutor("/tmp") + assert.Equal(t, "hello", e.applyFilter(" hello ", "trim")) + assert.Equal(t, "no spaces", e.applyFilter("no spaces", "trim")) + assert.Equal(t, "", e.applyFilter(" ", "trim")) +} + +func TestApplyFilter_Infra_Good_B64Decode(t *testing.T) { + e := NewExecutor("/tmp") + // b64decode currently returns value unchanged (placeholder) + assert.Equal(t, "dGVzdA==", e.applyFilter("dGVzdA==", "b64decode")) +} + +func TestApplyFilter_Infra_Good_UnknownFilter(t *testing.T) { + e := NewExecutor("/tmp") + // Unknown filters return value unchanged + assert.Equal(t, "hello", e.applyFilter("hello", "nonexistent_filter")) +} + +func TestTemplateString_Infra_Good_FilterInTemplate(t *testing.T) { + e := NewExecutor("/tmp") + // When a var is defined, the filter passes through + e.vars["defined_var"] = "hello" + result := e.templateString("{{ defined_var | default('fallback') }}", "", nil) + assert.Equal(t, "hello", result) +} + +func TestTemplateString_Infra_Good_DefaultFilterEmptyVar(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["empty_var"] = "" + // When var is empty string, default filter applies + result := e.applyFilter("", "default('fallback')") + assert.Equal(t, "fallback", result) +} + +func TestTemplateString_Infra_Good_BoolFilterInTemplate(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["flag"] = "yes" + + result := e.templateString("{{ flag | bool }}", "", nil) + assert.Equal(t, "true", result) +} + +func TestTemplateString_Infra_Good_TrimFilterInTemplate(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["padded"] = " trimmed " + + result := e.templateString("{{ padded | trim }}", "", nil) + assert.Equal(t, "trimmed", result) +} + +// =========================================================================== +// 1. Error Propagation — resolveLoop +// =========================================================================== + +func TestResolveLoop_Infra_Good_SliceAny(t *testing.T) { + e := NewExecutor("/tmp") + items := e.resolveLoop([]any{"a", "b", "c"}, "host1") + assert.Len(t, items, 3) + assert.Equal(t, "a", items[0]) + assert.Equal(t, "b", items[1]) + assert.Equal(t, "c", items[2]) +} + +func TestResolveLoop_Infra_Good_SliceString(t *testing.T) { + e := NewExecutor("/tmp") + items := e.resolveLoop([]string{"x", "y"}, "host1") + assert.Len(t, items, 2) + assert.Equal(t, "x", items[0]) + assert.Equal(t, "y", items[1]) +} + +func TestResolveLoop_Infra_Good_NilLoop(t *testing.T) { + e := NewExecutor("/tmp") + items := e.resolveLoop(nil, "host1") + assert.Nil(t, items) +} + +func TestResolveLoop_Infra_Good_VarReference(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["my_list"] = []any{"item1", "item2", "item3"} + + // When loop is a string that resolves to a variable containing a list + items := e.resolveLoop("{{ my_list }}", "host1") + // The template resolves "{{ my_list }}" but the result is a string representation, + // not the original list. The resolveLoop handles this by trying to look up + // the resolved value in vars again. + // Since templateString returns the string "[item1 item2 item3]", and that + // isn't a var name, items will be nil. This tests the edge case. + // The actual var name lookup happens when the loop value is just "my_list". + assert.Nil(t, items) +} + +func TestResolveLoop_Infra_Good_MixedTypes(t *testing.T) { + e := NewExecutor("/tmp") + items := e.resolveLoop([]any{"str", 42, true, map[string]any{"key": "val"}}, "host1") + assert.Len(t, items, 4) + assert.Equal(t, "str", items[0]) + assert.Equal(t, 42, items[1]) + assert.Equal(t, true, items[2]) +} + +// =========================================================================== +// 1. Error Propagation — handleNotify +// =========================================================================== + +func TestHandleNotify_Infra_Good_SingleString(t *testing.T) { + e := NewExecutor("/tmp") + e.handleNotify("restart nginx") + + assert.True(t, e.notified["restart nginx"]) + assert.False(t, e.notified["restart apache"]) +} + +func TestHandleNotify_Infra_Good_StringSlice(t *testing.T) { + e := NewExecutor("/tmp") + e.handleNotify([]string{"restart nginx", "reload haproxy"}) + + assert.True(t, e.notified["restart nginx"]) + assert.True(t, e.notified["reload haproxy"]) +} + +func TestHandleNotify_Infra_Good_AnySlice(t *testing.T) { + e := NewExecutor("/tmp") + e.handleNotify([]any{"handler1", "handler2", "handler3"}) + + assert.True(t, e.notified["handler1"]) + assert.True(t, e.notified["handler2"]) + assert.True(t, e.notified["handler3"]) +} + +func TestHandleNotify_Infra_Good_NilNotify(t *testing.T) { + e := NewExecutor("/tmp") + // Should not panic + e.handleNotify(nil) + assert.Empty(t, e.notified) +} + +func TestHandleNotify_Infra_Good_MultipleCallsAccumulate(t *testing.T) { + e := NewExecutor("/tmp") + e.handleNotify("handler1") + e.handleNotify("handler2") + + assert.True(t, e.notified["handler1"]) + assert.True(t, e.notified["handler2"]) +} + +// =========================================================================== +// 1. Error Propagation — normalizeConditions +// =========================================================================== + +func TestNormalizeConditions_Infra_Good_String(t *testing.T) { + result := normalizeConditions("my_var is defined") + assert.Equal(t, []string{"my_var is defined"}, result) +} + +func TestNormalizeConditions_Infra_Good_StringSlice(t *testing.T) { + result := normalizeConditions([]string{"cond1", "cond2"}) + assert.Equal(t, []string{"cond1", "cond2"}, result) +} + +func TestNormalizeConditions_Infra_Good_AnySlice(t *testing.T) { + result := normalizeConditions([]any{"cond1", "cond2"}) + assert.Equal(t, []string{"cond1", "cond2"}, result) +} + +func TestNormalizeConditions_Infra_Good_Nil(t *testing.T) { + result := normalizeConditions(nil) + assert.Nil(t, result) +} + +func TestNormalizeConditions_Infra_Good_IntIgnored(t *testing.T) { + // Non-string types in any slice are silently skipped + result := normalizeConditions([]any{"cond1", 42}) + assert.Equal(t, []string{"cond1"}, result) +} + +func TestNormalizeConditions_Infra_Good_UnsupportedType(t *testing.T) { + result := normalizeConditions(42) + assert.Nil(t, result) +} + +// =========================================================================== +// 2. Become/Sudo +// =========================================================================== + +func TestBecome_Infra_Good_SetBecomeTrue(t *testing.T) { + cfg := SSHConfig{ + Host: "test-host", + Port: 22, + User: "deploy", + Become: true, + BecomeUser: "root", + BecomePass: "secret", + } + client, err := NewSSHClient(cfg) + require.NoError(t, err) + + assert.True(t, client.become) + assert.Equal(t, "root", client.becomeUser) + assert.Equal(t, "secret", client.becomePass) +} + +func TestBecome_Infra_Good_SetBecomeFalse(t *testing.T) { + cfg := SSHConfig{ + Host: "test-host", + Port: 22, + User: "deploy", + } + client, err := NewSSHClient(cfg) + require.NoError(t, err) + + assert.False(t, client.become) + assert.Empty(t, client.becomeUser) + assert.Empty(t, client.becomePass) +} + +func TestBecome_Infra_Good_SetBecomeMethod(t *testing.T) { + cfg := SSHConfig{Host: "test-host"} + client, err := NewSSHClient(cfg) + require.NoError(t, err) + + assert.False(t, client.become) + + client.SetBecome(true, "admin", "pass123") + assert.True(t, client.become) + assert.Equal(t, "admin", client.becomeUser) + assert.Equal(t, "pass123", client.becomePass) +} + +func TestBecome_Infra_Good_DisableAfterEnable(t *testing.T) { + cfg := SSHConfig{Host: "test-host"} + client, err := NewSSHClient(cfg) + require.NoError(t, err) + + client.SetBecome(true, "root", "secret") + assert.True(t, client.become) + + client.SetBecome(false, "", "") + assert.False(t, client.become) + // becomeUser and becomePass are only updated if non-empty + assert.Equal(t, "root", client.becomeUser) + assert.Equal(t, "secret", client.becomePass) +} + +func TestBecome_Infra_Good_MockBecomeTracking(t *testing.T) { + mock := NewMockSSHClient() + assert.False(t, mock.become) + + mock.SetBecome(true, "root", "password") + assert.True(t, mock.become) + assert.Equal(t, "root", mock.becomeUser) + assert.Equal(t, "password", mock.becomePass) +} + +func TestBecome_Infra_Good_DefaultBecomeUserRoot(t *testing.T) { + // When become is true but no user specified, it defaults to root in the Run method + cfg := SSHConfig{ + Host: "test-host", + Become: true, + // BecomeUser not set + } + client, err := NewSSHClient(cfg) + require.NoError(t, err) + + assert.True(t, client.become) + assert.Empty(t, client.becomeUser) // Empty in config... + // The Run() method defaults to "root" when becomeUser is empty +} + +func TestBecome_Infra_Good_PasswordlessBecome(t *testing.T) { + cfg := SSHConfig{ + Host: "test-host", + Become: true, + BecomeUser: "root", + // No BecomePass and no Password — triggers sudo -n + } + client, err := NewSSHClient(cfg) + require.NoError(t, err) + + assert.True(t, client.become) + assert.Empty(t, client.becomePass) + assert.Empty(t, client.password) +} + +func TestBecome_Infra_Good_ExecutorPlayBecome(t *testing.T) { + // Test that getClient applies play-level become settings + e := NewExecutor("/tmp") + e.SetInventoryDirect(&Inventory{ + All: &InventoryGroup{ + Hosts: map[string]*Host{ + "host1": {AnsibleHost: "127.0.0.1"}, + }, + }, + }) + + play := &Play{ + Become: true, + BecomeUser: "admin", + } + + // getClient will attempt SSH connection which will fail, + // but we can verify the config would be set correctly + // by checking the SSHConfig construction logic. + // Since getClient creates real connections, we just verify + // that the become fields are set on the play. + assert.True(t, play.Become) + assert.Equal(t, "admin", play.BecomeUser) +} + +// =========================================================================== +// 3. Fact Gathering +// =========================================================================== + +func TestFacts_Infra_Good_UbuntuParsing(t *testing.T) { + e, mock := newTestExecutorWithMock("web1") + + // Mock os-release output for Ubuntu + mock.expectCommand(`hostname -f`, "web1.example.com\n", "", 0) + mock.expectCommand(`hostname -s`, "web1\n", "", 0) + mock.expectCommand(`cat /etc/os-release`, "ID=ubuntu\nVERSION_ID=\"24.04\"\n", "", 0) + mock.expectCommand(`uname -m`, "x86_64\n", "", 0) + mock.expectCommand(`uname -r`, "6.5.0-44-generic\n", "", 0) + + // Simulate fact gathering by directly populating facts + // using the same parsing logic as gatherFacts + facts := &Facts{} + + stdout, _, _, _ := mock.Run(nil, "hostname -f 2>/dev/null || hostname") + facts.FQDN = trimSpace(stdout) + + stdout, _, _, _ = mock.Run(nil, "hostname -s 2>/dev/null || hostname") + facts.Hostname = trimSpace(stdout) + + stdout, _, _, _ = mock.Run(nil, "cat /etc/os-release 2>/dev/null | grep -E '^(ID|VERSION_ID)=' | head -2") + for _, line := range splitLines(stdout) { + if hasPrefix(line, "ID=") { + facts.Distribution = trimQuotes(trimPrefix(line, "ID=")) + } + if hasPrefix(line, "VERSION_ID=") { + facts.Version = trimQuotes(trimPrefix(line, "VERSION_ID=")) + } + } + + stdout, _, _, _ = mock.Run(nil, "uname -m") + facts.Architecture = trimSpace(stdout) + + stdout, _, _, _ = mock.Run(nil, "uname -r") + facts.Kernel = trimSpace(stdout) + + e.facts["web1"] = facts + + assert.Equal(t, "web1.example.com", facts.FQDN) + assert.Equal(t, "web1", facts.Hostname) + assert.Equal(t, "ubuntu", facts.Distribution) + assert.Equal(t, "24.04", facts.Version) + assert.Equal(t, "x86_64", facts.Architecture) + assert.Equal(t, "6.5.0-44-generic", facts.Kernel) + + // Now verify template resolution with these facts + result := e.templateString("{{ ansible_hostname }}", "web1", nil) + assert.Equal(t, "web1", result) + + result = e.templateString("{{ ansible_distribution }}", "web1", nil) + assert.Equal(t, "ubuntu", result) +} + +func TestFacts_Infra_Good_CentOSParsing(t *testing.T) { + facts := &Facts{} + + osRelease := "ID=centos\nVERSION_ID=\"8\"\n" + for _, line := range splitLines(osRelease) { + if hasPrefix(line, "ID=") { + facts.Distribution = trimQuotes(trimPrefix(line, "ID=")) + } + if hasPrefix(line, "VERSION_ID=") { + facts.Version = trimQuotes(trimPrefix(line, "VERSION_ID=")) + } + } + + assert.Equal(t, "centos", facts.Distribution) + assert.Equal(t, "8", facts.Version) +} + +func TestFacts_Infra_Good_AlpineParsing(t *testing.T) { + facts := &Facts{} + + osRelease := "ID=alpine\nVERSION_ID=3.19.1\n" + for _, line := range splitLines(osRelease) { + if hasPrefix(line, "ID=") { + facts.Distribution = trimQuotes(trimPrefix(line, "ID=")) + } + if hasPrefix(line, "VERSION_ID=") { + facts.Version = trimQuotes(trimPrefix(line, "VERSION_ID=")) + } + } + + assert.Equal(t, "alpine", facts.Distribution) + assert.Equal(t, "3.19.1", facts.Version) +} + +func TestFacts_Infra_Good_DebianParsing(t *testing.T) { + facts := &Facts{} + + osRelease := "ID=debian\nVERSION_ID=\"12\"\n" + for _, line := range splitLines(osRelease) { + if hasPrefix(line, "ID=") { + facts.Distribution = trimQuotes(trimPrefix(line, "ID=")) + } + if hasPrefix(line, "VERSION_ID=") { + facts.Version = trimQuotes(trimPrefix(line, "VERSION_ID=")) + } + } + + assert.Equal(t, "debian", facts.Distribution) + assert.Equal(t, "12", facts.Version) +} + +func TestFacts_Infra_Good_HostnameFromCommand(t *testing.T) { + e := NewExecutor("/tmp") + e.facts["host1"] = &Facts{ + Hostname: "myserver", + FQDN: "myserver.example.com", + } + + assert.Equal(t, "myserver", e.templateString("{{ ansible_hostname }}", "host1", nil)) + assert.Equal(t, "myserver.example.com", e.templateString("{{ ansible_fqdn }}", "host1", nil)) +} + +func TestFacts_Infra_Good_ArchitectureResolution(t *testing.T) { + e := NewExecutor("/tmp") + e.facts["host1"] = &Facts{ + Architecture: "aarch64", + } + + result := e.templateString("{{ ansible_architecture }}", "host1", nil) + assert.Equal(t, "aarch64", result) +} + +func TestFacts_Infra_Good_KernelResolution(t *testing.T) { + e := NewExecutor("/tmp") + e.facts["host1"] = &Facts{ + Kernel: "5.15.0-91-generic", + } + + result := e.templateString("{{ ansible_kernel }}", "host1", nil) + assert.Equal(t, "5.15.0-91-generic", result) +} + +func TestFacts_Infra_Good_NoFactsForHost(t *testing.T) { + e := NewExecutor("/tmp") + // No facts gathered for host1 + result := e.templateString("{{ ansible_hostname }}", "host1", nil) + // Should remain unresolved + assert.Equal(t, "{{ ansible_hostname }}", result) +} + +func TestFacts_Infra_Good_LocalhostFacts(t *testing.T) { + // When connection is local, gatherFacts sets minimal facts + e := NewExecutor("/tmp") + e.facts["localhost"] = &Facts{ + Hostname: "localhost", + } + + result := e.templateString("{{ ansible_hostname }}", "localhost", nil) + assert.Equal(t, "localhost", result) +} + +// =========================================================================== +// 4. Idempotency +// =========================================================================== + +func TestIdempotency_Infra_Good_GroupAlreadyExists(t *testing.T) { + _, mock := newTestExecutorWithMock("host1") + + // Mock: getent group docker succeeds (group exists) — the || means groupadd is skipped + mock.expectCommand(`getent group docker`, "docker:x:999:\n", "", 0) + + task := &Task{ + Module: "group", + Args: map[string]any{ + "name": "docker", + "state": "present", + }, + } + + result, err := moduleGroupWithClient(nil, mock, task.Args) + require.NoError(t, err) + + // The module runs the command: getent group docker >/dev/null 2>&1 || groupadd docker + // Since getent succeeds (rc=0), groupadd is not executed by the shell. + // However, the module always reports changed=true because it does not + // check idempotency at the Go level. This tests the current behaviour. + assert.True(t, result.Changed) + assert.False(t, result.Failed) +} + +func TestIdempotency_Infra_Good_AuthorizedKeyAlreadyPresent(t *testing.T) { + _, mock := newTestExecutorWithMock("host1") + + testKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7xfG..." + + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA user@host" + + // Mock: getent passwd returns home dir + mock.expectCommand(`getent passwd deploy`, "/home/deploy\n", "", 0) + + // Mock: mkdir + chmod + chown for .ssh dir + mock.expectCommand(`mkdir -p`, "", "", 0) + + // Mock: grep finds the key (rc=0, key is present) + mock.expectCommand(`grep -qF`, "", "", 0) + + // Mock: chmod + chown for authorized_keys + mock.expectCommand(`chmod 600`, "", "", 0) + + result, err := moduleAuthorizedKeyWithClient(nil, mock, map[string]any{ + "user": "deploy", + "key": testKey, + "state": "present", + }) + require.NoError(t, err) + + // Module reports changed=true regardless (it doesn't check grep result at Go level) + // The grep || echo construct handles idempotency at the shell level + assert.NotNil(t, result) + assert.False(t, result.Failed) +} + +func TestIdempotency_Infra_Good_DockerComposeUpToDate(t *testing.T) { + _, mock := newTestExecutorWithMock("host1") + + // Mock: docker compose up -d returns "Up to date" in stdout + mock.expectCommand(`docker compose up -d`, "web1 Up to date\nnginx Up to date\n", "", 0) + + result, err := moduleDockerComposeWithClient(nil, mock, map[string]any{ + "project_src": "/opt/myapp", + "state": "present", + }) + require.NoError(t, err) + require.NotNil(t, result) + + // When stdout contains "Up to date", changed should be false + assert.False(t, result.Changed) + assert.False(t, result.Failed) +} + +func TestIdempotency_Infra_Good_DockerComposeChanged(t *testing.T) { + _, mock := newTestExecutorWithMock("host1") + + // Mock: docker compose up -d with actual changes + mock.expectCommand(`docker compose up -d`, "Creating web1 ... done\nCreating nginx ... done\n", "", 0) + + result, err := moduleDockerComposeWithClient(nil, mock, map[string]any{ + "project_src": "/opt/myapp", + "state": "present", + }) + require.NoError(t, err) + require.NotNil(t, result) + + // When stdout does NOT contain "Up to date", changed should be true + assert.True(t, result.Changed) + assert.False(t, result.Failed) +} + +func TestIdempotency_Infra_Good_DockerComposeUpToDateInStderr(t *testing.T) { + _, mock := newTestExecutorWithMock("host1") + + // Some versions of docker compose output status to stderr + mock.expectCommand(`docker compose up -d`, "", "web1 Up to date\n", 0) + + result, err := moduleDockerComposeWithClient(nil, mock, map[string]any{ + "project_src": "/opt/myapp", + "state": "present", + }) + require.NoError(t, err) + require.NotNil(t, result) + + // The docker compose module checks both stdout and stderr for "Up to date" + assert.False(t, result.Changed) +} + +func TestIdempotency_Infra_Good_GroupCreationWhenNew(t *testing.T) { + _, mock := newTestExecutorWithMock("host1") + + // Mock: getent fails (group does not exist), groupadd succeeds + mock.expectCommand(`getent group newgroup`, "", "no such group", 2) + // The overall command runs in shell: getent group newgroup >/dev/null 2>&1 || groupadd newgroup + // Since we match on the full command, the mock will return rc=0 default + mock.expectCommand(`getent group newgroup .* groupadd`, "", "", 0) + + result, err := moduleGroupWithClient(nil, mock, map[string]any{ + "name": "newgroup", + "state": "present", + }) + require.NoError(t, err) + + assert.True(t, result.Changed) + assert.False(t, result.Failed) +} + +func TestIdempotency_Infra_Good_ServiceStatChanged(t *testing.T) { + _, mock := newTestExecutorWithMock("host1") + + // Mock: stat reports the file exists + mock.addStat("/etc/config.conf", map[string]any{"exists": true, "isdir": false}) + + result, err := moduleStatWithClient(nil, mock, map[string]any{ + "path": "/etc/config.conf", + }) + require.NoError(t, err) + + // Stat module should always report changed=false + assert.False(t, result.Changed) + assert.NotNil(t, result.Data) + stat := result.Data["stat"].(map[string]any) + assert.True(t, stat["exists"].(bool)) +} + +func TestIdempotency_Infra_Good_StatFileNotFound(t *testing.T) { + _, mock := newTestExecutorWithMock("host1") + + // No stat info added — will return exists=false from mock + result, err := moduleStatWithClient(nil, mock, map[string]any{ + "path": "/nonexistent/file", + }) + require.NoError(t, err) + + assert.False(t, result.Changed) + assert.NotNil(t, result.Data) + stat := result.Data["stat"].(map[string]any) + assert.False(t, stat["exists"].(bool)) +} + +// =========================================================================== +// Additional cross-cutting edge cases +// =========================================================================== + +func TestResolveExpr_Infra_Good_HostVars(t *testing.T) { + e := NewExecutor("/tmp") + e.SetInventoryDirect(&Inventory{ + All: &InventoryGroup{ + Hosts: map[string]*Host{ + "host1": { + AnsibleHost: "10.0.0.1", + Vars: map[string]any{ + "custom_var": "custom_value", + }, + }, + }, + }, + }) + + result := e.templateString("{{ custom_var }}", "host1", nil) + assert.Equal(t, "custom_value", result) +} + +func TestTemplateArgs_Infra_Good_InventoryHostname(t *testing.T) { + e := NewExecutor("/tmp") + + args := map[string]any{ + "hostname": "{{ inventory_hostname }}", + } + + result := e.templateArgs(args, "web1", nil) + assert.Equal(t, "web1", result["hostname"]) +} + +func TestEvalCondition_Infra_Good_UnknownDefaultsTrue(t *testing.T) { + e := NewExecutor("/tmp") + // Unknown conditions default to true (permissive) + assert.True(t, e.evalCondition("some_complex_expression == 'value'", "host1")) +} + +func TestGetRegisteredVar_Infra_Good_DottedAccess(t *testing.T) { + e := NewExecutor("/tmp") + e.results["host1"] = map[string]*TaskResult{ + "my_cmd": {Stdout: "output_text", RC: 0}, + } + + // getRegisteredVar parses dotted names + result := e.getRegisteredVar("host1", "my_cmd.stdout") + // getRegisteredVar only looks up the base name (before the dot) + assert.NotNil(t, result) + assert.Equal(t, "output_text", result.Stdout) +} + +func TestGetRegisteredVar_Infra_Bad_NotRegistered(t *testing.T) { + e := NewExecutor("/tmp") + + result := e.getRegisteredVar("host1", "nonexistent") + assert.Nil(t, result) +} + +func TestGetRegisteredVar_Infra_Bad_WrongHost(t *testing.T) { + e := NewExecutor("/tmp") + e.results["host1"] = map[string]*TaskResult{ + "my_cmd": {Stdout: "output"}, + } + + // Different host has no results + result := e.getRegisteredVar("host2", "my_cmd") + assert.Nil(t, result) +} + +// =========================================================================== +// String helper utilities used by fact tests +// =========================================================================== + +func trimSpace(s string) string { + result := "" + for _, c := range s { + if c != '\n' && c != '\r' && c != ' ' && c != '\t' { + result += string(c) + } else if len(result) > 0 && result[len(result)-1] != ' ' { + result += " " + } + } + // Actually just use strings.TrimSpace + return stringsTrimSpace(s) +} + +func trimQuotes(s string) string { + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + return s +} + +func trimPrefix(s, prefix string) string { + if len(s) >= len(prefix) && s[:len(prefix)] == prefix { + return s[len(prefix):] + } + return s +} + +func hasPrefix(s, prefix string) bool { + return len(s) >= len(prefix) && s[:len(prefix)] == prefix +} + +func splitLines(s string) []string { + var lines []string + current := "" + for _, c := range s { + if c == '\n' { + lines = append(lines, current) + current = "" + } else { + current += string(c) + } + } + if current != "" { + lines = append(lines, current) + } + return lines +} + +func stringsTrimSpace(s string) string { + start := 0 + end := len(s) + for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') { + start++ + } + for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') { + end-- + } + return s[start:end] +}