github.com/jfrog/frogbot@v1.1.1-0.20231221090046-821a26f50338/integrationutils.go (about)

     1  package main
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"github.com/jfrog/frogbot/scanpullrequest"
     7  	"github.com/jfrog/frogbot/scanrepository"
     8  	"github.com/jfrog/frogbot/utils"
     9  	"github.com/jfrog/frogbot/utils/outputwriter"
    10  	"github.com/jfrog/froggit-go/vcsclient"
    11  	"github.com/jfrog/froggit-go/vcsutils"
    12  	"github.com/stretchr/testify/assert"
    13  	"github.com/stretchr/testify/require"
    14  	"os"
    15  	"strconv"
    16  	"strings"
    17  	"testing"
    18  	"time"
    19  )
    20  
    21  const (
    22  	repoName               = "integration"
    23  	issuesBranch           = "issues-branch"
    24  	mainBranch             = "main"
    25  	expectedNumberOfIssues = 11
    26  )
    27  
    28  type IntegrationTestDetails struct {
    29  	RepoName         string
    30  	RepoOwner        string
    31  	GitToken         string
    32  	GitCloneURL      string
    33  	GitProvider      string
    34  	GitProject       string
    35  	GitUsername      string
    36  	ApiEndpoint      string
    37  	PullRequestID    string
    38  	CustomBranchName string
    39  }
    40  
    41  func NewIntegrationTestDetails(token, gitProvider, gitCloneUrl, repoOwner string) *IntegrationTestDetails {
    42  	return &IntegrationTestDetails{
    43  		GitProject:  repoName,
    44  		RepoOwner:   repoOwner,
    45  		RepoName:    repoName,
    46  		GitToken:    token,
    47  		GitUsername: "frogbot",
    48  		GitProvider: gitProvider,
    49  		GitCloneURL: gitCloneUrl,
    50  	}
    51  }
    52  
    53  func buildGitManager(t *testing.T, testDetails *IntegrationTestDetails) *utils.GitManager {
    54  	gitManager, err := utils.NewGitManager().
    55  		SetAuth(testDetails.GitUsername, testDetails.GitToken).
    56  		SetEmailAuthor("frogbot-test@jfrog.com").
    57  		SetRemoteGitUrl(testDetails.GitCloneURL)
    58  	assert.NoError(t, err)
    59  	return gitManager
    60  }
    61  
    62  func getIssuesBranchName() string {
    63  	return fmt.Sprintf("%s-%s", issuesBranch, getTimestamp())
    64  }
    65  
    66  func getTimestamp() string {
    67  	return strconv.FormatInt(time.Now().Unix(), 10)
    68  }
    69  
    70  func setIntegrationTestEnvs(t *testing.T, testDetails *IntegrationTestDetails) func() {
    71  	// Frogbot sanitizes all the environment variables that start with 'JF',
    72  	// so we restore them at the end of the test to avoid collisions with other tests
    73  	envRestoreFunc := getJfrogEnvRestoreFunc(t)
    74  	unsetEnvs := utils.SetEnvsAndAssertWithCallback(t, map[string]string{
    75  		utils.RequirementsFileEnv:   "requirements.txt",
    76  		utils.GitPullRequestIDEnv:   testDetails.PullRequestID,
    77  		utils.GitProvider:           testDetails.GitProvider,
    78  		utils.GitTokenEnv:           testDetails.GitToken,
    79  		utils.GitRepoEnv:            testDetails.RepoName,
    80  		utils.GitRepoOwnerEnv:       testDetails.RepoOwner,
    81  		utils.BranchNameTemplateEnv: testDetails.CustomBranchName,
    82  		utils.GitApiEndpointEnv:     testDetails.ApiEndpoint,
    83  		utils.GitProjectEnv:         testDetails.GitProject,
    84  		utils.GitUsernameEnv:        testDetails.GitUsername,
    85  		utils.GitBaseBranchEnv:      mainBranch,
    86  	})
    87  	return func() {
    88  		envRestoreFunc()
    89  		unsetEnvs()
    90  	}
    91  }
    92  
    93  func createAndCheckoutIssueBranch(t *testing.T, testDetails *IntegrationTestDetails, tmpDir, currentIssuesBranch string) func() {
    94  	gitManager := buildGitManager(t, testDetails)
    95  	// buildGitManager in an empty directory automatically creates a default .git folder, which prevents cloning.
    96  	// So we remove the default .git and clone the repository with its .git content
    97  	err := vcsutils.RemoveTempDir(".git")
    98  	require.NoError(t, err)
    99  
   100  	err = gitManager.Clone(tmpDir, issuesBranch)
   101  	require.NoError(t, err)
   102  
   103  	err = gitManager.CreateBranchAndCheckout(currentIssuesBranch)
   104  	require.NoError(t, err)
   105  
   106  	// This step is necessary because GitHub limits the number of pull requests from the same commit of the source branch
   107  	_, err = os.Create("emptyfile.txt")
   108  	assert.NoError(t, err)
   109  	err = gitManager.AddAllAndCommit("emptyfile added")
   110  	assert.NoError(t, err)
   111  
   112  	err = gitManager.Push(false, currentIssuesBranch)
   113  	require.NoError(t, err)
   114  	return func() {
   115  		// Remove the branch from remote
   116  		err := gitManager.RemoveRemoteBranch(currentIssuesBranch)
   117  		assert.NoError(t, err)
   118  	}
   119  }
   120  
   121  func findRelevantPrID(pullRequests []vcsclient.PullRequestInfo, branch string) (prId int) {
   122  	for _, pr := range pullRequests {
   123  		if pr.Source.Name == branch && pr.Target.Name == mainBranch {
   124  			prId = int(pr.ID)
   125  			return
   126  		}
   127  	}
   128  	return
   129  }
   130  
   131  func getOpenPullRequests(t *testing.T, client vcsclient.VcsClient, testDetails *IntegrationTestDetails) []vcsclient.PullRequestInfo {
   132  	ctx := context.Background()
   133  	pullRequests, err := client.ListOpenPullRequests(ctx, testDetails.RepoOwner, testDetails.RepoName)
   134  	require.NoError(t, err)
   135  	return pullRequests
   136  }
   137  
   138  func runScanPullRequestCmd(t *testing.T, client vcsclient.VcsClient, testDetails *IntegrationTestDetails) {
   139  	// Change working dir to temp dir for cloning and branch checkout
   140  	tmpDir, restoreFunc := utils.ChangeToTempDirWithCallback(t)
   141  	defer func() {
   142  		assert.NoError(t, restoreFunc())
   143  	}()
   144  
   145  	// Get a timestamp based issues-branch
   146  	currentIssuesBranch := getIssuesBranchName()
   147  	removeBranchFunc := createAndCheckoutIssueBranch(t, testDetails, tmpDir, currentIssuesBranch)
   148  	defer removeBranchFunc()
   149  
   150  	ctx := context.Background()
   151  	// Create a pull request from the timestamp based issue branch against the main branch
   152  	err := client.CreatePullRequest(ctx, testDetails.RepoOwner, testDetails.RepoName, currentIssuesBranch, mainBranch, "scan pull request integration test", "")
   153  	require.NoError(t, err)
   154  
   155  	// Find the relevant pull request id
   156  	pullRequests := getOpenPullRequests(t, client, testDetails)
   157  	prId := findRelevantPrID(pullRequests, currentIssuesBranch)
   158  	testDetails.PullRequestID = strconv.Itoa(prId)
   159  	require.NotZero(t, prId)
   160  	defer func() {
   161  		closePullRequest(t, client, testDetails, prId)
   162  	}()
   163  
   164  	// Set the required environment variables for the scan-pull-request command
   165  	unsetEnvs := setIntegrationTestEnvs(t, testDetails)
   166  	defer unsetEnvs()
   167  
   168  	err = Exec(&scanpullrequest.ScanPullRequestCmd{}, utils.ScanPullRequest)
   169  	// Validate that issues were found and the relevant error returned
   170  	require.Errorf(t, err, scanpullrequest.SecurityIssueFoundErr)
   171  
   172  	validateResults(t, ctx, client, testDetails, prId)
   173  }
   174  
   175  func runScanRepositoryCmd(t *testing.T, client vcsclient.VcsClient, testDetails *IntegrationTestDetails) {
   176  	_, restoreFunc := utils.ChangeToTempDirWithCallback(t)
   177  	defer func() {
   178  		assert.NoError(t, restoreFunc())
   179  	}()
   180  
   181  	timestamp := getTimestamp()
   182  	// Add a timestamp to the fixing pull requests, to identify them later
   183  	testDetails.CustomBranchName = "frogbot-{IMPACTED_PACKAGE}-{BRANCH_NAME_HASH}-" + timestamp
   184  
   185  	// Set the required environment variables for the scan-repository command
   186  	unsetEnvs := setIntegrationTestEnvs(t, testDetails)
   187  	defer unsetEnvs()
   188  
   189  	err := Exec(&scanrepository.ScanRepositoryCmd{}, utils.ScanRepository)
   190  	require.NoError(t, err)
   191  
   192  	gitManager := buildGitManager(t, testDetails)
   193  
   194  	pullRequests := getOpenPullRequests(t, client, testDetails)
   195  
   196  	expectedBranchName := "frogbot-pyjwt-45ebb5a61916a91ae7c1e3ff7ffb6112-" + timestamp
   197  	prId := findRelevantPrID(pullRequests, expectedBranchName)
   198  	assert.NotZero(t, prId)
   199  	closePullRequest(t, client, testDetails, prId)
   200  	assert.NoError(t, gitManager.RemoveRemoteBranch(expectedBranchName))
   201  
   202  	expectedBranchName = "frogbot-pyyaml-985622f4dbf3a64873b6b8440288e005-" + timestamp
   203  	prId = findRelevantPrID(pullRequests, expectedBranchName)
   204  	assert.NotZero(t, prId)
   205  	closePullRequest(t, client, testDetails, prId)
   206  	assert.NoError(t, gitManager.RemoveRemoteBranch(expectedBranchName))
   207  }
   208  
   209  func validateResults(t *testing.T, ctx context.Context, client vcsclient.VcsClient, testDetails *IntegrationTestDetails, prID int) {
   210  	comments, err := client.ListPullRequestComments(ctx, testDetails.RepoOwner, testDetails.RepoName, prID)
   211  	require.NoError(t, err)
   212  
   213  	switch actualClient := client.(type) {
   214  	case *vcsclient.GitHubClient:
   215  		validateGitHubComments(t, ctx, actualClient, testDetails, prID, comments)
   216  	case *vcsclient.AzureReposClient:
   217  		validateAzureComments(t, comments)
   218  	case *vcsclient.BitbucketServerClient:
   219  		validateBitbucketServerComments(t, comments)
   220  	case *vcsclient.GitLabClient:
   221  		validateGitLabComments(t, comments)
   222  	}
   223  }
   224  
   225  func validateGitHubComments(t *testing.T, ctx context.Context, client *vcsclient.GitHubClient, testDetails *IntegrationTestDetails, prID int, comments []vcsclient.CommentInfo) {
   226  	assert.Len(t, comments, 1)
   227  	comment := comments[0]
   228  	assert.Contains(t, comment.Content, string(outputwriter.VulnerabilitiesPrBannerSource))
   229  
   230  	reviewComments, err := client.ListPullRequestReviewComments(ctx, testDetails.RepoOwner, testDetails.RepoName, prID)
   231  	assert.NoError(t, err)
   232  	assert.GreaterOrEqual(t, len(reviewComments), 10)
   233  }
   234  
   235  func validateAzureComments(t *testing.T, comments []vcsclient.CommentInfo) {
   236  	assert.GreaterOrEqual(t, len(comments), expectedNumberOfIssues)
   237  	assertBannerExists(t, comments, string(outputwriter.VulnerabilitiesPrBannerSource))
   238  }
   239  
   240  func validateBitbucketServerComments(t *testing.T, comments []vcsclient.CommentInfo) {
   241  	assert.GreaterOrEqual(t, len(comments), expectedNumberOfIssues)
   242  	assertBannerExists(t, comments, outputwriter.GetSimplifiedTitle(outputwriter.VulnerabilitiesPrBannerSource))
   243  }
   244  
   245  func validateGitLabComments(t *testing.T, comments []vcsclient.CommentInfo) {
   246  	assert.GreaterOrEqual(t, len(comments), expectedNumberOfIssues)
   247  	assertBannerExists(t, comments, string(outputwriter.VulnerabilitiesMrBannerSource))
   248  }
   249  
   250  func getJfrogEnvRestoreFunc(t *testing.T) func() {
   251  	jfrogEnvs := make(map[string]string)
   252  	for _, env := range os.Environ() {
   253  		envSplit := strings.Split(env, "=")
   254  		key := envSplit[0]
   255  		val := envSplit[1]
   256  		if strings.HasPrefix(key, "JF_") {
   257  			jfrogEnvs[key] = val
   258  		}
   259  	}
   260  
   261  	return func() {
   262  		for key, val := range jfrogEnvs {
   263  			assert.NoError(t, os.Setenv(key, val))
   264  		}
   265  	}
   266  }
   267  
   268  // This function retrieves the relevant VCS provider access token based on the corresponding environment variable.
   269  // If the environment variable is empty, the test is skipped.
   270  func getIntegrationToken(t *testing.T, tokenEnv string) string {
   271  	integrationRepoToken := os.Getenv(tokenEnv)
   272  	if integrationRepoToken == "" {
   273  		t.Skipf("%s is not set, skipping integration test", tokenEnv)
   274  	}
   275  	return integrationRepoToken
   276  }
   277  
   278  func assertBannerExists(t *testing.T, comments []vcsclient.CommentInfo, banner string) {
   279  	var isContains bool
   280  	var occurrences int
   281  	for _, c := range comments {
   282  		if strings.Contains(c.Content, banner) {
   283  			isContains = true
   284  			occurrences++
   285  		}
   286  	}
   287  
   288  	assert.True(t, isContains)
   289  	assert.Equal(t, 1, occurrences)
   290  }
   291  
   292  func closePullRequest(t *testing.T, client vcsclient.VcsClient, testDetails *IntegrationTestDetails, prID int) {
   293  	targetBranch := mainBranch
   294  	if _, isAzureClient := client.(*vcsclient.AzureReposClient); isAzureClient {
   295  		// The Azure API requires not adding parameters that won't be updated, so we omit the targetBranch in that case
   296  		targetBranch = ""
   297  	}
   298  	err := client.UpdatePullRequest(context.Background(), testDetails.RepoOwner, testDetails.RepoName, "integration test finished", "", targetBranch, prID, vcsutils.Closed)
   299  	assert.NoError(t, err)
   300  }