package vcs import ( "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "path" "strings" "testing" "time" version "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" gitlab "github.com/xanzy/go-gitlab" . "github.com/runatlantis/atlantis/testing" ) var projectID = 4580910 // Test that the base url gets set properly. func TestNewGitlabClient_BaseURL(t *testing.T) { gitlabClientUnderTest = true defer func() { gitlabClientUnderTest = false }() cases := []struct { Hostname string ExpBaseURL string }{ { "gitlab.com", "https://gitlab.com/api/v4/", }, { "custom.domain", "https://custom.domain/api/v4/", }, { "http://custom.domain", "http://custom.domain/api/v4/", }, { "http://custom.domain:8080", "http://custom.domain:8080/api/v4/", }, { "https://custom.domain", "https://custom.domain/api/v4/", }, { "https://custom.domain/", "https://custom.domain/api/v4/", }, { "https://custom.domain/basepath/", "https://custom.domain/basepath/api/v4/", }, } for _, c := range cases { t.Run(c.Hostname, func(t *testing.T) { log := logging.NewNoopLogger(t) client, err := NewGitlabClient(c.Hostname, "token", log) Ok(t, err) Equals(t, c.ExpBaseURL, client.Client.BaseURL().String()) }) } } // This function gets called even if GitlabClient is nil // so we need to test that. func TestGitlabClient_SupportsCommonMarkNil(t *testing.T) { var gl *GitlabClient Equals(t, false, gl.SupportsCommonMark()) } func TestGitlabClient_SupportsCommonMark(t *testing.T) { cases := []struct { version string exp bool }{ { "11.0", false, }, { "11.1", true, }, { "11.2", true, }, { "12.0", true, }, } for _, c := range cases { t.Run(c.version, func(t *testing.T) { vers, err := version.NewVersion(c.version) Ok(t, err) gl := GitlabClient{ Version: vers, } Equals(t, c.exp, gl.SupportsCommonMark()) }) } } func TestGitlabClient_GetModifiedFiles(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { attempts int }{ {1}, {2}, {3}, } changesPending, err := os.ReadFile("testdata/gitlab-changes-pending.json") Ok(t, err) changesAvailable, err := os.ReadFile("testdata/gitlab-changes-available.json") Ok(t, err) for _, c := range cases { t.Run(fmt.Sprintf("Gitlab returns MR changes after %d attempts", c.attempts), func(t *testing.T) { numAttempts := 0 testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v4/projects/lkysow%2Fatlantis-example/merge_requests/8312/changes?page=1&per_page=100": w.WriteHeader(200) numAttempts++ if numAttempts < c.attempts { w.Write(changesPending) // nolint: errcheck t.Logf("returning changesPending for attempt %d", numAttempts) return } t.Logf("returning changesAvailable for attempt %d", numAttempts) w.Write(changesAvailable) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } })) internalClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(testServer.URL)) Ok(t, err) client := &GitlabClient{ Client: internalClient, Version: nil, PollingInterval: time.Second * 0, PollingTimeout: time.Second * 10, } filenames, err := client.GetModifiedFiles( logger, models.Repo{ FullName: "lkysow/atlantis-example", Owner: "lkysow", Name: "atlantis-example", }, models.PullRequest{ Num: 8312, BaseRepo: models.Repo{ FullName: "lkysow/atlantis-example", Owner: "lkysow", Name: "atlantis-example", }, }) Ok(t, err) Equals(t, []string{"somefile.yaml"}, filenames) }) } } func TestGitlabClient_MergePull(t *testing.T) { logger := logging.NewNoopLogger(t) mergeSuccess, err := os.ReadFile("testdata/github-pull-request.json") Ok(t, err) pipelineSuccess, err := os.ReadFile("testdata/gitlab-pipeline-success.json") Ok(t, err) projectSuccess, err := os.ReadFile("testdata/gitlab-project-success.json") Ok(t, err) cases := []struct { description string glResponse []byte code int expErr string }{ { "success", mergeSuccess, 200, "", }, { "405", []byte(`{"message":"405 Method Not Allowed"}`), 405, "405 {message: 405 Method Not Allowed}", }, { "406", []byte(`{"message":"406 Branch cannot be merged"}`), 406, "406 {message: 406 Branch cannot be merged}", }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { // The first request should hit this URL. case "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/1/merge": w.WriteHeader(c.code) w.Write(c.glResponse) // nolint: errcheck case "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/1": w.WriteHeader(http.StatusOK) w.Write(pipelineSuccess) // nolint: errcheck case "/api/v4/projects/4580910": w.WriteHeader(http.StatusOK) w.Write(projectSuccess) // nolint: errcheck case "/api/v4/": // Rate limiter requests. w.WriteHeader(http.StatusOK) default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } })) internalClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(testServer.URL)) Ok(t, err) client := &GitlabClient{ Client: internalClient, Version: nil, } err = client.MergePull( logger, models.PullRequest{ Num: 1, BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", }, }, models.PullRequestOptions{ DeleteSourceBranchOnMerge: false, }) if c.expErr == "" { Ok(t, err) } else { ErrContains(t, c.expErr, err) ErrContains(t, "unable to merge merge request, it may not be in a mergeable state", err) } }) } } func TestGitlabClient_UpdateStatus(t *testing.T) { logger := logging.NewNoopLogger(t) pipelineSuccess, err := os.ReadFile("testdata/gitlab-pipeline-success.json") Ok(t, err) cases := []struct { status models.CommitStatus expState string }{ { models.PendingCommitStatus, "running", }, { models.SuccessCommitStatus, "success", }, { models.FailedCommitStatus, "failed", }, } for _, c := range cases { t.Run(c.expState, func(t *testing.T) { gotRequest := false testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v4/projects/runatlantis%2Fatlantis/statuses/sha": gotRequest = true body, err := io.ReadAll(r.Body) Ok(t, err) exp := fmt.Sprintf(`{"state":"%s","ref":"patch-1-merger","context":"src","target_url":"https://google.com","description":"description"}`, c.expState) Equals(t, exp, string(body)) defer r.Body.Close() // nolint: errcheck w.Write([]byte("{}")) // nolint: errcheck case "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/1": w.WriteHeader(http.StatusOK) w.Write(pipelineSuccess) // nolint: errcheck case "/api/v4/": // Rate limiter requests. w.WriteHeader(http.StatusOK) default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } })) internalClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(testServer.URL)) Ok(t, err) client := &GitlabClient{ Client: internalClient, Version: nil, } repo := models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", } err = client.UpdateStatus( logger, repo, models.PullRequest{ Num: 1, BaseRepo: repo, HeadCommit: "sha", HeadBranch: "test", }, c.status, "src", "description", "https://google.com") Ok(t, err) Assert(t, gotRequest, "expected to get the request") }) } } func TestGitlabClient_PullIsMergeable(t *testing.T) { logger := logging.NewNoopLogger(t) gitlabClientUnderTest = true gitlabVersionOver15_6 := "15.8.3-ee" gitlabVersion15_6 := "15.6.0-ee" gitlabVersionUnder15_6 := "15.3.2-ce" gitlabServerVersions := []string{gitlabVersionOver15_6, gitlabVersion15_6, gitlabVersionUnder15_6} vcsStatusName := "atlantis-test" defaultMr := 1 noHeadPipelineMR := 2 ciMustPassSuccessMR := 3 ciMustPassFailureMR := 4 needRebaseMR := 5 pipelineSuccess, err := os.ReadFile("testdata/gitlab-pipeline-success.json") Ok(t, err) projectSuccess, err := os.ReadFile("testdata/gitlab-project-success.json") Ok(t, err) detailedMergeStatusCiMustPass, err := os.ReadFile("testdata/gitlab-detailed-merge-status-ci-must-pass.json") Ok(t, err) detailedMergeStatusNeedRebase, err := os.ReadFile("testdata/gitlab-detailed-merge-status-need-rebase.json") Ok(t, err) headPipelineNotAvailable, err := os.ReadFile("testdata/gitlab-head-pipeline-not-available.json") Ok(t, err) cases := []struct { statusName string status models.CommitStatus gitlabVersion []string mrID int expState bool }{ { fmt.Sprintf("%s/apply: resource/default", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, defaultMr, true, }, { fmt.Sprintf("%s/apply", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, defaultMr, true, }, { fmt.Sprintf("%s/plan: resource/default", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, defaultMr, false, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.PendingCommitStatus, gitlabServerVersions, defaultMr, false, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.SuccessCommitStatus, gitlabServerVersions, defaultMr, true, }, { fmt.Sprintf("%s/apply", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, ciMustPassSuccessMR, true, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, ciMustPassFailureMR, false, }, { fmt.Sprintf("%s/apply", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, needRebaseMR, true, }, { fmt.Sprintf("%s/apply: resource/default", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, noHeadPipelineMR, true, }, { fmt.Sprintf("%s/apply", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, noHeadPipelineMR, true, }, { fmt.Sprintf("%s/plan: resource/default", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, noHeadPipelineMR, false, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.PendingCommitStatus, gitlabServerVersions, noHeadPipelineMR, false, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, noHeadPipelineMR, false, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.SuccessCommitStatus, gitlabServerVersions, noHeadPipelineMR, true, }, } for _, serverVersion := range gitlabServerVersions { for _, c := range cases { t.Run(c.statusName, func(t *testing.T) { testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v4/": // Rate limiter requests. w.WriteHeader(http.StatusOK) case fmt.Sprintf("/api/v4/projects/runatlantis%%2Fatlantis/merge_requests/%v", defaultMr): w.WriteHeader(http.StatusOK) w.Write(pipelineSuccess) // nolint: errcheck case fmt.Sprintf("/api/v4/projects/runatlantis%%2Fatlantis/merge_requests/%v", noHeadPipelineMR): w.WriteHeader(http.StatusOK) w.Write(headPipelineNotAvailable) // nolint: errcheck case fmt.Sprintf("/api/v4/projects/runatlantis%%2Fatlantis/merge_requests/%v", ciMustPassSuccessMR): w.WriteHeader(http.StatusOK) w.Write(detailedMergeStatusCiMustPass) // nolint: errcheck case fmt.Sprintf("/api/v4/projects/runatlantis%%2Fatlantis/merge_requests/%v", ciMustPassFailureMR): w.WriteHeader(http.StatusOK) w.Write(detailedMergeStatusCiMustPass) // nolint: errcheck case fmt.Sprintf("/api/v4/projects/runatlantis%%2Fatlantis/merge_requests/%v", needRebaseMR): w.WriteHeader(http.StatusOK) w.Write(detailedMergeStatusNeedRebase) // nolint: errcheck case fmt.Sprintf("/api/v4/projects/%v", projectID): w.WriteHeader(http.StatusOK) w.Write(projectSuccess) // nolint: errcheck case fmt.Sprintf("/api/v4/projects/%v/repository/commits/67cb91d3f6198189f433c045154a885784ba6977/statuses", projectID): w.WriteHeader(http.StatusOK) response := fmt.Sprintf(`[{"id":133702594,"sha":"67cb91d3f6198189f433c045154a885784ba6977","ref":"patch-1","status":"%s","name":"%s","target_url":null,"description":"ApplySuccess","created_at":"2018-12-12T18:31:57.957Z","started_at":null,"finished_at":"2018-12-12T18:31:58.480Z","allow_failure":false,"coverage":null,"author":{"id":1755902,"username":"lkysow","name":"LukeKysow","state":"active","avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon","web_url":"https://gitlab.com/lkysow"}}]`, c.status, c.statusName) w.Write([]byte(response)) // nolint: errcheck case "/api/v4/version": w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") type version struct { Version string } v := version{Version: serverVersion} err := json.NewEncoder(w).Encode(v) Ok(t, err) default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } })) internalClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(testServer.URL)) Ok(t, err) client := &GitlabClient{ Client: internalClient, Version: nil, } repo := models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", VCSHost: models.VCSHost{ Type: models.Gitlab, Hostname: "gitlab.com", }, } mergeable, err := client.PullIsMergeable( logger, repo, models.PullRequest{ Num: c.mrID, BaseRepo: repo, HeadCommit: "67cb91d3f6198189f433c045154a885784ba6977", }, vcsStatusName) Ok(t, err) Equals(t, c.expState, mergeable) }) } } } func TestGitlabClient_MarkdownPullLink(t *testing.T) { logger := logging.NewNoopLogger(t) gitlabClientUnderTest = true defer func() { gitlabClientUnderTest = false }() client, err := NewGitlabClient("gitlab.com", "token", logger) Ok(t, err) pull := models.PullRequest{Num: 1} s, _ := client.MarkdownPullLink(pull) exp := "!1" Equals(t, exp, s) } func TestGitlabClient_HideOldComments(t *testing.T) { logger := logging.NewNoopLogger(t) type notePutCallDetails struct { noteID string comment []string } type jsonBody struct { Body string } authorID := 1 authorUserName := "pipin" authorEmail := "admin@example.com" pullNum := 123 userCommentIDs := [1]string{"1"} planCommentIDs := [2]string{"3", "5"} systemCommentIDs := [1]string{"4"} summaryCommentIDs := [1]string{"2"} planComments := [3]string{"Ran Plan for 2 projects:", "Ran Plan for dir: `stack1` workspace: `default`", "Ran Plan for 2 projects:"} summaryHeader := fmt.Sprintf("
Superseded Atlantis %s", command.Plan.TitleString()) summaryFooter := "
" lineFeed := "\\n" issueResp := "[" + fmt.Sprintf(`{"id":%s,"body":"User comment","author":{"id": %d, "username":"%s", "email":"%s"},"system": false,"project_id": %d}`, userCommentIDs[0], authorID, authorUserName, authorEmail, pullNum) + "," + fmt.Sprintf(`{"id":%s,"body":"%s","author":{"id": %d, "username":"%s", "email":"%s"},"system": false,"project_id": %d}`, summaryCommentIDs[0], summaryHeader+lineFeed+planComments[2]+lineFeed+summaryFooter, authorID, authorUserName, authorEmail, pullNum) + "," + fmt.Sprintf(`{"id":%s,"body":"%s","author":{"id": %d, "username":"%s", "email":"%s"},"system": false,"project_id": %d}`, planCommentIDs[0], planComments[0], authorID, authorUserName, authorEmail, pullNum) + "," + fmt.Sprintf(`{"id":%s,"body":"System comment","author":{"id": %d, "username":"%s", "email":"%s"},"system": true,"project_id": %d}`, systemCommentIDs[0], authorID, authorUserName, authorEmail, pullNum) + "," + fmt.Sprintf(`{"id":%s,"body":"%s","author":{"id": %d, "username":"%s", "email":"%s"},"system": false,"project_id": %d}`, planCommentIDs[1], planComments[1], authorID, authorUserName, authorEmail, pullNum) + "]" repo := models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", VCSHost: models.VCSHost{ Type: models.Gitlab, Hostname: "gitlab.com", }, } cases := []struct { dir string processedComments int processedCommentIds []string processedPlanComment []string }{ { "", 2, []string{planCommentIDs[0], planCommentIDs[1]}, []string{planComments[0], planComments[1]}, }, { "stack1", 1, []string{planCommentIDs[1]}, []string{planComments[1]}, }, { "stack2", 0, []string{}, []string{}, }, } for _, c := range cases { t.Run(c.dir, func(t *testing.T) { gitlabClientUnderTest = true defer func() { gitlabClientUnderTest = false }() gotNotePutCalls := make([]notePutCallDetails, 0, 1) testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": switch r.RequestURI { case "/api/v4/user": w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") response := fmt.Sprintf(`{"id": %d,"username": "%s", "email": "%s"}`, authorID, authorUserName, authorEmail) w.Write([]byte(response)) // nolint: errcheck case fmt.Sprintf("/api/v4/projects/runatlantis%%2Fatlantis/merge_requests/%d/notes?order_by=created_at&sort=asc", pullNum): w.WriteHeader(http.StatusOK) response := issueResp w.Write([]byte(response)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } case "PUT": switch { case strings.HasPrefix(r.RequestURI, fmt.Sprintf("/api/v4/projects/runatlantis%%2Fatlantis/merge_requests/%d/notes/", pullNum)): w.WriteHeader(http.StatusOK) var body jsonBody json.NewDecoder(r.Body).Decode(&body) // nolint: errcheck notePutCallDetail := notePutCallDetails{ noteID: path.Base(r.RequestURI), comment: strings.Split(body.Body, "\n"), } gotNotePutCalls = append(gotNotePutCalls, notePutCallDetail) response := "{}" w.Write([]byte(response)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } default: t.Errorf("got unexpected method at %q", r.Method) http.Error(w, "not found", http.StatusNotFound) } }), ) internalClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(testServer.URL)) Ok(t, err) client := &GitlabClient{ Client: internalClient, Version: nil, } err = client.HidePrevCommandComments(logger, repo, pullNum, command.Plan.TitleString(), c.dir) Ok(t, err) // Check the correct number of plan comments have been processed Equals(t, c.processedComments, len(gotNotePutCalls)) // Check the correct comments have been processed for i := 0; i < c.processedComments; i++ { Equals(t, c.processedCommentIds[i], gotNotePutCalls[i].noteID) Equals(t, summaryHeader, gotNotePutCalls[i].comment[0]) Equals(t, c.processedPlanComment[i], gotNotePutCalls[i].comment[1]) Equals(t, summaryFooter, gotNotePutCalls[i].comment[2]) } }) } } func TestGithubClient_GetPullLabels(t *testing.T) { logger := logging.NewNoopLogger(t) mergeSuccessWithLabel, err := os.ReadFile("testdata/gitlab-merge-success-with-label.json") Ok(t, err) testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/1": w.WriteHeader(http.StatusOK) w.Write(mergeSuccessWithLabel) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } })) internalClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(testServer.URL)) Ok(t, err) client := &GitlabClient{ Client: internalClient, Version: nil, } labels, err := client.GetPullLabels( logger, models.Repo{ FullName: "runatlantis/atlantis", }, models.PullRequest{ Num: 1, }, ) Ok(t, err) Equals(t, []string{"work in progress"}, labels) } func TestGithubClient_GetPullLabels_EmptyResponse(t *testing.T) { logger := logging.NewNoopLogger(t) pipelineSuccess, err := os.ReadFile("testdata/gitlab-pipeline-success.json") Ok(t, err) testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/1": w.WriteHeader(http.StatusOK) w.Write(pipelineSuccess) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } })) internalClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(testServer.URL)) Ok(t, err) client := &GitlabClient{ Client: internalClient, Version: nil, } labels, err := client.GetPullLabels( logger, models.Repo{ FullName: "runatlantis/atlantis", }, models.PullRequest{ Num: 1, }) Ok(t, err) Equals(t, 0, len(labels)) }