From c5f8b4475fcfe25254b80ab8088e4aae0bffdc20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Tue, 22 Apr 2025 16:31:00 +0200 Subject: [PATCH] Provisioning: add some unit test coverage for Github repository (#104284) * Test validate * Complete tests for validate * Add tests for validate * Add unit tests Read * Add unit tests ReadTree * Add unit tests Create * More specific on apierrors * Improve coverage * Add unit tests for Update * Add unit tests for Write * Add tests for deletion * Add test for recursion error * Add unit tests History * Add basic scenarios webhook method * Add cases for push * Add unit tests for pull request event * Remove addressed FIXME * Meta import * Use sha256 * Fix linting error use of As --- .../apis/provisioning/repository/github.go | 77 +- .../provisioning/repository/github_test.go | 3052 ++++++++++++++++- .../apis/provisioning/secrets/secret.go | 2 + .../apis/provisioning/secrets/secret_mock.go | 154 + 4 files changed, 3223 insertions(+), 62 deletions(-) create mode 100644 pkg/registry/apis/provisioning/secrets/secret_mock.go diff --git a/pkg/registry/apis/provisioning/repository/github.go b/pkg/registry/apis/provisioning/repository/github.go index 21ff70f3ae5..f303bbeac94 100644 --- a/pkg/registry/apis/provisioning/repository/github.go +++ b/pkg/registry/apis/provisioning/repository/github.go @@ -99,8 +99,7 @@ func (r *githubRepository) Validate() (list field.ErrorList) { } if gh.Branch == "" { list = append(list, field.Required(field.NewPath("spec", "github", "branch"), "a github branch is required")) - } - if !isValidGitBranchName(gh.Branch) { + } else if !isValidGitBranchName(gh.Branch) { list = append(list, field.Invalid(field.NewPath("spec", "github", "branch"), gh.Branch, "invalid branch name")) } // TODO: Use two fields for token @@ -193,12 +192,7 @@ func (r *githubRepository) Read(ctx context.Context, filePath, ref string) (*Fil content, dirContent, err := r.gh.GetContents(ctx, r.owner, r.repo, finalPath, ref) if err != nil { if errors.Is(err, pgh.ErrResourceNotFound) { - return nil, &apierrors.StatusError{ - ErrStatus: metav1.Status{ - Message: fmt.Sprintf("file not found; path=%s ref=%s", finalPath, ref), - Code: http.StatusNotFound, - }, - } + return nil, ErrFileNotFound } return nil, fmt.Errorf("get contents: %w", err) @@ -227,8 +221,7 @@ func (r *githubRepository) ReadTree(ctx context.Context, ref string) ([]FileTree ref = r.config.Spec.GitHub.Branch } - ctx, logger := r.logger(ctx, ref) - + ctx, _ = r.logger(ctx, ref) tree, truncated, err := r.gh.GetTree(ctx, r.owner, r.repo, r.config.Spec.GitHub.Path, ref, true) if err != nil { if errors.Is(err, pgh.ErrResourceNotFound) { @@ -239,9 +232,11 @@ func (r *githubRepository) ReadTree(ctx context.Context, ref string) ([]FileTree }, } } + return nil, fmt.Errorf("get tree: %w", err) } + if truncated { - logger.Warn("tree from github was truncated") + return nil, fmt.Errorf("tree truncated") } entries := make([]FileTreeEntry, 0, len(tree)) @@ -271,7 +266,7 @@ func (r *githubRepository) Create(ctx context.Context, path, ref string, data [] ctx, _ = r.logger(ctx, ref) if err := r.ensureBranchExists(ctx, ref); err != nil { - return fmt.Errorf("create branch on create: %w", err) + return err } finalPath := safepath.Join(r.config.Spec.GitHub.Path, path) @@ -306,7 +301,7 @@ func (r *githubRepository) Update(ctx context.Context, path, ref string, data [] ctx, _ = r.logger(ctx, ref) if err := r.ensureBranchExists(ctx, ref); err != nil { - return fmt.Errorf("create branch on update: %w", err) + return err } finalPath := safepath.Join(r.config.Spec.GitHub.Path, path) @@ -339,16 +334,15 @@ func (r *githubRepository) Write(ctx context.Context, path string, ref string, d } ctx, _ = r.logger(ctx, ref) - finalPath := safepath.Join(r.config.Spec.GitHub.Path, path) - _, err := r.Read(ctx, finalPath, ref) + _, err := r.Read(ctx, path, ref) if err != nil && !(errors.Is(err, ErrFileNotFound)) { - return fmt.Errorf("failed to check if file exists before writing: %w", err) + return fmt.Errorf("check if file exists before writing: %w", err) } if err == nil { - return r.Update(ctx, finalPath, ref, data, message) + return r.Update(ctx, path, ref, data, message) } - return r.Create(ctx, finalPath, ref, data, message) + return r.Create(ctx, path, ref, data, message) } func (r *githubRepository) Delete(ctx context.Context, path, ref, comment string) error { @@ -358,42 +352,42 @@ func (r *githubRepository) Delete(ctx context.Context, path, ref, comment string ctx, _ = r.logger(ctx, ref) if err := r.ensureBranchExists(ctx, ref); err != nil { - return fmt.Errorf("create branch on delete: %w", err) + return err } + // TODO: should add some protection against deleting the root directory? + + // Inside deleteRecursively, all paths are relative to the root of the repository + // so we need to prepend the prefix there but only here. finalPath := safepath.Join(r.config.Spec.GitHub.Path, path) return r.deleteRecursively(ctx, finalPath, ref, comment) } func (r *githubRepository) deleteRecursively(ctx context.Context, path, ref, comment string) error { - finalPath := safepath.Join(r.config.Spec.GitHub.Path, path) - file, contents, err := r.gh.GetContents(ctx, r.owner, r.repo, finalPath, ref) + file, contents, err := r.gh.GetContents(ctx, r.owner, r.repo, path, ref) if err != nil { if errors.Is(err, pgh.ErrResourceNotFound) { - return &apierrors.StatusError{ - ErrStatus: metav1.Status{ - Message: "file not found", - Code: http.StatusNotFound, - }, - } + return ErrFileNotFound } - return fmt.Errorf("finding file to delete: %w", err) + + return fmt.Errorf("find file to delete: %w", err) } if file != nil && !file.IsDirectory() { - return r.gh.DeleteFile(ctx, r.owner, r.repo, finalPath, ref, comment, file.GetSHA()) + return r.gh.DeleteFile(ctx, r.owner, r.repo, path, ref, comment, file.GetSHA()) } for _, c := range contents { + p := c.GetPath() if c.IsDirectory() { - if err := r.deleteRecursively(ctx, c.GetPath(), ref, comment); err != nil { - return fmt.Errorf("delete file recursive: %w", err) + if err := r.deleteRecursively(ctx, p, ref, comment); err != nil { + return fmt.Errorf("delete directory recursively: %w", err) } continue } - if err := r.gh.DeleteFile(ctx, r.owner, r.repo, c.GetPath(), ref, comment, c.GetSHA()); err != nil { + if err := r.gh.DeleteFile(ctx, r.owner, r.repo, p, ref, comment, c.GetSHA()); err != nil { return fmt.Errorf("delete file: %w", err) } } @@ -411,12 +405,7 @@ func (r *githubRepository) History(ctx context.Context, path, ref string) ([]pro commits, err := r.gh.Commits(ctx, r.owner, r.repo, finalPath, ref) if err != nil { if errors.Is(err, pgh.ErrResourceNotFound) { - return nil, &apierrors.StatusError{ - ErrStatus: metav1.Status{ - Message: "path not found", - Code: http.StatusNotFound, - }, - } + return nil, ErrFileNotFound } return nil, fmt.Errorf("get commits: %w", err) @@ -553,12 +542,12 @@ func (r *githubRepository) parseWebhook(messageType string, payload []byte) (*pr Code: http.StatusOK, Message: "ping received", }, nil + default: + return &provisioning.WebhookResponse{ + Code: http.StatusNotImplemented, + Message: fmt.Sprintf("unsupported messageType: %s", messageType), + }, nil } - - return &provisioning.WebhookResponse{ - Code: http.StatusNotImplemented, - Message: fmt.Sprintf("unsupported messageType: %s", messageType), - }, nil } func (r *githubRepository) parsePushEvent(event *github.PushEvent) (*provisioning.WebhookResponse, error) { @@ -598,7 +587,7 @@ func (r *githubRepository) parsePullRequestEvent(event *github.PullRequestEvent) } cfg := r.config.Spec.GitHub if cfg == nil { - return nil, fmt.Errorf("missing github config") + return nil, fmt.Errorf("missing GitHub config") } if event.GetRepo().GetFullName() != fmt.Sprintf("%s/%s", r.owner, r.repo) { diff --git a/pkg/registry/apis/provisioning/repository/github_test.go b/pkg/registry/apis/provisioning/repository/github_test.go index f6939510f87..69aa4a8b6c1 100644 --- a/pkg/registry/apis/provisioning/repository/github_test.go +++ b/pkg/registry/apis/provisioning/repository/github_test.go @@ -1,20 +1,29 @@ package repository import ( - context "context" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" "fmt" "net/http" "os" "path" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" - mock "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + field "k8s.io/apimachinery/pkg/util/validation/field" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets" ) func TestIsValidGitBranchName(t *testing.T) { @@ -56,6 +65,488 @@ func TestIsValidGitBranchName(t *testing.T) { }) } } +func TestGitHubRepositoryValidate(t *testing.T) { + tests := []struct { + name string + config *provisioning.Repository + expectedErrors int + errorFields []string + }{ + { + name: "Valid configuration", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/grafana", + Branch: "main", + Token: "valid-token", + Path: "dashboards", + }, + }, + }, + expectedErrors: 0, + }, + { + name: "Valid configuration with .git suffix", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/grafana.git", + Branch: "main", + Token: "valid-token", + Path: "dashboards", + }, + }, + }, + expectedErrors: 0, + }, + { + name: "Missing GitHub config", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: nil, + }, + }, + expectedErrors: 1, + errorFields: []string{"spec.github"}, + }, + { + name: "Missing URL", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "", + Branch: "main", + Token: "valid-token", + Path: "dashboards", + }, + }, + }, + expectedErrors: 1, + errorFields: []string{"spec.github.url"}, + }, + { + name: "Invalid URL format", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "invalid-url", + Branch: "main", + Token: "valid-token", + Path: "dashboards", + }, + }, + }, + expectedErrors: 1, + errorFields: []string{"spec.github.url"}, + }, + { + name: "Fail to parse URL", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/user%", + Branch: "main", + Token: "valid-token", + Path: "dashboards", + }, + }, + }, + expectedErrors: 1, + errorFields: []string{"spec.github.url"}, + }, + { + name: "URL not starting with https://github.com/", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://gitlab.com/grafana/grafana", + Branch: "main", + Token: "valid-token", + Path: "dashboards", + }, + }, + }, + expectedErrors: 1, + errorFields: []string{"spec.github.url"}, + }, + { + name: "Missing repo name", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana", + Branch: "main", + Token: "valid-token", + Path: "dashboards", + }, + }, + }, + expectedErrors: 1, + errorFields: []string{"spec.github.url"}, + }, + { + name: "Missing branch", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/grafana", + Branch: "", + Token: "valid-token", + Path: "dashboards", + }, + }, + }, + expectedErrors: 1, + errorFields: []string{"spec.github.branch"}, + }, + { + name: "Invalid branch name", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/grafana", + Branch: "feature//invalid", + Token: "valid-token", + Path: "dashboards", + }, + }, + }, + expectedErrors: 1, + errorFields: []string{"spec.github.branch"}, + }, + { + name: "Missing token", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/grafana", + Branch: "main", + Token: "", + Path: "dashboards", + }, + }, + }, + expectedErrors: 1, + errorFields: []string{"spec.github.token"}, + }, + { + name: "Unsafe path", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/grafana", + Branch: "main", + Token: "valid-token", + Path: "../dashboards", + }, + }, + }, + expectedErrors: 1, + errorFields: []string{"spec.github.prefix"}, + }, + { + name: "Absolute path", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/grafana", + Branch: "main", + Token: "valid-token", + Path: "/dashboards", + }, + }, + }, + expectedErrors: 1, + errorFields: []string{"spec.github.prefix"}, + }, + { + name: "Multiple errors", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "", + Branch: "", + Token: "", + Path: "/dashboards", + }, + }, + }, + expectedErrors: 4, + errorFields: []string{"spec.github.url", "spec.github.branch", "spec.github.token", "spec.github.prefix"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a GitHub repository with the test config + repo := &githubRepository{ + config: tt.config, + } + + // Validate the configuration + errors := repo.Validate() + + // Check the number of errors + assert.Equal(t, tt.expectedErrors, len(errors), "Expected %d errors, got %d, errors: %v", tt.expectedErrors, len(errors), errors) + + // If we expect errors, check that they are for the right fields + if tt.expectedErrors > 0 { + errorFields := make([]string, 0, len(errors)) + for _, err := range errors { + errorFields = append(errorFields, err.Field) + } + for _, expectedField := range tt.errorFields { + assert.Contains(t, errorFields, expectedField, "Expected error for field %s", expectedField) + } + } + }) + } +} + +func TestGitHubRepository_Test(t *testing.T) { + tests := []struct { + name string + config *provisioning.Repository + mockSetup func(t *testing.T, client *pgh.MockClient) + expectedResult *provisioning.TestResults + expectedError error + }{ + { + name: "Authentication failure", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/grafana", + Branch: "main", + Token: "invalid-token", + }, + }, + }, + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("IsAuthenticated", mock.Anything).Return(errors.New("authentication failed")) + }, + expectedResult: &provisioning.TestResults{ + Code: http.StatusBadRequest, + Success: false, + Errors: []provisioning.ErrorDetails{{ + Type: metav1.CauseTypeFieldValueInvalid, + Field: "spec.github.token", + Detail: "authentication failed", + }}, + }, + expectedError: nil, + }, + { + name: "Invalid URL", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/invalid", + Branch: "main", + Token: "valid-token", + }, + }, + }, + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("IsAuthenticated", mock.Anything).Return(nil) + }, + expectedResult: &provisioning.TestResults{ + Code: http.StatusBadRequest, + Success: false, + Errors: []provisioning.ErrorDetails{{ + Type: metav1.CauseTypeFieldValueInvalid, + Field: "spec.github.url", + Detail: "unable to parse repo+owner from url", + }}, + }, + expectedError: nil, + }, + { + name: "Failed to check if repo exists", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/nonexistent", + Branch: "main", + Token: "valid-token", + }, + }, + }, + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("IsAuthenticated", mock.Anything).Return(nil) + client.On("RepoExists", mock.Anything, "grafana", "nonexistent").Return(false, errors.New("failed to check if repo exists")) + }, + expectedResult: &provisioning.TestResults{ + Code: http.StatusBadRequest, + Success: false, + Errors: []provisioning.ErrorDetails{{ + Type: metav1.CauseType(field.ErrorTypeInvalid), + Field: "spec.github.url", + Detail: "failed to check if repo exists", + }}, + }, + expectedError: nil, + }, + { + name: "Repository does not exist", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/nonexistent", + Branch: "main", + Token: "valid-token", + }, + }, + }, + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("IsAuthenticated", mock.Anything).Return(nil) + client.On("RepoExists", mock.Anything, "grafana", "nonexistent").Return(false, nil) + }, + expectedResult: &provisioning.TestResults{ + Code: http.StatusBadRequest, + Success: false, + Errors: []provisioning.ErrorDetails{{ + Type: metav1.CauseType(field.ErrorTypeNotFound), + Field: "spec.github.url", + }}, + }, + expectedError: nil, + }, + { + name: "Branch does not exist", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/grafana", + Branch: "nonexistent-branch", + Token: "valid-token", + }, + }, + }, + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("IsAuthenticated", mock.Anything).Return(nil) + client.On("RepoExists", mock.Anything, "grafana", "grafana").Return(true, nil) + client.On("BranchExists", mock.Anything, "grafana", "grafana", "nonexistent-branch").Return(false, nil) + }, + expectedResult: &provisioning.TestResults{ + Code: http.StatusBadRequest, + Success: false, + Errors: []provisioning.ErrorDetails{{ + Type: metav1.CauseType(field.ErrorTypeNotFound), + Field: "spec.github.branch", + }}, + }, + expectedError: nil, + }, + { + name: "Branch check error", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/grafana", + Branch: "main", + Token: "valid-token", + }, + }, + }, + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("IsAuthenticated", mock.Anything).Return(nil) + client.On("RepoExists", mock.Anything, "grafana", "grafana").Return(true, nil) + client.On("BranchExists", mock.Anything, "grafana", "grafana", "main").Return(false, errors.New("API rate limit exceeded")) + }, + expectedResult: &provisioning.TestResults{ + Code: http.StatusBadRequest, + Success: false, + Errors: []provisioning.ErrorDetails{{ + Type: metav1.CauseType(field.ErrorTypeInvalid), + Field: "spec.github.branch", + Detail: "API rate limit exceeded", + }}, + }, + expectedError: nil, + }, + { + name: "Successful test", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/grafana", + Branch: "main", + Token: "valid-token", + }, + }, + }, + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("IsAuthenticated", mock.Anything).Return(nil) + client.On("RepoExists", mock.Anything, "grafana", "grafana").Return(true, nil) + client.On("BranchExists", mock.Anything, "grafana", "grafana", "main").Return(true, nil) + }, + expectedResult: &provisioning.TestResults{ + Code: http.StatusOK, + Success: true, + }, + expectedError: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock GitHub client + mockClient := pgh.NewMockClient(t) + + // Set up the mock expectations + if tt.mockSetup != nil { + tt.mockSetup(t, mockClient) + } + + // Create a GitHub repository with the test config and mock client + repo := &githubRepository{ + config: tt.config, + gh: mockClient, + owner: "grafana", + repo: "grafana", + } + + // If the config has a different URL, parse and set the owner/repo + if tt.config.Spec.GitHub.URL != "https://github.com/grafana/grafana" { + owner, githubRepo, _ := parseOwnerRepo(tt.config.Spec.GitHub.URL) + repo.owner = owner + repo.repo = githubRepo + } + + // Test the repository + result, err := repo.Test(context.Background()) + + // Check the error + if tt.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tt.expectedError.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + + // Check the result + if tt.expectedResult != nil { + assert.Equal(t, tt.expectedResult.Code, result.Code) + assert.Equal(t, tt.expectedResult.Success, result.Success) + + if len(tt.expectedResult.Errors) > 0 { + assert.Equal(t, len(tt.expectedResult.Errors), len(result.Errors)) + + for i, expectedError := range tt.expectedResult.Errors { + assert.Equal(t, expectedError.Type, result.Errors[i].Type) + assert.Equal(t, expectedError.Field, result.Errors[i].Field) + assert.Equal(t, expectedError.Detail, result.Errors[i].Detail) + } + } + } + + // Verify all expectations were met + mockClient.AssertExpectations(t) + }) + } +} func TestParseWebhooks(t *testing.T) { tests := []struct { @@ -109,7 +600,7 @@ func TestParseWebhooks(t *testing.T) { gh := &githubRepository{ config: &provisioning.Repository{ - ObjectMeta: v1.ObjectMeta{ + ObjectMeta: metav1.ObjectMeta{ Name: "unit-test-repo", }, Spec: provisioning.RepositorySpec{ @@ -153,13 +644,27 @@ func TestParseWebhooks(t *testing.T) { func TestReadTree(t *testing.T) { tests := []struct { - name string - path string - tree []pgh.RepositoryContent - expected []FileTreeEntry + name string + path string + ref string + expectedRef string + tree []pgh.RepositoryContent + expected []FileTreeEntry + getTreeErr error + truncated bool + expectedError error }{ - {name: "empty tree", tree: []pgh.RepositoryContent{}, expected: []FileTreeEntry{}}, - {name: "single file", tree: func() []pgh.RepositoryContent { + {name: "empty ref", ref: "", expectedRef: "develop", tree: []pgh.RepositoryContent{}, expected: []FileTreeEntry{}}, + {name: "unknown error to get tree", ref: "develop", expectedRef: "develop", tree: []pgh.RepositoryContent{}, getTreeErr: errors.New("unknown error"), expectedError: errors.New("get tree: unknown error")}, + {name: "tree not found error", ref: "develop", expectedRef: "develop", tree: []pgh.RepositoryContent{}, getTreeErr: pgh.ErrResourceNotFound, expectedError: &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Message: "tree not found; ref=develop", + Code: http.StatusNotFound, + }, + }}, + {name: "tree truncated", ref: "develop", expectedRef: "develop", tree: []pgh.RepositoryContent{}, truncated: true, expectedError: errors.New("tree truncated")}, + {name: "empty tree", ref: "develop", expectedRef: "develop", tree: []pgh.RepositoryContent{}, expected: []FileTreeEntry{}}, + {name: "single file", ref: "develop", expectedRef: "develop", tree: func() []pgh.RepositoryContent { content := pgh.NewMockRepositoryContent(t) content.EXPECT().GetPath().Return("file.txt") content.EXPECT().GetSize().Return(int64(100)) @@ -169,7 +674,7 @@ func TestReadTree(t *testing.T) { }(), expected: []FileTreeEntry{ {Path: "file.txt", Size: 100, Hash: "abc123", Blob: true}, }}, - {name: "single directory", tree: func() []pgh.RepositoryContent { + {name: "single directory", ref: "develop", expectedRef: "develop", tree: func() []pgh.RepositoryContent { content := pgh.NewMockRepositoryContent(t) content.EXPECT().GetPath().Return("dir") content.EXPECT().IsDirectory().Return(true) @@ -180,7 +685,7 @@ func TestReadTree(t *testing.T) { }(), expected: []FileTreeEntry{ {Path: "dir/", Blob: false}, }}, - {name: "mixed content", tree: func() []pgh.RepositoryContent { + {name: "mixed content", ref: "develop", expectedRef: "develop", tree: func() []pgh.RepositoryContent { file1 := pgh.NewMockRepositoryContent(t) file1.EXPECT().GetPath().Return("file1.txt") file1.EXPECT().GetSize().Return(int64(100)) @@ -205,7 +710,7 @@ func TestReadTree(t *testing.T) { {Path: "dir/", Blob: false}, {Path: "file2.txt", Size: 200, Hash: "def456", Blob: true}, }}, - {name: "with path prefix", path: "prefix", tree: func() []pgh.RepositoryContent { + {name: "with path prefix", ref: "develop", expectedRef: "develop", tree: func() []pgh.RepositoryContent { file := pgh.NewMockRepositoryContent(t) file.EXPECT().GetPath().Return("file.txt") file.EXPECT().GetSize().Return(int64(100)) @@ -234,17 +739,2528 @@ func TestReadTree(t *testing.T) { config: &provisioning.Repository{ Spec: provisioning.RepositorySpec{ GitHub: &provisioning.GitHubRepositoryConfig{ - Path: tt.path, + Path: tt.path, + Branch: "develop", }, }, }, gh: ghMock, } - ghMock.On("GetTree", mock.Anything, "owner", "repo", tt.path, "some-ref", true).Return(tt.tree, false, nil) - tree, err := gh.ReadTree(context.Background(), "some-ref") - require.NoError(t, err) - require.Equal(t, tt.expected, tree) + ghMock.On("GetTree", mock.Anything, "owner", "repo", tt.path, tt.expectedRef, true).Return(tt.tree, tt.truncated, tt.getTreeErr) + tree, err := gh.ReadTree(context.Background(), tt.ref) + if tt.expectedError != nil { + require.Error(t, err) + require.Equal(t, tt.expectedError.Error(), err.Error()) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, tree) + } + }) + } +} + +func TestGitHubRepository_Read(t *testing.T) { + tests := []struct { + name string + config *provisioning.Repository + filePath string + ref string + mockSetup func(t *testing.T, client *pgh.MockClient) + expectedResult *FileInfo + expectedError error + }{ + { + name: "File found successfully", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "configs", + Branch: "main", + }, + }, + }, + filePath: "dashboard.json", + ref: "main", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + fileContent := pgh.NewMockRepositoryContent(t) + fileContent.EXPECT().GetFileContent().Return("file content", nil) + fileContent.EXPECT().GetSHA().Return("abc123") + client.On("GetContents", mock.Anything, "grafana", "grafana", "configs/dashboard.json", "main"). + Return(fileContent, nil, nil) + }, + expectedResult: &FileInfo{ + Path: "dashboard.json", + Ref: "main", + Data: []byte("file content"), + Hash: "abc123", + }, + expectedError: nil, + }, + { + name: "Directory found successfully", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "configs", + Branch: "main", + }, + }, + }, + filePath: "dashboards", + ref: "main", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + dirContent := []pgh.RepositoryContent{ + // Directory contents not used in this test + } + client.On("GetContents", mock.Anything, "grafana", "grafana", "configs/dashboards", "main"). + Return(nil, dirContent, nil) + }, + expectedResult: &FileInfo{ + Path: "dashboards", + Ref: "main", + }, + expectedError: nil, + }, + { + name: "File not found", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "configs", + Branch: "main", + }, + }, + }, + filePath: "nonexistent.json", + ref: "main", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("GetContents", mock.Anything, "grafana", "grafana", "configs/nonexistent.json", "main"). + Return(nil, nil, pgh.ErrResourceNotFound) + }, + expectedResult: nil, + expectedError: ErrFileNotFound, + }, + { + name: "Error getting file content", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "configs", + Branch: "main", + }, + }, + }, + filePath: "dashboard.json", + ref: "main", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + fileContent := pgh.NewMockRepositoryContent(t) + fileContent.EXPECT().GetFileContent().Return("", errors.New("failed to decode content")) + client.On("GetContents", mock.Anything, "grafana", "grafana", "configs/dashboard.json", "main"). + Return(fileContent, nil, nil) + }, + expectedResult: nil, + expectedError: fmt.Errorf("get content: %w", errors.New("failed to decode content")), + }, + { + name: "GitHub API error", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "configs", + Branch: "main", + }, + }, + }, + filePath: "dashboard.json", + ref: "main", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("GetContents", mock.Anything, "grafana", "grafana", "configs/dashboard.json", "main"). + Return(nil, nil, errors.New("API rate limit exceeded")) + }, + expectedResult: nil, + expectedError: fmt.Errorf("get contents: %w", errors.New("API rate limit exceeded")), + }, + { + name: "Use default branch when ref is empty", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "configs", + Branch: "develop", + }, + }, + }, + filePath: "dashboard.json", + ref: "", // Empty ref should use default branch + mockSetup: func(t *testing.T, client *pgh.MockClient) { + fileContent := pgh.NewMockRepositoryContent(t) + fileContent.EXPECT().GetFileContent().Return("file content", nil) + fileContent.EXPECT().GetSHA().Return("abc123") + client.On("GetContents", mock.Anything, "grafana", "grafana", "configs/dashboard.json", "develop"). + Return(fileContent, nil, nil) + }, + expectedResult: &FileInfo{ + Path: "dashboard.json", + Ref: "develop", + Data: []byte("file content"), + Hash: "abc123", + }, + expectedError: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock GitHub client + mockClient := pgh.NewMockClient(t) + + // Set up the mock expectations + if tt.mockSetup != nil { + tt.mockSetup(t, mockClient) + } + + // Create a GitHub repository with the test config and mock client + repo := &githubRepository{ + config: tt.config, + gh: mockClient, + owner: "grafana", + repo: "grafana", + } + + // Call the Read method + result, err := repo.Read(context.Background(), tt.filePath, tt.ref) + + // Check the error + if tt.expectedError != nil { + require.Error(t, err) + var statusErr *apierrors.StatusError + if errors.As(tt.expectedError, &statusErr) { + var actualStatusErr *apierrors.StatusError + require.True(t, errors.As(err, &actualStatusErr), "Expected StatusError but got different error type") + require.Equal(t, statusErr.Status().Code, actualStatusErr.Status().Code) + require.Equal(t, statusErr.Status().Message, actualStatusErr.Status().Message) + } else { + require.Equal(t, tt.expectedError.Error(), err.Error()) + } + } else { + require.NoError(t, err) + } + + // Check the result + if tt.expectedResult != nil { + require.Equal(t, tt.expectedResult.Path, result.Path) + require.Equal(t, tt.expectedResult.Ref, result.Ref) + require.Equal(t, tt.expectedResult.Data, result.Data) + require.Equal(t, tt.expectedResult.Hash, result.Hash) + } else { + require.Nil(t, result) + } + + // Verify all mock expectations were met + mockClient.AssertExpectations(t) + }) + } +} + +func TestGitHubRepository_Create(t *testing.T) { + tests := []struct { + name string + config *provisioning.Repository + path string + ref string + data []byte + comment string + mockSetup func(t *testing.T, mockClient *pgh.MockClient) + expectedError error + }{ + { + name: "successful file creation", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "feature-branch", + data: []byte("dashboard content"), + comment: "Add new dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "feature-branch").Return(true, nil) + mockClient.EXPECT().CreateFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "feature-branch", "Add new dashboard", []byte("dashboard content")).Return(nil) + }, + expectedError: nil, + }, + { + name: "create with default branch", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "", + data: []byte("dashboard content"), + comment: "Add new dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + mockClient.EXPECT().CreateFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main", "Add new dashboard", []byte("dashboard content")).Return(nil) + }, + expectedError: nil, + }, + { + name: "branch already exists error", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "feature-branch", + data: []byte("dashboard content"), + comment: "Add new dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "feature-branch").Return(false, nil) + mockClient.EXPECT().CreateBranch(mock.Anything, "grafana", "grafana", "main", "feature-branch").Return(pgh.ErrResourceAlreadyExists) + }, + expectedError: &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusConflict, + Message: "branch already exists", + }, + }, + }, + { + name: "branch does not exist error", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "feature-branch", + data: []byte("dashboard content"), + comment: "Add new dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "feature-branch").Return(false, nil) + mockClient.EXPECT().CreateBranch(mock.Anything, "grafana", "grafana", "main", "feature-branch").Return(fmt.Errorf("failed to create branch")) + }, + expectedError: fmt.Errorf("create branch: %w", fmt.Errorf("failed to create branch")), + }, + { + name: "branch does not exist but it's created", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "feature-branch", + data: []byte("dashboard content"), + comment: "Add new dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "feature-branch").Return(false, nil) + mockClient.EXPECT().CreateBranch(mock.Anything, "grafana", "grafana", "main", "feature-branch").Return(nil) + mockClient.EXPECT().CreateFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "feature-branch", "Add new dashboard", []byte("dashboard content")).Return(nil) + }, + expectedError: nil, + }, + { + name: "invalid branch name", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "feature//branch", + data: []byte("dashboard content"), + comment: "Add new dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + // No mock expectations needed as validation should fail before any GitHub API calls + }, + expectedError: &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusBadRequest, + Message: "invalid branch name", + }, + }, + }, + { + name: "branch exists check fails", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "feature-branch", + data: []byte("dashboard content"), + comment: "Add new dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "feature-branch").Return(false, fmt.Errorf("failed to check branch")) + }, + expectedError: fmt.Errorf("check branch exists: %w", fmt.Errorf("failed to check branch")), + }, + { + name: "file already exists", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "main", + data: []byte("dashboard content"), + comment: "Add new dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + mockClient.EXPECT().CreateFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main", "Add new dashboard", []byte("dashboard content")).Return(pgh.ErrResourceAlreadyExists) + }, + expectedError: &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Message: "file already exists", + Code: http.StatusConflict, + }, + }, + }, + { + name: "create directory with .keep file", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboards/", + ref: "main", + data: nil, + comment: "Add dashboards directory", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + mockClient.EXPECT().CreateFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/.keep", "main", "Add dashboards directory", []byte{}).Return(nil) + }, + expectedError: nil, + }, + { + name: "error when providing data for directory", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboards/", + ref: "main", + data: []byte("some data"), + comment: "Add dashboards directory", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + }, + expectedError: apierrors.NewBadRequest("data cannot be provided for a directory"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock GitHub client + mockClient := pgh.NewMockClient(t) + + // Set up the mock expectations + if tt.mockSetup != nil { + tt.mockSetup(t, mockClient) + } + + // Create a GitHub repository with the test config and mock client + repo := &githubRepository{ + config: tt.config, + gh: mockClient, + owner: "grafana", + repo: "grafana", + } + + // Call the Create method + err := repo.Create(context.Background(), tt.path, tt.ref, tt.data, tt.comment) + + // Check the error + if tt.expectedError != nil { + require.Error(t, err) + var statusErr *apierrors.StatusError + if errors.As(tt.expectedError, &statusErr) { + var actualStatusErr *apierrors.StatusError + require.True(t, errors.As(err, &actualStatusErr), "Expected StatusError but got different error type") + require.Equal(t, statusErr.Status().Code, actualStatusErr.Status().Code) + require.Equal(t, statusErr.Status().Message, actualStatusErr.Status().Message) + } else { + require.Equal(t, tt.expectedError.Error(), err.Error()) + } + } else { + require.NoError(t, err) + } + + // Verify all mock expectations were met + mockClient.AssertExpectations(t) + }) + } +} + +func TestGitHubRepository_Update(t *testing.T) { + tests := []struct { + name string + config *provisioning.Repository + path string + ref string + data []byte + comment string + mockSetup func(t *testing.T, client *pgh.MockClient) + expectedError error + }{ + { + name: "Successfully update file", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + Path: "base/path", + }, + }, + }, + path: "test/file.txt", + ref: "feature-branch", + data: []byte("updated content"), + comment: "Update test file", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + fileContent := pgh.NewMockRepositoryContent(t) + fileContent.EXPECT().GetSHA().Return("abc123") + fileContent.EXPECT().IsDirectory().Return(false) + client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(true, nil) + client.On("GetContents", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch"). + Return(fileContent, nil, nil) + client.On("UpdateFile", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch", + "Update test file", "abc123", []byte("updated content")).Return(nil) + }, + expectedError: nil, + }, + { + name: "Use default branch when ref is empty", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + Path: "base/path", + }, + }, + }, + path: "test/file.txt", + ref: "", + data: []byte("updated content"), + comment: "Update test file", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + fileContent := pgh.NewMockRepositoryContent(t) + fileContent.EXPECT().GetSHA().Return("abc123") + fileContent.EXPECT().IsDirectory().Return(false) + client.On("BranchExists", mock.Anything, "grafana", "grafana", "main").Return(true, nil) + client.On("GetContents", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "main"). + Return(fileContent, nil, nil) + client.On("UpdateFile", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "main", + "Update test file", "abc123", []byte("updated content")).Return(nil) + }, + expectedError: nil, + }, + { + name: "Branch does not exist", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + Path: "base/path", + }, + }, + }, + path: "test/file.txt", + ref: "feature-branch", + data: []byte("updated content"), + comment: "Update test file", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(false, nil) + client.On("CreateBranch", mock.Anything, "grafana", "grafana", "main", "feature-branch").Return(errors.New("failed to create branch")) + }, + expectedError: errors.New("create branch: failed to create branch"), + }, + { + name: "Invalid branch name", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + Path: "base/path", + }, + }, + }, + path: "test/file.txt", + ref: "invalid//branch", + data: []byte("updated content"), + comment: "Update test file", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + // No mock calls expected + }, + expectedError: &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusBadRequest, + Message: "invalid branch name", + }, + }, + }, + { + name: "Branch exists check fails", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + Path: "base/path", + }, + }, + }, + path: "test/file.txt", + ref: "feature-branch", + data: []byte("updated content"), + comment: "Update test file", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(false, errors.New("failed to check branch")) + }, + expectedError: errors.New("check branch exists: failed to check branch"), + }, + { + name: "Branch does not exist but it's created successfully", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + Path: "base/path", + }, + }, + }, + path: "test/file.txt", + ref: "feature-branch", + data: []byte("updated content"), + comment: "Update test file", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(false, nil) + client.On("CreateBranch", mock.Anything, "grafana", "grafana", "main", "feature-branch").Return(nil) + fileContent := pgh.NewMockRepositoryContent(t) + fileContent.EXPECT().GetSHA().Return("abc123") + fileContent.EXPECT().IsDirectory().Return(false) + client.On("GetContents", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch"). + Return(fileContent, nil, nil) + client.On("UpdateFile", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch", + "Update test file", "abc123", []byte("updated content")).Return(nil) + }, + expectedError: nil, + }, + { + name: "Branch already exists error", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + Path: "base/path", + }, + }, + }, + path: "test/file.txt", + ref: "feature-branch", + data: []byte("updated content"), + comment: "Update test file", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(false, nil) + client.On("CreateBranch", mock.Anything, "grafana", "grafana", "main", "feature-branch").Return(pgh.ErrResourceAlreadyExists) + }, + expectedError: &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusConflict, + Message: "branch already exists", + }, + }, + }, + { + name: "File not found", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + Path: "base/path", + }, + }, + }, + path: "test/file.txt", + ref: "feature-branch", + data: []byte("updated content"), + comment: "Update test file", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(true, nil) + client.On("GetContents", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch"). + Return(nil, nil, pgh.ErrResourceNotFound) + }, + expectedError: &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Message: "file not found", + Code: http.StatusNotFound, + }, + }, + }, + { + name: "Error getting file contents", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + Path: "base/path", + }, + }, + }, + path: "test/file.txt", + ref: "feature-branch", + data: []byte("updated content"), + comment: "Update test file", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(true, nil) + client.On("GetContents", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch"). + Return(nil, nil, errors.New("API error")) + }, + expectedError: errors.New("get content before file update: API error"), + }, + { + name: "Cannot update directory", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + Path: "base/path", + }, + }, + }, + path: "test/directory", + ref: "feature-branch", + data: []byte("updated content"), + comment: "Update test directory", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(true, nil) + + // Create a directory file + dirFile := pgh.NewMockRepositoryContent(t) + dirFile.EXPECT().IsDirectory().Return(true) + + client.On("GetContents", mock.Anything, "grafana", "grafana", "base/path/test/directory", "feature-branch"). + Return(dirFile, nil, nil) + }, + expectedError: apierrors.NewBadRequest("cannot update a directory"), + }, + { + name: "Error updating file", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + Path: "base/path", + }, + }, + }, + path: "test/file.txt", + ref: "feature-branch", + data: []byte("updated content"), + comment: "Update test file", + mockSetup: func(t *testing.T, client *pgh.MockClient) { + fileContent := pgh.NewMockRepositoryContent(t) + fileContent.EXPECT().GetSHA().Return("abc123") + fileContent.EXPECT().IsDirectory().Return(false) + client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(true, nil) + client.On("GetContents", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch"). + Return(fileContent, nil, nil) + client.On("UpdateFile", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch", + "Update test file", "abc123", []byte("updated content")).Return(errors.New("update failed")) + }, + expectedError: errors.New("update file: update failed"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock GitHub client + mockClient := pgh.NewMockClient(t) + + // Set up the mock expectations + if tt.mockSetup != nil { + tt.mockSetup(t, mockClient) + } + + // Create a GitHub repository with the test config and mock client + repo := &githubRepository{ + config: tt.config, + gh: mockClient, + owner: "grafana", + repo: "grafana", + } + + // Call the Update method + err := repo.Update(context.Background(), tt.path, tt.ref, tt.data, tt.comment) + + // Check the error + if tt.expectedError != nil { + require.Error(t, err) + var statusErr *apierrors.StatusError + if errors.As(tt.expectedError, &statusErr) { + var actualStatusErr *apierrors.StatusError + require.True(t, errors.As(err, &actualStatusErr), "Expected StatusError but got different error type") + require.Equal(t, statusErr.Status().Code, actualStatusErr.Status().Code) + require.Equal(t, statusErr.Status().Message, actualStatusErr.Status().Message) + } else { + require.Equal(t, tt.expectedError.Error(), err.Error()) + } + } else { + require.NoError(t, err) + } + + // Verify all mock expectations were met + mockClient.AssertExpectations(t) + }) + } +} + +func TestGitHubRepository_Write(t *testing.T) { + tests := []struct { + name string + config *provisioning.Repository + path string + ref string + data []byte + message string + mockSetup func(t *testing.T, mockClient *pgh.MockClient) + expectedError error + }{ + { + name: "write to existing file (update)", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "main", + data: []byte("updated content"), + message: "Update dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + fileContent := pgh.NewMockRepositoryContent(t) + fileContent.EXPECT().GetFileContent().Return("existing content", nil) + fileContent.EXPECT().GetSHA().Return("abc123") + fileContent.EXPECT().IsDirectory().Return(false) + + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main"). + Return(fileContent, nil, nil) + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + mockClient.EXPECT().UpdateFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main", + "Update dashboard", "abc123", []byte("updated content")).Return(nil) + }, + expectedError: nil, + }, + { + name: "write to non-existing file (create)", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "new-dashboard.json", + ref: "main", + data: []byte("new content"), + message: "Create new dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/new-dashboard.json", "main"). + Return(nil, nil, pgh.ErrResourceNotFound) + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + mockClient.EXPECT().CreateFile(mock.Anything, "grafana", "grafana", "grafana/new-dashboard.json", "main", + "Create new dashboard", []byte("new content")).Return(nil) + }, + expectedError: nil, + }, + { + name: "write with default branch", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "", + data: []byte("content"), + message: "Update dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + fileContent := pgh.NewMockRepositoryContent(t) + fileContent.EXPECT().GetFileContent().Return("existing content", nil) + fileContent.EXPECT().GetSHA().Return("abc123") + fileContent.EXPECT().IsDirectory().Return(false) + + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main"). + Return(fileContent, nil, nil) + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + mockClient.EXPECT().UpdateFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main", + "Update dashboard", "abc123", []byte("content")).Return(nil) + }, + expectedError: nil, + }, + { + name: "error checking if file exists", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "main", + data: []byte("content"), + message: "Update dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main"). + Return(nil, nil, errors.New("connection error")) + }, + expectedError: errors.New("check if file exists before writing: get contents: connection error"), + }, + { + name: "error during update", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "main", + data: []byte("updated content"), + message: "Update dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + fileContent := pgh.NewMockRepositoryContent(t) + fileContent.EXPECT().GetFileContent().Return("existing content", nil) + fileContent.EXPECT().GetSHA().Return("abc123") + fileContent.EXPECT().IsDirectory().Return(false) + + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main"). + Return(fileContent, nil, nil) + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + mockClient.EXPECT().UpdateFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main", + "Update dashboard", "abc123", []byte("updated content")).Return(errors.New("update failed")) + }, + expectedError: errors.New("update file: update failed"), + }, + { + name: "error during create", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "new-dashboard.json", + ref: "main", + data: []byte("new content"), + message: "Create new dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/new-dashboard.json", "main"). + Return(nil, nil, pgh.ErrResourceNotFound) + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + mockClient.EXPECT().CreateFile(mock.Anything, "grafana", "grafana", "grafana/new-dashboard.json", "main", + "Create new dashboard", []byte("new content")).Return(errors.New("create failed")) + }, + expectedError: errors.New("create failed"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock GitHub client + mockClient := pgh.NewMockClient(t) + + // Set up the mock expectations + if tt.mockSetup != nil { + tt.mockSetup(t, mockClient) + } + + // Create a GitHub repository with the test config and mock client + repo := &githubRepository{ + config: tt.config, + gh: mockClient, + owner: "grafana", + repo: "grafana", + } + + // Call the Write method + err := repo.Write(context.Background(), tt.path, tt.ref, tt.data, tt.message) + + // Check the error + if tt.expectedError != nil { + require.Error(t, err) + require.Equal(t, tt.expectedError.Error(), err.Error()) + } else { + require.NoError(t, err) + } + + // Verify all mock expectations were met + mockClient.AssertExpectations(t) + }) + } +} + +func TestGitHubRepository_Delete(t *testing.T) { + tests := []struct { + name string + config *provisioning.Repository + path string + ref string + comment string + mockSetup func(t *testing.T, mockClient *pgh.MockClient) + expectedError error + }{ + { + name: "delete existing file", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "main", + comment: "Delete dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + fileContent := pgh.NewMockRepositoryContent(t) + fileContent.EXPECT().IsDirectory().Return(false) + fileContent.EXPECT().GetSHA().Return("abc123") + + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main"). + Return(fileContent, nil, nil) + mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main", + "Delete dashboard", "abc123").Return(nil) + }, + expectedError: nil, + }, + { + name: "delete with default branch", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "", + comment: "Delete dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + fileContent := pgh.NewMockRepositoryContent(t) + fileContent.EXPECT().IsDirectory().Return(false) + fileContent.EXPECT().GetSHA().Return("abc123") + + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main"). + Return(fileContent, nil, nil) + mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main", + "Delete dashboard", "abc123").Return(nil) + }, + expectedError: nil, + }, + { + name: "delete directory recursively", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboards", + ref: "main", + comment: "Delete dashboards directory", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + dirContent := pgh.NewMockRepositoryContent(t) + dirContent.EXPECT().IsDirectory().Return(true) + + // Directory contents + file1Content := pgh.NewMockRepositoryContent(t) + file1Content.EXPECT().GetPath().Return("grafana/dashboards/dashboard1.json") + file1Content.EXPECT().IsDirectory().Return(false) + file1Content.EXPECT().GetSHA().Return("file1-sha") + + file2Content := pgh.NewMockRepositoryContent(t) + file2Content.EXPECT().GetPath().Return("grafana/dashboards/dashboard2.json") + file2Content.EXPECT().IsDirectory().Return(false) + file2Content.EXPECT().GetSHA().Return("file2-sha") + + subDirContent := pgh.NewMockRepositoryContent(t) + subDirContent.EXPECT().GetPath().Return("grafana/dashboards/subfolder") + subDirContent.EXPECT().IsDirectory().Return(true) + + // Subfolder contents + subFile1Content := pgh.NewMockRepositoryContent(t) + subFile1Content.EXPECT().GetPath().Return("grafana/dashboards/subfolder/subdashboard.json") + subFile1Content.EXPECT().IsDirectory().Return(false) + subFile1Content.EXPECT().GetSHA().Return("subfile-sha") + + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + + // Get main directory + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboards", "main"). + Return(dirContent, []pgh.RepositoryContent{file1Content, file2Content, subDirContent}, nil) + + // Get subfolder contents + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboards/subfolder", "main"). + Return(subDirContent, []pgh.RepositoryContent{subFile1Content}, nil) + + // Delete files in reverse order (depth-first) + mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/subfolder/subdashboard.json", "main", + "Delete dashboards directory", "subfile-sha").Return(nil) + mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/dashboard2.json", "main", + "Delete dashboards directory", "file2-sha").Return(nil) + mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/dashboard1.json", "main", + "Delete dashboards directory", "file1-sha").Return(nil) + }, + expectedError: nil, + }, + { + name: "delete directory recursively fails in the middle", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboards", + ref: "main", + comment: "Delete dashboards directory", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + dirContent := pgh.NewMockRepositoryContent(t) + dirContent.EXPECT().IsDirectory().Return(true) + + // Directory contents + file1Content := pgh.NewMockRepositoryContent(t) + file1Content.EXPECT().GetPath().Return("grafana/dashboards/dashboard1.json") + file1Content.EXPECT().IsDirectory().Return(false) + file1Content.EXPECT().GetSHA().Return("file1-sha") + + file2Content := pgh.NewMockRepositoryContent(t) + file2Content.EXPECT().GetPath().Return("grafana/dashboards/dashboard2.json") + file2Content.EXPECT().IsDirectory().Return(false) + file2Content.EXPECT().GetSHA().Return("file2-sha") + + subDirContent := pgh.NewMockRepositoryContent(t) + subDirContent.EXPECT().IsDirectory().Return(true) + subDirContent.EXPECT().GetPath().Return("grafana/dashboards/subfolder") + + // Subfolder contents + subFile1Content := pgh.NewMockRepositoryContent(t) + subFile1Content.EXPECT().GetPath().Return("grafana/dashboards/subfolder/subdashboard.json") + subFile1Content.EXPECT().IsDirectory().Return(false) + subFile1Content.EXPECT().GetSHA().Return("subfile-sha") + + subFile2Content := pgh.NewMockRepositoryContent(t) + subFile2Content.EXPECT().GetPath().Return("grafana/dashboards/subfolder/subdashboard2.json") + subFile2Content.EXPECT().IsDirectory().Return(false) + subFile2Content.EXPECT().GetSHA().Return("subfile2-sha") + + subFile3Content := pgh.NewMockRepositoryContent(t) + + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + + // Get main directory + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboards", "main"). + Return(dirContent, []pgh.RepositoryContent{file1Content, file2Content, subDirContent}, nil) + + // Get subfolder contents + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboards/subfolder", "main"). + Return(subDirContent, []pgh.RepositoryContent{subFile1Content, subFile2Content, subFile3Content}, nil) + + // Delete first file successfully + mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/dashboard1.json", "main", + "Delete dashboards directory", "file1-sha").Return(nil) + + // Second file deletion fails + mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/dashboard2.json", "main", + "Delete dashboards directory", "file2-sha").Return(nil) + + // Delete subfolder files + mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/subfolder/subdashboard.json", "main", + "Delete dashboards directory", "subfile-sha").Return(nil) + + mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/subfolder/subdashboard2.json", "main", + "Delete dashboards directory", "subfile2-sha").Return(errors.New("permission denied")) + }, + expectedError: errors.New("delete directory recursively: delete file: permission denied"), + }, + { + name: "file not found", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "nonexistent.json", + ref: "main", + comment: "Delete nonexistent file", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/nonexistent.json", "main"). + Return(nil, nil, pgh.ErrResourceNotFound) + }, + expectedError: ErrFileNotFound, + }, + { + name: "branch does not exist and creation fails", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "feature", + comment: "Delete dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "feature").Return(false, nil) + mockClient.EXPECT().CreateBranch(mock.Anything, "grafana", "grafana", "main", "feature"). + Return(errors.New("failed to create branch")) + }, + expectedError: errors.New("create branch: failed to create branch"), + }, + { + name: "error checking if branch exists", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "main", + comment: "Delete dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main"). + Return(false, errors.New("API error")) + }, + expectedError: errors.New("check branch exists: API error"), + }, + { + name: "error getting file content", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "main", + comment: "Delete dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main"). + Return(nil, nil, errors.New("API rate limit exceeded")) + }, + expectedError: fmt.Errorf("find file to delete: %w", errors.New("API rate limit exceeded")), + }, + { + name: "error deleting file", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "main", + comment: "Delete dashboard", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + fileContent := pgh.NewMockRepositoryContent(t) + fileContent.EXPECT().IsDirectory().Return(false) + fileContent.EXPECT().GetSHA().Return("abc123") + + mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil) + mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main"). + Return(fileContent, nil, nil) + mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main", + "Delete dashboard", "abc123").Return(errors.New("delete failed")) + }, + expectedError: errors.New("delete failed"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock GitHub client + mockClient := pgh.NewMockClient(t) + + // Set up the mock expectations + if tt.mockSetup != nil { + tt.mockSetup(t, mockClient) + } + + // Create a GitHub repository with the test config and mock client + repo := &githubRepository{ + config: tt.config, + gh: mockClient, + owner: "grafana", + repo: "grafana", + } + + // Call the Delete method + err := repo.Delete(context.Background(), tt.path, tt.ref, tt.comment) + + // Check the error + if tt.expectedError != nil { + require.Error(t, err) + require.Equal(t, tt.expectedError.Error(), err.Error()) + } else { + require.NoError(t, err) + } + + // Verify all mock expectations were met + mockClient.AssertExpectations(t) + }) + } +} + +func TestGitHubRepository_History(t *testing.T) { + tests := []struct { + name string + config *provisioning.Repository + path string + ref string + mockSetup func(t *testing.T, mockClient *pgh.MockClient) + expected []provisioning.HistoryItem + expectedError error + }{ + { + name: "successful history retrieval", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "main", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + commits := []pgh.Commit{ + { + Ref: "abc123", + Message: "Update dashboard", + Author: &pgh.CommitAuthor{ + Name: "John Doe", + Username: "johndoe", + AvatarURL: "https://example.com/avatar1.png", + }, + Committer: &pgh.CommitAuthor{ + Name: "John Doe", + Username: "johndoe", + AvatarURL: "https://example.com/avatar1.png", + }, + CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + }, + { + Ref: "def456", + Message: "Initial commit", + Author: &pgh.CommitAuthor{ + Name: "Jane Smith", + Username: "janesmith", + AvatarURL: "https://example.com/avatar2.png", + }, + Committer: &pgh.CommitAuthor{ + Name: "Bob Johnson", + Username: "bjohnson", + AvatarURL: "https://example.com/avatar3.png", + }, + CreatedAt: time.Date(2022, 12, 31, 10, 0, 0, 0, time.UTC), + }, + } + + mockClient.EXPECT().Commits(mock.Anything, "grafana", "grafana", "dashboard.json", "main"). + Return(commits, nil) + }, + expected: []provisioning.HistoryItem{ + { + Ref: "abc123", + Message: "Update dashboard", + Authors: []provisioning.Author{ + { + Name: "John Doe", + Username: "johndoe", + AvatarURL: "https://example.com/avatar1.png", + }, + }, + CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli(), + }, + { + Ref: "def456", + Message: "Initial commit", + Authors: []provisioning.Author{ + { + Name: "Jane Smith", + Username: "janesmith", + AvatarURL: "https://example.com/avatar2.png", + }, + { + Name: "Bob Johnson", + Username: "bjohnson", + AvatarURL: "https://example.com/avatar3.png", + }, + }, + CreatedAt: time.Date(2022, 12, 31, 10, 0, 0, 0, time.UTC).UnixMilli(), + }, + }, + }, + { + name: "committer same as author", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "main", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + commits := []pgh.Commit{ + { + Ref: "abc123", + Message: "Update dashboard", + Author: &pgh.CommitAuthor{ + Name: "John Doe", + Username: "johndoe", + AvatarURL: "https://example.com/avatar1.png", + }, + Committer: &pgh.CommitAuthor{ + Name: "John Doe", + Username: "johndoe", + AvatarURL: "https://example.com/avatar1.png", + }, + CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + }, + } + + mockClient.EXPECT().Commits(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main"). + Return(commits, nil) + }, + expected: []provisioning.HistoryItem{ + { + Ref: "abc123", + Message: "Update dashboard", + Authors: []provisioning.Author{ + { + Name: "John Doe", + Username: "johndoe", + AvatarURL: "https://example.com/avatar1.png", + }, + }, + CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli(), + }, + }, + }, + { + name: "file not found", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "nonexistent.json", + ref: "main", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().Commits(mock.Anything, "grafana", "grafana", "grafana/nonexistent.json", "main"). + Return(nil, pgh.ErrResourceNotFound) + }, + expectedError: ErrFileNotFound, + }, + { + name: "prefixed path", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "custom/prefix", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "main", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + commits := []pgh.Commit{ + { + Ref: "abc123", + Message: "Update dashboard", + Author: &pgh.CommitAuthor{ + Name: "John Doe", + Username: "johndoe", + AvatarURL: "https://example.com/avatar1.png", + }, + CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + }, + } + + mockClient.EXPECT().Commits(mock.Anything, "grafana", "grafana", "custom/prefix/dashboard.json", "main"). + Return(commits, nil) + }, + expected: []provisioning.HistoryItem{ + { + Ref: "abc123", + Message: "Update dashboard", + Authors: []provisioning.Author{ + { + Name: "John Doe", + Username: "johndoe", + AvatarURL: "https://example.com/avatar1.png", + }, + }, + CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli(), + }, + }, + }, + { + name: "other error", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "main", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + mockClient.EXPECT().Commits(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main"). + Return(nil, errors.New("api error")) + }, + expectedError: errors.New("get commits: api error"), + }, + { + name: "use default branch when ref is empty", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Path: "grafana", + Branch: "main", + }, + }, + }, + path: "dashboard.json", + ref: "", + mockSetup: func(t *testing.T, mockClient *pgh.MockClient) { + commits := []pgh.Commit{ + { + Ref: "abc123", + Message: "Update dashboard", + Author: &pgh.CommitAuthor{ + Name: "John Doe", + Username: "johndoe", + AvatarURL: "https://example.com/avatar1.png", + }, + CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + }, + } + + mockClient.EXPECT().Commits(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main"). + Return(commits, nil) + }, + expected: []provisioning.HistoryItem{ + { + Ref: "abc123", + Message: "Update dashboard", + Authors: []provisioning.Author{ + { + Name: "John Doe", + Username: "johndoe", + AvatarURL: "https://example.com/avatar1.png", + }, + }, + CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli(), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock GitHub client + mockClient := pgh.NewMockClient(t) + + // Set up the mock expectations + if tt.mockSetup != nil { + tt.mockSetup(t, mockClient) + } + + // Create a GitHub repository with the test config and mock client + repo := &githubRepository{ + config: tt.config, + gh: mockClient, + owner: "grafana", + repo: "grafana", + } + + // Call the History method + history, err := repo.History(context.Background(), tt.path, tt.ref) + + // Check the error + if tt.expectedError != nil { + require.Error(t, err) + var statusErr *apierrors.StatusError + if errors.As(tt.expectedError, &statusErr) { + var actualStatusErr *apierrors.StatusError + require.True(t, errors.As(err, &actualStatusErr), "Expected StatusError but got different error type") + require.Equal(t, statusErr.Status().Message, actualStatusErr.Status().Message) + require.Equal(t, statusErr.Status().Code, actualStatusErr.Status().Code) + } else { + require.Equal(t, tt.expectedError.Error(), err.Error()) + } + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, history) + } + + // Verify all mock expectations were met + mockClient.AssertExpectations(t) + }) + } +} + +func TestGitHubRepository_Webhook(t *testing.T) { + tests := []struct { + name string + config *provisioning.Repository + webhookSecret string + setupRequest func() *http.Request + mockSetup func(t *testing.T, mockSecrets *secrets.MockService) + expected *provisioning.WebhookResponse + expectedError error + }{ + { + name: "missing webhook configuration", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + Status: provisioning.RepositoryStatus{ + // No webhook configuration + }, + }, + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/webhook", nil) + return req + }, + expectedError: fmt.Errorf("unexpected webhook request"), + }, + { + name: "secret decryption error", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/webhook", nil) + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return(nil, errors.New("decryption failed")) + }, + expectedError: fmt.Errorf("failed to decrypt secret: decryption failed"), + }, + { + name: "invalid signature", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader("invalid payload")) + req.Header.Set("X-Hub-Signature-256", "invalid") + req.Header.Set("Content-Type", "application/json") + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expectedError: apierrors.NewUnauthorized("invalid signature"), + }, + { + name: "ping event", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{}` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "ping") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expected: &provisioning.WebhookResponse{ + Code: http.StatusOK, + Message: "ping received", + }, + }, + { + name: "push event for different branch", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + Sync: provisioning.SyncOptions{ + Enabled: true, + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{ + "ref": "refs/heads/feature", + "repository": { + "full_name": "grafana/grafana" + } + }` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "push") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expected: &provisioning.WebhookResponse{ + Code: http.StatusOK, + }, + }, + { + name: "push event for main branch", + config: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + }, + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + Sync: provisioning.SyncOptions{ + Enabled: true, + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{ + "ref": "refs/heads/main", + "repository": { + "full_name": "grafana/grafana" + } + }` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "push") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expected: &provisioning.WebhookResponse{ + Code: http.StatusAccepted, + Job: &provisioning.JobSpec{ + Repository: "test-repo", + Action: provisioning.JobActionPull, + Pull: &provisioning.SyncJobOptions{ + Incremental: true, + }, + }, + }, + }, + { + name: "push event with missing repository", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{ + "ref": "refs/heads/main" + }` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "push") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expectedError: fmt.Errorf("missing repository in push event"), + }, + { + name: "push event with repository mismatch", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{ + "ref": "refs/heads/main", + "repository": { + "full_name": "different-owner/different-repo" + } + }` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "push") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expectedError: fmt.Errorf("repository mismatch"), + }, + { + name: "push event when sync is disabled", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + Sync: provisioning.SyncOptions{ + Enabled: false, + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{ + "ref": "refs/heads/main", + "repository": { + "full_name": "grafana/grafana" + } + }` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "push") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expected: &provisioning.WebhookResponse{ + Code: http.StatusOK, + }, + }, + { + name: "pull request event - opened", + config: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + }, + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{ + "action": "opened", + "pull_request": { + "html_url": "https://github.com/grafana/grafana/pull/123", + "number": 123, + "head": { + "ref": "feature-branch", + "sha": "abcdef1234567890" + }, + "base": { + "ref": "main" + } + }, + "repository": { + "full_name": "grafana/grafana" + } + }` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "pull_request") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expected: &provisioning.WebhookResponse{ + Code: http.StatusAccepted, + Message: "pull request: opened", + Job: &provisioning.JobSpec{ + Repository: "test-repo", + Action: provisioning.JobActionPullRequest, + PullRequest: &provisioning.PullRequestJobOptions{ + URL: "https://github.com/grafana/grafana/pull/123", + PR: 123, + Ref: "feature-branch", + Hash: "abcdef1234567890", + }, + }, + }, + }, + { + name: "pull request event - synchronize", + config: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + }, + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{ + "action": "synchronize", + "pull_request": { + "html_url": "https://github.com/grafana/grafana/pull/123", + "number": 123, + "head": { + "ref": "feature-branch", + "sha": "abcdef1234567890" + }, + "base": { + "ref": "main" + } + }, + "repository": { + "full_name": "grafana/grafana" + } + }` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "pull_request") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expected: &provisioning.WebhookResponse{ + Code: http.StatusAccepted, + Message: "pull request: synchronize", + Job: &provisioning.JobSpec{ + Repository: "test-repo", + Action: provisioning.JobActionPullRequest, + PullRequest: &provisioning.PullRequestJobOptions{ + URL: "https://github.com/grafana/grafana/pull/123", + PR: 123, + Ref: "feature-branch", + Hash: "abcdef1234567890", + }, + }, + }, + }, + { + name: "pull request event - wrong base branch", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{ + "action": "opened", + "pull_request": { + "html_url": "https://github.com/grafana/grafana/pull/123", + "number": 123, + "head": { + "ref": "feature-branch", + "sha": "abcdef1234567890" + }, + "base": { + "ref": "develop" + } + }, + "repository": { + "full_name": "grafana/grafana" + } + }` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "pull_request") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expected: &provisioning.WebhookResponse{ + Code: http.StatusOK, + Message: "ignoring pull request event as develop is not the configured branch", + }, + }, + { + name: "pull request event - ignored action", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{ + "action": "closed", + "pull_request": { + "html_url": "https://github.com/grafana/grafana/pull/123", + "number": 123, + "head": { + "ref": "feature-branch", + "sha": "abcdef1234567890" + }, + "base": { + "ref": "main" + } + }, + "repository": { + "full_name": "grafana/grafana" + } + }` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "pull_request") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expected: &provisioning.WebhookResponse{ + Code: http.StatusOK, + Message: "ignore pull request event: closed", + }, + }, + { + name: "pull request event missing repository", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{ + "action": "opened", + "pull_request": { + "html_url": "https://github.com/grafana/grafana/pull/123", + "number": 123, + "head": { + "ref": "feature-branch", + "sha": "abcdef1234567890" + }, + "base": { + "ref": "main" + } + } + }` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "pull_request") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expectedError: fmt.Errorf("missing repository in pull request event"), + }, + { + name: "pull request event with missing GitHub config", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + // GitHub config is intentionally missing + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{ + "action": "opened", + "pull_request": { + "html_url": "https://github.com/grafana/grafana/pull/123", + "number": 123, + "head": { + "ref": "feature-branch", + "sha": "abcdef1234567890" + }, + "base": { + "ref": "main" + } + }, + "repository": { + "full_name": "grafana/grafana" + } + }` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "pull_request") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expectedError: fmt.Errorf("missing GitHub config"), + }, + { + name: "pull request event with repository mismatch", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{ + "action": "opened", + "pull_request": { + "html_url": "https://github.com/different-owner/different-repo/pull/123", + "number": 123, + "head": { + "ref": "feature-branch", + "sha": "abcdef1234567890" + }, + "base": { + "ref": "main" + } + }, + "repository": { + "full_name": "different-owner/different-repo" + } + }` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "pull_request") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expectedError: fmt.Errorf("repository mismatch"), + }, + { + name: "pull request event missing pull request info", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{ + "action": "opened", + "repository": { + "full_name": "grafana/grafana" + } + }` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "pull_request") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expectedError: fmt.Errorf("expected PR in event"), + }, + { + name: "unsupported event type", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + Status: provisioning.RepositoryStatus{ + Webhook: &provisioning.WebhookStatus{ + EncryptedSecret: []byte("encrypted-secret"), + }, + }, + }, + webhookSecret: "webhook-secret", + setupRequest: func() *http.Request { + payload := `{}` + req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload)) + req.Header.Set("X-GitHub-Event", "team") + req.Header.Set("Content-Type", "application/json") + + // Create a valid signature + mac := hmac.New(sha256.New, []byte("webhook-secret")) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + req.Header.Set("X-Hub-Signature-256", "sha256="+signature) + + return req + }, + mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) { + mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")). + Return([]byte("webhook-secret"), nil) + }, + expected: &provisioning.WebhookResponse{ + Code: http.StatusNotImplemented, + Message: "unsupported messageType: team", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock secrets service + mockSecrets := secrets.NewMockService(t) + + // Set up the mock expectations + if tt.mockSetup != nil { + tt.mockSetup(t, mockSecrets) + } + + // Create a GitHub repository with the test config + repo := &githubRepository{ + config: tt.config, + owner: "grafana", + repo: "grafana", + secrets: mockSecrets, + } + + // Call the Webhook method + response, err := repo.Webhook(context.Background(), tt.setupRequest()) + + // Check the error + if tt.expectedError != nil { + require.Error(t, err) + var statusErr *apierrors.StatusError + if errors.As(tt.expectedError, &statusErr) { + var actualStatusErr *apierrors.StatusError + require.True(t, errors.As(err, &actualStatusErr), "Expected StatusError but got different error type") + require.Equal(t, statusErr.Status().Message, actualStatusErr.Status().Message) + require.Equal(t, statusErr.Status().Code, actualStatusErr.Status().Code) + } else { + require.Equal(t, tt.expectedError.Error(), err.Error()) + } + } else { + require.NoError(t, err) + require.Equal(t, tt.expected.Code, response.Code) + require.Equal(t, tt.expected.Message, response.Message) + + if tt.expected.Job != nil { + require.NotNil(t, response.Job) + require.Equal(t, tt.expected.Job.Action, response.Job.Action) + if tt.expected.Job.Pull != nil { + require.Equal(t, tt.expected.Job.Pull.Incremental, response.Job.Pull.Incremental) + } + if tt.expected.Job.PullRequest != nil { + require.Equal(t, tt.expected.Job.PullRequest.URL, response.Job.PullRequest.URL) + require.Equal(t, tt.expected.Job.PullRequest.PR, response.Job.PullRequest.PR) + require.Equal(t, tt.expected.Job.PullRequest.Ref, response.Job.PullRequest.Ref) + require.Equal(t, tt.expected.Job.PullRequest.Hash, response.Job.PullRequest.Hash) + } + } else { + require.Nil(t, response.Job) + } + } + + // Verify all mock expectations were met + mockSecrets.AssertExpectations(t) }) } } diff --git a/pkg/registry/apis/provisioning/secrets/secret.go b/pkg/registry/apis/provisioning/secrets/secret.go index f0e8498825d..10e09f548d3 100644 --- a/pkg/registry/apis/provisioning/secrets/secret.go +++ b/pkg/registry/apis/provisioning/secrets/secret.go @@ -11,6 +11,8 @@ import ( // // FIXME: this is a temporary service/package until we can make use of // the new secrets service in app platform. +// +//go:generate mockery --name Service --structname MockService --inpackage --filename secret_mock.go --with-expecter type Service interface { Encrypt(ctx context.Context, data []byte) ([]byte, error) Decrypt(ctx context.Context, data []byte) ([]byte, error) diff --git a/pkg/registry/apis/provisioning/secrets/secret_mock.go b/pkg/registry/apis/provisioning/secrets/secret_mock.go new file mode 100644 index 00000000000..d182c819e3c --- /dev/null +++ b/pkg/registry/apis/provisioning/secrets/secret_mock.go @@ -0,0 +1,154 @@ +// Code generated by mockery v2.52.4. DO NOT EDIT. + +package secrets + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockService is an autogenerated mock type for the Service type +type MockService struct { + mock.Mock +} + +type MockService_Expecter struct { + mock *mock.Mock +} + +func (_m *MockService) EXPECT() *MockService_Expecter { + return &MockService_Expecter{mock: &_m.Mock} +} + +// Decrypt provides a mock function with given fields: ctx, data +func (_m *MockService) Decrypt(ctx context.Context, data []byte) ([]byte, error) { + ret := _m.Called(ctx, data) + + if len(ret) == 0 { + panic("no return value specified for Decrypt") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, []byte) ([]byte, error)); ok { + return rf(ctx, data) + } + if rf, ok := ret.Get(0).(func(context.Context, []byte) []byte); ok { + r0 = rf(ctx, data) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok { + r1 = rf(ctx, data) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockService_Decrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Decrypt' +type MockService_Decrypt_Call struct { + *mock.Call +} + +// Decrypt is a helper method to define mock.On call +// - ctx context.Context +// - data []byte +func (_e *MockService_Expecter) Decrypt(ctx interface{}, data interface{}) *MockService_Decrypt_Call { + return &MockService_Decrypt_Call{Call: _e.mock.On("Decrypt", ctx, data)} +} + +func (_c *MockService_Decrypt_Call) Run(run func(ctx context.Context, data []byte)) *MockService_Decrypt_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]byte)) + }) + return _c +} + +func (_c *MockService_Decrypt_Call) Return(_a0 []byte, _a1 error) *MockService_Decrypt_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockService_Decrypt_Call) RunAndReturn(run func(context.Context, []byte) ([]byte, error)) *MockService_Decrypt_Call { + _c.Call.Return(run) + return _c +} + +// Encrypt provides a mock function with given fields: ctx, data +func (_m *MockService) Encrypt(ctx context.Context, data []byte) ([]byte, error) { + ret := _m.Called(ctx, data) + + if len(ret) == 0 { + panic("no return value specified for Encrypt") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, []byte) ([]byte, error)); ok { + return rf(ctx, data) + } + if rf, ok := ret.Get(0).(func(context.Context, []byte) []byte); ok { + r0 = rf(ctx, data) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok { + r1 = rf(ctx, data) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockService_Encrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Encrypt' +type MockService_Encrypt_Call struct { + *mock.Call +} + +// Encrypt is a helper method to define mock.On call +// - ctx context.Context +// - data []byte +func (_e *MockService_Expecter) Encrypt(ctx interface{}, data interface{}) *MockService_Encrypt_Call { + return &MockService_Encrypt_Call{Call: _e.mock.On("Encrypt", ctx, data)} +} + +func (_c *MockService_Encrypt_Call) Run(run func(ctx context.Context, data []byte)) *MockService_Encrypt_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]byte)) + }) + return _c +} + +func (_c *MockService_Encrypt_Call) Return(_a0 []byte, _a1 error) *MockService_Encrypt_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockService_Encrypt_Call) RunAndReturn(run func(context.Context, []byte) ([]byte, error)) *MockService_Encrypt_Call { + _c.Call.Return(run) + return _c +} + +// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockService { + mock := &MockService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +}