Skip to content
34 changes: 34 additions & 0 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
}
95 changes: 95 additions & 0 deletions internal/cli/step_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
73 changes: 73 additions & 0 deletions internal/expression/evaluator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
110 changes: 110 additions & 0 deletions internal/security/path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading