Files
portainer/api/http/handler/customtemplates/customtemplate_git_fetch_test.go
T
2026-06-08 15:01:55 -03:00

367 lines
9.7 KiB
Go

package customtemplates
import (
"bytes"
"context"
"errors"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/pkg/fips"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
)
func init() {
fips.InitFIPS(false)
}
var testFileContent = "abcdefg"
type TestGitService struct {
portainer.GitService
targetFilePath string
}
func (g *TestGitService) CloneRepository(
_ context.Context,
destination string,
repositoryURL,
referenceName string,
username,
password string,
tlsSkipVerify bool,
) error {
time.Sleep(100 * time.Millisecond)
return createTestFile(g.targetFilePath)
}
func (g *TestGitService) LatestCommitID(
_ context.Context,
repositoryURL,
referenceName,
username,
password string,
tlsSkipVerify bool,
) (string, error) {
return "", nil
}
type TestFileService struct {
portainer.FileService
}
func (f *TestFileService) GetFileContent(projectPath, configFilePath string) ([]byte, error) {
return os.ReadFile(filesystem.JoinPaths(projectPath, configFilePath))
}
type InvalidTestGitService struct {
portainer.GitService
targetFilePath string
}
func (g *InvalidTestGitService) CloneRepository(
_ context.Context,
dest,
repoUrl,
refName,
username,
password string,
tlsSkipVerify bool,
) error {
return errors.New("simulate network error")
}
func (g *InvalidTestGitService) LatestCommitID(
_ context.Context,
repositoryURL,
referenceName,
username,
password string,
tlsSkipVerify bool,
) (string, error) {
return "", nil
}
func createTestFile(targetPath string) error {
f, err := os.Create(targetPath)
if err != nil {
return err
}
defer logs.CloseAndLogErr(f)
_, err = f.WriteString(testFileContent)
return err
}
func prepareTestFolder(projectPath, filename string) error {
if err := os.MkdirAll(projectPath, fs.ModePerm); err != nil {
return err
}
return createTestFile(filesystem.JoinPaths(projectPath, filename))
}
func singleAPIRequest(h *Handler, jwt string, expect string) error {
type response struct {
FileContent string
}
req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}"))
testhelpers.AddTestSecurityCookie(req, jwt)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
return errors.New("unexpected status code: " + http.StatusText(rr.Code))
}
body, err := io.ReadAll(rr.Body)
if err != nil {
return err
}
var resp response
if err := json.Unmarshal(body, &resp); err != nil {
return err
}
if resp.FileContent != expect {
return errors.New("unexpected file content: " + resp.FileContent + ", expected: " + expect)
}
return nil
}
func Test_customTemplateGitFetch(t *testing.T) {
t.Parallel()
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
// create user(s)
user1 := &portainer.User{ID: 1, Username: "user-1", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()}
err := store.User().Create(user1)
require.NoError(t, err, "error creating user 1")
user2 := &portainer.User{ID: 2, Username: "user-2", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()}
err = store.User().Create(user2)
require.NoError(t, err, "error creating user 2")
dir, err := os.Getwd()
require.NoError(t, err, "error to get working directory")
src := &portainer.Source{
ID: 1,
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
},
}
err = store.Source().Create(src)
require.NoError(t, err, "error creating source")
const configFilePath = "test-config-path.txt"
template1 := &portainer.CustomTemplate{
ID: 1,
Title: "custom-template-1",
ProjectPath: filesystem.JoinPaths(dir, "fixtures/custom_template_1"),
Artifact: &portainer.Artifact{
Files: []portainer.ArtifactFile{{Path: configFilePath, SourceID: src.ID}},
},
}
err = store.CustomTemplateService.Create(template1)
require.NoError(t, err, "error creating custom template 1")
// prepare testing folder
err = prepareTestFolder(template1.ProjectPath, configFilePath)
require.NoError(t, err, "error creating testing folder")
defer func() {
err := os.RemoveAll(filesystem.JoinPaths(dir, "fixtures"))
require.NoError(t, err)
}()
// setup services
jwtService, err := jwt.NewService("1h", store)
require.NoError(t, err, "Error initiating jwt service")
requestBouncer := security.NewRequestBouncer(t.Context(), store, jwtService, nil)
gitService := &TestGitService{
targetFilePath: filesystem.JoinPaths(template1.ProjectPath, configFilePath),
}
fileService := &TestFileService{}
h := NewHandler(requestBouncer, store, fileService, gitService)
// generate two standard users' tokens
jwt1, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: user1.ID, Username: user1.Username, Role: user1.Role})
require.NoError(t, err)
jwt2, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: user2.ID, Username: user2.Username, Role: user2.Role})
require.NoError(t, err)
t.Run("can return the expected file content by a single call from one user", func(t *testing.T) {
err := singleAPIRequest(h, jwt1, "abcdefg")
require.NoError(t, err)
})
t.Run("can return the expected file content by multiple calls from one user", func(t *testing.T) {
var g errgroup.Group
for range 5 {
g.Go(func() error {
return singleAPIRequest(h, jwt1, "abcdefg")
})
}
err := g.Wait()
require.NoError(t, err)
})
t.Run("can return the expected file content by multiple calls from different users", func(t *testing.T) {
var g errgroup.Group
for i := range 10 {
g.Go(func() error {
if i%2 == 0 {
return singleAPIRequest(h, jwt1, "abcdefg")
}
return singleAPIRequest(h, jwt2, "abcdefg")
})
}
err := g.Wait()
require.NoError(t, err)
})
t.Run("can return the expected file content after a new commit is made", func(t *testing.T) {
err := singleAPIRequest(h, jwt1, "abcdefg")
require.NoError(t, err)
testFileContent = "gfedcba"
err = singleAPIRequest(h, jwt2, "gfedcba")
require.NoError(t, err)
})
t.Run("restore git repository if it is failed to download the new git repository", func(t *testing.T) {
invalidGitService := &InvalidTestGitService{
targetFilePath: filesystem.JoinPaths(template1.ProjectPath, configFilePath),
}
h := NewHandler(requestBouncer, store, fileService, invalidGitService)
req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}"))
testhelpers.AddTestSecurityCookie(req, jwt1)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
is.Equal(http.StatusInternalServerError, rr.Code)
var errResp httperror.HandlerError
err = json.NewDecoder(rr.Body).Decode(&errResp)
require.NoError(t, err, "failed to parse error body")
assert.FileExists(t, gitService.targetFilePath, "previous git repository is not restored")
fileContent, err := os.ReadFile(gitService.targetFilePath)
require.NoError(t, err, "failed to read target file")
assert.Equal(t, "gfedcba", string(fileContent))
})
}
func TestCustomTemplateGitFetch_NilArtifactReturnsBadRequest(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
template := &portainer.CustomTemplate{ID: 1, Title: "no-git-template"}
err := store.CustomTemplateService.Create(template)
require.NoError(t, err)
h := NewHandler(testhelpers.NewTestRequestBouncer(), store, &TestFileService{}, &TestGitService{})
req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}"))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
require.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestCustomTemplateGitFetch_EmptySourceIDsReturnsBadRequest(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
template := &portainer.CustomTemplate{
ID: 1,
Title: "empty-source-ids",
Artifact: &portainer.Artifact{
Files: []portainer.ArtifactFile{},
},
}
err := store.CustomTemplateService.Create(template)
require.NoError(t, err)
h := NewHandler(testhelpers.NewTestRequestBouncer(), store, &TestFileService{}, &TestGitService{})
req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}"))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
require.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestCustomTemplateGitFetch_SourceWithNilGitConfigReturnsInternalError(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
src := &portainer.Source{Type: portainer.SourceTypeGit}
err := store.Source().Create(src)
require.NoError(t, err)
template := &portainer.CustomTemplate{
ID: 1,
Title: "nil-git-config",
Artifact: &portainer.Artifact{
Files: []portainer.ArtifactFile{{SourceID: src.ID}},
},
}
err = store.CustomTemplateService.Create(template)
require.NoError(t, err)
h := NewHandler(testhelpers.NewTestRequestBouncer(), store, &TestFileService{}, &TestGitService{})
req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}"))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
require.Equal(t, http.StatusInternalServerError, rr.Code)
}