From f5908a018f65d0dd85182cde6bcc0639a0f5d3d1 Mon Sep 17 00:00:00 2001 From: Radoslav Dimitrov Date: Wed, 29 May 2024 03:12:02 +0300 Subject: [PATCH] Add more unit tests - cache, config, ghrest, cli, action, image Signed-off-by: Radoslav Dimitrov --- go.mod | 2 + go.sum | 5 + internal/cli/cli_test.go | 223 +++++++++++++++++++ internal/traverse/traverse_test.go | 264 +++++++++++++++++++++++ pkg/replacer/actions/actions_test.go | 310 +++++++++++++++++++++------ pkg/replacer/image/image_test.go | 196 ++++++++++++++--- pkg/replacer/replacer_test.go | 60 +++--- pkg/utils/config/config.go | 8 +- pkg/utils/config/config_test.go | 147 +++++++++++++ pkg/utils/ghrest/ghrest_test.go | 114 ++++++++++ pkg/utils/store/cache_test.go | 119 ++++++++++ 11 files changed, 1316 insertions(+), 132 deletions(-) create mode 100644 internal/cli/cli_test.go create mode 100644 internal/traverse/traverse_test.go create mode 100644 pkg/utils/config/config_test.go create mode 100644 pkg/utils/ghrest/ghrest_test.go create mode 100644 pkg/utils/store/cache_test.go diff --git a/go.mod b/go.mod index a9e8b7b..43ab306 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/docker/docker v24.0.9+incompatible // indirect github.com/docker/docker-credential-helpers v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.16.5 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect @@ -36,4 +37,5 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/vbatts/tar-split v0.11.3 // indirect golang.org/x/sys v0.15.0 // indirect + gopkg.in/h2non/gock.v1 v1.1.2 // indirect ) diff --git a/go.sum b/go.sum index 2853a50..12429b6 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ github.com/google/go-github/v61 v61.0.0 h1:VwQCBwhyE9JclCI+22/7mLB1PuU9eowCXKY5p github.com/google/go-github/v61 v61.0.0/go.mod h1:0WR+KmsWX75G2EbpyGsGmradjo3IiciuI4BmdVCobQY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= @@ -41,6 +43,7 @@ github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/Qd github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= @@ -91,6 +94,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= +gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..f98217b --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,223 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/memfs" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestNewHelper(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + cmdArgs []string + expected *Helper + expectedError bool + }{ + { + name: "ValidFlags", + cmdArgs: []string{"--dry-run", "--quiet", "--error", "--regex", "test"}, + expected: &Helper{ + DryRun: true, + Quiet: true, + ErrOnModified: true, + Regex: "test", + }, + expectedError: false, + }, + { + name: "MissingFlags", + cmdArgs: []string{}, + expected: &Helper{}, + expectedError: false, + }, + { + name: "InvalidFlags", + cmdArgs: []string{"--nonexistent"}, + expected: nil, + expectedError: true, + }, + } + + for _, tt := range testCases { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{} + DeclareFrizbeeFlags(cmd, true) + cmd.SetArgs(tt.cmdArgs) + + if tt.expectedError { + assert.Error(t, cmd.Execute()) + return + } + + assert.NoError(t, cmd.Execute()) + + helper, err := NewHelper(cmd) + if tt.expectedError { + assert.Error(t, err) + assert.Nil(t, helper) + } else { + assert.NoError(t, err) + assert.NotNil(t, helper) + assert.Equal(t, tt.expected.DryRun, helper.DryRun) + assert.Equal(t, tt.expected.Quiet, helper.Quiet) + assert.Equal(t, tt.expected.ErrOnModified, helper.ErrOnModified) + assert.Equal(t, tt.expected.Regex, helper.Regex) + } + }) + } +} + +func TestProcessOutput(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + helper *Helper + path string + processed []string + modified map[string]string + expectedOutput string + expectError bool + }{ + { + name: "QuietMode", + helper: &Helper{ + Quiet: true, + Cmd: &cobra.Command{}, + }, + path: "test/path", + processed: []string{"file1.txt", "file2.txt"}, + modified: map[string]string{"file1.txt": "new content"}, + expectedOutput: "", + expectError: false, + }, + { + name: "DryRunMode", + helper: &Helper{ + Quiet: false, + DryRun: true, + Cmd: &cobra.Command{}, + }, + path: "test/path", + processed: []string{"file1.txt"}, + modified: map[string]string{"file1.txt": "new content"}, + expectedOutput: "Processed: file1.txt\nModified: file1.txt\nnew content", + expectError: false, + }, + { + name: "ErrorOpeningFile", + helper: &Helper{ + Quiet: false, + Cmd: &cobra.Command{}, + }, + path: "invalid/path", + modified: map[string]string{"invalid/path": "new content"}, + expectedOutput: "", + expectError: true, + }, + } + + for _, tt := range testCases { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Set up command output + var output strings.Builder + tt.helper.Cmd.SetOut(&output) + tt.helper.Cmd.SetErr(&output) + + // Create in-memory filesystem and add files + fs := memfs.New() + for path, content := range tt.modified { + dir := filepath.Join(tt.path, filepath.Dir(path)) + assert.NoError(t, fs.MkdirAll(dir, 0755)) + file, err := fs.Create(filepath.Join(tt.path, path)) + if err == nil { + _, _ = file.Write([]byte(content)) + assert.NoError(t, file.Close()) + } + } + + // Process the output using the in-memory filesystem + err := tt.helper.ProcessOutput(tt.path, tt.processed, tt.modified) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Contains(t, output.String(), tt.expectedOutput) + } + }) + } +} + +func TestIsPath(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + setup func(fs billy.Filesystem) + path string + expected bool + }{ + { + name: "ExistingFile", + setup: func(fs billy.Filesystem) { + file, _ := fs.Create("testfile.txt") + assert.NoError(t, file.Close()) + }, + path: "testfile.txt", + expected: true, + }, + { + name: "NonExistentFile", + setup: func(_ billy.Filesystem) {}, + path: "nonexistent.txt", + expected: false, + }, + { + name: "ExistingDirectory", + setup: func(fs billy.Filesystem) { + assert.NoError(t, fs.MkdirAll("testdir", 0755)) + }, + path: "testdir", + expected: true, + }, + { + name: "NonExistentDirectory", + setup: func(_ billy.Filesystem) {}, + path: "nonexistentdir", + expected: false, + }, + } + + for _, tt := range testCases { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Use in-memory filesystem for testing + fs := memfs.New() + tt.setup(fs) + + // Check if the path exists in the in-memory filesystem + _, err := fs.Stat(tt.path) + result := err == nil + + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/traverse/traverse_test.go b/internal/traverse/traverse_test.go new file mode 100644 index 0000000..701b838 --- /dev/null +++ b/internal/traverse/traverse_test.go @@ -0,0 +1,264 @@ +package traverse + +import ( + "errors" + "os" + "testing" + "time" + + "github.com/go-git/go-billy/v5/memfs" + "github.com/stretchr/testify/assert" +) + +func TestYamlDockerfiles(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + fsContent map[string]string + baseDir string + expected []string + expectError bool + }{ + { + name: "NoYAMLOrDockerfile", + fsContent: map[string]string{ + "base/file.txt": "content", + }, + baseDir: "base", + expected: []string{}, + expectError: false, + }, + { + name: "WithYAMLFiles", + fsContent: map[string]string{ + "base/file.yml": "content", + "base/file.yaml": "content", + "base/not_included.txt": "content", + }, + baseDir: "base", + expected: []string{ + "base/file.yml", + "base/file.yaml", + }, + expectError: false, + }, + { + name: "WithDockerfiles", + fsContent: map[string]string{ + "base/Dockerfile": "content", + "base/nested/dockerfile": "content", + "base/not_included.txt": "content", + }, + baseDir: "base", + expected: []string{ + "base/Dockerfile", + "base/nested/dockerfile", + }, + expectError: false, + }, + { + name: "MixedFiles", + fsContent: map[string]string{ + "base/file.yml": "content", + "base/Dockerfile": "content", + "base/nested/file.yaml": "content", + "base/nested/dockerfile": "content", + "base/not_included.txt": "content", + }, + baseDir: "base", + expected: []string{ + "base/file.yml", + "base/Dockerfile", + "base/nested/file.yaml", + "base/nested/dockerfile", + }, + expectError: false, + }, + { + name: "ErrorInProcessingFile", + fsContent: map[string]string{ + "base/file.yml": "content", + }, + baseDir: "base", + expectError: true, + }, + } + + for _, tt := range testCases { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fs := memfs.New() + for name, content := range tt.fsContent { + f, _ := fs.Create(name) + _, _ = f.Write([]byte(content)) + assert.NoError(t, f.Close()) + } + + var processedFiles []string + err := YamlDockerfiles(fs, tt.baseDir, func(path string) error { + if tt.expectError { + return errors.New("error in processing file") + } + processedFiles = append(processedFiles, path) + return nil + }) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.ElementsMatch(t, tt.expected, processedFiles) + } + }) + } +} + +func TestTraverse(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + fsContent map[string]string + baseDir string + expected []string + expectError bool + }{ + { + name: "TraverseFiles", + fsContent: map[string]string{ + "base/file1.txt": "content", + "base/file2.txt": "content", + "base/nested/file": "content", + }, + baseDir: "base", + expected: []string{ + "base", + "base/file1.txt", + "base/file2.txt", + "base/nested", + "base/nested/file", + }, + expectError: false, + }, + { + name: "TraverseWithError", + fsContent: map[string]string{ + "base/file.txt": "content", + }, + baseDir: "base", + expectError: true, + }, + } + + for _, tt := range testCases { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fs := memfs.New() + for name, content := range tt.fsContent { + f, _ := fs.Create(name) + _, _ = f.Write([]byte(content)) + assert.NoError(t, f.Close()) + } + + var processedFiles []string + err := Traverse(fs, tt.baseDir, func(path string, _ os.FileInfo) error { + if tt.expectError { + return errors.New("error in traversing file") + } + processedFiles = append(processedFiles, path) + return nil + }) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.ElementsMatch(t, tt.expected, processedFiles) + } + }) + } +} + +func TestIsYAMLOrDockerfile(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + fileName string + isDir bool + expected bool + }{ + { + name: "YAMLFile", + fileName: "config.yaml", + isDir: false, + expected: true, + }, + { + name: "YMLFile", + fileName: "config.yml", + isDir: false, + expected: true, + }, + { + name: "Dockerfile", + fileName: "Dockerfile", + isDir: false, + expected: true, + }, + { + name: "dockerfile", + fileName: "dockerfile", + isDir: false, + expected: true, + }, + { + name: "NonYAMLOrDockerfile", + fileName: "config.txt", + isDir: false, + expected: false, + }, + { + name: "Directory", + fileName: "config", + isDir: true, + expected: false, + }, + } + + for _, tt := range testCases { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + info := &fileInfoMock{ + name: tt.fileName, + dir: tt.isDir, + } + + result := isYAMLOrDockerfile(info) + assert.Equal(t, tt.expected, result) + }) + } +} + +// fileInfoMock is a mock implementation of os.FileInfo for testing. +type fileInfoMock struct { + name string + dir bool +} + +func (f *fileInfoMock) Name() string { return f.name } +func (_ *fileInfoMock) Size() int64 { return 0 } +func (_ *fileInfoMock) Mode() os.FileMode { return 0 } +func (_ *fileInfoMock) ModTime() time.Time { return time.Time{} } +func (f *fileInfoMock) IsDir() bool { return f.dir } +func (_ *fileInfoMock) Sys() interface{} { return nil } diff --git a/pkg/replacer/actions/actions_test.go b/pkg/replacer/actions/actions_test.go index 06e2949..318f7ad 100644 --- a/pkg/replacer/actions/actions_test.go +++ b/pkg/replacer/actions/actions_test.go @@ -7,130 +7,310 @@ import ( "github.com/stretchr/testify/require" + "github.com/stacklok/frizbee/pkg/utils/config" "github.com/stacklok/frizbee/pkg/utils/ghrest" + "github.com/stacklok/frizbee/pkg/utils/store" ) +func TestNewParser(t *testing.T) { + t.Parallel() + + parser := New() + require.NotNil(t, parser, "Parser should not be nil") + require.Equal(t, GitHubActionsRegex, parser.regex, "Default regex should be GitHubActionsRegex") + require.NotNil(t, parser.cache, "Cache should be initialized") +} + +func TestSetCache(t *testing.T) { + t.Parallel() + + parser := New() + cache := store.NewRefCacher() + parser.SetCache(cache) + require.Equal(t, cache, parser.cache, "Cache should be set correctly") +} + +func TestSetAndGetRegex(t *testing.T) { + t.Parallel() + + parser := New() + tests := []struct { + name string + newRegex string + }{ + { + name: "Set and get new regex", + newRegex: `new-regex`, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + parser.SetRegex(tt.newRegex) + require.Equal(t, tt.newRegex, parser.GetRegex(), "Regex should be set and retrieved correctly") + }) + } +} + +func TestReplaceLocalPath(t *testing.T) { + t.Parallel() + + parser := New() + ctx := context.Background() + cfg := config.Config{} + restIf := &ghrest.Client{} + + tests := []struct { + name string + matchedLine string + }{ + { + name: "Replace local path", + matchedLine: "./local/path", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := parser.Replace(ctx, tt.matchedLine, restIf, cfg) + require.Error(t, err, "Should return error for local path") + require.Contains(t, err.Error(), "reference skipped", "Error should indicate reference skipped") + }) + } +} + +func TestReplaceExcludedPath(t *testing.T) { + t.Parallel() + + parser := New() + ctx := context.Background() + cfg := config.Config{GHActions: config.GHActions{Filter: config.Filter{Exclude: []string{"actions/checkout"}}}} + restIf := &ghrest.Client{} + + tests := []struct { + name string + matchedLine string + }{ + { + name: "Replace excluded path", + matchedLine: "uses: actions/checkout@v2", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := parser.Replace(ctx, tt.matchedLine, restIf, cfg) + require.Error(t, err, "Should return error for excluded path") + require.Contains(t, err.Error(), "reference skipped", "Error should indicate reference skipped") + }) + } +} + +func TestConvertToEntityRef(t *testing.T) { + t.Parallel() + + parser := New() + + tests := []struct { + name string + reference string + wantErr bool + }{ + {"Valid action reference", "uses: actions/checkout@v2", false}, + {"Valid docker reference", "docker://mydocker/image:tag", false}, + {"Invalid reference format", "invalid-reference", true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ref, err := parser.ConvertToEntityRef(tt.reference) + if tt.wantErr { + require.Error(t, err, "Expected error but got none") + } else { + require.NoError(t, err, "Expected no error but got %v", err) + require.NotNil(t, ref, "EntityRef should not be nil") + } + }) + } +} + +func TestIsLocal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want bool + }{ + {"Local path with ./", "./local/path", true}, + {"Local path with ../", "../local/path", true}, + {"Non-local path", "non/local/path", false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.want, isLocal(tt.input), "IsLocal should return correct value") + }) + } +} + +func TestShouldExclude(t *testing.T) { + t.Parallel() + + cfg := &config.GHActions{Filter: config.Filter{Exclude: []string{"actions/checkout", "actions/setup"}}} + + tests := []struct { + name string + input string + want bool + }{ + {"Excluded path", "actions/checkout", true}, + {"Non-excluded path", "actions/unknown", false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.want, shouldExclude(cfg, tt.input), "ShouldExclude should return correct value") + }) + } +} + +func TestParseActionReference(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantAction string + wantRef string + wantErr bool + }{ + {"Valid action reference", "actions/checkout@v2", "actions/checkout", "v2", false}, + {"Invalid reference format", "invalid-reference", "", "", true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + action, ref, err := ParseActionReference(tt.input) + if tt.wantErr { + require.Error(t, err, "Expected error but got none") + } else { + require.NoError(t, err, "Expected no error but got %v", err) + require.Equal(t, tt.wantAction, action, "Action should be parsed correctly") + require.Equal(t, tt.wantRef, ref, "Reference should be parsed correctly") + } + }) + } +} + func TestGetChecksum(t *testing.T) { t.Parallel() tok := os.Getenv("GITHUB_TOKEN") + ctx := context.Background() + ghcli := ghrest.NewClient(tok) - type args struct { - action string - ref string - } tests := []struct { name string - args args + args struct{ action, ref string } want string wantErr bool }{ { - name: "actions/checkout with v4.1.1", - args: args{ - action: "actions/checkout", - ref: "v4.1.1", - }, + name: "actions/checkout with v4.1.1", + args: struct{ action, ref string }{action: "actions/checkout", ref: "v4.1.1"}, want: "b4ffde65f46336ab88eb53be808477a3936bae11", wantErr: false, }, { - name: "actions/checkout with v3.6.0", - args: args{ - action: "actions/checkout", - ref: "v3.6.0", - }, + name: "actions/checkout with v3.6.0", + args: struct{ action, ref string }{action: "actions/checkout", ref: "v3.6.0"}, want: "f43a0e5ff2bd294095638e18286ca9a3d1956744", wantErr: false, }, { - name: "actions/checkout with checksum returns checksum", - args: args{ - action: "actions/checkout", - ref: "1d96c772d19495a3b5c517cd2bc0cb401ea0529f", - }, + name: "actions/checkout with checksum returns checksum", + args: struct{ action, ref string }{action: "actions/checkout", ref: "1d96c772d19495a3b5c517cd2bc0cb401ea0529f"}, want: "1d96c772d19495a3b5c517cd2bc0cb401ea0529f", wantErr: false, }, { - name: "aquasecurity/trivy-action with 0.14.0", - args: args{ - action: "aquasecurity/trivy-action", - ref: "0.14.0", - }, + name: "aquasecurity/trivy-action with 0.14.0", + args: struct{ action, ref string }{action: "aquasecurity/trivy-action", ref: "0.14.0"}, want: "2b6a709cf9c4025c5438138008beaddbb02086f0", wantErr: false, }, { - name: "aquasecurity/trivy-action with branch returns checksum", - args: args{ - action: "aquasecurity/trivy-action", - ref: "bump-trivy", - }, + name: "aquasecurity/trivy-action with branch returns checksum", + args: struct{ action, ref string }{action: "aquasecurity/trivy-action", ref: "bump-trivy"}, want: "fb5e1b36be448e92ca98648c661bd7e9da1f1317", wantErr: false, }, { - name: "actions/checkout with invalid tag returns error", - args: args{ - action: "actions/checkout", - ref: "v4.1.1.1", - }, + name: "actions/checkout with invalid tag returns error", + args: struct{ action, ref string }{action: "actions/checkout", ref: "v4.1.1.1"}, want: "", wantErr: true, }, { - name: "actions/checkout with invalid action returns error", - args: args{ - action: "invalid-action", - ref: "v4.1.1", - }, + name: "actions/checkout with invalid action returns error", + args: struct{ action, ref string }{action: "invalid-action", ref: "v4.1.1"}, want: "", wantErr: true, }, { - name: "actions/checkout with empty action returns error", - args: args{ - action: "", - ref: "v4.1.1", - }, + name: "actions/checkout with empty action returns error", + args: struct{ action, ref string }{action: "", ref: "v4.1.1"}, want: "", wantErr: true, }, { - name: "actions/checkout with empty tag returns error", - args: args{ - action: "actions/checkout", - ref: "", - }, + name: "actions/checkout with empty tag returns error", + args: struct{ action, ref string }{action: "actions/checkout", ref: ""}, want: "", wantErr: true, }, { - name: "bufbuild/buf-setup-action with v1 is an array", - args: args{ - action: "bufbuild/buf-setup-action", - ref: "v1", - }, - want: "dde0b9351db90fbf78e345f41a57de8514bf1091", + name: "bufbuild/buf-setup-action with v1 is an array", + args: struct{ action, ref string }{action: "bufbuild/buf-setup-action", ref: "v1"}, + want: "dde0b9351db90fbf78e345f41a57de8514bf1091", + wantErr: false, }, { - name: "anchore/sbom-action/download-syft with a sub-action works", - args: args{ - action: "anchore/sbom-action/download-syft", - ref: "v0.14.3", - }, - want: "78fc58e266e87a38d4194b2137a3d4e9bcaf7ca1", + name: "anchore/sbom-action/download-syft with a sub-action works", + args: struct{ action, ref string }{action: "anchore/sbom-action/download-syft", ref: "v0.14.3"}, + want: "78fc58e266e87a38d4194b2137a3d4e9bcaf7ca1", + wantErr: false, }, } + for _, tt := range tests { tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() - ghcli := ghrest.NewClient(tok) - got, err := GetChecksum(context.Background(), ghcli, tt.args.action, tt.args.ref) + got, err := GetChecksum(ctx, ghcli, tt.args.action, tt.args.ref) if tt.wantErr { require.Error(t, err, "Wanted error, got none") require.Empty(t, got, "Wanted empty string, got %v", got) diff --git a/pkg/replacer/image/image_test.go b/pkg/replacer/image/image_test.go index 8943fd2..5594798 100644 --- a/pkg/replacer/image/image_test.go +++ b/pkg/replacer/image/image_test.go @@ -3,69 +3,199 @@ package image import ( "context" "testing" - "time" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/frizbee/pkg/utils/config" + "github.com/stacklok/frizbee/pkg/utils/store" ) -func TestGetImageDigestFromRef(t *testing.T) { +func TestNewParser(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + }{ + {"New parser initialization"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + parser := New() + require.NotNil(t, parser, "Parser should not be nil") + require.Equal(t, ContainerImageRegex, parser.regex, "Default regex should be ContainerImageRegex") + require.NotNil(t, parser.cache, "Cache should be initialized") + }) + } +} + +func TestSetCache(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cache store.RefCacher + }{ + {"Set cache for parser", store.NewRefCacher()}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + parser := New() + parser.SetCache(tt.cache) + require.Equal(t, tt.cache, parser.cache, "Cache should be set correctly") + }) + } +} + +func TestSetAndGetRegex(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + newRegex string + }{ + {"Set and get new regex", `new-regex`}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + parser := New() + parser.SetRegex(tt.newRegex) + require.Equal(t, tt.newRegex, parser.GetRegex(), "Regex should be set and retrieved correctly") + }) + } +} + +func TestReplaceExcludedPath(t *testing.T) { + t.Parallel() + + parser := New() + ctx := context.Background() + cfg := config.Config{GHActions: config.GHActions{Filter: config.Filter{Exclude: []string{"scratch"}}}} + + tests := []struct { + name string + matchedLine string + }{ + {"Replace excluded path", "FROM scratch"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := parser.Replace(ctx, tt.matchedLine, nil, cfg) + require.Error(t, err, "Should return error for excluded path") + require.Contains(t, err.Error(), "reference skipped", "Error should indicate reference skipped") + }) + } +} + +func TestConvertToEntityRef(t *testing.T) { t.Parallel() - type args struct { - refstr string + parser := New() + + tests := []struct { + name string + reference string + wantErr bool + }{ + {"Valid container reference with tag", "ghcr.io/stacklok/minder/helm/minder:0.20231123.829_ref.26ca90b", false}, + {"Valid container reference with digest", "ghcr.io/stacklok/minder/helm/minder@sha256:a29f8a8d28f0af7f70a4b3dd3e33c8c8cc5cf9e88e802e2700cf272a0b6140ec", false}, + {"Invalid reference format", "invalid:reference:format", true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ref, err := parser.ConvertToEntityRef(tt.reference) + if tt.wantErr { + require.Error(t, err, "Expected error but got none") + } else { + require.NoError(t, err, "Expected no error but got %v", err) + require.NotNil(t, ref, "EntityRef should not be nil") + } + }) } +} + +func TestGetImageDigestFromRef(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tests := []struct { name string - args args + refstr string want string wantErr bool }{ { - name: "valid 1", - args: args{ - refstr: "ghcr.io/stacklok/minder/helm/minder:0.20231123.829_ref.26ca90b", - }, - want: "sha256:a29f8a8d28f0af7f70a4b3dd3e33c8c8cc5cf9e88e802e2700cf272a0b6140ec", + name: "Valid image reference 1", + refstr: "ghcr.io/stacklok/minder/helm/minder:0.20231123.829_ref.26ca90b", + want: "sha256:a29f8a8d28f0af7f70a4b3dd3e33c8c8cc5cf9e88e802e2700cf272a0b6140ec", }, { - name: "valid 2", - args: args{ - refstr: "devopsfaith/krakend:2.5.0", - }, - want: "sha256:6a3c8e5e1a4948042bfb364ed6471e16b4a26d0afb6c3c01ebcb88b3fa551036", + name: "Valid image reference 2", + refstr: "devopsfaith/krakend:2.5.0", + want: "sha256:6a3c8e5e1a4948042bfb364ed6471e16b4a26d0afb6c3c01ebcb88b3fa551036", }, { - name: "invalid ref string", - args: args{ - refstr: "ghcr.io/stacklok/minder/helm/minder!", - }, + name: "Invalid ref string", + refstr: "ghcr.io/stacklok/minder/helm/minder!", wantErr: true, }, { - name: "unexistent container in unexistent registry", - args: args{ - refstr: "beeeeer.io/ipa/toppling-goliath/king-sue:1.0.0", - }, + name: "Nonexistent container in nonexistent registry", + refstr: "beeeeer.io/ipa/toppling-goliath/king-sue:1.0.0", wantErr: true, }, } + for _, tt := range tests { tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - got, err := GetImageDigestFromRef(ctx, tt.args.refstr, "", nil) + got, err := GetImageDigestFromRef(ctx, tt.refstr, "", nil) if tt.wantErr { - assert.Error(t, err) - assert.Nil(t, got) + require.Error(t, err) + require.Nil(t, got) return } - assert.NoError(t, err) - assert.Equal(t, tt.want, got.Ref) + require.NoError(t, err) + require.Equal(t, tt.want, got.Ref) + }) + } +} + +func TestShouldExclude(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref string + want bool + }{ + {"Exclude scratch", "scratch", true}, + {"Do not exclude ubuntu", "ubuntu", false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := shouldExclude(tt.ref) + require.Equal(t, tt.want, got, "shouldExclude should return the correct exclusion status") }) } } diff --git a/pkg/replacer/replacer_test.go b/pkg/replacer/replacer_test.go index 4f42277..5194d41 100644 --- a/pkg/replacer/replacer_test.go +++ b/pkg/replacer/replacer_test.go @@ -20,9 +20,7 @@ import ( "os" "strings" "testing" - "time" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stacklok/frizbee/internal/cli" @@ -125,18 +123,17 @@ func TestReplacer_ParseContainerImageString(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() + ctx := context.Background() r := NewContainerImagesReplacer(&config.Config{}) got, err := r.ParseString(ctx, tt.args.refstr) if tt.wantErr { - assert.Error(t, err) - assert.Empty(t, got) + require.Error(t, err) + require.Empty(t, got) return } - assert.NoError(t, err) - assert.Equal(t, tt.want, got) + require.NoError(t, err) + require.Equal(t, tt.want, got) }) } } @@ -295,9 +292,10 @@ func TestReplacer_ParseGitHubActionString(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() + ctx := context.Background() r := NewGitHubActionsReplacer(&config.Config{}).WithGitHubClient(os.Getenv("GITHUB_TOKEN")) - got, err := r.ParseString(context.Background(), tt.args.action) + got, err := r.ParseString(ctx, tt.args.action) if tt.wantErr { require.Error(t, err, "Wanted error, got none") require.Empty(t, got, "Wanted empty string, got %v", got) @@ -353,28 +351,28 @@ services: t.Run(tt.name, func(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() + ctx := context.Background() + r := NewContainerImagesReplacer(&config.Config{}) modified, newContent, err := r.ParseFile(ctx, strings.NewReader(tt.before)) if tt.modified { - assert.True(t, modified) - assert.NotEmpty(t, newContent) + require.True(t, modified) + require.NotEmpty(t, newContent) } else { - assert.False(t, modified) - assert.Empty(t, newContent) + require.False(t, modified) + require.Empty(t, newContent) } if tt.wantErr { - assert.False(t, modified) - assert.Empty(t, newContent) - assert.Error(t, err) + require.False(t, modified) + require.Empty(t, newContent) + require.Error(t, err) return } - assert.NoError(t, err) - assert.Equal(t, tt.expected, newContent) + require.NoError(t, err) + require.Equal(t, tt.expected, newContent) }) } } @@ -473,8 +471,8 @@ jobs: t.Run(tt.name, func(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() + ctx := context.Background() + r := NewGitHubActionsReplacer(&config.Config{}).WithGitHubClient(os.Getenv(cli.GitHubTokenEnvKey)) if tt.useCustomRegex { r = r.WithUserRegex(tt.regex) @@ -482,22 +480,22 @@ jobs: modified, newContent, err := r.ParseFile(ctx, strings.NewReader(tt.before)) if tt.modified { - assert.True(t, modified) - assert.Equal(t, tt.expected, newContent) + require.True(t, modified) + require.Equal(t, tt.expected, newContent) } else { - assert.False(t, modified) - assert.Equal(t, tt.before, newContent) + require.False(t, modified) + require.Equal(t, tt.before, newContent) } if tt.wantErr { - assert.False(t, modified) - assert.Equal(t, tt.before, newContent) - assert.Error(t, err) + require.False(t, modified) + require.Equal(t, tt.before, newContent) + require.Error(t, err) return } - assert.NoError(t, err) - assert.Equal(t, tt.expected, newContent) + require.NoError(t, err) + require.Equal(t, tt.expected, newContent) }) } } diff --git a/pkg/utils/config/config.go b/pkg/utils/config/config.go index 7fd8335..91977f1 100644 --- a/pkg/utils/config/config.go +++ b/pkg/utils/config/config.go @@ -19,6 +19,7 @@ package config import ( "errors" "fmt" + "io" "os" "path/filepath" @@ -89,12 +90,13 @@ func ParseConfigFileFromFS(fs billy.Filesystem, configfile string) (*Config, err return nil, fmt.Errorf("failed to open config file: %w", err) } - // nolint:errcheck // we don't care about the error here - defer cfgF.Close() + defer cfgF.Close() // nolint:errcheck dec := yaml.NewDecoder(cfgF) - if err := dec.Decode(cfg); err != nil { + if err == io.EOF { + return cfg, nil + } return nil, fmt.Errorf("failed to decode config file: %w", err) } diff --git a/pkg/utils/config/config_test.go b/pkg/utils/config/config_test.go new file mode 100644 index 0000000..c9a92f7 --- /dev/null +++ b/pkg/utils/config/config_test.go @@ -0,0 +1,147 @@ +package config + +import ( + "context" + "testing" + + "github.com/go-git/go-billy/v5/memfs" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestFromCommand(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + contextCfg *Config + platformFlag string + expectedCfg *Config + expectError bool + }{ + { + name: "NoConfigInContext", + contextCfg: nil, + expectError: true, + }, + { + name: "WithConfigInContext", + contextCfg: &Config{Platform: "linux/arm64"}, + expectedCfg: &Config{Platform: "linux/arm64"}, + }, + { + name: "WithPlatformFlag", + contextCfg: &Config{Platform: "linux/amd64"}, + platformFlag: "windows/arm64", + expectedCfg: &Config{Platform: "windows/arm64"}, + }, + } + + for _, tt := range testCases { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + cmd := &cobra.Command{} + if tt.contextCfg != nil { + ctx := context.WithValue(ctx, ContextConfigKey, tt.contextCfg) + cmd.SetContext(ctx) + } else { + cmd.SetContext(ctx) + } + if tt.platformFlag != "" { + cmd.Flags().String("platform", "", "platform") + require.NoError(t, cmd.Flags().Set("platform", tt.platformFlag)) + } + + cfg, err := FromCommand(cmd) + if tt.expectError { + require.Error(t, err) + require.Nil(t, cfg) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedCfg, cfg) + } + }) + } +} + +func TestParseConfigFile(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + fsContent map[string]string + fileName string + expectedResult *Config + expectError bool + }{ + { + name: "FileNotFound", + fileName: "nonexistent.yaml", + expectedResult: &Config{}, + }, + { + name: "InvalidYaml", + fileName: "invalid.yaml", + fsContent: map[string]string{"invalid.yaml": "invalid yaml content"}, + expectError: true, + }, + { + name: "ValidYaml", + fileName: "valid.yaml", + fsContent: map[string]string{ + "valid.yaml": ` +platform: linux/amd64 +ghactions: + exclude: + - pattern1 + - pattern2 +`, + }, + expectedResult: &Config{ + Platform: "linux/amd64", + GHActions: GHActions{ + Filter: Filter{ + Exclude: []string{"pattern1", "pattern2"}, + }, + }, + }, + }, + { + name: "EmptyFile", + fileName: "empty.yaml", + fsContent: map[string]string{"empty.yaml": ""}, + expectedResult: &Config{}, + }, + } + + for _, tt := range testCases { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fs := memfs.New() + for name, content := range tt.fsContent { + f, _ := fs.Create(name) + _, _ = f.Write([]byte(content)) + require.NoError(t, f.Close()) + } + + cfg, err := ParseConfigFileFromFS(fs, tt.fileName) + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedResult.Platform, cfg.Platform) + if cfg.GHActions.Exclude != nil { + require.Equal(t, tt.expectedResult.GHActions.Exclude, cfg.GHActions.Exclude) + } + } + }) + } +} diff --git a/pkg/utils/ghrest/ghrest_test.go b/pkg/utils/ghrest/ghrest_test.go new file mode 100644 index 0000000..7150fbd --- /dev/null +++ b/pkg/utils/ghrest/ghrest_test.go @@ -0,0 +1,114 @@ +package ghrest + +import ( + "context" + "errors" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/h2non/gock.v1" +) + +const ContextTimeout = 4 * time.Second + +// nolint:gocyclo +func TestClientFunctions(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + token string + method string + url string + mockResponse *gock.Response + expectedMethod string + expectedURL string + expectError bool + expectedStatus int + expectedBody string + }{ + { + name: "NewClient", + token: "test_token", + expectedMethod: "", + expectedURL: "", + }, + { + name: "NewRequest GET", + token: "", + method: "GET", + url: "test_url", + expectedMethod: http.MethodGet, + expectedURL: "https://api.github.com/test_url", + }, + { + name: "Do successful request", + token: "", + method: "GET", + url: "test", + mockResponse: gock.New("https://api.github.com").Get("/test").Reply(200).BodyString(`{"message": "hello world"}`), + expectedMethod: http.MethodGet, + expectedURL: "https://api.github.com/test", + expectedStatus: http.StatusOK, + expectedBody: `{"message": "hello world"}`, + }, + { + name: "Do failed request", + token: "", + method: "GET", + url: "test", + mockResponse: gock.New("https://api.github.com").Get("/test").ReplyError(errors.New("failed request")), + expectedMethod: http.MethodGet, + expectedURL: "https://api.github.com/test", + expectError: true, + }, + } + + for _, tt := range testCases { + tt := tt + + if tt.mockResponse != nil { + defer gock.Off() + //gock.DisableNetworking() + //t.Logf("Mock response configured for %s %s", tt.method, tt.url) + } + + client := NewClient(tt.token) + + if tt.name == "NewClient" { + assert.NotNil(t, client, "NewClient returned nil") + assert.NotNil(t, client.client, "NewClient returned client with nil GitHub client") + return + } + + req, err := client.NewRequest(tt.method, tt.url, nil) + require.NoError(t, err) + require.Equal(t, req.Method, tt.expectedMethod) + require.Equal(t, req.URL.String(), tt.expectedURL) + + if tt.name == "NewRequest GET" { + return + } + + ctx := context.Background() + + resp, err := client.Do(ctx, req) + if tt.expectError { + require.NotNil(t, err, "Expected error, got nil") + require.Nil(t, resp, "Expected nil response, got %v", resp) + return + } + require.Nil(t, err, "Expected no error, got %v", err) + + require.Equal(t, resp.StatusCode, tt.expectedStatus) + + body, err := io.ReadAll(resp.Body) + require.Nil(t, err) + require.Equal(t, string(body), tt.expectedBody) + defer resp.Body.Close() // nolint:errcheck + } +} diff --git a/pkg/utils/store/cache_test.go b/pkg/utils/store/cache_test.go new file mode 100644 index 0000000..4575c80 --- /dev/null +++ b/pkg/utils/store/cache_test.go @@ -0,0 +1,119 @@ +package store + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +type Cacher interface { + Store(key, value string) + Load(key string) (string, bool) +} + +// TestCacher tests the creation and basic functionality of both refCacher and unsafeCacher. +func TestCacher(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + cacher Cacher + key string + storeValue string + loadKey string + expectedVal string + expectFound bool + }{ + { + name: "RefCacher store and load existing key", + cacher: NewRefCacher(), + key: "key1", + storeValue: "value1", + loadKey: "key1", + expectedVal: "value1", + expectFound: true, + }, + { + name: "RefCacher load non-existing key", + cacher: NewRefCacher(), + key: "key1", + storeValue: "value1", + loadKey: "key2", + expectedVal: "", + expectFound: false, + }, + { + name: "UnsafeCacher store and load existing key", + cacher: NewUnsafeCacher(), + key: "key1", + storeValue: "value1", + loadKey: "key1", + expectedVal: "value1", + expectFound: true, + }, + { + name: "UnsafeCacher load non-existing key", + cacher: NewUnsafeCacher(), + key: "key1", + storeValue: "value1", + loadKey: "key2", + expectedVal: "", + expectFound: false, + }, + } + + for _, tt := range testCases { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tt.cacher.Store(tt.key, tt.storeValue) + val, ok := tt.cacher.Load(tt.loadKey) + require.Equal(t, tt.expectFound, ok) + require.Equal(t, tt.expectedVal, val) + }) + } +} + +// TestConcurrency tests the thread-safety of refCacher. +func TestConcurrency(t *testing.T) { + t.Parallel() + + cacher := NewRefCacher() + iterations := 1000 + done := make(chan bool) + + // Concurrently store values + for i := 0; i < iterations; i++ { + go func(i int) { + key := fmt.Sprintf("key%d", i) + value := fmt.Sprintf("value%d", i) + cacher.Store(key, value) + done <- true + }(i) + } + + // Wait for all goroutines to finish storing + for i := 0; i < iterations; i++ { + <-done + } + + // Concurrently load values + for i := 0; i < iterations; i++ { + go func(i int) { + key := fmt.Sprintf("key%d", i) + val, ok := cacher.Load(key) + expectedVal := fmt.Sprintf("value%d", i) + require.True(t, ok) + require.Equal(t, expectedVal, val) + done <- true + }(i) + } + + // Wait for all goroutines to finish loading + for i := 0; i < iterations; i++ { + <-done + } +}