diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 8786b3a..a4579ab 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/watany-dev/raptor/internal/executor" + "github.com/watany-dev/raptor/internal/workflow" ) // setupTestGitRepo creates a minimal git repository for testing. @@ -1533,3 +1534,36 @@ jobs: t.Errorf("Error should mention nonexistent dependency, got: %v", err) } } + +// TestRunner_shouldSkipJob_DependencyNotExecuted tests the edge case where +// a dependency job was not executed (defensive programming branch) +func TestRunner_shouldSkipJob_DependencyNotExecuted(t *testing.T) { + t.Parallel() + + runner := NewRunner(newMockExecutor()) + + // Create a job that depends on "missing_job" + job := &workflow.Job{ + Needs: []string{"missing_job"}, + } + + // Create runContext with empty jobResults (no jobs executed yet) + runCtx := &runContext{ + jobResults: make(map[string]*RunResult), + } + + // Call shouldSkipJob - should return true because dependency was not executed + shouldSkip, reason := runner.shouldSkipJob(job, runCtx) + + if !shouldSkip { + t.Error("shouldSkipJob() should return true when dependency was not executed") + } + + if !strings.Contains(reason, "missing_job") { + t.Errorf("reason should mention 'missing_job', got: %s", reason) + } + + if !strings.Contains(reason, "was not executed") { + t.Errorf("reason should mention 'was not executed', got: %s", reason) + } +} diff --git a/internal/cli/step_executor_test.go b/internal/cli/step_executor_test.go index 516cc86..324c726 100644 --- a/internal/cli/step_executor_test.go +++ b/internal/cli/step_executor_test.go @@ -946,6 +946,101 @@ func TestStepExecutor_handleUnsupportedAction(t *testing.T) { }) } +// TestStepExecutor_Execute_PermissiveModeWarning tests that permissive mode logs warning but continues +// This covers lines 100-101 where permissive mode logs warning and continues +func TestStepExecutor_Execute_PermissiveModeWarning(t *testing.T) { + t.Run("logs warning and continues when permissive mode and condition has error", func(t *testing.T) { + logBuf, cleanup := captureSlog(t) + defer cleanup() + + tmpDir := t.TempDir() + envFilePath := filepath.Join(tmpDir, "GITHUB_ENV") + pathFilePath := filepath.Join(tmpDir, "GITHUB_PATH") + + mock := newMockExecutor(executor.Result{ExitCode: 0, Stdout: "executed\n"}) + evaluator := expression.NewConditionEvaluator() + evaluator.StrictMode = false // Permissive mode + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + se := NewStepExecutor(mock, evaluator, stdout, stderr, tmpDir, envFilePath, pathFilePath) + + ctx := NewExecutionContext(map[string]string{}) + + // Step with an invalid condition - in permissive mode it should warn and continue + step := &workflow.Step{ + Name: "Step with warning", + If: "${{ unknownFunction() }}", + Run: "echo test", + } + + result, err := se.Execute(step, 0, ctx) + if err != nil { + t.Fatalf("Execute() error = %v; permissive mode should not return error", err) + } + + // Step should have been executed (not skipped) + if result.Skipped { + t.Error("Step should not be skipped in permissive mode") + } + + // Executor should have been called + if len(mock.calls) != 1 { + t.Errorf("Executor should be called once, got %d calls", len(mock.calls)) + } + + // Warning should be logged + logOutput := logBuf.String() + if !strings.Contains(logOutput, "condition evaluation warning") { + t.Errorf("Log should contain 'condition evaluation warning', got: %s", logOutput) + } + }) +} + +// TestStepExecutor_Execute_StrictModeError tests the strict mode error path in Execute +// This covers lines 94-98 where strict mode is enabled and evaluation fails +func TestStepExecutor_Execute_StrictModeError(t *testing.T) { + t.Run("returns error when strict mode is enabled and condition evaluation fails", func(t *testing.T) { + tmpDir := t.TempDir() + envFilePath := filepath.Join(tmpDir, "GITHUB_ENV") + pathFilePath := filepath.Join(tmpDir, "GITHUB_PATH") + + mock := newMockExecutor(executor.Result{ExitCode: 0, Stdout: "should not run\n"}) + evaluator := expression.NewConditionEvaluator() + evaluator.StrictMode = true // Enable strict mode + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + se := NewStepExecutor(mock, evaluator, stdout, stderr, tmpDir, envFilePath, pathFilePath) + + ctx := NewExecutionContext(map[string]string{}) + + // Step with an invalid condition that will fail parsing in strict mode + // Using an unknown function will cause a parse/evaluation error + step := &workflow.Step{ + Name: "Failing Step", + If: "${{ unknownFunction() }}", + Run: "echo test", + } + + _, err := se.Execute(step, 0, ctx) + if err == nil { + t.Error("Execute() expected error in strict mode when condition evaluation fails") + } + + if !strings.Contains(err.Error(), "condition evaluation failed") { + t.Errorf("Error should contain 'condition evaluation failed', got: %v", err) + } + + // Executor should not have been called since condition evaluation failed + if len(mock.calls) != 0 { + t.Errorf("Executor should not be called when condition fails, got %d calls", len(mock.calls)) + } + }) +} + // TestStepExecutor_Execute_UsesStep tests that Execute correctly skips steps with uses func TestStepExecutor_Execute_UsesStep(t *testing.T) { t.Run("skips step with uses field", func(t *testing.T) { diff --git a/internal/expression/evaluator_test.go b/internal/expression/evaluator_test.go index 2ae1a44..26b7abd 100644 --- a/internal/expression/evaluator_test.go +++ b/internal/expression/evaluator_test.go @@ -736,3 +736,76 @@ func TestStrictMode(t *testing.T) { }) } } + +// TestEvaluateNode_UnknownNodeType tests the error path for unknown node types +func TestEvaluateNode_UnknownNodeType(t *testing.T) { + // Create a custom type that implements Node but is not handled + ctx := &EvaluationContext{ + Env: nil, + JobSuccess: true, + } + + // Pass nil node - this should trigger the default case + _, err := evaluateNode(nil, ctx) + if err == nil { + t.Error("evaluateNode() expected error for nil node") + } +} + +// TestEvaluateUnary_UnknownOperator tests the error path for unknown unary operators +func TestEvaluateUnary_UnknownOperator(t *testing.T) { + ctx := &EvaluationContext{ + Env: nil, + JobSuccess: true, + } + + // Create an UnaryExpr with an unknown operator + unary := &UnaryExpr{ + Operator: TokenType(999), // Unknown operator + Operand: &BoolLiteral{Value: true}, + } + + _, err := evaluateUnary(unary, ctx) + if err == nil { + t.Error("evaluateUnary() expected error for unknown operator") + } +} + +// TestEvaluateBinary_UnknownOperator tests the error path for unknown binary operators +func TestEvaluateBinary_UnknownOperator(t *testing.T) { + ctx := &EvaluationContext{ + Env: nil, + JobSuccess: true, + } + + // Create a BinaryExpr with an unknown operator + binary := &BinaryExpr{ + Operator: TokenType(999), // Unknown operator + Left: &BoolLiteral{Value: true}, + Right: &BoolLiteral{Value: false}, + } + + _, err := evaluateBinary(binary, ctx) + if err == nil { + t.Error("evaluateBinary() expected error for unknown operator") + } +} + +// TestResolveIdentifier_NilEnv tests env.VAR resolution when ctx.Env is nil +func TestResolveIdentifier_NilEnv(t *testing.T) { + ctx := &EvaluationContext{ + Env: nil, // nil environment + JobSuccess: true, + } + + // Resolve env.VAR when Env is nil + result, err := resolveIdentifier("env.MY_VAR", ctx) + if err != nil { + t.Errorf("resolveIdentifier() error = %v", err) + } + + // Should return empty string + if result != "" { + t.Errorf("resolveIdentifier() = %v, want empty string", result) + } +} diff --git a/internal/security/path_test.go b/internal/security/path_test.go index b55564e..0e99eb9 100644 --- a/internal/security/path_test.go +++ b/internal/security/path_test.go @@ -253,6 +253,116 @@ func TestValidateWorkingDirectory_AllBranches(t *testing.T) { // TestValidatePathWithSymlinkResolution tests symlink-based path traversal detection func TestValidatePathWithSymlinkResolution(t *testing.T) { + t.Run("os.Lstat error other than NotExist should return error", func(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("skipping permission test as root") + } + + tmpDir := t.TempDir() + workspaceDir := filepath.Join(tmpDir, "workspace") + subDir := filepath.Join(workspaceDir, "protected") + + // Create directory structure + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatalf("failed to create subdir: %v", err) + } + + // Create a file inside protected directory + targetFile := filepath.Join(subDir, "file.txt") + if err := os.WriteFile(targetFile, []byte("content"), 0644); err != nil { + t.Fatalf("failed to create file: %v", err) + } + + // Remove execute permission from parent directory to trigger Lstat error + if err := os.Chmod(subDir, 0000); err != nil { + t.Fatalf("failed to chmod: %v", err) + } + defer func() { + _ = os.Chmod(subDir, 0755) + }() + + // This should fail with permission error, not NotExist + err := ValidatePathWithSymlinkResolution("protected/file.txt", workspaceDir) + if err == nil { + t.Error("ValidatePathWithSymlinkResolution() should return error for permission denied") + } + if err != nil && !strings.Contains(err.Error(), "failed to stat path") { + t.Logf("Got error: %v", err) + } + }) + + t.Run("broken symlink should return error", func(t *testing.T) { + tmpDir := t.TempDir() + workspaceDir := filepath.Join(tmpDir, "workspace") + + // Create directory + if err := os.MkdirAll(workspaceDir, 0755); err != nil { + t.Fatalf("failed to create workspace dir: %v", err) + } + + // Create a broken symlink (pointing to non-existent file) + brokenLink := filepath.Join(workspaceDir, "broken-link") + if err := os.Symlink("/nonexistent/path/that/does/not/exist", brokenLink); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + // This should fail because EvalSymlinks will fail on broken symlink + err := ValidatePathWithSymlinkResolution("broken-link", workspaceDir) + if err == nil { + t.Error("ValidatePathWithSymlinkResolution() should return error for broken symlink") + } + if err != nil && !strings.Contains(err.Error(), "failed to resolve symlinks") { + t.Logf("Got error: %v", err) + } + }) + + t.Run("EvalSymlinks error on basePath should return error", func(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("skipping permission test as root") + } + + tmpDir := t.TempDir() + workspaceDir := filepath.Join(tmpDir, "workspace") + parentDir := filepath.Join(tmpDir, "parent") + linkToParent := filepath.Join(tmpDir, "link-to-parent") + + // Create directories + if err := os.MkdirAll(workspaceDir, 0755); err != nil { + t.Fatalf("failed to create workspace dir: %v", err) + } + if err := os.MkdirAll(parentDir, 0755); err != nil { + t.Fatalf("failed to create parent dir: %v", err) + } + + // Create a symlink to parent + if err := os.Symlink(parentDir, linkToParent); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + // Create a file in workspace + testFile := filepath.Join(workspaceDir, "test.txt") + if err := os.WriteFile(testFile, []byte("content"), 0644); err != nil { + t.Fatalf("failed to create file: %v", err) + } + + // Make the symlink target unreadable to cause EvalSymlinks to fail on basePath + // This is tricky - we need to cause EvalSymlinks(basePath) to fail + // One way is to use a broken symlink as basePath + brokenBase := filepath.Join(tmpDir, "broken-base") + if err := os.Symlink("/nonexistent/base/path", brokenBase); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + // The path exists relative to workspace, but basePath symlink is broken + err := ValidatePathWithSymlinkResolution("test.txt", brokenBase) + // This will fail at the basic validation or EvalSymlinks stage + if err == nil { + // The path doesn't exist relative to broken base, so it might pass + // because os.Lstat returns NotExist + t.Log("Path validation passed (expected for non-existent path)") + } + }) + t.Run("symlink pointing outside workspace should be rejected", func(t *testing.T) { tmpDir := t.TempDir() workspaceDir := filepath.Join(tmpDir, "workspace") diff --git a/internal/util/git_test.go b/internal/util/git_test.go index 8fc7dc5..02cc59d 100644 --- a/internal/util/git_test.go +++ b/internal/util/git_test.go @@ -3,6 +3,7 @@ package util import ( "context" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -184,6 +185,72 @@ func TestGitHeadRef_DefaultBranch(t *testing.T) { t.Logf("GitHeadRef() returned: %q (length: %d)", ref, len(ref)) } +// TestGitHeadRef_DetachedHead tests GitHeadRef returns empty string for detached HEAD +func TestGitHeadRef_DetachedHead(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + // Initialize a new git repository + ctx := context.Background() + cmd := exec.CommandContext(ctx, "git", "init") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to init git repo: %v", err) + } + + // Configure git user for the commit + cmd = exec.CommandContext(ctx, "git", "config", "user.email", "test@test.com") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to config git: %v", err) + } + cmd = exec.CommandContext(ctx, "git", "config", "user.name", "Test") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to config git: %v", err) + } + + // Create a file and commit it + testFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + cmd = exec.CommandContext(ctx, "git", "add", ".") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } + cmd = exec.CommandContext(ctx, "git", "commit", "--no-gpg-sign", "-m", "initial") + cmd.Dir = tmpDir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("failed to git commit: %v, output: %s", err, output) + } + + // Get the commit SHA + sha, err := GitHeadSHA(ctx, tmpDir) + if err != nil { + t.Fatalf("failed to get HEAD SHA: %v", err) + } + + // Detach HEAD by checking out the commit SHA directly + cmd = exec.CommandContext(ctx, "git", "checkout", sha) + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to checkout detached HEAD: %v", err) + } + + // Now GitHeadRef should return empty string (detached HEAD) + ref, err := GitHeadRef(ctx, tmpDir) + if err != nil { + t.Errorf("GitHeadRef() error = %v; expected no error for detached HEAD", err) + } + if ref != "" { + t.Errorf("GitHeadRef() = %q; expected empty string for detached HEAD", ref) + } +} + // TestFindGitRoot_MultipleDirectories tests finding git root from nested directories func TestFindGitRoot_MultipleDirectories(t *testing.T) { t.Parallel() diff --git a/internal/workflow/load_test.go b/internal/workflow/load_test.go index f85b0d2..9cb644e 100644 --- a/internal/workflow/load_test.go +++ b/internal/workflow/load_test.go @@ -997,4 +997,131 @@ func TestExtractJobOrderFromNode_DirectCall(t *testing.T) { t.Errorf("extractJobOrderFromNode() = %v, want nil for empty node", result) } }) + + // Test with nil key node in doc.Content (line 65) + t.Run("nil key node in content is skipped", func(t *testing.T) { + root := &yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{ + { + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + nil, // nil key node + {Kind: yaml.ScalarNode, Value: "value"}, + {Kind: yaml.ScalarNode, Value: "jobs"}, + { + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "job1"}, + {Kind: yaml.MappingNode}, + }, + }, + }, + }, + }, + } + result := extractJobOrderFromNode(root) + // Should skip the nil key and find "jobs" + if len(result) != 1 || result[0] != "job1" { + t.Errorf("extractJobOrderFromNode() = %v, want [job1]", result) + } + }) + + // Test with nil value node in doc.Content (line 65) + t.Run("nil value node in content is skipped", func(t *testing.T) { + root := &yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{ + { + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "name"}, + nil, // nil value node + {Kind: yaml.ScalarNode, Value: "jobs"}, + { + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "job1"}, + {Kind: yaml.MappingNode}, + }, + }, + }, + }, + }, + } + result := extractJobOrderFromNode(root) + // Should skip the nil value and find "jobs" + if len(result) != 1 || result[0] != "job1" { + t.Errorf("extractJobOrderFromNode() = %v, want [job1]", result) + } + }) + + // Test with nil job key node in jobs.Content (line 74) + t.Run("nil job key node is skipped", func(t *testing.T) { + root := &yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{ + { + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "jobs"}, + { + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + nil, // nil job key node + {Kind: yaml.MappingNode}, + {Kind: yaml.ScalarNode, Value: "job2"}, + {Kind: yaml.MappingNode}, + }, + }, + }, + }, + }, + } + result := extractJobOrderFromNode(root) + // Should skip the nil job key and find "job2" + if len(result) != 1 || result[0] != "job2" { + t.Errorf("extractJobOrderFromNode() = %v, want [job2]", result) + } + }) + + // Test with nil doc node (line 56) + t.Run("nil doc node returns nil", func(t *testing.T) { + root := &yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{ + nil, // nil doc node + }, + } + result := extractJobOrderFromNode(root) + if result != nil { + t.Errorf("extractJobOrderFromNode() = %v, want nil for nil doc node", result) + } + }) +} + +// TestStringOrSlice_UnmarshalYAML_DecodeError tests the error path in UnmarshalYAML +func TestStringOrSlice_UnmarshalYAML_DecodeError(t *testing.T) { + t.Parallel() + + // Test case: needs field with invalid value that cannot be decoded as string slice + yamlContent := `name: Test +jobs: + test: + runs-on: ubuntu-latest + needs: + invalid: value + steps: + - run: echo test` + + tmpDir := t.TempDir() + workflowPath := filepath.Join(tmpDir, "invalid_needs.yml") + if err := os.WriteFile(workflowPath, []byte(yamlContent), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + _, err := LoadWorkflowFile(workflowPath) + if err == nil { + t.Error("LoadWorkflowFile() expected error for invalid needs mapping") + } }