diff --git a/commands_test.go b/commands_test.go new file mode 100644 index 0000000..cf5395a --- /dev/null +++ b/commands_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "testing" +) + +// TestNewAgentCommand verifies that the agent command is created properly with all required fields. +// Test: Creates an agent command and validates its structure +// Expected: Command should be non-nil with Use="agent", non-empty descriptions, and RunE function set +func TestNewAgentCommand(t *testing.T) { + cmd := NewAgentCommand() + + if cmd == nil { + t.Fatal("NewAgentCommand should not return nil") + } + + if cmd.Use != "agent" { + t.Errorf("Expected Use to be 'agent', got '%s'", cmd.Use) + } + + if cmd.Short == "" { + t.Error("Short description should not be empty") + } + + if cmd.Long == "" { + t.Error("Long description should not be empty") + } + + if cmd.RunE == nil { + t.Error("RunE should be set") + } +} + +// TestNewUnbootstrapCommand verifies that the unbootstrap command is created properly with all required fields. +// Test: Creates an unbootstrap command and validates its structure +// Expected: Command should be non-nil with Use="unbootstrap", non-empty descriptions, and RunE function set +func TestNewUnbootstrapCommand(t *testing.T) { + cmd := NewUnbootstrapCommand() + + if cmd == nil { + t.Fatal("NewUnbootstrapCommand should not return nil") + } + + if cmd.Use != "unbootstrap" { + t.Errorf("Expected Use to be 'unbootstrap', got '%s'", cmd.Use) + } + + if cmd.Short == "" { + t.Error("Short description should not be empty") + } + + if cmd.Long == "" { + t.Error("Long description should not be empty") + } + + if cmd.RunE == nil { + t.Error("RunE should be set") + } +} + +// TestNewVersionCommand verifies that the version command is created properly with all required fields. +// Test: Creates a version command and validates its structure +// Expected: Command should be non-nil with Use="version", non-empty descriptions, and Run function set +func TestNewVersionCommand(t *testing.T) { + cmd := NewVersionCommand() + + if cmd == nil { + t.Fatal("NewVersionCommand should not return nil") + } + + if cmd.Use != "version" { + t.Errorf("Expected Use to be 'version', got '%s'", cmd.Use) + } + + if cmd.Short == "" { + t.Error("Short description should not be empty") + } + + if cmd.Long == "" { + t.Error("Long description should not be empty") + } + + if cmd.Run == nil { + t.Error("Run should be set") + } +} + +// TestVersionVariables verifies that version variables (set at build time via ldflags) can be modified. +// Test: Saves current values, sets test values, verifies they're set correctly, then restores originals +// Expected: All three variables (Version, GitCommit, BuildTime) should be settable and readable +func TestVersionVariables(t *testing.T) { + // Test that version variables can be set + oldVersion := Version + oldGitCommit := GitCommit + oldBuildTime := BuildTime + + Version = "test-version" + GitCommit = "test-commit" + BuildTime = "test-time" + + if Version != "test-version" { + t.Error("Version should be settable") + } + + if GitCommit != "test-commit" { + t.Error("GitCommit should be settable") + } + + if BuildTime != "test-time" { + t.Error("BuildTime should be settable") + } + + // Restore original values + Version = oldVersion + GitCommit = oldGitCommit + BuildTime = oldBuildTime +} + +// TestAllCommands is a table-driven test that verifies all CLI commands can be created without errors. +// Test: Iterates through all command types (agent, unbootstrap, version) and creates each one +// Expected: All commands should be created successfully and return non-nil objects +func TestAllCommands(t *testing.T) { + // Verify all command constructors work properly + tests := []struct { + name string + cmd string + }{ + {"agent command exists", "agent"}, + {"unbootstrap command exists", "unbootstrap"}, + {"version command exists", "version"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cmd interface{} + switch tt.cmd { + case "agent": + cmd = NewAgentCommand() + case "unbootstrap": + cmd = NewUnbootstrapCommand() + case "version": + cmd = NewVersionCommand() + } + + if cmd == nil { + t.Errorf("Command %s should not be nil", tt.cmd) + } + }) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..171133a --- /dev/null +++ b/main_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "testing" +) + +// TestCommandConstructors verifies that all command constructors used by main() function work correctly. +// Test: Creates agent, unbootstrap, and version commands to ensure main() dependencies are functional +// Expected: All command constructors should return non-nil cobra.Command objects +// Note: Cannot directly test main() execution as it handles signals and runs indefinitely +func TestCommandConstructors(t *testing.T) { + // Verify that all command constructors used in main() work properly + // We can't directly test main() execution, but we can test the components it uses + + // Test that command creation works + rootCmd := NewAgentCommand() + if rootCmd == nil { + t.Error("Should be able to create agent command") + } + + unbootstrapCmd := NewUnbootstrapCommand() + if unbootstrapCmd == nil { + t.Error("Should be able to create unbootstrap command") + } + + versionCmd := NewVersionCommand() + if versionCmd == nil { + t.Error("Should be able to create version command") + } +} + +// TestConfigPath verifies that the global configPath variable can be set and retrieved. +// Test: Saves current value, sets a test path, verifies it's set correctly, then restores original +// Expected: configPath variable should be readable and writable (used for --config flag) +func TestConfigPath(t *testing.T) { + // Test that configPath variable is accessible + oldPath := configPath + configPath = "/test/path" + + if configPath != "/test/path" { + t.Error("configPath should be settable") + } + + // Restore + configPath = oldPath +} diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go new file mode 100644 index 0000000..5a5371d --- /dev/null +++ b/pkg/auth/auth_test.go @@ -0,0 +1,258 @@ +package auth + +import ( + "context" + "testing" + + "go.goms.io/aks/AKSFlexNode/pkg/config" +) + +// TestNewAuthProvider verifies that the AuthProvider constructor returns a valid instance. +// Test: Creates a new AuthProvider using the constructor +// Expected: AuthProvider should not be nil +func TestNewAuthProvider(t *testing.T) { + provider := NewAuthProvider() + if provider == nil { + t.Error("NewAuthProvider should not return nil") + } +} + +// TestArcCredential verifies that ArcCredential method can be called without panicking. +// Test: Attempts to create Azure Arc managed identity credential +// Expected: Method should not panic (error is expected in non-Arc environments) +// Note: Will fail in test environment without Arc MSI, which is expected behavior +func TestArcCredential(t *testing.T) { + provider := NewAuthProvider() + + // Note: This will fail if not running in an Arc-enabled environment + // We're testing that it returns a credential object, not that it works + _, err := provider.ArcCredential() + + // We expect an error in test environment (no Arc MSI available) + // Just verify the method doesn't panic + if err == nil { + t.Log("Arc credential created successfully (unexpected in test environment)") + } else { + t.Logf("Arc credential creation failed as expected in test environment: %v", err) + } +} + +// TestServiceCredential verifies service principal credential creation with valid configuration. +// Test: Creates credentials using service principal (tenant ID, client ID, client secret) +// Expected: Credential object should be created successfully without errors +func TestServiceCredential(t *testing.T) { + provider := NewAuthProvider() + + tests := []struct { + name string + cfg *config.Config + wantErr bool + }{ + { + name: "valid service principal config", + cfg: &config.Config{ + Azure: config.AzureConfig{ + ServicePrincipal: &config.ServicePrincipalConfig{ + TenantID: "test-tenant-id", + ClientID: "test-client-id", + ClientSecret: "test-secret", + }, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cred, err := provider.serviceCredential(tt.cfg) + + if tt.wantErr && err == nil { + t.Error("Expected error but got none") + } + + if !tt.wantErr && err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if !tt.wantErr && cred == nil { + t.Error("Credential should not be nil") + } + }) + } +} + +// TestCLICredential verifies Azure CLI credential creation without panicking. +// Test: Attempts to create Azure CLI credential +// Expected: Method should not panic (error is expected without Azure CLI configured) +// Note: Will fail in environments without Azure CLI installed/configured +func TestCLICredential(t *testing.T) { + provider := NewAuthProvider() + + // Note: This will fail if Azure CLI is not installed/configured + // We're testing that it doesn't panic + _, err := provider.cliCredential() + + // We expect an error in environments without Azure CLI configured + if err == nil { + t.Log("CLI credential created successfully") + } else { + t.Logf("CLI credential creation failed (may be expected): %v", err) + } +} + +// TestUserCredential verifies the correct credential type is selected based on configuration. +// Test: Creates credentials with and without service principal configuration +// Expected: Uses service principal when configured, falls back to Azure CLI otherwise +func TestUserCredential(t *testing.T) { + provider := NewAuthProvider() + + tests := []struct { + name string + cfg *config.Config + useSP bool + }{ + { + name: "with service principal", + cfg: &config.Config{ + Azure: config.AzureConfig{ + ServicePrincipal: &config.ServicePrincipalConfig{ + TenantID: "test-tenant-id", + ClientID: "test-client-id", + ClientSecret: "test-secret", + }, + }, + }, + useSP: true, + }, + { + name: "without service principal (fallback to CLI)", + cfg: &config.Config{ + Azure: config.AzureConfig{ + ServicePrincipal: nil, + }, + }, + useSP: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cred, err := provider.UserCredential(tt.cfg) + + // Don't fail on error - environment may not have Azure CLI + if err != nil { + t.Logf("UserCredential returned error (may be expected): %v", err) + return + } + + if cred == nil { + t.Error("Credential should not be nil when no error") + } + }) + } +} + +// TestGetAccessToken verifies access token retrieval for default ARM resource scope. +// Test: Attempts to get access token using test credentials for Azure Resource Manager +// Expected: Should fail with test credentials but not panic +func TestGetAccessToken(t *testing.T) { + provider := NewAuthProvider() + + // Create a service principal credential (will fail to get token without valid creds) + cfg := &config.Config{ + Azure: config.AzureConfig{ + ServicePrincipal: &config.ServicePrincipalConfig{ + TenantID: "test-tenant-id", + ClientID: "test-client-id", + ClientSecret: "test-secret", + }, + }, + } + + cred, err := provider.serviceCredential(cfg) + if err != nil { + t.Fatalf("Failed to create credential: %v", err) + } + + ctx := context.Background() + + // This will fail with invalid credentials, but shouldn't panic + _, err = provider.GetAccessToken(ctx, cred) + if err == nil { + t.Error("Expected error with test credentials") + } else { + t.Logf("GetAccessToken failed as expected with test credentials: %v", err) + } +} + +// TestGetAccessTokenForResource verifies access token retrieval for specific resource scopes. +// Test: Attempts to get access tokens for ARM and Microsoft Graph resources +// Expected: Should fail with test credentials but handle different resource scopes correctly +func TestGetAccessTokenForResource(t *testing.T) { + provider := NewAuthProvider() + + // Create a service principal credential + cfg := &config.Config{ + Azure: config.AzureConfig{ + ServicePrincipal: &config.ServicePrincipalConfig{ + TenantID: "test-tenant-id", + ClientID: "test-client-id", + ClientSecret: "test-secret", + }, + }, + } + + cred, err := provider.serviceCredential(cfg) + if err != nil { + t.Fatalf("Failed to create credential: %v", err) + } + + ctx := context.Background() + + tests := []struct { + name string + resource string + }{ + { + name: "ARM resource", + resource: "https://management.azure.com/.default", + }, + { + name: "Graph resource", + resource: "https://graph.microsoft.com/.default", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // This will fail with invalid credentials + _, err := provider.GetAccessTokenForResource(ctx, cred, tt.resource) + if err == nil { + t.Error("Expected error with test credentials") + } else { + t.Logf("GetAccessTokenForResource failed as expected: %v", err) + } + }) + } +} + +// TestCheckCLIAuthStatus verifies Azure CLI authentication status check. +// Test: Checks if user is authenticated via Azure CLI +// Expected: May pass or fail depending on environment (error expected if not logged in) +func TestCheckCLIAuthStatus(t *testing.T) { + provider := NewAuthProvider() + ctx := context.Background() + + // This will fail if Azure CLI is not installed or user not logged in + err := provider.CheckCLIAuthStatus(ctx) + + if err == nil { + t.Log("CLI auth status check passed (user is logged in)") + } else { + t.Logf("CLI auth status check failed (expected if not logged in): %v", err) + } +} + +// Note: We don't test InteractiveAzLogin and EnsureAuthenticated as they require user interaction +// These should be tested manually or with integration tests diff --git a/pkg/bootstrapper/bootstrapper_test.go b/pkg/bootstrapper/bootstrapper_test.go new file mode 100644 index 0000000..5e196b2 --- /dev/null +++ b/pkg/bootstrapper/bootstrapper_test.go @@ -0,0 +1,51 @@ +package bootstrapper + +import ( + "testing" + + "github.com/sirupsen/logrus" + "go.goms.io/aks/AKSFlexNode/pkg/config" +) + +// TestNew verifies that the Bootstrapper constructor initializes correctly. +// Test: Creates a new Bootstrapper with config and logger +// Expected: Returns non-nil Bootstrapper with initialized BaseExecutor +func TestNew(t *testing.T) { + cfg := &config.Config{} + logger := logrus.New() + + bootstrapper := New(cfg, logger) + + if bootstrapper == nil { + t.Fatal("New should not return nil") + } + + if bootstrapper.BaseExecutor == nil { + t.Error("BaseExecutor should be initialized") + } +} + +// TestBootstrapperStructure verifies the Bootstrapper structure and initialization. +// Test: Creates a Bootstrapper and checks its structure components +// Expected: Bootstrapper and BaseExecutor should both be properly initialized +// Note: Full Bootstrap/Unbootstrap integration tests require complete system environment +func TestBootstrapperStructure(t *testing.T) { + // Test that Bootstrapper has the expected structure + cfg := &config.Config{} + logger := logrus.New() + bootstrapper := New(cfg, logger) + + // Just verify that the bootstrapper is initialized properly + // Methods Bootstrap and Unbootstrap exist as methods on the struct + if bootstrapper == nil { + t.Fatal("Bootstrapper should not be nil") + } + + if bootstrapper.BaseExecutor == nil { + t.Error("BaseExecutor should be initialized") + } +} + +// Note: Full integration tests for Bootstrap and Unbootstrap require +// a complete system environment with Arc, containers, k8s, etc. +// Those should be in integration test suite, not unit tests. diff --git a/pkg/bootstrapper/executor_test.go b/pkg/bootstrapper/executor_test.go new file mode 100644 index 0000000..8bc0ead --- /dev/null +++ b/pkg/bootstrapper/executor_test.go @@ -0,0 +1,377 @@ +package bootstrapper + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/sirupsen/logrus" + "go.goms.io/aks/AKSFlexNode/pkg/config" +) + +// mockExecutor is a mock implementation of Executor interface for testing bootstrap execution flow. +// It tracks execution state and can simulate successful or failed execution. +type mockExecutor struct { + name string + shouldFail bool + isCompleted bool + executed bool +} + +func (m *mockExecutor) Execute(ctx context.Context) error { + m.executed = true + if m.shouldFail { + return errors.New("mock execution error") + } + return nil +} + +func (m *mockExecutor) IsCompleted(ctx context.Context) bool { + return m.isCompleted +} + +func (m *mockExecutor) GetName() string { + return m.name +} + +// mockStepExecutor extends mockExecutor with Validate method for testing validation logic. +// Used to test bootstrap steps that implement the StepExecutor interface. +type mockStepExecutor struct { + mockExecutor + validateError error +} + +func (m *mockStepExecutor) Validate(ctx context.Context) error { + return m.validateError +} + +// TestNewBaseExecutor verifies BaseExecutor constructor initialization. +// Test: Creates BaseExecutor with config and logger +// Expected: Returns non-nil executor with config and logger properly set +func TestNewBaseExecutor(t *testing.T) { + cfg := &config.Config{} + logger := logrus.New() + + executor := NewBaseExecutor(cfg, logger) + + if executor == nil { + t.Fatal("NewBaseExecutor should not return nil") + } + + if executor.config != cfg { + t.Error("Config should be set") + } + + if executor.logger != logger { + t.Error("Logger should be set") + } +} + +// TestExecuteSteps_Success verifies successful execution of all steps in a bootstrap sequence. +// Test: Executes 3 steps that all succeed +// Expected: All steps execute successfully, result shows Success=true with StepCount=3 +func TestExecuteSteps_Success(t *testing.T) { + cfg := &config.Config{} + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) // Reduce noise + executor := NewBaseExecutor(cfg, logger) + + steps := []Executor{ + &mockExecutor{name: "step1", shouldFail: false, isCompleted: false}, + &mockExecutor{name: "step2", shouldFail: false, isCompleted: false}, + &mockExecutor{name: "step3", shouldFail: false, isCompleted: false}, + } + + ctx := context.Background() + result, err := executor.ExecuteSteps(ctx, steps, "bootstrap") + + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + if result == nil { + t.Fatal("Result should not be nil") + } + + if !result.Success { + t.Error("Result should be successful") + } + + if result.StepCount != 3 { + t.Errorf("Expected 3 steps, got %d", result.StepCount) + } + + if len(result.StepResults) != 3 { + t.Errorf("Expected 3 step results, got %d", len(result.StepResults)) + } + + // Verify all steps were executed + for i, step := range steps { + mockStep := step.(*mockExecutor) + if !mockStep.executed { + t.Errorf("Step %d should have been executed", i) + } + } +} + +// TestExecuteSteps_BootstrapFailure verifies bootstrap fails fast on first error. +// Test: Executes steps where step2 fails +// Expected: Execution stops at step2, step3 never executes, returns error with StepCount=2 +func TestExecuteSteps_BootstrapFailure(t *testing.T) { + cfg := &config.Config{} + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + executor := NewBaseExecutor(cfg, logger) + + steps := []Executor{ + &mockExecutor{name: "step1", shouldFail: false, isCompleted: false}, + &mockExecutor{name: "step2", shouldFail: true, isCompleted: false}, // This should fail + &mockExecutor{name: "step3", shouldFail: false, isCompleted: false}, + } + + ctx := context.Background() + result, err := executor.ExecuteSteps(ctx, steps, "bootstrap") + + if err == nil { + t.Error("Expected error for bootstrap failure") + } + + if result == nil { + t.Fatal("Result should not be nil") + } + + if result.Success { + t.Error("Result should not be successful") + } + + if result.StepCount != 2 { + t.Errorf("Expected 2 steps executed before failure, got %d", result.StepCount) + } + + if result.Error == "" { + t.Error("Result should have error message") + } + + // Verify step3 was not executed (bootstrap fails fast) + step3 := steps[2].(*mockExecutor) + if step3.executed { + t.Error("Step 3 should not have been executed after failure") + } +} + +// TestExecuteSteps_UnbootstrapContinuesOnFailure verifies unbootstrap continues after failures. +// Test: Executes unbootstrap steps where step2 fails +// Expected: All 3 steps execute despite failure, result shows Success=false but StepCount=3 +func TestExecuteSteps_UnbootstrapContinuesOnFailure(t *testing.T) { + cfg := &config.Config{} + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + executor := NewBaseExecutor(cfg, logger) + + steps := []Executor{ + &mockExecutor{name: "step1", shouldFail: false, isCompleted: false}, + &mockExecutor{name: "step2", shouldFail: true, isCompleted: false}, // This fails + &mockExecutor{name: "step3", shouldFail: false, isCompleted: false}, + } + + ctx := context.Background() + result, err := executor.ExecuteSteps(ctx, steps, "unbootstrap") + + // Unbootstrap doesn't return error, continues on failure + if err != nil { + t.Errorf("Unbootstrap should not return error, got: %v", err) + } + + if result == nil { + t.Fatal("Result should not be nil") + } + + if result.Success { + t.Error("Result should not be fully successful with one failed step") + } + + if result.StepCount != 3 { + t.Errorf("Expected all 3 steps to be attempted, got %d", result.StepCount) + } + + // Verify all steps were executed (unbootstrap continues) + for i, step := range steps { + mockStep := step.(*mockExecutor) + if !mockStep.executed { + t.Errorf("Step %d should have been executed", i) + } + } +} + +// TestExecuteSteps_SkipsCompletedSteps verifies already-completed steps are skipped. +// Test: Executes steps where step1 and step3 are marked as completed +// Expected: Only step2 executes, completed steps are skipped but counted as successful +func TestExecuteSteps_SkipsCompletedSteps(t *testing.T) { + cfg := &config.Config{} + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + executor := NewBaseExecutor(cfg, logger) + + steps := []Executor{ + &mockExecutor{name: "step1", shouldFail: false, isCompleted: true}, // Already completed + &mockExecutor{name: "step2", shouldFail: false, isCompleted: false}, + &mockExecutor{name: "step3", shouldFail: false, isCompleted: true}, // Already completed + } + + ctx := context.Background() + result, err := executor.ExecuteSteps(ctx, steps, "bootstrap") + + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + if !result.Success { + t.Error("Result should be successful") + } + + // Verify completed steps were not executed + step1 := steps[0].(*mockExecutor) + if step1.executed { + t.Error("Completed step 1 should not have been executed") + } + + step2 := steps[1].(*mockExecutor) + if !step2.executed { + t.Error("Incomplete step 2 should have been executed") + } + + step3 := steps[2].(*mockExecutor) + if step3.executed { + t.Error("Completed step 3 should not have been executed") + } +} + +// TestExecuteSteps_ValidationFailure verifies bootstrap fails when validation fails. +// Test: Executes a step that fails validation (before execution) +// Expected: Step never executes, returns error indicating validation failure +func TestExecuteSteps_ValidationFailure(t *testing.T) { + cfg := &config.Config{} + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + executor := NewBaseExecutor(cfg, logger) + + steps := []Executor{ + &mockStepExecutor{ + mockExecutor: mockExecutor{name: "step1", shouldFail: false, isCompleted: false}, + validateError: errors.New("validation failed"), + }, + } + + ctx := context.Background() + result, err := executor.ExecuteSteps(ctx, steps, "bootstrap") + + if err == nil { + t.Error("Expected error for validation failure") + } + + if result == nil { + t.Fatal("Result should not be nil") + } + + if result.Success { + t.Error("Result should not be successful") + } + + // Verify step was not executed due to validation failure + step1 := steps[0].(*mockStepExecutor) + if step1.executed { + t.Error("Step should not have been executed after validation failure") + } +} + +// TestCountSuccessfulSteps verifies counting of successful steps from results. +// Test: Counts successful steps in a mixed success/failure result set +// Expected: Returns count of 3 successful steps out of 4 total +func TestCountSuccessfulSteps(t *testing.T) { + cfg := &config.Config{} + logger := logrus.New() + executor := NewBaseExecutor(cfg, logger) + + stepResults := []StepResult{ + {StepName: "step1", Success: true}, + {StepName: "step2", Success: false}, + {StepName: "step3", Success: true}, + {StepName: "step4", Success: true}, + } + + count := executor.countSuccessfulSteps(stepResults) + + if count != 3 { + t.Errorf("Expected 3 successful steps, got %d", count) + } +} + +// TestCreateStepResult verifies StepResult creation with timing and status. +// Test: Creates step results for both successful and failed scenarios +// Expected: StepResult contains correct name, success status, duration, and error message +func TestCreateStepResult(t *testing.T) { + cfg := &config.Config{} + logger := logrus.New() + executor := NewBaseExecutor(cfg, logger) + + startTime := time.Now() + time.Sleep(10 * time.Millisecond) + + result := executor.createStepResult("test-step", startTime, true, "") + + if result.StepName != "test-step" { + t.Errorf("Expected step name 'test-step', got '%s'", result.StepName) + } + + if !result.Success { + t.Error("Expected success to be true") + } + + if result.Duration == 0 { + t.Error("Duration should be greater than 0") + } + + if result.Error != "" { + t.Error("Error should be empty for successful result") + } + + // Test with error + resultWithError := executor.createStepResult("test-step", startTime, false, "test error") + + if resultWithError.Success { + t.Error("Expected success to be false") + } + + if resultWithError.Error != "test error" { + t.Errorf("Expected error 'test error', got '%s'", resultWithError.Error) + } +} + +// TestExecutionResult verifies ExecutionResult structure and field population. +// Test: Creates an ExecutionResult with multiple step results +// Expected: All fields (Success, StepCount, Duration, StepResults) are properly populated +func TestExecutionResult(t *testing.T) { + result := &ExecutionResult{ + Success: true, + StepCount: 3, + Duration: time.Second, + StepResults: []StepResult{ + {StepName: "step1", Success: true, Duration: time.Millisecond * 100}, + {StepName: "step2", Success: true, Duration: time.Millisecond * 200}, + {StepName: "step3", Success: true, Duration: time.Millisecond * 300}, + }, + } + + if !result.Success { + t.Error("Result should be successful") + } + + if result.StepCount != 3 { + t.Errorf("Expected 3 steps, got %d", result.StepCount) + } + + if len(result.StepResults) != 3 { + t.Errorf("Expected 3 step results, got %d", len(result.StepResults)) + } +} diff --git a/pkg/components/arc/consts_test.go b/pkg/components/arc/consts_test.go new file mode 100644 index 0000000..22ce6cd --- /dev/null +++ b/pkg/components/arc/consts_test.go @@ -0,0 +1,62 @@ +package arc + +import ( + "testing" +) + +// TestRoleDefinitionIDs verifies Azure role definition ID mappings. +// Test: Validates roleDefinitionIDs map contains all required Azure roles with correct GUIDs +// Expected: Map should contain Reader, Contributor, Network Contributor, and AKS admin roles +func TestRoleDefinitionIDs(t *testing.T) { + expectedRoles := map[string]string{ + "Reader": "acdd72a7-3385-48ef-bd42-f606fba81ae7", + "Network Contributor": "4d97b98b-1d4f-4787-a291-c67834d212e7", + "Contributor": "b24988ac-6180-42a0-ab88-20f7382dd24c", + "Azure Kubernetes Service RBAC Cluster Admin": "b1ff04bb-8a4e-4dc4-8eb5-8693973ce19b", + "Azure Kubernetes Service Cluster Admin Role": "0ab0b1a8-8aac-4efd-b8c2-3ee1fb270be8", + } + + if len(roleDefinitionIDs) != len(expectedRoles) { + t.Errorf("Expected %d role definitions, got %d", len(expectedRoles), len(roleDefinitionIDs)) + } + + for role, id := range expectedRoles { + if roleDefinitionIDs[role] != id { + t.Errorf("roleDefinitionIDs[%s] = %s, want %s", role, roleDefinitionIDs[role], id) + } + } +} + +// TestArcServices verifies Azure Arc service names list. +// Test: Validates arcServices array contains all required Arc services +// Expected: Array should contain himdsd, gcarcservice, and extd services +func TestArcServices(t *testing.T) { + expectedServices := []string{"himdsd", "gcarcservice", "extd"} + + if len(arcServices) != len(expectedServices) { + t.Errorf("Expected %d arc services, got %d", len(expectedServices), len(arcServices)) + } + + for i, service := range expectedServices { + if arcServices[i] != service { + t.Errorf("arcServices[%d] = %s, want %s", i, arcServices[i], service) + } + } +} + +// TestRoleDefinitionIDsAreGUIDs verifies role definition IDs are valid GUIDs. +// Test: Checks all role definition IDs have correct GUID format (36 chars with dashes) +// Expected: All IDs should be properly formatted GUIDs with dashes at positions 8, 13, 18, 23 +func TestRoleDefinitionIDsAreGUIDs(t *testing.T) { + // Test that all role definition IDs are in GUID format + for role, id := range roleDefinitionIDs { + if len(id) != 36 { + t.Errorf("Role %s has ID with wrong length: %d (expected 36)", role, len(id)) + } + + // Check for correct dashes + if id[8] != '-' || id[13] != '-' || id[18] != '-' || id[23] != '-' { + t.Errorf("Role %s has ID with incorrect GUID format: %s", role, id) + } + } +} diff --git a/pkg/components/cni/consts_test.go b/pkg/components/cni/consts_test.go new file mode 100644 index 0000000..435d0dd --- /dev/null +++ b/pkg/components/cni/consts_test.go @@ -0,0 +1,98 @@ +package cni + +import ( + "testing" +) + +// TestCNIConstants verifies CNI configuration path and version constants. +// Test: Validates CNI directories, config file names, plugin names, and version strings +// Expected: All paths should match standard CNI installation locations and specifications +func TestCNIConstants(t *testing.T) { + tests := []struct { + name string + value string + expected string + }{ + {"DefaultCNIBinDir", DefaultCNIBinDir, "/opt/cni/bin"}, + {"DefaultCNIConfDir", DefaultCNIConfDir, "/etc/cni/net.d"}, + {"DefaultCNILibDir", DefaultCNILibDir, "/var/lib/cni"}, + {"bridgeConfigFile", bridgeConfigFile, "10-bridge.conf"}, + {"bridgePlugin", bridgePlugin, "bridge"}, + {"hostLocalPlugin", hostLocalPlugin, "host-local"}, + {"loopbackPlugin", loopbackPlugin, "loopback"}, + {"portmapPlugin", portmapPlugin, "portmap"}, + {"bandwidthPlugin", bandwidthPlugin, "bandwidth"}, + {"tuningPlugin", tuningPlugin, "tuning"}, + {"DefaultCNIVersion", DefaultCNIVersion, "1.5.1"}, + {"DefaultCNISpecVersion", DefaultCNISpecVersion, "0.3.1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.value != tt.expected { + t.Errorf("%s = %s, want %s", tt.name, tt.value, tt.expected) + } + }) + } +} + +// TestCNIDirectories verifies CNI directory paths array. +// Test: Validates cniDirs array contains all required CNI directories +// Expected: Array should contain bin, conf, and lib directories in correct order +func TestCNIDirectories(t *testing.T) { + if len(cniDirs) != 3 { + t.Errorf("Expected 3 CNI directories, got %d", len(cniDirs)) + } + + expectedDirs := []string{ + "/opt/cni/bin", + "/etc/cni/net.d", + "/var/lib/cni", + } + + for i, dir := range cniDirs { + if dir != expectedDirs[i] { + t.Errorf("cniDirs[%d] = %s, want %s", i, dir, expectedDirs[i]) + } + } +} + +// TestRequiredCNIPlugins verifies required CNI plugins list. +// Test: Validates requiredCNIPlugins array contains essential plugins +// Expected: Array should contain bridge, host-local, and loopback plugins +func TestRequiredCNIPlugins(t *testing.T) { + if len(requiredCNIPlugins) != 3 { + t.Errorf("Expected 3 required CNI plugins, got %d", len(requiredCNIPlugins)) + } + + expectedPlugins := []string{ + "bridge", + "host-local", + "loopback", + } + + for i, plugin := range requiredCNIPlugins { + if plugin != expectedPlugins[i] { + t.Errorf("requiredCNIPlugins[%d] = %s, want %s", i, plugin, expectedPlugins[i]) + } + } +} + +// TestCNIVariables verifies CNI download configuration variables. +// Test: Validates filename template and download URL template +// Expected: Variables should contain proper format specifiers for architecture and version +func TestCNIVariables(t *testing.T) { + if cniFileName == "" { + t.Error("cniFileName should not be empty") + } + + if cniDownLoadURL == "" { + t.Error("cniDownLoadURL should not be empty") + } + + // Verify format + expectedCNIFileName := "cni-plugins-linux-%s-v%s.tgz" + if cniFileName != expectedCNIFileName { + t.Errorf("cniFileName = %s, want %s", cniFileName, expectedCNIFileName) + } +} diff --git a/pkg/components/containerd/consts_test.go b/pkg/components/containerd/consts_test.go new file mode 100644 index 0000000..b422f74 --- /dev/null +++ b/pkg/components/containerd/consts_test.go @@ -0,0 +1,86 @@ +package containerd + +import ( + "testing" +) + +// TestContainerdConstants verifies containerd installation path constants. +// Test: Validates binary directory, config directory, service file, and data directory paths +// Expected: All paths should match standard containerd installation locations +func TestContainerdConstants(t *testing.T) { + tests := []struct { + name string + value string + expected string + }{ + {"systemBinDir", systemBinDir, "/usr/bin"}, + {"defaultContainerdBinaryDir", defaultContainerdBinaryDir, "/usr/bin/containerd"}, + {"defaultContainerdConfigDir", defaultContainerdConfigDir, "/etc/containerd"}, + {"containerdConfigFile", containerdConfigFile, "/etc/containerd/config.toml"}, + {"containerdServiceFile", containerdServiceFile, "/etc/systemd/system/containerd.service"}, + {"containerdDataDir", containerdDataDir, "/var/lib/containerd"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.value != tt.expected { + t.Errorf("%s = %s, want %s", tt.name, tt.value, tt.expected) + } + }) + } +} + +// TestContainerdDirs verifies containerd directories array. +// Test: Validates containerdDirs array contains required configuration directory +// Expected: Array should contain /etc/containerd directory +func TestContainerdDirs(t *testing.T) { + if len(containerdDirs) != 1 { + t.Errorf("Expected 1 containerd directory, got %d", len(containerdDirs)) + } + + if containerdDirs[0] != "/etc/containerd" { + t.Errorf("containerdDirs[0] = %s, want /etc/containerd", containerdDirs[0]) + } +} + +// TestContainerdBinaries verifies containerd binary names list. +// Test: Validates containerdBinaries array contains all required binaries +// Expected: Array should contain ctr, containerd, and all shim variants +func TestContainerdBinaries(t *testing.T) { + expectedBinaries := []string{ + "ctr", + "containerd", + "containerd-shim", + "containerd-shim-runc-v1", + "containerd-shim-runc-v2", + "containerd-stress", + } + + if len(containerdBinaries) != len(expectedBinaries) { + t.Errorf("Expected %d binaries, got %d", len(expectedBinaries), len(containerdBinaries)) + } + + for i, binary := range containerdBinaries { + if binary != expectedBinaries[i] { + t.Errorf("containerdBinaries[%d] = %s, want %s", i, binary, expectedBinaries[i]) + } + } +} + +// TestContainerdVariables verifies containerd download configuration variables. +// Test: Validates filename template and download URL template +// Expected: Variables should contain proper format specifiers for version and architecture +func TestContainerdVariables(t *testing.T) { + if containerdFileName == "" { + t.Error("containerdFileName should not be empty") + } + + if containerdDownloadURL == "" { + t.Error("containerdDownloadURL should not be empty") + } + + expectedFileName := "containerd-%s-linux-%s.tar.gz" + if containerdFileName != expectedFileName { + t.Errorf("containerdFileName = %s, want %s", containerdFileName, expectedFileName) + } +} diff --git a/pkg/components/kube_binaries/consts_test.go b/pkg/components/kube_binaries/consts_test.go new file mode 100644 index 0000000..e9004a3 --- /dev/null +++ b/pkg/components/kube_binaries/consts_test.go @@ -0,0 +1,68 @@ +package kube_binaries + +import ( + "testing" +) + +// TestKubeBinariesConstants verifies all Kubernetes binary path constants. +// Test: Validates binary directory, binary names, and full paths for kubectl, kubelet, kubeadm +// Expected: All paths should match standard Kubernetes installation locations (/usr/local/bin) +func TestKubeBinariesConstants(t *testing.T) { + tests := []struct { + name string + value string + expected string + }{ + {"binDir", binDir, "/usr/local/bin"}, + {"kubeletBinary", kubeletBinary, "kubelet"}, + {"kubectlBinary", kubectlBinary, "kubectl"}, + {"kubeadmBinary", kubeadmBinary, "kubeadm"}, + {"kubeletPath", kubeletPath, "/usr/local/bin/kubelet"}, + {"kubectlPath", kubectlPath, "/usr/local/bin/kubectl"}, + {"kubeadmPath", kubeadmPath, "/usr/local/bin/kubeadm"}, + {"KubernetesRepoList", KubernetesRepoList, "/etc/apt/sources.list.d/kubernetes.list"}, + {"KubernetesKeyring", KubernetesKeyring, "/etc/apt/keyrings/kubernetes-apt-keyring.gpg"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.value != tt.expected { + t.Errorf("%s = %s, want %s", tt.name, tt.value, tt.expected) + } + }) + } +} + +// TestKubeBinariesVariables verifies Kubernetes download configuration variables and binary paths array. +// Test: Validates filename template, URL template, tar path, and binary paths array +// Expected: Variables should have proper format specifiers, array should contain all 3 binary paths +func TestKubeBinariesVariables(t *testing.T) { + if kubernetesFileName == "" { + t.Error("kubernetesFileName should not be empty") + } + + if defaultKubernetesURLTemplate == "" { + t.Error("defaultKubernetesURLTemplate should not be empty") + } + + if kubernetesTarPath == "" { + t.Error("kubernetesTarPath should not be empty") + } + + // Test kubeBinariesPaths array + if len(kubeBinariesPaths) != 3 { + t.Errorf("Expected 3 binary paths, got %d", len(kubeBinariesPaths)) + } + + expectedPaths := []string{ + "/usr/local/bin/kubelet", + "/usr/local/bin/kubectl", + "/usr/local/bin/kubeadm", + } + + for i, path := range kubeBinariesPaths { + if path != expectedPaths[i] { + t.Errorf("kubeBinariesPaths[%d] = %s, want %s", i, path, expectedPaths[i]) + } + } +} diff --git a/pkg/components/kubelet/consts_test.go b/pkg/components/kubelet/consts_test.go new file mode 100644 index 0000000..bb9ea1c --- /dev/null +++ b/pkg/components/kubelet/consts_test.go @@ -0,0 +1,41 @@ +package kubelet + +import ( + "testing" +) + +// TestKubeletConstants verifies kubelet configuration path constants. +// Test: Validates all kubelet-related paths including config, service files, and data directories +// Expected: All paths should match standard Kubernetes kubelet installation locations +func TestKubeletConstants(t *testing.T) { + tests := []struct { + name string + value string + expected string + }{ + {"etcDefaultDir", etcDefaultDir, "/etc/default"}, + {"kubeletServiceDir", kubeletServiceDir, "/etc/systemd/system/kubelet.service.d"}, + {"etcKubernetesDir", etcKubernetesDir, "/etc/kubernetes"}, + {"kubeletManifestsDir", kubeletManifestsDir, "/etc/kubernetes/manifests"}, + {"kubeletVolumePluginDir", kubeletVolumePluginDir, "/etc/kubernetes/volumeplugins"}, + {"kubeletDefaultsPath", kubeletDefaultsPath, "/etc/default/kubelet"}, + {"kubeletServicePath", kubeletServicePath, "/etc/systemd/system/kubelet.service"}, + {"kubeletContainerdConfig", kubeletContainerdConfig, "/etc/systemd/system/kubelet.service.d/10-containerd.conf"}, + {"kubeletTLSBootstrapConfig", kubeletTLSBootstrapConfig, "/etc/systemd/system/kubelet.service.d/10-tlsbootstrap.conf"}, + {"kubeletConfigPath", kubeletConfigPath, "/var/lib/kubelet/config.yaml"}, + {"kubeletKubeConfig", kubeletKubeConfig, "/etc/kubernetes/kubelet.conf"}, + {"kubeletBootstrapKubeConfig", kubeletBootstrapKubeConfig, "/etc/kubernetes/bootstrap-kubelet.conf"}, + {"kubeletVarDir", kubeletVarDir, "/var/lib/kubelet"}, + {"kubeletKubeconfigPath", kubeletKubeconfigPath, "/var/lib/kubelet/kubeconfig"}, + {"kubeletTokenScriptPath", kubeletTokenScriptPath, "/var/lib/kubelet/token.sh"}, + {"aksServiceResourceID", aksServiceResourceID, "6dae42f8-4368-4678-94ff-3960e28e3630"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.value != tt.expected { + t.Errorf("%s = %s, want %s", tt.name, tt.value, tt.expected) + } + }) + } +} diff --git a/pkg/components/npd/consts_test.go b/pkg/components/npd/consts_test.go new file mode 100644 index 0000000..1904da9 --- /dev/null +++ b/pkg/components/npd/consts_test.go @@ -0,0 +1,48 @@ +package npd + +import ( + "testing" +) + +// TestNPDConstants verifies Node Problem Detector (NPD) path constants. +// Test: Validates NPD binary, config, service paths, and temp directory +// Expected: All paths should match standard NPD installation locations +func TestNPDConstants(t *testing.T) { + tests := []struct { + name string + value string + expected string + }{ + {"npdBinaryPath", npdBinaryPath, "/usr/bin/node-problem-detector"}, + {"npdConfigPath", npdConfigPath, "/etc/node-problem-detector/kernel-monitor.json"}, + {"npdServicePath", npdServicePath, "/etc/systemd/system/node-problem-detector.service"}, + {"kubeletKubeconfigPath", kubeletKubeconfigPath, "/var/lib/kubelet/kubeconfig"}, + {"tempDir", tempDir, "/tmp/npd"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.value != tt.expected { + t.Errorf("%s = %s, want %s", tt.name, tt.value, tt.expected) + } + }) + } +} + +// TestNPDVariables verifies NPD download configuration variables. +// Test: Validates filename template and download URL template +// Expected: Variables should contain proper format specifiers for version and architecture +func TestNPDVariables(t *testing.T) { + if npdFileName == "" { + t.Error("npdFileName should not be empty") + } + + if npdDownloadURL == "" { + t.Error("npdDownloadURL should not be empty") + } + + expectedFileName := "npd-%s.tar.gz" + if npdFileName != expectedFileName { + t.Errorf("npdFileName = %s, want %s", npdFileName, expectedFileName) + } +} diff --git a/pkg/components/runc/consts_test.go b/pkg/components/runc/consts_test.go new file mode 100644 index 0000000..c1aa51c --- /dev/null +++ b/pkg/components/runc/consts_test.go @@ -0,0 +1,40 @@ +package runc + +import ( + "testing" +) + +// TestRuncConstants verifies runc binary path constant is correctly defined. +// Test: Checks that runcBinaryPath constant matches expected value +// Expected: runcBinaryPath should be "/usr/bin/runc" +func TestRuncConstants(t *testing.T) { + // Test that constants are properly defined + if runcBinaryPath != "/usr/bin/runc" { + t.Errorf("Expected runcBinaryPath to be '/usr/bin/runc', got '%s'", runcBinaryPath) + } +} + +// TestRuncVariables verifies runc download configuration variables. +// Test: Validates runcFileName format string and runcDownloadURL template +// Expected: Variables should contain proper format specifiers for version and architecture +func TestRuncVariables(t *testing.T) { + // Test that variables are accessible + if runcFileName == "" { + t.Error("runcFileName should not be empty") + } + + if runcDownloadURL == "" { + t.Error("runcDownloadURL should not be empty") + } + + // Test that runcFileName contains format specifier + if runcFileName != "runc.%s" { + t.Errorf("Expected runcFileName to be 'runc.%%s', got '%s'", runcFileName) + } + + // Test that runcDownloadURL contains format specifiers + expectedPrefix := "https://github.com/opencontainers/runc/releases/download/v" + if len(runcDownloadURL) < len(expectedPrefix) { + t.Error("runcDownloadURL is too short") + } +} diff --git a/pkg/components/services/consts_test.go b/pkg/components/services/consts_test.go new file mode 100644 index 0000000..a85229a --- /dev/null +++ b/pkg/components/services/consts_test.go @@ -0,0 +1,38 @@ +package services + +import ( + "testing" + "time" +) + +// TestServicesConstants verifies service name constants for system services. +// Test: Validates containerd and kubelet service names +// Expected: Service names should match systemd service unit names (without .service extension) +func TestServicesConstants(t *testing.T) { + tests := []struct { + name string + value string + expected string + }{ + {"ContainerdService", ContainerdService, "containerd"}, + {"KubeletService", KubeletService, "kubelet"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.value != tt.expected { + t.Errorf("%s = %s, want %s", tt.name, tt.value, tt.expected) + } + }) + } +} + +// TestServiceStartupTimeout verifies the service startup timeout constant. +// Test: Checks ServiceStartupTimeout value +// Expected: Timeout should be 30 seconds for service startup operations +func TestServiceStartupTimeout(t *testing.T) { + expected := 30 * time.Second + if ServiceStartupTimeout != expected { + t.Errorf("ServiceStartupTimeout = %v, want %v", ServiceStartupTimeout, expected) + } +} diff --git a/pkg/components/system_configuration/consts_test.go b/pkg/components/system_configuration/consts_test.go new file mode 100644 index 0000000..3422eb6 --- /dev/null +++ b/pkg/components/system_configuration/consts_test.go @@ -0,0 +1,29 @@ +package system_configuration + +import ( + "testing" +) + +// TestSystemConfigurationConstants verifies system configuration file path constants. +// Test: Validates sysctl directory and configuration file paths +// Expected: Paths should match standard Linux system configuration locations +func TestSystemConfigurationConstants(t *testing.T) { + tests := []struct { + name string + value string + expected string + }{ + {"sysctlDir", sysctlDir, "/etc/sysctl.d"}, + {"sysctlConfigPath", sysctlConfigPath, "/etc/sysctl.d/999-sysctl-aks.conf"}, + {"resolvConfPath", resolvConfPath, "/etc/resolv.conf"}, + {"resolvConfSource", resolvConfSource, "/run/systemd/resolve/resolv.conf"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.value != tt.expected { + t.Errorf("%s = %s, want %s", tt.name, tt.value, tt.expected) + } + }) + } +} diff --git a/pkg/status/types_test.go b/pkg/status/types_test.go new file mode 100644 index 0000000..941cdb0 --- /dev/null +++ b/pkg/status/types_test.go @@ -0,0 +1,220 @@ +package status + +import ( + "encoding/json" + "testing" + "time" +) + +// TestNodeStatus verifies NodeStatus structure serialization and deserialization. +// Test: Creates a NodeStatus with all fields populated, marshals to JSON, then unmarshals back +// Expected: All fields (versions, running status, Arc status) should round-trip correctly through JSON +func TestNodeStatus(t *testing.T) { + now := time.Now() + + status := &NodeStatus{ + KubeletVersion: "v1.26.0", + RuncVersion: "1.1.12", + ContainerdVersion: "1.7.0", + KubeletRunning: true, + KubeletReady: "True", + ContainerdRunning: true, + ArcStatus: ArcStatus{ + Registered: true, + Connected: true, + MachineName: "test-machine", + ResourceID: "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.HybridCompute/machines/test-machine", + Location: "eastus", + ResourceGroup: "test-rg", + LastHeartbeat: now, + AgentVersion: "1.0.0", + }, + LastUpdated: now, + AgentVersion: "dev", + } + + // Test JSON marshaling + data, err := json.Marshal(status) + if err != nil { + t.Fatalf("Failed to marshal NodeStatus: %v", err) + } + + // Test JSON unmarshaling + var unmarshaled NodeStatus + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("Failed to unmarshal NodeStatus: %v", err) + } + + // Verify key fields + if unmarshaled.KubeletVersion != status.KubeletVersion { + t.Errorf("KubeletVersion mismatch: got %s, want %s", unmarshaled.KubeletVersion, status.KubeletVersion) + } + + if unmarshaled.RuncVersion != status.RuncVersion { + t.Errorf("RuncVersion mismatch: got %s, want %s", unmarshaled.RuncVersion, status.RuncVersion) + } + + if unmarshaled.ContainerdVersion != status.ContainerdVersion { + t.Errorf("ContainerdVersion mismatch: got %s, want %s", unmarshaled.ContainerdVersion, status.ContainerdVersion) + } + + if unmarshaled.KubeletRunning != status.KubeletRunning { + t.Errorf("KubeletRunning mismatch: got %v, want %v", unmarshaled.KubeletRunning, status.KubeletRunning) + } + + if unmarshaled.ContainerdRunning != status.ContainerdRunning { + t.Errorf("ContainerdRunning mismatch: got %v, want %v", unmarshaled.ContainerdRunning, status.ContainerdRunning) + } + + if unmarshaled.AgentVersion != status.AgentVersion { + t.Errorf("AgentVersion mismatch: got %s, want %s", unmarshaled.AgentVersion, status.AgentVersion) + } +} + +// TestArcStatus verifies ArcStatus structure in different connection states. +// Test: Tests Arc status in various states (registered+connected, registered only, not registered) +// Expected: All Arc status fields should serialize/deserialize correctly in all states +func TestArcStatus(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + status ArcStatus + }{ + { + name: "fully registered and connected", + status: ArcStatus{ + Registered: true, + Connected: true, + MachineName: "test-machine", + ResourceID: "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.HybridCompute/machines/test-machine", + Location: "eastus", + ResourceGroup: "test-rg", + LastHeartbeat: now, + AgentVersion: "1.0.0", + }, + }, + { + name: "registered but not connected", + status: ArcStatus{ + Registered: true, + Connected: false, + MachineName: "test-machine", + ResourceID: "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.HybridCompute/machines/test-machine", + Location: "eastus", + ResourceGroup: "test-rg", + AgentVersion: "1.0.0", + }, + }, + { + name: "not registered", + status: ArcStatus{ + Registered: false, + Connected: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test JSON marshaling + data, err := json.Marshal(tt.status) + if err != nil { + t.Fatalf("Failed to marshal ArcStatus: %v", err) + } + + // Test JSON unmarshaling + var unmarshaled ArcStatus + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("Failed to unmarshal ArcStatus: %v", err) + } + + // Verify key fields + if unmarshaled.Registered != tt.status.Registered { + t.Errorf("Registered mismatch: got %v, want %v", unmarshaled.Registered, tt.status.Registered) + } + + if unmarshaled.Connected != tt.status.Connected { + t.Errorf("Connected mismatch: got %v, want %v", unmarshaled.Connected, tt.status.Connected) + } + + if unmarshaled.MachineName != tt.status.MachineName { + t.Errorf("MachineName mismatch: got %s, want %s", unmarshaled.MachineName, tt.status.MachineName) + } + }) + } +} + +// TestNodeStatus_EmptyStatus verifies empty NodeStatus handles default values correctly. +// Test: Creates empty NodeStatus, marshals and unmarshals +// Expected: Boolean fields should default to false, serialization should succeed +func TestNodeStatus_EmptyStatus(t *testing.T) { + status := &NodeStatus{} + + // Test that empty status can be marshaled + data, err := json.Marshal(status) + if err != nil { + t.Fatalf("Failed to marshal empty NodeStatus: %v", err) + } + + // Test that it can be unmarshaled back + var unmarshaled NodeStatus + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("Failed to unmarshal empty NodeStatus: %v", err) + } + + // Verify defaults + if unmarshaled.KubeletRunning { + t.Error("Empty status should have KubeletRunning as false") + } + + if unmarshaled.ContainerdRunning { + t.Error("Empty status should have ContainerdRunning as false") + } +} + +// TestNodeStatus_JSONFieldNames verifies JSON field names match expected camelCase format. +// Test: Marshals NodeStatus to JSON and checks field names in output +// Expected: All fields should use camelCase naming (kubeletVersion, not KubeletVersion) +func TestNodeStatus_JSONFieldNames(t *testing.T) { + status := &NodeStatus{ + KubeletVersion: "v1.26.0", + RuncVersion: "1.1.12", + ContainerdVersion: "1.7.0", + KubeletRunning: true, + KubeletReady: "True", + ContainerdRunning: true, + AgentVersion: "dev", + LastUpdated: time.Now(), + } + + data, err := json.Marshal(status) + if err != nil { + t.Fatalf("Failed to marshal NodeStatus: %v", err) + } + + // Unmarshal to map to check JSON field names + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal to map: %v", err) + } + + // Check that expected fields are present with correct JSON names + expectedFields := []string{ + "kubeletVersion", + "runcVersion", + "containerdVersion", + "kubeletRunning", + "kubeletReady", + "containerdRunning", + "arcStatus", + "lastUpdated", + "agentVersion", + } + + for _, field := range expectedFields { + if _, exists := m[field]; !exists { + t.Errorf("Expected field %s not found in JSON output", field) + } + } +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go new file mode 100644 index 0000000..9693d90 --- /dev/null +++ b/pkg/utils/utils_test.go @@ -0,0 +1,420 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +// TestFileExists verifies the FileExists utility function for checking file existence. +// Test: Creates a temporary file and checks existence before and after creation +// Expected: Returns false for non-existent files, true for existing files +func TestFileExists(t *testing.T) { + // Create temp file + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "test.txt") + + // File doesn't exist yet + if FileExists(tempFile) { + t.Error("FileExists should return false for non-existent file") + } + + // Create file + if err := os.WriteFile(tempFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // File exists now + if !FileExists(tempFile) { + t.Error("FileExists should return true for existing file") + } +} + +// TestFileExistsAndValid verifies file existence validation (non-zero size check). +// Test: Creates files with and without content, checks both existence and validity +// Expected: Returns true only for files that exist and have content (size > 0) +func TestFileExistsAndValid(t *testing.T) { + tempDir := t.TempDir() + + tests := []struct { + name string + content []byte + expected bool + }{ + { + name: "valid file with content", + content: []byte("test content"), + expected: true, + }, + { + name: "empty file", + content: []byte(""), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempFile := filepath.Join(tempDir, tt.name+".txt") + if err := os.WriteFile(tempFile, tt.content, 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + result := FileExistsAndValid(tempFile) + if result != tt.expected { + t.Errorf("FileExistsAndValid() = %v, want %v", result, tt.expected) + } + }) + } + + // Test non-existent file + if FileExistsAndValid("/non/existent/file") { + t.Error("FileExistsAndValid should return false for non-existent file") + } +} + +// TestDirectoryExists verifies directory existence checking functionality. +// Test: Checks existence of directory, file (not directory), and non-existent path +// Expected: Returns true only for actual directories, false for files and non-existent paths +func TestDirectoryExists(t *testing.T) { + tempDir := t.TempDir() + + // Directory exists + if !DirectoryExists(tempDir) { + t.Error("DirectoryExists should return true for existing directory") + } + + // Create a file (not directory) + tempFile := filepath.Join(tempDir, "file.txt") + if err := os.WriteFile(tempFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // File is not a directory + if DirectoryExists(tempFile) { + t.Error("DirectoryExists should return false for file") + } + + // Non-existent path + if DirectoryExists(filepath.Join(tempDir, "nonexistent")) { + t.Error("DirectoryExists should return false for non-existent path") + } +} + +// TestRequiresSudoAccess verifies the sudo requirement detection logic for various commands and paths. +// Test: Tests commands that always need sudo (systemctl, apt), conditionally need sudo (mkdir, cp on system paths), and never need sudo (echo) +// Expected: Correctly identifies sudo requirements based on command name and file path arguments +func TestRequiresSudoAccess(t *testing.T) { + tests := []struct { + name string + command string + args []string + expected bool + }{ + { + name: "systemctl always needs sudo", + command: "systemctl", + args: []string{"start", "service"}, + expected: true, + }, + { + name: "apt always needs sudo", + command: "apt", + args: []string{"install", "package"}, + expected: true, + }, + { + name: "mkdir on system path needs sudo", + command: "mkdir", + args: []string{"/etc/test"}, + expected: true, + }, + { + name: "mkdir on user path doesn't need sudo", + command: "mkdir", + args: []string{"/home/user/test"}, + expected: false, + }, + { + name: "cp to /usr needs sudo", + command: "cp", + args: []string{"file.txt", "/usr/bin/file"}, + expected: true, + }, + { + name: "cp in home doesn't need sudo", + command: "cp", + args: []string{"file1.txt", "/home/user/file2.txt"}, + expected: false, + }, + { + name: "echo never needs sudo", + command: "echo", + args: []string{"hello"}, + expected: false, + }, + { + name: "rm in /var needs sudo", + command: "rm", + args: []string{"-rf", "/var/lib/test"}, + expected: true, + }, + { + name: "azcmagent always needs sudo", + command: "azcmagent", + args: []string{"connect"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := requiresSudoAccess(tt.command, tt.args) + if result != tt.expected { + t.Errorf("requiresSudoAccess(%s, %v) = %v, want %v", tt.command, tt.args, result, tt.expected) + } + }) + } +} + +// TestShouldIgnoreCleanupError verifies error filtering for cleanup operations. +// Test: Tests various error types to determine which should be ignored during cleanup +// Expected: Errors matching cleanup patterns (not loaded, does not exist, No such file) should be ignored +func TestShouldIgnoreCleanupError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error should not be ignored", + err: nil, + expected: false, + }, + { + name: "os.ErrNotExist matches 'does not exist' pattern", + err: os.ErrNotExist, + expected: true, // "file does not exist" matches "does not exist" pattern + }, + { + name: "PathError with ErrNotExist should be ignored", + err: &os.PathError{Op: "remove", Path: "/test", Err: os.ErrNotExist}, + expected: true, // Error message contains "no such file or directory" + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := shouldIgnoreCleanupError(tt.err) + if result != tt.expected { + t.Errorf("shouldIgnoreCleanupError(%v) = %v, want %v", tt.err, result, tt.expected) + } + }) + } +} + +// TestCreateTempFile verifies temporary file creation with specific content. +// Test: Creates a temp file with pattern and content, then verifies file exists and content matches +// Expected: File should be created with correct content and be readable +func TestCreateTempFile(t *testing.T) { + content := []byte("test content") + pattern := "test-*.txt" + + file, err := CreateTempFile(pattern, content) + if err != nil { + t.Fatalf("CreateTempFile failed: %v", err) + } + defer func() { + _ = file.Close() + CleanupTempFile(file.Name()) + }() + + // Verify file exists + if !FileExists(file.Name()) { + t.Error("Temp file should exist") + } + + // Verify content + readContent, err := os.ReadFile(file.Name()) + if err != nil { + t.Fatalf("Failed to read temp file: %v", err) + } + + if string(readContent) != string(content) { + t.Errorf("Content mismatch: got %q, want %q", readContent, content) + } +} + +// TestWriteFileAtomic verifies atomic file writing (write to temp, then rename). +// Test: Writes content atomically and verifies file existence, content, and permissions +// Expected: File should be created with correct content and permissions without partial writes +func TestWriteFileAtomic(t *testing.T) { + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "test.txt") + content := []byte("test content") + perm := os.FileMode(0644) + + err := WriteFileAtomic(testFile, content, perm) + if err != nil { + t.Fatalf("WriteFileAtomic failed: %v", err) + } + + // Verify file exists + if !FileExists(testFile) { + t.Error("File should exist after WriteFileAtomic") + } + + // Verify content + readContent, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + if string(readContent) != string(content) { + t.Errorf("Content mismatch: got %q, want %q", readContent, content) + } + + // Verify permissions + stat, err := os.Stat(testFile) + if err != nil { + t.Fatalf("Failed to stat file: %v", err) + } + + if stat.Mode().Perm() != perm { + t.Errorf("Permission mismatch: got %v, want %v", stat.Mode().Perm(), perm) + } +} + +// TestGetArc verifies system architecture detection and normalization. +// Test: Calls GetArc to detect system architecture +// Expected: Returns one of the valid architectures (amd64, arm64, arm) based on system +func TestGetArc(t *testing.T) { + arch, err := GetArc() + if err != nil { + t.Fatalf("GetArc failed: %v", err) + } + + // Verify it returns a valid architecture string + validArchs := []string{"amd64", "arm64", "arm"} + valid := false + for _, validArch := range validArchs { + if arch == validArch { + valid = true + break + } + } + + if !valid { + t.Errorf("GetArc returned unexpected architecture: %s", arch) + } +} + +// TestExtractClusterInfo verifies kubeconfig parsing for server URL and CA certificate extraction. +// Test: Tests valid kubeconfig, empty config, missing clusters, and missing server +// Expected: Successfully extracts server URL and CA data from valid kubeconfig, errors on invalid inputs +func TestExtractClusterInfo(t *testing.T) { + tests := []struct { + name string + kubeconfig string + wantErr bool + wantServer string + }{ + { + name: "valid kubeconfig", + kubeconfig: `apiVersion: v1 +kind: Config +clusters: +- cluster: + certificate-authority-data: dGVzdAo= + server: https://test.example.com:6443 + name: test-cluster +contexts: +- context: + cluster: test-cluster + user: test-user + name: test-context +current-context: test-context +users: +- name: test-user + user: + token: test-token +`, + wantErr: false, + wantServer: "https://test.example.com:6443", + }, + { + name: "empty kubeconfig", + kubeconfig: ``, + wantErr: true, + }, + { + name: "kubeconfig without clusters", + kubeconfig: `apiVersion: v1 +kind: Config +clusters: [] +`, + wantErr: true, + }, + { + name: "kubeconfig without server", + kubeconfig: `apiVersion: v1 +kind: Config +clusters: +- cluster: + certificate-authority-data: dGVzdAo= + name: test-cluster +`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server, caData, err := ExtractClusterInfo([]byte(tt.kubeconfig)) + + if tt.wantErr { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if server != tt.wantServer { + t.Errorf("Server mismatch: got %q, want %q", server, tt.wantServer) + } + + if caData == "" { + t.Error("CA data should not be empty") + } + }) + } +} + +// TestCleanupTempFile verifies temporary file cleanup without panicking. +// Test: Creates a file, cleans it up, verifies it's removed, then tests cleanup of non-existent file +// Expected: File should be removed successfully, cleanup of non-existent file should not panic +func TestCleanupTempFile(t *testing.T) { + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "test.txt") + + // Create a file + if err := os.WriteFile(tempFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Cleanup should not panic + CleanupTempFile(tempFile) + + // File should be removed + if FileExists(tempFile) { + t.Error("File should be removed after CleanupTempFile") + } + + // Cleanup non-existent file should not panic + CleanupTempFile("/non/existent/file") +}