Files
Roberto Jiménez Sánchez 047499a363 Provisioning: introduce concept of provisioning extras (#104981)
* Spike: Extras

* Attempt to wire it up

* Hack

* Fix issue with jobs

* Wire more things up

* Fix more wiring stuff

* Remove webhook secret key from main registration

* Move secret encryption also outside register

* Add TODOs in code

* Add more explanations

* Move connectors to different package

* Move pull request job into webhooks

* Separate registration

* Remove duplicate files

* Fix missing function

* Extract webhook repository logic out of the core github repository

* Use status patcher in webhook connector

* Fix change in go mod

* Change hooks signature

* Remove TODOs

* Remove Webhook methos from go-git

* Remove leftover

* Fix mistake in OpenAPI spec

* Fix some tests

* Fix some issues

* Fix linting
2025-05-13 09:50:43 +02:00

1643 lines
43 KiB
Go

package gogit
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"testing"
"time"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
plumbing "github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport/client"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/plumbing/transport/server"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
)
type dummySecret struct{}
func (d *dummySecret) Decrypt(ctx context.Context, encrypted []byte) ([]byte, error) {
token, ok := os.LookupEnv("gitwraptoken")
if !ok {
return nil, fmt.Errorf("missing token in environment")
}
return []byte(token), nil
}
func (d *dummySecret) Encrypt(ctx context.Context, plain []byte) ([]byte, error) {
panic("not implemented")
}
// FIXME!! NOTE!!!!!
// This is really just a sketchpad while trying to get things working
// the test makes destructive changes to a real git repository :)
// this should be removed before committing to main (likely sooner)
// and replaced with integration tests that check the more specific results
func TestGoGitWrapper(t *testing.T) {
_, ok := os.LookupEnv("gitwraptoken")
if !ok {
t.Skipf("no token found in environment")
}
ctx := context.Background()
wrap, err := Clone(ctx, "testdata/clone", &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Namespace: "ns",
Name: "unit-tester",
},
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
URL: "https://github.com/grafana/git-ui-sync-demo",
Branch: "ryan-test",
},
},
},
repository.CloneOptions{
PushOnWrites: false,
CreateIfNotExists: true,
Progress: os.Stdout,
},
&dummySecret{},
)
require.NoError(t, err)
tree, err := wrap.ReadTree(ctx, "")
require.NoError(t, err)
jj, err := json.MarshalIndent(tree, "", " ")
require.NoError(t, err)
fmt.Printf("TREE:%s\n", string(jj))
ctx = repository.WithAuthorSignature(ctx, repository.CommitSignature{
Name: "xxxxx",
Email: "rrr@yyyy.zzz",
When: time.Now(),
})
for i := 0; i < 10; i++ {
fname := fmt.Sprintf("deep/path/in/test_%d.txt", i)
fmt.Printf("Write:%s\n", fname)
err = wrap.Write(ctx, fname, "", []byte(fmt.Sprintf("body/%d %s", i, time.Now())), "the commit message")
require.NoError(t, err)
}
fmt.Printf("push...\n")
err = wrap.Push(ctx, repository.PushOptions{
Timeout: 10,
Progress: os.Stdout,
})
require.NoError(t, err)
}
func TestReadTree(t *testing.T) {
dir := t.TempDir()
gitRepo, err := git.PlainInit(dir, false)
require.NoError(t, err, "failed to init a new git repository")
tree, err := gitRepo.Worktree()
require.NoError(t, err, "failed to get worktree")
repo := &GoGitRepo{
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Name: "test",
Namespace: "default",
},
Spec: v0alpha1.RepositorySpec{
Title: "test",
Workflows: []v0alpha1.Workflow{v0alpha1.WriteWorkflow},
Type: v0alpha1.GitHubRepositoryType,
GitHub: &v0alpha1.GitHubRepositoryConfig{
URL: "https://github.com/grafana/__unit-test",
Path: "grafana/",
Branch: "main",
},
},
Status: v0alpha1.RepositoryStatus{},
},
decryptedPassword: "password",
repo: gitRepo,
tree: &worktree{
Worktree: tree,
},
dir: dir,
}
err = os.WriteFile(filepath.Join(dir, "test.txt"), []byte("test"), 0644)
require.NoError(t, err, "failed to write test file")
err = os.Mkdir(filepath.Join(dir, "grafana"), 0750)
require.NoError(t, err, "failed to mkdir grafana")
err = os.WriteFile(filepath.Join(dir, "grafana", "test2.txt"), []byte("test"), 0644)
require.NoError(t, err, "failed to write grafana/test2 file")
ctx := context.Background()
entries, err := repo.ReadTree(ctx, "HEAD")
require.NoError(t, err, "failed to read tree")
// Here is the meat of why this test exists: the ReadTree call should only read the config.Spec.GitHub.Path files.
// All prefixes are removed (i.e. a file is just its name, not ${Path}/${Name}).
// And it does not include the directory in the listing, as it pretends to be the root.
require.Len(t, entries, 1, "entries from ReadTree")
require.Equal(t, entries[0].Path, "test2.txt", "entry path")
}
func TestGoGitRepo_History(t *testing.T) {
repo := &GoGitRepo{
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Name: "test",
Namespace: "default",
},
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
}
// Test History method
ctx := context.Background()
_, err := repo.History(ctx, "test.txt", "")
require.Error(t, err, "History should return an error as it's not implemented")
require.Contains(t, err.Error(), "history is not yet implemented")
}
func TestGoGitRepo_Validate(t *testing.T) {
repo := &GoGitRepo{
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Name: "test",
Namespace: "default",
},
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
}
// Test Validate method
errs := repo.Validate()
require.Empty(t, errs, "Validate should return no errors")
}
func TestGoGitRepo_Read(t *testing.T) {
// Setup test cases
tests := []struct {
name string
path string
ref string
setupMock func(fs billy.Filesystem)
expectError bool
errorType error
checkResult func(t *testing.T, info *repository.FileInfo)
}{
{
name: "successfully read file",
path: "test.txt",
ref: "",
setupMock: func(fs billy.Filesystem) {
// Create a test file
f, err := fs.Create("grafana/test.txt")
require.NoError(t, err, "failed to create test file")
_, err = f.Write([]byte("test content"))
require.NoError(t, err, "failed to write test content")
err = f.Close()
require.NoError(t, err, "failed to close test file")
},
expectError: false,
checkResult: func(t *testing.T, info *repository.FileInfo) {
require.Equal(t, "test.txt", info.Path)
require.Equal(t, "test content", string(info.Data))
require.NotNil(t, info.Modified)
},
},
{
name: "empty path",
path: "",
ref: "",
setupMock: func(fs billy.Filesystem) {},
expectError: true,
errorType: fmt.Errorf("expected path"),
},
{
name: "ref not supported",
path: "test.txt",
ref: "main",
setupMock: func(fs billy.Filesystem) {},
expectError: true,
errorType: fmt.Errorf("ref unsupported"),
},
{
name: "file not found",
path: "nonexistent.txt",
ref: "",
setupMock: func(fs billy.Filesystem) {
// Don't create the file
},
expectError: true,
errorType: repository.ErrFileNotFound,
},
{
name: "read directory",
path: "testdir",
ref: "",
setupMock: func(fs billy.Filesystem) {
// Create a test directory
err := fs.MkdirAll("grafana/testdir", 0755)
require.NoError(t, err, "failed to create test directory")
},
expectError: false,
checkResult: func(t *testing.T, info *repository.FileInfo) {
require.Equal(t, "testdir", info.Path)
require.Nil(t, info.Data)
require.NotNil(t, info.Modified)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup filesystem and repo
fs := memfs.New()
tt.setupMock(fs)
// Create a worktree with the filesystem
repo := &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
tree: &worktree{
Worktree: &git.Worktree{
Filesystem: fs,
},
},
}
// Test Read method
ctx := context.Background()
info, err := repo.Read(ctx, tt.path, tt.ref)
// Check results
if tt.expectError {
require.Error(t, err)
if tt.errorType != nil {
if errors.Is(tt.errorType, repository.ErrFileNotFound) {
require.ErrorIs(t, err, repository.ErrFileNotFound)
} else {
require.Contains(t, err.Error(), tt.errorType.Error())
}
}
} else {
require.NoError(t, err)
require.NotNil(t, info)
tt.checkResult(t, info)
}
})
}
}
func TestGoGitRepo_Delete(t *testing.T) {
tests := []struct {
name string
path string
ref string
pushOnWrite bool
setupMock func(mockTree *MockWorktree)
expectError bool
errorType error
}{
{
name: "delete existing file",
path: "testfile.txt",
ref: "",
pushOnWrite: false,
setupMock: func(mockTree *MockWorktree) {
mockTree.On("Remove", "grafana/testfile.txt").Return(plumbing.Hash{}, nil)
},
expectError: false,
},
{
name: "delete non-existent file",
path: "nonexistent.txt",
ref: "",
pushOnWrite: false,
setupMock: func(mockTree *MockWorktree) {
mockTree.On("Remove", "grafana/nonexistent.txt").Return(plumbing.Hash{}, fs.ErrNotExist)
},
expectError: true,
errorType: repository.ErrFileNotFound,
},
{
name: "delete with other error",
path: "testfile.txt",
ref: "",
pushOnWrite: false,
setupMock: func(mockTree *MockWorktree) {
mockTree.On("Remove", "grafana/testfile.txt").Return(plumbing.Hash{}, fmt.Errorf("some other error"))
},
expectError: true,
errorType: fmt.Errorf("some other error"),
},
{
name: "empty path",
path: "",
ref: "",
pushOnWrite: false,
setupMock: func(mockTree *MockWorktree) {},
expectError: true,
errorType: fmt.Errorf("expected path"),
},
{
name: "with ref",
path: "testfile.txt",
ref: "main",
pushOnWrite: false,
setupMock: func(mockTree *MockWorktree) {
},
expectError: true,
errorType: fmt.Errorf("ref unsupported"),
},
{
name: "delete with push on write enabled",
path: "testfile.txt",
ref: "",
pushOnWrite: true,
setupMock: func(mockTree *MockWorktree) {
mockTree.On("Remove", "grafana/testfile.txt").Return(plumbing.Hash{}, nil)
mockTree.On("Commit", "test delete", mock.MatchedBy(func(opts *git.CommitOptions) bool {
return opts.Author != nil &&
opts.Author.Name == "Test User" &&
opts.Author.Email == "test@example.com" &&
opts.Author.When.After(time.Now().Add(-time.Minute)) &&
opts.Author.When.Before(time.Now().Add(time.Minute))
})).Return(plumbing.Hash{}, nil)
},
expectError: false,
},
{
name: "delete with empty commit",
path: "testfile.txt",
ref: "",
pushOnWrite: true,
setupMock: func(mockTree *MockWorktree) {
mockTree.On("Remove", "grafana/testfile.txt").Return(plumbing.Hash{}, nil)
mockTree.On("Commit", "test delete", mock.MatchedBy(func(opts *git.CommitOptions) bool {
return opts.Author != nil &&
opts.Author.Name == "Test User" &&
opts.Author.Email == "test@example.com" &&
opts.Author.When.After(time.Now().Add(-time.Minute)) &&
opts.Author.When.Before(time.Now().Add(time.Minute))
})).Return(plumbing.Hash{}, git.ErrEmptyCommit)
},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup filesystem and repo
mockTree := NewMockWorktree(t)
tt.setupMock(mockTree)
// Create a worktree with the filesystem
repo := &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
tree: mockTree,
opts: repository.CloneOptions{
PushOnWrites: tt.pushOnWrite,
},
}
// Test Delete method
ctx := context.Background()
// Set author signature for the test
ctx = repository.WithAuthorSignature(ctx, repository.CommitSignature{
Name: "Test User",
Email: "test@example.com",
When: time.Now(),
})
err := repo.Delete(ctx, tt.path, tt.ref, "test delete")
// Check results
if tt.expectError {
require.Error(t, err)
if tt.errorType != nil {
if errors.Is(tt.errorType, repository.ErrFileNotFound) {
require.ErrorIs(t, err, repository.ErrFileNotFound)
} else {
require.Contains(t, err.Error(), tt.errorType.Error())
}
}
} else {
require.NoError(t, err)
}
mockTree.AssertExpectations(t)
})
}
}
// FIXME: missing coverage for Update / Create because we use Write for both
// when I think it shouldn't be the case as it's inconsistent with the other repository implementations
func TestGoGitRepo_Write(t *testing.T) {
tests := []struct {
name string
path string
ref string
data []byte
pushOnWrite bool
setupMock func(mockTree *MockWorktree)
expectError bool
errorType error
}{
{
name: "successful write",
path: "test.txt",
ref: "",
data: []byte("test content"),
pushOnWrite: true,
setupMock: func(mockTree *MockWorktree) {
fs := memfs.New()
mockTree.On("Filesystem").Return(fs)
mockTree.On("Add", "grafana/test.txt").Return(plumbing.NewHash("abc123"), nil)
mockTree.On("Commit", "test write", mock.MatchedBy(func(opts *git.CommitOptions) bool {
return opts.Author != nil &&
opts.Author.Name == "Test User" &&
opts.Author.Email == "test@example.com" &&
opts.Author.When.After(time.Now().Add(-time.Minute)) &&
opts.Author.When.Before(time.Now().Add(time.Minute))
})).Return(plumbing.NewHash("def456"), nil)
},
expectError: false,
},
{
name: "create folder only",
path: "testdir/",
ref: "",
data: []byte{},
pushOnWrite: true,
setupMock: func(mockTree *MockWorktree) {
fs := memfs.New()
mockTree.On("Filesystem").Return(fs)
// No Add or Commit calls expected for directory creation
},
expectError: false,
},
{
name: "successful write without commit",
path: "test.txt",
ref: "",
data: []byte("test content"),
pushOnWrite: false,
setupMock: func(mockTree *MockWorktree) {
fs := memfs.New()
mockTree.On("Filesystem").Return(fs)
mockTree.On("Add", "grafana/test.txt").Return(plumbing.NewHash("abc123"), nil)
},
expectError: false,
},
{
name: "write with directory creation",
path: "dir/test.txt",
ref: "",
data: []byte("test content"),
pushOnWrite: true,
setupMock: func(mockTree *MockWorktree) {
fs := memfs.New()
mockTree.On("Filesystem").Return(fs)
mockTree.On("Add", "grafana/dir/test.txt").Return(plumbing.NewHash("abc123"), nil)
mockTree.On("Commit", "test write", mock.Anything).Return(plumbing.NewHash("def456"), nil)
},
expectError: false,
},
{
name: "error on add",
path: "test.txt",
ref: "",
data: []byte("test content"),
pushOnWrite: true,
setupMock: func(mockTree *MockWorktree) {
fs := memfs.New()
mockTree.On("Filesystem").Return(fs)
mockTree.On("Add", "grafana/test.txt").Return(plumbing.NewHash(""), fmt.Errorf("add error"))
},
expectError: true,
errorType: fmt.Errorf("add error"),
},
{
name: "error with ref",
path: "test.txt",
ref: "main",
data: []byte("test content"),
pushOnWrite: true,
setupMock: func(mockTree *MockWorktree) {
// No mock setup needed as it should fail before using the mock
},
expectError: true,
errorType: fmt.Errorf("ref unsupported"),
},
{
name: "empty path",
path: "",
ref: "",
data: []byte("test content"),
pushOnWrite: true,
setupMock: func(mockTree *MockWorktree) {
// No mock setup needed as it should fail before using the mock
},
expectError: true,
errorType: fmt.Errorf("expected path"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup filesystem and repo
mockTree := NewMockWorktree(t)
tt.setupMock(mockTree)
// Create a worktree with the filesystem
repo := &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
tree: mockTree,
opts: repository.CloneOptions{
PushOnWrites: tt.pushOnWrite,
},
}
// Test Write method
ctx := context.Background()
// Set author signature for the test
ctx = repository.WithAuthorSignature(ctx, repository.CommitSignature{
Name: "Test User",
Email: "test@example.com",
When: time.Now(),
})
err := repo.Update(ctx, tt.path, tt.ref, tt.data, "test write")
// Check results
if tt.expectError {
require.Error(t, err)
if tt.errorType != nil {
require.Contains(t, err.Error(), tt.errorType.Error())
}
} else {
require.NoError(t, err)
}
mockTree.AssertExpectations(t)
})
}
}
func TestGoGitRepo_Test(t *testing.T) {
tests := []struct {
name string
treeInitialized bool
expectedResult bool
}{
{
name: "tree is initialized",
treeInitialized: true,
expectedResult: true,
},
{
name: "tree is not initialized",
treeInitialized: false,
expectedResult: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mock tree
mockTree := NewMockWorktree(t)
// Create repo with or without initialized tree
repo := &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
tree: nil,
}
if tt.treeInitialized {
repo.tree = mockTree
}
// Test the Test method
ctx := context.Background()
result, err := repo.Test(ctx)
// Verify results
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, tt.expectedResult, result.Success)
})
}
}
func TestGoGitRepo_Config(t *testing.T) {
// Create a test repository configuration
testConfig := &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Name: "test-repo",
Namespace: "test-namespace",
},
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
}
// Create a repository instance with the test configuration
repo := &GoGitRepo{
config: testConfig,
tree: NewMockWorktree(t),
}
// Call the Config method
result := repo.Config()
// Verify the result
require.NotNil(t, result)
require.Equal(t, testConfig, result)
require.Equal(t, "test-repo", result.Name)
require.Equal(t, "test-namespace", result.Namespace)
require.Equal(t, "grafana/", result.Spec.GitHub.Path)
}
func TestGoGitRepo_Remove(t *testing.T) {
tests := []struct {
name string
setupMock func(t *testing.T) (*GoGitRepo, string)
expectError bool
expectedErrMsg string
}{
{
name: "successful removal",
setupMock: func(t *testing.T) (*GoGitRepo, string) {
// Create a temporary directory that will be removed
tempDir, err := os.MkdirTemp("", "test-repo-*")
require.NoError(t, err)
// Create a repository instance
repo := &GoGitRepo{
dir: tempDir,
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Name: "test-repo",
Namespace: "test-namespace",
},
},
}
return repo, tempDir
},
expectError: false,
},
{
name: "directory already removed",
setupMock: func(t *testing.T) (*GoGitRepo, string) {
// Create a temporary directory
tempDir, err := os.MkdirTemp("", "test-repo-*")
require.NoError(t, err)
// Remove it immediately to simulate it being already gone
err = os.RemoveAll(tempDir)
require.NoError(t, err)
// Create a repository instance pointing to the removed directory
repo := &GoGitRepo{
dir: tempDir,
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Name: "test-repo",
Namespace: "test-namespace",
},
},
}
return repo, tempDir
},
expectError: false, // RemoveAll doesn't error if directory doesn't exist
},
{
name: "invalid directory path",
setupMock: func(t *testing.T) (*GoGitRepo, string) {
// Create a repository instance with an invalid directory path
// that should cause an error when trying to remove
invalidPath := string([]byte{0})
repo := &GoGitRepo{
dir: invalidPath,
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Name: "test-repo",
Namespace: "test-namespace",
},
},
}
return repo, invalidPath
},
expectError: true,
expectedErrMsg: "invalid argument",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup the test
repo, _ := tt.setupMock(t)
// Test the Remove method
ctx := context.Background()
err := repo.Remove(ctx)
// Verify results
if tt.expectError {
require.Error(t, err)
if tt.expectedErrMsg != "" {
require.Contains(t, err.Error(), tt.expectedErrMsg)
}
} else {
require.NoError(t, err)
// Verify the directory no longer exists
_, statErr := os.Stat(repo.dir)
require.True(t, os.IsNotExist(statErr), "Directory should not exist after removal")
}
})
}
}
func TestGoGitRepo_Push(t *testing.T) {
tests := []struct {
name string
setupMock func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree)
pushOpts repository.PushOptions
expectError bool
errorType error
}{
{
name: "successful push",
setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
mockRepo := NewMockRepository(t)
mockRepo.On("PushContext", mock.Anything, mock.MatchedBy(func(o *git.PushOptions) bool {
if o.Auth == nil {
return false
}
// Verify we're using basic auth with expected credentials
basicAuth, ok := o.Auth.(*githttp.BasicAuth)
if !ok {
return false
}
return basicAuth.Username == "grafana" && basicAuth.Password == "test-token"
})).Return(nil)
repo := &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
repo: mockRepo,
decryptedPassword: "test-token",
opts: repository.CloneOptions{
PushOnWrites: true,
},
}
return repo, mockRepo, nil
},
pushOpts: repository.PushOptions{},
expectError: false,
},
{
name: "push error",
setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
mockRepo := NewMockRepository(t)
mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(fmt.Errorf("network error"))
repo := &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
repo: mockRepo,
decryptedPassword: "test-token",
opts: repository.CloneOptions{
PushOnWrites: true,
},
}
return repo, mockRepo, nil
},
pushOpts: repository.PushOptions{},
expectError: true,
errorType: fmt.Errorf("network error"),
},
{
name: "already up to date",
setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
mockRepo := NewMockRepository(t)
mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(git.NoErrAlreadyUpToDate)
repo := &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
repo: mockRepo,
decryptedPassword: "test-token",
opts: repository.CloneOptions{
PushOnWrites: true,
},
}
return repo, mockRepo, nil
},
pushOpts: repository.PushOptions{},
expectError: false,
},
{
name: "push with custom timeout",
setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
mockRepo := NewMockRepository(t)
mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(nil)
repo := &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
repo: mockRepo,
decryptedPassword: "test-token",
opts: repository.CloneOptions{
PushOnWrites: true,
},
}
return repo, mockRepo, nil
},
pushOpts: repository.PushOptions{
Timeout: 5 * time.Minute,
},
expectError: false,
},
{
name: "push with custom progress writer",
setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
mockRepo := NewMockRepository(t)
mockRepo.On("PushContext", mock.Anything, mock.MatchedBy(func(o *git.PushOptions) bool {
return o.Progress != nil && o.Progress != io.Discard
})).Return(nil)
repo := &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
repo: mockRepo,
decryptedPassword: "test-token",
opts: repository.CloneOptions{
PushOnWrites: true,
},
}
return repo, mockRepo, nil
},
pushOpts: repository.PushOptions{
Progress: &bytes.Buffer{},
},
expectError: false,
},
{
name: "push with BeforeFn success",
setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
mockRepo := NewMockRepository(t)
mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(nil)
repo := &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
repo: mockRepo,
decryptedPassword: "test-token",
opts: repository.CloneOptions{
PushOnWrites: true,
},
}
return repo, mockRepo, nil
},
pushOpts: repository.PushOptions{
BeforeFn: func() error {
return nil
},
},
expectError: false,
},
{
name: "push with BeforeFn error",
setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
// No mock expectations since BeforeFn will fail before PushContext is called
repo := &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
repo: NewMockRepository(t),
decryptedPassword: "test-token",
opts: repository.CloneOptions{
PushOnWrites: true,
},
}
return repo, repo.repo.(*MockRepository), nil
},
pushOpts: repository.PushOptions{
BeforeFn: func() error {
return fmt.Errorf("before function failed")
},
},
expectError: true,
errorType: fmt.Errorf("before function failed"),
},
{
name: "push with PushOnWrites=false commits changes",
setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
mockRepo := NewMockRepository(t)
mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(nil)
mockTree := NewMockWorktree(t)
mockTree.On("Commit", "exported from grafana", mock.MatchedBy(func(o *git.CommitOptions) bool {
return o.All == true
})).Return(plumbing.NewHash("abc123"), nil)
repo := &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
repo: mockRepo,
tree: mockTree,
decryptedPassword: "test-token",
opts: repository.CloneOptions{
PushOnWrites: false,
},
}
return repo, mockRepo, mockTree
},
pushOpts: repository.PushOptions{},
expectError: false,
},
{
name: "push with PushOnWrites=false and empty commit",
setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
mockRepo := NewMockRepository(t)
mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(nil)
mockTree := NewMockWorktree(t)
mockTree.On("Commit", "exported from grafana", mock.Anything).Return(plumbing.ZeroHash, git.ErrEmptyCommit)
repo := &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
repo: mockRepo,
tree: mockTree,
decryptedPassword: "test-token",
opts: repository.CloneOptions{
PushOnWrites: false,
},
}
return repo, mockRepo, mockTree
},
pushOpts: repository.PushOptions{},
expectError: false,
},
{
name: "push with PushOnWrites=false and commit error",
setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
mockTree := NewMockWorktree(t)
mockTree.On("Commit", "exported from grafana", mock.Anything).Return(plumbing.ZeroHash, fmt.Errorf("commit error"))
repo := &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
repo: NewMockRepository(t),
tree: mockTree,
decryptedPassword: "test-token",
opts: repository.CloneOptions{
PushOnWrites: false,
},
}
return repo, repo.repo.(*MockRepository), mockTree
},
pushOpts: repository.PushOptions{},
expectError: true,
errorType: fmt.Errorf("commit error"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup the test
repo, mockRepo, mockTree := tt.setupMock(t)
// Test the Push method
ctx := context.Background()
err := repo.Push(ctx, tt.pushOpts)
// Verify results
if tt.expectError {
require.Error(t, err)
if tt.errorType != nil {
require.Contains(t, err.Error(), tt.errorType.Error())
}
} else {
require.NoError(t, err)
}
// Verify mock expectations if mocks were created
if mockRepo != nil {
mockRepo.AssertExpectations(t)
}
if mockTree != nil {
mockTree.AssertExpectations(t)
}
})
}
}
func TestGoGitRepo_ReadTree(t *testing.T) {
tests := []struct {
name string
setupMock func(t *testing.T) *GoGitRepo
ref string
expectError bool
expectedErrMsg string
expectedFiles []repository.FileTreeEntry
}{
{
name: "successful read with files",
setupMock: func(t *testing.T) *GoGitRepo {
mockFS := memfs.New()
// Create test files in the mock filesystem
require.NoError(t, mockFS.MkdirAll("grafana/folder1", 0750))
file1, err := mockFS.Create("grafana/file1.txt")
require.NoError(t, err)
_, err = file1.Write([]byte("test content"))
require.NoError(t, err)
require.NoError(t, file1.Close())
file2, err := mockFS.Create("grafana/folder1/file2.txt")
require.NoError(t, err)
_, err = file2.Write([]byte("nested file content"))
require.NoError(t, err)
require.NoError(t, file2.Close())
mockTree := NewMockWorktree(t)
mockTree.On("Filesystem").Return(mockFS)
return &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "grafana/",
},
},
},
tree: mockTree,
}
},
ref: "main",
expectError: false,
expectedFiles: []repository.FileTreeEntry{
{Path: "file1.txt", Size: 12, Blob: true, Hash: "TODO/12"},
{Path: "folder1", Size: 0, Blob: false},
{Path: "folder1/file2.txt", Size: 19, Blob: true, Hash: "TODO/19"},
},
},
{
name: "filesystem error",
setupMock: func(t *testing.T) *GoGitRepo {
mockTree := NewMockWorktree(t)
mockFS := memfs.New()
// Create a filesystem that will return an error when accessed
mockTree.On("Filesystem").Return(mockFS)
return &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "non-existent-path/",
},
},
},
tree: mockTree,
}
},
ref: "main",
expectError: false, // ReadTree handles fs.ErrNotExist by returning empty entries
expectedFiles: []repository.FileTreeEntry{},
},
{
name: "successful read with empty path",
setupMock: func(t *testing.T) *GoGitRepo {
mockFS := memfs.New()
// Create test files in the mock filesystem
file1, err := mockFS.Create("file1.txt")
require.NoError(t, err)
_, err = file1.Write([]byte("test content"))
require.NoError(t, err)
require.NoError(t, file1.Close())
require.NoError(t, mockFS.MkdirAll("folder1", 0750))
file2, err := mockFS.Create("folder1/file2.txt")
require.NoError(t, err)
_, err = file2.Write([]byte("nested file content"))
require.NoError(t, err)
require.NoError(t, file2.Close())
// Create .git directory which should be ignored
require.NoError(t, mockFS.MkdirAll(".git", 0750))
gitFile, err := mockFS.Create(".git/config")
require.NoError(t, err)
_, err = gitFile.Write([]byte("git config"))
require.NoError(t, err)
require.NoError(t, gitFile.Close())
mockTree := NewMockWorktree(t)
mockTree.On("Filesystem").Return(mockFS)
return &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "",
},
},
},
tree: mockTree,
}
},
ref: "main",
expectError: false,
expectedFiles: []repository.FileTreeEntry{
{Path: "file1.txt", Size: 12, Blob: true, Hash: "TODO/12"},
{Path: "folder1", Size: 0, Blob: false},
{Path: "folder1/file2.txt", Size: 19, Blob: true, Hash: "TODO/19"},
},
},
{
name: "filesystem error",
setupMock: func(t *testing.T) *GoGitRepo {
mockTree := NewMockWorktree(t)
mockFS := memfs.New()
// Create a filesystem that will return an error when accessed
mockTree.On("Filesystem").Return(mockFS)
return &GoGitRepo{
config: &v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
Path: "non-existent-path/",
},
},
},
tree: mockTree,
}
},
ref: "main",
expectError: false, // ReadTree handles fs.ErrNotExist by returning empty entries
expectedFiles: []repository.FileTreeEntry{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup the test
repo := tt.setupMock(t)
// Test the ReadTree method
ctx := context.Background()
entries, err := repo.ReadTree(ctx, tt.ref)
// Verify results
if tt.expectError {
require.Error(t, err)
if tt.expectedErrMsg != "" {
require.Contains(t, err.Error(), tt.expectedErrMsg)
}
} else {
require.NoError(t, err)
// Sort entries for consistent comparison
sort.Slice(entries, func(i, j int) bool {
return entries[i].Path < entries[j].Path
})
sort.Slice(tt.expectedFiles, func(i, j int) bool {
return tt.expectedFiles[i].Path < tt.expectedFiles[j].Path
})
require.Equal(t, len(tt.expectedFiles), len(entries), "Number of entries should match")
for i, expected := range tt.expectedFiles {
require.Equal(t, expected.Path, entries[i].Path, "Path should match")
require.Equal(t, expected.Size, entries[i].Size, "Size should match")
require.Equal(t, expected.Blob, entries[i].Blob, "Blob flag should match")
if expected.Blob {
require.Equal(t, expected.Hash, entries[i].Hash, "Hash should match")
}
}
}
// Verify mock expectations
repo.tree.(*MockWorktree).AssertExpectations(t)
})
}
}
func TestClone(t *testing.T) {
tests := []struct {
name string
root string
config *v0alpha1.Repository
createRepo bool
opts repository.CloneOptions
setupMock func(secrets *secrets.MockService)
expectError bool
errorMsg string
}{
{
name: "successful clone",
root: "testdata/clone",
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Namespace: "test-ns",
Name: "test-repo",
},
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
URL: "https://github.com/test/repo",
Branch: "main",
},
},
},
createRepo: true,
opts: repository.CloneOptions{
PushOnWrites: false,
},
setupMock: func(mockSecrets *secrets.MockService) {
mockSecrets.On("Decrypt", mock.Anything, mock.Anything).Return([]byte("test-token"), nil)
},
expectError: false,
},
{
name: "successful clone with create if not exists",
root: "testdata/clone",
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Namespace: "test-ns",
Name: "test-repo",
},
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
URL: "https://github.com/test/repo",
Branch: "non-existent-branch",
},
},
},
createRepo: true,
opts: repository.CloneOptions{
PushOnWrites: false,
CreateIfNotExists: true,
},
setupMock: func(mockSecrets *secrets.MockService) {
mockSecrets.On("Decrypt", mock.Anything, mock.Anything).Return([]byte("test-token"), nil)
},
expectError: false,
},
{
name: "timeout cancellation",
root: "testdata/clone",
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Namespace: "test-ns",
Name: "test-repo",
},
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
URL: "https://github.com/test/repo",
Branch: "main",
},
},
},
opts: repository.CloneOptions{
Timeout: 1 * time.Millisecond, // Very short timeout to trigger cancellation
},
setupMock: func(mockSecrets *secrets.MockService) {
mockSecrets.On("Decrypt", mock.Anything, mock.Anything).Return([]byte("test-token"), nil)
// Simulate a slow operation that will be cancelled by timeout
time.Sleep(20 * time.Millisecond)
},
expectError: true,
errorMsg: "context deadline exceeded",
},
{
name: "empty root",
root: "",
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Namespace: "test-ns",
Name: "test-repo",
},
},
setupMock: func(mockSecrets *secrets.MockService) {},
expectError: true,
errorMsg: "missing root config",
},
{
name: "missing namespace",
root: "testdata/clone",
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Name: "test-repo",
},
},
setupMock: func(mockSecrets *secrets.MockService) {},
expectError: true,
errorMsg: "missing namespace",
},
{
name: "missing name",
root: "testdata/clone",
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Namespace: "test-ns",
},
},
setupMock: func(mockSecrets *secrets.MockService) {},
expectError: true,
errorMsg: "missing name",
},
{
name: "beforeFn error",
root: "testdata/clone",
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Namespace: "test-ns",
Name: "test-repo",
},
},
opts: repository.CloneOptions{
BeforeFn: func() error {
return fmt.Errorf("beforeFn error")
},
},
setupMock: func(mockSecrets *secrets.MockService) {},
expectError: true,
errorMsg: "beforeFn error",
},
{
name: "secret decryption error",
root: "testdata/clone",
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Namespace: "test-ns",
Name: "test-repo",
},
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
EncryptedToken: []byte("test-token"),
},
},
},
setupMock: func(mockSecrets *secrets.MockService) {
mockSecrets.On("Decrypt", mock.Anything, mock.Anything).Return([]byte("test-token"), fmt.Errorf("error decrypting token"))
},
expectError: true,
errorMsg: "error decrypting token",
},
{
name: "clone error",
root: "testdata/clone",
config: &v0alpha1.Repository{
ObjectMeta: v1.ObjectMeta{
Namespace: "test-ns",
Name: "test-repo",
},
Spec: v0alpha1.RepositorySpec{
GitHub: &v0alpha1.GitHubRepositoryConfig{
URL: "https://github.com/test/repo",
Branch: "main",
},
},
},
setupMock: func(mockSecrets *secrets.MockService) {
mockSecrets.On("Decrypt", mock.Anything, mock.Anything).Return([]byte("test-token"), nil)
},
expectError: true,
errorMsg: "clone error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup test environment
mockSecrets := secrets.NewMockService(t)
tt.setupMock(mockSecrets)
// Create a temporary directory for each test
if tt.root != "" {
tempDir := t.TempDir()
tt.root = tempDir
}
if tt.createRepo {
tt.config.Spec.GitHub.URL = createTestRepo(t)
}
// Execute the test
ctx := context.Background()
repo, err := Clone(ctx, tt.root, tt.config, tt.opts, mockSecrets)
// Verify results
if tt.expectError {
require.Error(t, err)
require.Contains(t, err.Error(), tt.errorMsg)
require.Nil(t, repo)
} else {
require.NoError(t, err)
require.NotNil(t, repo)
// Verify the returned repository
gitRepo, ok := repo.(*GoGitRepo)
require.True(t, ok)
require.Equal(t, tt.config, gitRepo.config)
require.NotEmpty(t, gitRepo.dir)
require.NotNil(t, gitRepo.tree)
require.NotNil(t, gitRepo.repo)
// Clean up
err = repo.Remove(ctx)
require.NoError(t, err)
}
mockSecrets.AssertExpectations(t)
})
}
}
func createTestRepo(t *testing.T) string {
// Create memory filesystem
fs := memfs.New()
// Initialize new repo
repo, err := git.Init(memory.NewStorage(), fs)
require.NoError(t, err, "Failed to init test repo")
w, err := repo.Worktree()
require.NoError(t, err, "Failed to get worktree")
// Create a dummy file
f, err := fs.Create("README.md")
require.NoError(t, err, "Failed to create file")
_, err = f.Write([]byte("Hello, world!"))
require.NoError(t, err, "Failed to write content")
err = f.Close()
require.NoError(t, err, "Failed to close file")
// Add and commit the file
_, err = w.Add("README.md")
require.NoError(t, err, "Failed to add file")
// Create initial commit
_, err = w.Commit("initial commit", &git.CommitOptions{
Author: &object.Signature{
Name: "Test User",
Email: "test@example.com",
When: time.Now(),
},
})
require.NoError(t, err, "Failed to commit")
// Create a branch
headRef, err := repo.Head()
require.NoError(t, err, "Failed to get HEAD reference")
// Create a new branch reference pointing to the current HEAD commit
branchRef := plumbing.NewBranchReferenceName("main")
ref := plumbing.NewHashReference(branchRef, headRef.Hash())
// Save the reference to create the branch
err = repo.Storer.SetReference(ref)
require.NoError(t, err, "Failed to create branch")
// Checkout the new branch
err = w.Checkout(&git.CheckoutOptions{
Branch: branchRef,
})
require.NoError(t, err, "Failed to checkout branch")
// Create a map of repositories for the server
repos := make(map[string]*git.Repository)
repos["test-repo.git"] = repo
// Create and install the server
loader := server.MapLoader{
"file://test-repo.git": repo.Storer,
}
srv := server.NewServer(loader)
client.InstallProtocol("file", srv)
return "file://test-repo.git"
}