diff --git a/cmd/configdiff/compare.go b/cmd/configdiff/compare.go index 5bef81e..0a49a28 100644 --- a/cmd/configdiff/compare.go +++ b/cmd/configdiff/compare.go @@ -1,6 +1,8 @@ package main import ( + "crypto/rand" + "encoding/hex" "fmt" "os" "path/filepath" @@ -117,8 +119,9 @@ func compareFiles(oldFile, newFile string) (bool, error) { } // Format and output results (unless quiet mode) + var output string if !quiet { - output, err := cli.FormatOutput(result, cli.OutputOptions{ + output, err = cli.FormatOutput(result, cli.OutputOptions{ Format: outputFormat, NoColor: noColor, MaxValueLength: maxValueLength, @@ -132,8 +135,17 @@ func compareFiles(oldFile, newFile string) (bool, error) { fmt.Println(output) } + // Write GitHub Actions outputs if in GHA environment + hasChanges := cli.HasChanges(result) + if githubOutput := os.Getenv("GITHUB_OUTPUT"); githubOutput != "" { + if err := writeGitHubOutputs(githubOutput, hasChanges, output); err != nil { + // Log error but don't fail the command + fmt.Fprintf(os.Stderr, "Warning: Failed to write GitHub Actions outputs: %v\n", err) + } + } + // Return whether changes were found - return cli.HasChanges(result), nil + return hasChanges, nil } // compareDirectories recursively compares two directories. @@ -255,3 +267,32 @@ func fileExists(path string) bool { } return !info.IsDir() } + +// writeGitHubOutputs writes GitHub Actions outputs to the GITHUB_OUTPUT file +func writeGitHubOutputs(outputFile string, hasChanges bool, diffOutput string) error { + f, err := os.OpenFile(outputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + // Write has-changes output + if _, err := fmt.Fprintf(f, "has-changes=%v\n", hasChanges); err != nil { + return err + } + + // Generate random delimiter to prevent injection attacks + // See: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#multiline-strings + delimiterBytes := make([]byte, 16) + if _, err := rand.Read(delimiterBytes); err != nil { + return fmt.Errorf("failed to generate random delimiter: %w", err) + } + delimiter := "ghadelimiter_" + hex.EncodeToString(delimiterBytes) + + // Write diff-output using heredoc format with random delimiter + if _, err := fmt.Fprintf(f, "diff-output<<%s\n%s\n%s\n", delimiter, diffOutput, delimiter); err != nil { + return err + } + + return nil +} diff --git a/cmd/configdiff/main_test.go b/cmd/configdiff/main_test.go index 0b642b0..34b0991 100644 --- a/cmd/configdiff/main_test.go +++ b/cmd/configdiff/main_test.go @@ -308,6 +308,23 @@ func findSubstring(s, substr string) bool { return false } +func splitLines(s string) []string { + var lines []string + var line string + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, line) + line = "" + } else { + line += string(s[i]) + } + } + if line != "" { + lines = append(lines, line) + } + return lines +} + func TestCompareFilesReturnValue(t *testing.T) { tmpDir := t.TempDir() @@ -415,3 +432,144 @@ func TestDirectoryComparisonDoesNotExitEarly(t *testing.T) { // If we get here, the function completed successfully without os.Exit // The os.Exit would happen in the caller (compare function), not in compareDirectories } + +func TestWriteGitHubOutputs(t *testing.T) { + tmpDir := t.TempDir() + outputFile := filepath.Join(tmpDir, "github_output.txt") + + tests := []struct { + name string + hasChanges bool + diffOutput string + wantErr bool + }{ + { + name: "with changes", + hasChanges: true, + diffOutput: "path: /config/value\nold: 1\nnew: 2", + wantErr: false, + }, + { + name: "no changes", + hasChanges: false, + diffOutput: "", + wantErr: false, + }, + { + name: "multiline output", + hasChanges: true, + diffOutput: "Changes:\n Modified: /config/value\n Added: /config/newkey\n Removed: /config/oldkey", + wantErr: false, + }, + { + name: "output containing EOF (injection test)", + hasChanges: true, + diffOutput: "Some text\nEOF\ninjected-output=malicious\nMore text", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Remove output file between tests + os.Remove(outputFile) + + err := writeGitHubOutputs(outputFile, tt.hasChanges, tt.diffOutput) + if (err != nil) != tt.wantErr { + t.Errorf("writeGitHubOutputs() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + return + } + + // Read the output file + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + output := string(content) + + // Verify has-changes output + expectedHasChanges := fmt.Sprintf("has-changes=%v\n", tt.hasChanges) + if !contains(output, expectedHasChanges) { + t.Errorf("Output missing expected has-changes line: %q", expectedHasChanges) + } + + // Verify diff-output heredoc format with random delimiter + if !contains(output, "diff-output< len("diff-output<<") && line[:13] == "diff-output<<" { + delimiter = line[13:] + diffStartIdx = i + 1 + break + } + } + + if delimiter == "" { + t.Error("Failed to extract delimiter from output") + } else { + // Verify delimiter ends the heredoc + found := false + for i := diffStartIdx; i < len(lines); i++ { + if lines[i] == delimiter { + found = true + break + } + } + if !found { + t.Errorf("Delimiter %q not found at end of heredoc", delimiter) + } + } + + // Verify diff content is present + if tt.diffOutput != "" && !contains(output, tt.diffOutput) { + t.Errorf("Output missing expected diff content: %q", tt.diffOutput) + } + }) + } +} + +func TestWriteGitHubOutputsAppend(t *testing.T) { + tmpDir := t.TempDir() + outputFile := filepath.Join(tmpDir, "github_output.txt") + + // Write initial content + initialContent := "existing-output=test\n" + if err := os.WriteFile(outputFile, []byte(initialContent), 0644); err != nil { + t.Fatalf("Failed to write initial content: %v", err) + } + + // Append GitHub outputs + err := writeGitHubOutputs(outputFile, true, "diff content") + if err != nil { + t.Fatalf("writeGitHubOutputs() error = %v", err) + } + + // Read file + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + output := string(content) + + // Verify initial content is preserved + if !contains(output, initialContent) { + t.Error("Initial content was not preserved") + } + + // Verify new content was appended + if !contains(output, "has-changes=true") { + t.Error("New content was not appended") + } +} diff --git a/report/report_test.go b/report/report_test.go index 188beb3..32da1ff 100644 --- a/report/report_test.go +++ b/report/report_test.go @@ -420,12 +420,12 @@ func TestGenerateStat(t *testing.T) { tests := []struct { name string changes []diff.Change - want []string // substrings that should appear in output + golden string }{ { name: "empty changes", changes: []diff.Change{}, - want: []string{"No changes detected"}, + golden: "stat_empty.txt", }, { name: "single modification", @@ -437,7 +437,7 @@ func TestGenerateStat(t *testing.T) { NewValue: tree.NewString("2.0"), }, }, - want: []string{"/version", "1 paths changed", "1 modifications(~)"}, + golden: "stat_single_modify.txt", }, { name: "multiple changes", @@ -459,26 +459,69 @@ func TestGenerateStat(t *testing.T) { NewValue: tree.NewString("new"), }, }, - want: []string{ - "/newKey", - "/oldKey", - "/changedKey", - "3 paths changed", - "1 additions(+)", - "1 deletions(-)", - "1 modifications(~)", + golden: "stat_multiple.txt", + }, + { + name: "mixed changes", + changes: []diff.Change{ + { + Type: diff.ChangeTypeAdd, + Path: "/config/new", + NewValue: tree.NewString("value"), + }, + { + Type: diff.ChangeTypeAdd, + Path: "/config/another", + NewValue: tree.NewNumber(42), + }, + { + Type: diff.ChangeTypeRemove, + Path: "/old/setting", + OldValue: tree.NewBool(true), + }, + { + Type: diff.ChangeTypeModify, + Path: "/replicas", + OldValue: tree.NewNumber(2), + NewValue: tree.NewNumber(5), + }, + { + Type: diff.ChangeTypeMove, + Path: "/position", + OldValue: tree.NewNumber(0), + NewValue: tree.NewNumber(1), + }, }, + golden: "stat_mixed.txt", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := GenerateStat(tt.changes) - for _, substr := range tt.want { - if !contains(got, substr) { - t.Errorf("GenerateStat() output missing %q\nGot:\n%s", substr, got) + + goldenPath := filepath.Join("..", "testdata", "report", tt.golden) + + if *updateGolden { + // Update golden file + if err := os.MkdirAll(filepath.Dir(goldenPath), 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + if err := os.WriteFile(goldenPath, []byte(got), 0644); err != nil { + t.Fatalf("Failed to update golden file: %v", err) } } + + // Read golden file + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("Failed to read golden file %s: %v (run with -update to create)", goldenPath, err) + } + + if got != string(want) { + t.Errorf("GenerateStat() output differs from golden file %s\nGot:\n%s\nWant:\n%s", tt.golden, got, string(want)) + t.Logf("Run with -update flag to update golden files") + } }) } } @@ -488,16 +531,16 @@ func TestGenerateSideBySide(t *testing.T) { name string changes []diff.Change opts Options - want []string // substrings that should appear in output + golden string }{ { name: "empty changes", changes: []diff.Change{}, opts: Options{NoColor: true}, - want: []string{"No changes detected"}, + golden: "side_by_side_empty.txt", }, { - name: "addition", + name: "single addition", changes: []diff.Change{ { Type: diff.ChangeTypeAdd, @@ -505,17 +548,11 @@ func TestGenerateSideBySide(t *testing.T) { NewValue: tree.NewString("value"), }, }, - opts: Options{NoColor: true}, - want: []string{ - "Old Value", - "New Value", - "/newKey", - "(none)", - "value", - }, + opts: Options{NoColor: true}, + golden: "side_by_side_add.txt", }, { - name: "removal", + name: "single removal", changes: []diff.Change{ { Type: diff.ChangeTypeRemove, @@ -523,12 +560,8 @@ func TestGenerateSideBySide(t *testing.T) { OldValue: tree.NewString("value"), }, }, - opts: Options{NoColor: true}, - want: []string{ - "/oldKey", - "value", - "(removed)", - }, + opts: Options{NoColor: true}, + golden: "side_by_side_remove.txt", }, { name: "modification", @@ -540,23 +573,60 @@ func TestGenerateSideBySide(t *testing.T) { NewValue: tree.NewString("new"), }, }, - opts: Options{NoColor: true}, - want: []string{ - "/key", - "old", - "new", + opts: Options{NoColor: true}, + golden: "side_by_side_modify.txt", + }, + { + name: "multiple changes", + changes: []diff.Change{ + { + Type: diff.ChangeTypeAdd, + Path: "/env", + NewValue: tree.NewString("production"), + }, + { + Type: diff.ChangeTypeRemove, + Path: "/debug", + OldValue: tree.NewBool(true), + }, + { + Type: diff.ChangeTypeModify, + Path: "/replicas", + OldValue: tree.NewNumber(2), + NewValue: tree.NewNumber(5), + }, }, + opts: Options{NoColor: true}, + golden: "side_by_side_multiple.txt", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := GenerateSideBySide(tt.changes, tt.opts) - for _, substr := range tt.want { - if !contains(got, substr) { - t.Errorf("GenerateSideBySide() output missing %q\nGot:\n%s", substr, got) + + goldenPath := filepath.Join("..", "testdata", "report", tt.golden) + + if *updateGolden { + // Update golden file + if err := os.MkdirAll(filepath.Dir(goldenPath), 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + if err := os.WriteFile(goldenPath, []byte(got), 0644); err != nil { + t.Fatalf("Failed to update golden file: %v", err) } } + + // Read golden file + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("Failed to read golden file %s: %v (run with -update to create)", goldenPath, err) + } + + if got != string(want) { + t.Errorf("GenerateSideBySide() output differs from golden file %s\nGot:\n%s\nWant:\n%s", tt.golden, got, string(want)) + t.Logf("Run with -update flag to update golden files") + } }) } } @@ -567,17 +637,17 @@ func TestGenerateGitDiff(t *testing.T) { changes []diff.Change oldFile string newFile string - want []string // substrings that should appear in output + golden string }{ { name: "empty changes", changes: []diff.Change{}, oldFile: "old.yaml", newFile: "new.yaml", - want: []string{}, // empty diff returns empty string + golden: "git_diff_empty.txt", }, { - name: "addition", + name: "single addition", changes: []diff.Change{ { Type: diff.ChangeTypeAdd, @@ -587,15 +657,10 @@ func TestGenerateGitDiff(t *testing.T) { }, oldFile: "old.yaml", newFile: "new.yaml", - want: []string{ - "diff --configdiff a/old.yaml b/new.yaml", - "--- a/old.yaml", - "+++ b/new.yaml", - "+/newKey: \"value\"", - }, + golden: "git_diff_add.txt", }, { - name: "removal", + name: "single removal", changes: []diff.Change{ { Type: diff.ChangeTypeRemove, @@ -605,10 +670,7 @@ func TestGenerateGitDiff(t *testing.T) { }, oldFile: "old.yaml", newFile: "new.yaml", - want: []string{ - "diff --configdiff", - "-/oldKey: \"value\"", - }, + golden: "git_diff_remove.txt", }, { name: "modification", @@ -622,28 +684,60 @@ func TestGenerateGitDiff(t *testing.T) { }, oldFile: "old.yaml", newFile: "new.yaml", - want: []string{ - "diff --configdiff", - "-/key: \"old\"", - "+/key: \"new\"", + golden: "git_diff_modify.txt", + }, + { + name: "multiple changes", + changes: []diff.Change{ + { + Type: diff.ChangeTypeAdd, + Path: "/env", + NewValue: tree.NewString("production"), + }, + { + Type: diff.ChangeTypeRemove, + Path: "/debug", + OldValue: tree.NewBool(true), + }, + { + Type: diff.ChangeTypeModify, + Path: "/replicas", + OldValue: tree.NewNumber(2), + NewValue: tree.NewNumber(5), + }, }, + oldFile: "config.yaml", + newFile: "config.yaml", + golden: "git_diff_multiple.txt", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := GenerateGitDiff(tt.changes, tt.oldFile, tt.newFile) - if len(tt.want) == 0 { - if got != "" { - t.Errorf("GenerateGitDiff() = %q, want empty string", got) + + goldenPath := filepath.Join("..", "testdata", "report", tt.golden) + + if *updateGolden { + // Update golden file + if err := os.MkdirAll(filepath.Dir(goldenPath), 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) } - return - } - for _, substr := range tt.want { - if !contains(got, substr) { - t.Errorf("GenerateGitDiff() output missing %q\nGot:\n%s", substr, got) + if err := os.WriteFile(goldenPath, []byte(got), 0644); err != nil { + t.Fatalf("Failed to update golden file: %v", err) } } + + // Read golden file + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("Failed to read golden file %s: %v (run with -update to create)", goldenPath, err) + } + + if got != string(want) { + t.Errorf("GenerateGitDiff() output differs from golden file %s\nGot:\n%s\nWant:\n%s", tt.golden, got, string(want)) + t.Logf("Run with -update flag to update golden files") + } }) } } diff --git a/testdata/report/git_diff_add.txt b/testdata/report/git_diff_add.txt new file mode 100644 index 0000000..35f3605 --- /dev/null +++ b/testdata/report/git_diff_add.txt @@ -0,0 +1,5 @@ +diff --configdiff a/old.yaml b/new.yaml +--- a/old.yaml ++++ b/new.yaml +@@ /newKey @@ ++/newKey: "value" diff --git a/testdata/report/git_diff_empty.txt b/testdata/report/git_diff_empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/testdata/report/git_diff_modify.txt b/testdata/report/git_diff_modify.txt new file mode 100644 index 0000000..004fff9 --- /dev/null +++ b/testdata/report/git_diff_modify.txt @@ -0,0 +1,6 @@ +diff --configdiff a/old.yaml b/new.yaml +--- a/old.yaml ++++ b/new.yaml +@@ /key @@ +-/key: "old" ++/key: "new" diff --git a/testdata/report/git_diff_multiple.txt b/testdata/report/git_diff_multiple.txt new file mode 100644 index 0000000..c90e5cd --- /dev/null +++ b/testdata/report/git_diff_multiple.txt @@ -0,0 +1,10 @@ +diff --configdiff a/config.yaml b/config.yaml +--- a/config.yaml ++++ b/config.yaml +@@ /env @@ ++/env: "production" +@@ /debug @@ +-/debug: true +@@ /replicas @@ +-/replicas: 2 ++/replicas: 5 diff --git a/testdata/report/git_diff_remove.txt b/testdata/report/git_diff_remove.txt new file mode 100644 index 0000000..63f0711 --- /dev/null +++ b/testdata/report/git_diff_remove.txt @@ -0,0 +1,5 @@ +diff --configdiff a/old.yaml b/new.yaml +--- a/old.yaml ++++ b/new.yaml +@@ /oldKey @@ +-/oldKey: "value" diff --git a/testdata/report/side_by_side_add.txt b/testdata/report/side_by_side_add.txt new file mode 100644 index 0000000..eea5b18 --- /dev/null +++ b/testdata/report/side_by_side_add.txt @@ -0,0 +1,8 @@ +Summary: Summary: +1 added (1 total) + +──────────────────────────────────────────────────────────────────────────────── +Old Value | New Value +──────────────────────────────────────────────────────────────────────────────── +/newKey + (none) | "value" + diff --git a/testdata/report/side_by_side_empty.txt b/testdata/report/side_by_side_empty.txt new file mode 100644 index 0000000..241994a --- /dev/null +++ b/testdata/report/side_by_side_empty.txt @@ -0,0 +1 @@ +No changes detected. diff --git a/testdata/report/side_by_side_modify.txt b/testdata/report/side_by_side_modify.txt new file mode 100644 index 0000000..a167286 --- /dev/null +++ b/testdata/report/side_by_side_modify.txt @@ -0,0 +1,8 @@ +Summary: Summary: ~1 modified (1 total) + +──────────────────────────────────────────────────────────────────────────────── +Old Value | New Value +──────────────────────────────────────────────────────────────────────────────── +/key + "old" | "new" + diff --git a/testdata/report/side_by_side_multiple.txt b/testdata/report/side_by_side_multiple.txt new file mode 100644 index 0000000..4cd4450 --- /dev/null +++ b/testdata/report/side_by_side_multiple.txt @@ -0,0 +1,14 @@ +Summary: Summary: +1 added, -1 removed, ~1 modified (3 total) + +──────────────────────────────────────────────────────────────────────────────── +Old Value | New Value +──────────────────────────────────────────────────────────────────────────────── +/env + (none) | "production" + +/debug + true | (removed) + +/replicas + 2 | 5 + diff --git a/testdata/report/side_by_side_remove.txt b/testdata/report/side_by_side_remove.txt new file mode 100644 index 0000000..0aea57f --- /dev/null +++ b/testdata/report/side_by_side_remove.txt @@ -0,0 +1,8 @@ +Summary: Summary: -1 removed (1 total) + +──────────────────────────────────────────────────────────────────────────────── +Old Value | New Value +──────────────────────────────────────────────────────────────────────────────── +/oldKey + "value" | (removed) + diff --git a/testdata/report/stat_empty.txt b/testdata/report/stat_empty.txt new file mode 100644 index 0000000..241994a --- /dev/null +++ b/testdata/report/stat_empty.txt @@ -0,0 +1 @@ +No changes detected. diff --git a/testdata/report/stat_mixed.txt b/testdata/report/stat_mixed.txt new file mode 100644 index 0000000..df1ee6d --- /dev/null +++ b/testdata/report/stat_mixed.txt @@ -0,0 +1,6 @@ + /config/another | ++++++++++++++++++++++++++++++++++++++++ + /config/new | ++++++++++++++++++++++++++++++++++++++++ + /old/setting | ---------------------------------------- + /position | + /replicas | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 5 paths changed, 2 additions(+), 1 deletions(-), 1 modifications(~), 1 moves(→) diff --git a/testdata/report/stat_multiple.txt b/testdata/report/stat_multiple.txt new file mode 100644 index 0000000..05c3702 --- /dev/null +++ b/testdata/report/stat_multiple.txt @@ -0,0 +1,4 @@ + /changedKey | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + /newKey | ++++++++++++++++++++++++++++++++++++++++ + /oldKey | ---------------------------------------- + 3 paths changed, 1 additions(+), 1 deletions(-), 1 modifications(~) diff --git a/testdata/report/stat_single_modify.txt b/testdata/report/stat_single_modify.txt new file mode 100644 index 0000000..f33c9e8 --- /dev/null +++ b/testdata/report/stat_single_modify.txt @@ -0,0 +1,2 @@ + /version | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 1 paths changed, 1 modifications(~)