sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/bugzilla/client_test.go (about)

     1  /*
     2  Copyright 2019 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package bugzilla
    18  
    19  import (
    20  	"crypto/tls"
    21  	"encoding/json"
    22  	"fmt"
    23  	"io"
    24  	"net/http"
    25  	"net/http/httptest"
    26  	"regexp"
    27  	"strconv"
    28  	"strings"
    29  	"testing"
    30  	"time"
    31  
    32  	"github.com/google/go-cmp/cmp"
    33  	"github.com/sirupsen/logrus"
    34  	"k8s.io/apimachinery/pkg/util/diff"
    35  	"k8s.io/apimachinery/pkg/util/sets"
    36  )
    37  
    38  var (
    39  	bugData         = []byte(`{"bugs":[{"alias":[],"assigned_to":"Steve Kuznetsov","assigned_to_detail":{"email":"skuznets","id":381851,"name":"skuznets","real_name":"Steve Kuznetsov"},"blocks":[],"cc":["Sudha Ponnaganti"],"cc_detail":[{"email":"sponnaga","id":426940,"name":"sponnaga","real_name":"Sudha Ponnaganti"}],"classification":"Red Hat","component":["Test Infrastructure"],"creation_time":"2019-05-01T19:33:36Z","creator":"Dan Mace","creator_detail":{"email":"dmace","id":330250,"name":"dmace","real_name":"Dan Mace"},"deadline":null,"depends_on":[],"docs_contact":"","dupe_of":null,"groups":[],"id":1705243,"is_cc_accessible":true,"is_confirmed":true,"is_creator_accessible":true,"is_open":true,"keywords":[],"last_change_time":"2019-05-17T15:13:13Z","op_sys":"Unspecified","platform":"Unspecified","priority":"unspecified","product":"OpenShift Container Platform","qa_contact":"","resolution":"","see_also":[],"severity":"medium","status":"VERIFIED","summary":"[ci] cli image flake affecting *-images jobs","target_milestone":"---","target_release":["3.11.z"],"url":"","version":["3.11.0"],"whiteboard":""}],"faults":[]}`)
    40  	bugStruct       = &Bug{Alias: []string{}, AssignedTo: "Steve Kuznetsov", AssignedToDetail: &User{Email: "skuznets", ID: 381851, Name: "skuznets", RealName: "Steve Kuznetsov"}, Blocks: []int{}, CC: []string{"Sudha Ponnaganti"}, CCDetail: []User{{Email: "sponnaga", ID: 426940, Name: "sponnaga", RealName: "Sudha Ponnaganti"}}, Classification: "Red Hat", Component: []string{"Test Infrastructure"}, CreationTime: "2019-05-01T19:33:36Z", Creator: "Dan Mace", CreatorDetail: &User{Email: "dmace", ID: 330250, Name: "dmace", RealName: "Dan Mace"}, DependsOn: []int{}, ID: 1705243, IsCCAccessible: true, IsConfirmed: true, IsCreatorAccessible: true, IsOpen: true, Groups: []string{}, Keywords: []string{}, LastChangeTime: "2019-05-17T15:13:13Z", OperatingSystem: "Unspecified", Platform: "Unspecified", Priority: "unspecified", Product: "OpenShift Container Platform", SeeAlso: []string{}, Severity: "medium", Status: "VERIFIED", Summary: "[ci] cli image flake affecting *-images jobs", TargetRelease: []string{"3.11.z"}, TargetMilestone: "---", Version: []string{"3.11.0"}}
    41  	bugAccessDenied = []byte(`{"error":true,"code":102,"message":"You are not authorized to access bug #2. To see this bug, you must first log in to an account with the appropriate permissions."}`)
    42  	bugInvalidBugID = []byte(`{"error":true,"code":101,"message":"Bug #3 does not exist."}`)
    43  )
    44  
    45  func clientForUrl(url string) *client {
    46  	return &client{
    47  		logger: logrus.WithField("testing", "true"),
    48  		delegate: &delegate{
    49  			authMethod: "x-bugzilla-api-key",
    50  			endpoint:   url,
    51  			client: &http.Client{
    52  				Transport: &http.Transport{
    53  					TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
    54  				},
    55  			},
    56  			getAPIKey: func() []byte {
    57  				return []byte("api-key")
    58  			},
    59  		},
    60  	}
    61  }
    62  
    63  func TestGetBug(t *testing.T) {
    64  	testServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    65  		if r.Header.Get("X-BUGZILLA-API-KEY") != "api-key" {
    66  			t.Error("did not get api-key passed in X-BUGZILLA-API-KEY header")
    67  			http.Error(w, "403 Forbidden", http.StatusForbidden)
    68  			return
    69  		}
    70  		if r.Method != http.MethodGet {
    71  			t.Errorf("incorrect method to get a bug: %s", r.Method)
    72  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
    73  			return
    74  		}
    75  		if !strings.HasPrefix(r.URL.Path, "/rest/bug/") {
    76  			t.Errorf("incorrect path to get a bug: %s", r.URL.Path)
    77  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
    78  			return
    79  		}
    80  		if id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/rest/bug/")); err != nil {
    81  			t.Errorf("malformed bug id: %s", r.URL.Path)
    82  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
    83  			return
    84  		} else {
    85  			if id == 1705243 {
    86  				w.Write(bugData)
    87  			} else if id == 2 {
    88  				w.Write(bugAccessDenied)
    89  			} else if id == 3 {
    90  				w.Write(bugInvalidBugID)
    91  			} else {
    92  				http.Error(w, "404 Not Found", http.StatusNotFound)
    93  			}
    94  		}
    95  	}))
    96  	defer testServer.Close()
    97  	client := clientForUrl(testServer.URL)
    98  
    99  	// this should give us what we want
   100  	bug, err := client.GetBug(1705243)
   101  	if err != nil {
   102  		t.Errorf("expected no error, but got one: %v", err)
   103  	}
   104  	if diff := cmp.Diff(bug, bugStruct); diff != "" {
   105  		t.Errorf("got incorrect bug: %v", diff)
   106  	}
   107  
   108  	// this should 404
   109  	otherBug, err := client.GetBug(1)
   110  	if err == nil {
   111  		t.Error("expected an error, but got none")
   112  	} else if !IsNotFound(err) {
   113  		t.Errorf("expected a not found error, got %v", err)
   114  	}
   115  	if otherBug != nil {
   116  		t.Errorf("expected no bug, got: %v", otherBug)
   117  	}
   118  
   119  	// this should return access denied
   120  	accessDeniedBug, err := client.GetBug(2)
   121  	if err == nil {
   122  		t.Error("expected an error, but got none")
   123  	} else if !IsAccessDenied(err) {
   124  		t.Errorf("expected an access denied error, got %v", err)
   125  	}
   126  	if accessDeniedBug != nil {
   127  		t.Errorf("expected no bug, got: %v", accessDeniedBug)
   128  	}
   129  
   130  	// this should return invalid Bug ID
   131  	invalidIDBug, err := client.GetBug(3)
   132  	if err == nil {
   133  		t.Error("expected an error, but got none")
   134  	} else if !IsInvalidBugID(err) {
   135  		t.Errorf("expected an invalid bug error, got %v", err)
   136  	}
   137  	if invalidIDBug != nil {
   138  		t.Errorf("expected no bug, got: %v", invalidIDBug)
   139  	}
   140  }
   141  
   142  func TestCreateBug(t *testing.T) {
   143  	testServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   144  		if r.Header.Get("X-BUGZILLA-API-KEY") != "api-key" {
   145  			t.Error("did not get api-key passed in X-BUGZILLA-API-KEY header")
   146  			http.Error(w, "403 Forbidden", http.StatusForbidden)
   147  			return
   148  		}
   149  		if r.Method != http.MethodPost {
   150  			t.Errorf("incorrect method to create a bug: %s", r.Method)
   151  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   152  			return
   153  		}
   154  		if !strings.HasPrefix(r.URL.Path, "/rest/bug") {
   155  			t.Errorf("incorrect path to create a bug: %s", r.URL.Path)
   156  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   157  			return
   158  		}
   159  		raw, err := io.ReadAll(r.Body)
   160  		if err != nil {
   161  			t.Errorf("failed to read request body: %v", err)
   162  			http.Error(w, "500 Server Error", http.StatusInternalServerError)
   163  			return
   164  		}
   165  		payload := &BugCreate{}
   166  		if err := json.Unmarshal(raw, &payload); err != nil {
   167  			t.Errorf("malformed JSONRPC payload: %s", string(raw))
   168  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   169  			return
   170  		}
   171  		if _, err := w.Write([]byte(`{"id" : 12345}`)); err != nil {
   172  			t.Fatalf("failed to send JSONRPC response: %v", err)
   173  		}
   174  	}))
   175  	defer testServer.Close()
   176  	client := clientForUrl(testServer.URL)
   177  
   178  	// this should create a new bug
   179  	if id, err := client.CreateBug(&BugCreate{Description: "This is a test bug"}); err != nil {
   180  		t.Errorf("expected no error, but got one: %v", err)
   181  	} else if id != 12345 {
   182  		t.Errorf("expected id of 12345, got %d", id)
   183  	}
   184  }
   185  
   186  func TestGetComments(t *testing.T) {
   187  	commentsJSON := []byte(`{
   188  		"bugs": {
   189  		  "12345": {
   190  			"comments": [
   191  			  {
   192  				"time": "2020-04-21T13:50:04Z",
   193  				"text": "test bug to fix problem in removing from cc list.",
   194  				"bug_id": 12345,
   195  				"count": 0,
   196  				"attachment_id": null,
   197  				"is_private": false,
   198  				"is_markdown" : true,
   199  				"tags": [],
   200  				"creator": "user@bugzilla.org",
   201  				"creation_time": "2020-04-21T13:50:04Z",
   202  				"id": 75
   203  			  },
   204  			  {
   205  				"time": "2020-04-21T13:52:02Z",
   206  				"text": "Bug appears to be fixed",
   207  				"bug_id": 12345,
   208  				"count": 1,
   209  				"attachment_id": null,
   210  				"is_private": false,
   211  				"is_markdown" : true,
   212  				"tags": [],
   213  				"creator": "user2@bugzilla.org",
   214  				"creation_time": "2020-04-21T13:52:02Z",
   215  				"id": 76
   216  			  }
   217  			]
   218  		  }
   219  		},
   220  		"comments": {}
   221  	  }`)
   222  	commentsStruct := []Comment{{
   223  		ID:           75,
   224  		BugID:        12345,
   225  		AttachmentID: nil,
   226  		Count:        0,
   227  		Text:         "test bug to fix problem in removing from cc list.",
   228  		Creator:      "user@bugzilla.org",
   229  		Time:         time.Date(2020, time.April, 21, 13, 50, 04, 0, time.UTC),
   230  		CreationTime: time.Date(2020, time.April, 21, 13, 50, 04, 0, time.UTC),
   231  		IsPrivate:    false,
   232  		IsMarkdown:   true,
   233  		Tags:         []string{},
   234  	}, {
   235  		ID:           76,
   236  		BugID:        12345,
   237  		AttachmentID: nil,
   238  		Count:        1,
   239  		Text:         "Bug appears to be fixed",
   240  		Creator:      "user2@bugzilla.org",
   241  		Time:         time.Date(2020, time.April, 21, 13, 52, 02, 0, time.UTC),
   242  		CreationTime: time.Date(2020, time.April, 21, 13, 52, 02, 0, time.UTC),
   243  		IsPrivate:    false,
   244  		IsMarkdown:   true,
   245  		Tags:         []string{},
   246  	}}
   247  	testServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   248  		if r.Header.Get("X-BUGZILLA-API-KEY") != "api-key" {
   249  			t.Error("did not get api-key passed in X-BUGZILLA-API-KEY header")
   250  			http.Error(w, "403 Forbidden", http.StatusForbidden)
   251  			return
   252  		}
   253  		if r.Method != http.MethodGet {
   254  			t.Errorf("incorrect method to get bug comments: %s", r.Method)
   255  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   256  			return
   257  		}
   258  		if !strings.HasPrefix(r.URL.Path, "/rest/bug/") {
   259  			t.Errorf("incorrect path to get bug comments: %s", r.URL.Path)
   260  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   261  			return
   262  		}
   263  		if !strings.HasSuffix(r.URL.Path, "/comment") {
   264  			t.Errorf("incorrect path to get bug comments: %s", r.URL.Path)
   265  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   266  			return
   267  		}
   268  		if id, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/rest/bug/"), "/comment")); err != nil {
   269  			t.Errorf("malformed bug id: %s", r.URL.Path)
   270  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   271  		} else {
   272  			if id == 12345 {
   273  				w.Write(commentsJSON)
   274  			} else if id == 2 {
   275  				w.Write(bugAccessDenied)
   276  			} else if id == 3 {
   277  				w.Write(bugInvalidBugID)
   278  			} else {
   279  				http.Error(w, "404 Not Found", http.StatusNotFound)
   280  			}
   281  		}
   282  	}))
   283  	defer testServer.Close()
   284  	client := clientForUrl(testServer.URL)
   285  
   286  	comments, err := client.GetComments(12345)
   287  	if err != nil {
   288  		t.Errorf("expected no error, but got one: %v", err)
   289  	}
   290  	if diff := cmp.Diff(comments, commentsStruct); diff != "" {
   291  		t.Errorf("got incorrect comments: %v", diff)
   292  	}
   293  
   294  	// this should 404
   295  	otherBug, err := client.GetComments(1)
   296  	if err == nil {
   297  		t.Error("expected an error, but got none")
   298  	} else if !IsNotFound(err) {
   299  		t.Errorf("expected a not found error, got %v", err)
   300  	}
   301  	if otherBug != nil {
   302  		t.Errorf("expected no bug, got: %v", otherBug)
   303  	}
   304  
   305  	// this should return access denied
   306  	accessDeniedBug, err := client.GetComments(2)
   307  	if err == nil {
   308  		t.Error("expected an error, but got none")
   309  	} else if !IsAccessDenied(err) {
   310  		t.Errorf("expected an access denied error, got %v", err)
   311  	}
   312  	if accessDeniedBug != nil {
   313  		t.Errorf("expected no bug, got: %v", accessDeniedBug)
   314  	}
   315  
   316  	// this should return invalid Bug ID
   317  	invalidIDBug, err := client.GetComments(3)
   318  	if err == nil {
   319  		t.Error("expected an error, but got none")
   320  	} else if !IsInvalidBugID(err) {
   321  		t.Errorf("expected an invalid bug error, got %v", err)
   322  	}
   323  	if invalidIDBug != nil {
   324  		t.Errorf("expected no bug, got: %v", invalidIDBug)
   325  	}
   326  }
   327  
   328  func TestCreateComment(t *testing.T) {
   329  	testServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   330  		if r.Header.Get("X-BUGZILLA-API-KEY") != "api-key" {
   331  			t.Error("did not get api-key passed in X-BUGZILLA-API-KEY header")
   332  			http.Error(w, "403 Forbidden", http.StatusForbidden)
   333  			return
   334  		}
   335  		if r.Method != http.MethodPost {
   336  			t.Errorf("incorrect method to create a comment: %s", r.Method)
   337  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   338  			return
   339  		}
   340  		if !regexp.MustCompile(`^/rest/bug/\d+/comment$`).MatchString(r.URL.Path) {
   341  			t.Errorf("incorrect path to create a comment: %s", r.URL.Path)
   342  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   343  			return
   344  		}
   345  		raw, err := io.ReadAll(r.Body)
   346  		if err != nil {
   347  			t.Errorf("failed to read request body: %v", err)
   348  			http.Error(w, "500 Server Error", http.StatusInternalServerError)
   349  			return
   350  		}
   351  		payload := &CommentCreate{}
   352  		if err := json.Unmarshal(raw, &payload); err != nil {
   353  			t.Errorf("malformed JSONRPC payload: %s", string(raw))
   354  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   355  			return
   356  		}
   357  		if _, err := w.Write([]byte(`{"id" : 12345}`)); err != nil {
   358  			t.Fatalf("failed to send JSONRPC response: %v", err)
   359  		}
   360  	}))
   361  	defer testServer.Close()
   362  	client := clientForUrl(testServer.URL)
   363  
   364  	// this should create a new comment
   365  	if id, err := client.CreateComment(&CommentCreate{ID: 2, Comment: "This is a test bug"}); err != nil {
   366  		t.Errorf("expected no error, but got one: %v", err)
   367  	} else if id != 12345 {
   368  		t.Errorf("expected id of 12345, got %d", id)
   369  	}
   370  }
   371  
   372  func TestCloneBugStruct(t *testing.T) {
   373  	testCases := []struct {
   374  		name          string
   375  		bug           Bug
   376  		comments      []Comment
   377  		targetRelease []string
   378  		expected      BugCreate
   379  	}{{
   380  		name: "Clone bug",
   381  		bug: Bug{
   382  			Alias:           []string{"this_is_an_alias"},
   383  			AssignedTo:      "user@example.com",
   384  			CC:              []string{"user2@example.com", "user3@example.com"},
   385  			Component:       []string{"TestComponent"},
   386  			Flags:           []Flag{{ID: 1, Name: "Test Flag"}},
   387  			Groups:          []string{"group1"},
   388  			ID:              123,
   389  			Keywords:        []string{"segfault"},
   390  			OperatingSystem: "Fedora",
   391  			Platform:        "x86_64",
   392  			Priority:        "unspecified",
   393  			Product:         "testing product",
   394  			QAContact:       "user3@example.com",
   395  			Resolution:      "FIXED",
   396  			Severity:        "Urgent",
   397  			Status:          "VERIFIED",
   398  			Summary:         "Segfault when opening program",
   399  			TargetMilestone: "milestone1",
   400  			Version:         []string{"31"},
   401  		},
   402  		comments: []Comment{{
   403  			Text:      "There is a segfault that occurs when opening applications.",
   404  			IsPrivate: true,
   405  		}},
   406  		expected: BugCreate{
   407  			Alias:            []string{"this_is_an_alias"},
   408  			AssignedTo:       "user@example.com",
   409  			CC:               []string{"user2@example.com", "user3@example.com"},
   410  			Component:        []string{"TestComponent"},
   411  			Flags:            []Flag{{ID: 1, Name: "Test Flag"}},
   412  			Groups:           []string{"group1"},
   413  			Keywords:         []string{"segfault"},
   414  			OperatingSystem:  "Fedora",
   415  			Platform:         "x86_64",
   416  			Priority:         "unspecified",
   417  			Product:          "testing product",
   418  			QAContact:        "user3@example.com",
   419  			Severity:         "Urgent",
   420  			Summary:          "Segfault when opening program",
   421  			TargetMilestone:  "milestone1",
   422  			Version:          []string{"31"},
   423  			Description:      "+++ This bug was initially created as a clone of Bug #123 +++\n\nThere is a segfault that occurs when opening applications.",
   424  			CommentIsPrivate: true,
   425  		},
   426  	}, {
   427  		name: "Clone bug with multiple comments",
   428  		bug: Bug{
   429  			ID: 123,
   430  		},
   431  		comments: []Comment{{
   432  			Text: "There is a segfault that occurs when opening applications.",
   433  		}, {
   434  			Text:         "This is another comment.",
   435  			Time:         time.Date(2020, time.May, 7, 2, 3, 4, 0, time.UTC),
   436  			CreationTime: time.Date(2020, time.May, 7, 2, 3, 4, 0, time.UTC),
   437  			Tags:         []string{"description"},
   438  			Creator:      "Test Commenter",
   439  		}},
   440  		expected: BugCreate{
   441  			Description: `+++ This bug was initially created as a clone of Bug #123 +++
   442  
   443  There is a segfault that occurs when opening applications.
   444  
   445  --- Additional comment from Test Commenter on 2020-05-07 02:03:04 UTC ---
   446  
   447  This is another comment.`,
   448  		},
   449  	}, {
   450  		name: "Clone bug with one private comments",
   451  		bug: Bug{
   452  			ID: 123,
   453  		},
   454  		comments: []Comment{{
   455  			Text: "There is a segfault that occurs when opening applications.",
   456  		}, {
   457  			Text:         "This is another comment.",
   458  			Time:         time.Date(2020, time.May, 7, 2, 3, 4, 0, time.UTC),
   459  			CreationTime: time.Date(2020, time.May, 7, 2, 3, 4, 0, time.UTC),
   460  			IsPrivate:    true,
   461  			Tags:         []string{"description"},
   462  			Creator:      "Test Commenter",
   463  		}},
   464  		expected: BugCreate{
   465  			Description: `+++ This bug was initially created as a clone of Bug #123 +++
   466  
   467  There is a segfault that occurs when opening applications.
   468  
   469  --- Additional comment from Test Commenter on 2020-05-07 02:03:04 UTC ---
   470  
   471  This is another comment.`,
   472  			CommentIsPrivate: true,
   473  		},
   474  	}}
   475  	for _, testCase := range testCases {
   476  		newBug := cloneBugStruct(&testCase.bug, nil, testCase.comments)
   477  		if diff := cmp.Diff(*newBug, testCase.expected); diff != "" {
   478  			t.Errorf("%s: Difference in expected BugCreate and actual: %s", testCase.name, diff)
   479  		}
   480  	}
   481  	// test max length truncation
   482  	bug := Bug{}
   483  	baseComment := Comment{Text: "This is a test comment"}
   484  	comments := []Comment{}
   485  	// Make sure comments are at lest 65535 in total length
   486  	for i := 0; i < (65535 / len(baseComment.Text)); i++ {
   487  		comments = append(comments, baseComment)
   488  	}
   489  	newBug := cloneBugStruct(&bug, nil, comments)
   490  	if len(newBug.Description) != 65535 {
   491  		t.Errorf("Truncation error in cloneBug; expected description length of 65535, got %d", len(newBug.Description))
   492  	}
   493  }
   494  
   495  func TestUpdateBug(t *testing.T) {
   496  	testServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   497  		if r.Header.Get("X-BUGZILLA-API-KEY") != "api-key" {
   498  			t.Error("did not get api-key passed in X-BUGZILLA-API-KEY header")
   499  			http.Error(w, "403 Forbidden", http.StatusForbidden)
   500  			return
   501  		}
   502  		if r.Header.Get("Content-Type") != "application/json" {
   503  			t.Error("did not correctly set content-type header for JSON")
   504  			http.Error(w, "403 Forbidden", http.StatusForbidden)
   505  			return
   506  		}
   507  		if r.Method != http.MethodPut {
   508  			t.Errorf("incorrect method to update a bug: %s", r.Method)
   509  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   510  			return
   511  		}
   512  		if !strings.HasPrefix(r.URL.Path, "/rest/bug/") {
   513  			t.Errorf("incorrect path to update a bug: %s", r.URL.Path)
   514  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   515  			return
   516  		}
   517  		if id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/rest/bug/")); err != nil {
   518  			t.Errorf("malformed bug id: %s", r.URL.Path)
   519  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   520  			return
   521  		} else {
   522  			if id == 1705243 {
   523  				raw, err := io.ReadAll(r.Body)
   524  				if err != nil {
   525  					t.Errorf("failed to read update body: %v", err)
   526  				}
   527  				if actual, expected := string(raw), `{"depends_on":{"add":[1705242]},"status":"UPDATED"}`; actual != expected {
   528  					t.Errorf("got incorrect update: expected %v, got %v", expected, actual)
   529  				}
   530  			} else if id == 2 {
   531  				w.Header().Set("Content-Type", "application/json")
   532  				w.WriteHeader(http.StatusOK)
   533  				fmt.Fprintln(w, `{"documentation":"https://bugzilla.redhat.com/docs/en/html/api/index.html","error":true,"code":32000,"message":"Subcomponet is mandatory for the component 'Cloud Compute' in the product 'OpenShift Container Platform'."}`)
   534  			} else {
   535  				http.Error(w, "404 Not Found", http.StatusNotFound)
   536  			}
   537  		}
   538  	}))
   539  	defer testServer.Close()
   540  	client := clientForUrl(testServer.URL)
   541  
   542  	update := BugUpdate{
   543  		DependsOn: &IDUpdate{
   544  			Add: []int{1705242},
   545  		},
   546  		Status: "UPDATED",
   547  	}
   548  
   549  	// this should run an update
   550  	if err := client.UpdateBug(1705243, update); err != nil {
   551  		t.Errorf("expected no error, but got one: %v", err)
   552  	}
   553  
   554  	// this should 404
   555  	err := client.UpdateBug(1, update)
   556  	if err == nil {
   557  		t.Error("expected an error, but got none")
   558  	} else if !IsNotFound(err) {
   559  		t.Errorf("expected a not found error, got %v", err)
   560  	}
   561  
   562  	// this is a 200 with an error payload
   563  	if err := client.UpdateBug(2, update); err == nil {
   564  		t.Error("expected an error, but got none")
   565  	}
   566  }
   567  
   568  func TestAddPullRequestAsExternalBug(t *testing.T) {
   569  	var testCases = []struct {
   570  		name            string
   571  		trackerId       uint
   572  		id              int
   573  		expectedPayload string
   574  		response        string
   575  		expectedError   bool
   576  		expectedChanged bool
   577  	}{
   578  		{
   579  			name:            "update succeeds, makes a change",
   580  			id:              1705243,
   581  			expectedPayload: `{"jsonrpc":"1.0","method":"ExternalBugs.add_external_bug","params":[{"bug_ids":[1705243],"external_bugs":[{"ext_type_url":"https://github.com/","ext_bz_bug_id":"org/repo/pull/1"}]}],"id":"identifier"}`,
   582  			response:        `{"error":null,"id":"identifier","result":{"bugs":[{"alias":[],"changes":{"ext_bz_bug_map.ext_bz_bug_id":{"added":"Github org/repo/pull/1","removed":""}},"id":1705243}]}}`,
   583  			expectedError:   false,
   584  			expectedChanged: true,
   585  		},
   586  		{
   587  			name:            "explicit tracker ID is used, update succeeds, makes a change",
   588  			trackerId:       123,
   589  			id:              17052430,
   590  			expectedPayload: `{"jsonrpc":"1.0","method":"ExternalBugs.add_external_bug","params":[{"bug_ids":[17052430],"external_bugs":[{"ext_type_id":123,"ext_bz_bug_id":"org/repo/pull/1"}]}],"id":"identifier"}`,
   591  			response:        `{"error":null,"id":"identifier","result":{"bugs":[{"alias":[],"changes":{"ext_bz_bug_map.ext_bz_bug_id":{"added":"Github org/repo/pull/1","removed":""}},"id":17052430}]}}`,
   592  			expectedError:   false,
   593  			expectedChanged: true,
   594  		},
   595  		{
   596  			name:            "update succeeds, makes a change as part of multiple changes reported",
   597  			id:              1705244,
   598  			expectedPayload: `{"jsonrpc":"1.0","method":"ExternalBugs.add_external_bug","params":[{"bug_ids":[1705244],"external_bugs":[{"ext_type_url":"https://github.com/","ext_bz_bug_id":"org/repo/pull/1"}]}],"id":"identifier"}`,
   599  			response:        `{"error":null,"id":"identifier","result":{"bugs":[{"alias":[],"changes":{"ext_bz_bug_map.ext_bz_bug_id":{"added":"Github org/repo/pull/1","removed":""}},"id":1705244},{"alias":[],"changes":{"ext_bz_bug_map.ext_bz_bug_id":{"added":"Github org/repo/pull/2","removed":""}},"id":1705244}]}}`,
   600  			expectedError:   false,
   601  			expectedChanged: true,
   602  		},
   603  		{
   604  			name:            "update succeeds, makes no change",
   605  			id:              1705245,
   606  			expectedPayload: `{"jsonrpc":"1.0","method":"ExternalBugs.add_external_bug","params":[{"bug_ids":[1705245],"external_bugs":[{"ext_type_url":"https://github.com/","ext_bz_bug_id":"org/repo/pull/1"}]}],"id":"identifier"}`,
   607  			response:        `{"error":null,"id":"identifier","result":{"bugs":[]}}`,
   608  			expectedError:   false,
   609  			expectedChanged: false,
   610  		},
   611  		{
   612  			name:            "update fails, makes no change",
   613  			id:              1705246,
   614  			expectedPayload: `{"jsonrpc":"1.0","method":"ExternalBugs.add_external_bug","params":[{"bug_ids":[1705246],"external_bugs":[{"ext_type_url":"https://github.com/","ext_bz_bug_id":"org/repo/pull/1"}]}],"id":"identifier"}`,
   615  			response:        `{"error":{"code": 100400,"message":"Invalid params for JSONRPC 1.0."},"id":"identifier","result":null}`,
   616  			expectedError:   true,
   617  			expectedChanged: false,
   618  		},
   619  		{
   620  			name:            "get unrelated JSONRPC response",
   621  			id:              1705247,
   622  			expectedPayload: `{"jsonrpc":"1.0","method":"ExternalBugs.add_external_bug","params":[{"bug_ids":[1705247],"external_bugs":[{"ext_type_url":"https://github.com/","ext_bz_bug_id":"org/repo/pull/1"}]}],"id":"identifier"}`,
   623  			response:        `{"error":null,"id":"oops","result":{"bugs":[]}}`,
   624  			expectedError:   true,
   625  			expectedChanged: false,
   626  		},
   627  		{
   628  			name:            "update already made earlier, makes no change",
   629  			id:              1705248,
   630  			expectedPayload: `{"jsonrpc":"1.0","method":"ExternalBugs.add_external_bug","params":[{"bug_ids":[1705248],"external_bugs":[{"ext_type_url":"https://github.com/","ext_bz_bug_id":"org/repo/pull/1"}]}],"id":"identifier"}`,
   631  			response:        `{"error":{"code": 100500,"message":"DBD::Pg::db do failed: ERROR:  duplicate key value violates unique constraint \"ext_bz_bug_map_bug_id_idx\"\nDETAIL:  Key (bug_id, ext_bz_id, ext_bz_bug_id)=(1778894, 131, openshift/installer/pull/2728) already exists. [for Statement \"INSERT INTO ext_bz_bug_map (ext_description, ext_bz_id, ext_bz_bug_id, ext_priority, ext_last_updated, bug_id, ext_status) VALUES (?,?,?,?,?,?,?)\"]\n\u003cpre\u003e\n at /var/www/html/bugzilla/Bugzilla/Object.pm line 754.\n\tBugzilla::Object::insert_create_data('Bugzilla::Extension::ExternalBugs::Bug', 'HASH(0x55eec2747a30)') called at /loader/0x55eec2720cc0/Bugzilla/Extension/ExternalBugs/Bug.pm line 118\n\tBugzilla::Extension::ExternalBugs::Bug::create('Bugzilla::Extension::ExternalBugs::Bug', 'HASH(0x55eed47b6d20)') called at /var/www/html/bugzilla/extensions/ExternalBugs/Extension.pm line 858\n\tBugzilla::Extension::ExternalBugs::bug_start_of_update('Bugzilla::Extension::ExternalBugs=HASH(0x55eecf484038)', 'HASH(0x55eed09302e8)') called at /var/www/html/bugzilla/Bugzilla/Hook.pm line 21\n\tBugzilla::Hook::process('bug_start_of_update', 'HASH(0x55eed09302e8)') called at /var/www/html/bugzilla/Bugzilla/Bug.pm line 1168\n\tBugzilla::Bug::update('Bugzilla::Bug=HASH(0x55eed048b350)') called at /loader/0x55eec2720cc0/Bugzilla/Extension/ExternalBugs/WebService.pm line 80\n\tBugzilla::Extension::ExternalBugs::WebService::add_external_bug('Bugzilla::WebService::Server::JSONRPC::Bugzilla::Extension::E...', 'HASH(0x55eed38bd710)') called at (eval 5435) line 1\n\teval ' $procedure-\u003e{code}-\u003e($self, @params) \n;' called at /usr/share/perl5/vendor_perl/JSON/RPC/Legacy/Server.pm line 220\n\tJSON::RPC::Legacy::Server::_handle('Bugzilla::WebService::Server::JSONRPC::Bugzilla::Extension::E...', 'HASH(0x55eed1990ef0)') called at /var/www/html/bugzilla/Bugzilla/WebService/Server/JSONRPC.pm line 295\n\tBugzilla::WebService::Server::JSONRPC::_handle('Bugzilla::WebService::Server::JSONRPC::Bugzilla::Extension::E...', 'HASH(0x55eed1990ef0)') called at /usr/share/perl5/vendor_perl/JSON/RPC/Legacy/Server.pm line 126\n\tJSON::RPC::Legacy::Server::handle('Bugzilla::WebService::Server::JSONRPC::Bugzilla::Extension::E...') called at /var/www/html/bugzilla/Bugzilla/WebService/Server/JSONRPC.pm line 70\n\tBugzilla::WebService::Server::JSONRPC::handle('Bugzilla::WebService::Server::JSONRPC::Bugzilla::Extension::E...') called at /var/www/html/bugzilla/jsonrpc.cgi line 31\n\tModPerl::ROOT::Bugzilla::ModPerl::ResponseHandler::var_www_html_bugzilla_jsonrpc_2ecgi::handler('Apache2::RequestRec=SCALAR(0x55eed3231870)') called at /usr/lib64/perl5/vendor_perl/ModPerl/RegistryCooker.pm line 207\n\teval {...} called at /usr/lib64/perl5/vendor_perl/ModPerl/RegistryCooker.pm line 207\n\tModPerl::RegistryCooker::run('Bugzilla::ModPerl::ResponseHandler=HASH(0x55eed023da08)') called at /usr/lib64/perl5/vendor_perl/ModPerl/RegistryCooker.pm line 173\n\tModPerl::RegistryCooker::default_handler('Bugzilla::ModPerl::ResponseHandler=HASH(0x55eed023da08)') called at /usr/lib64/perl5/vendor_perl/ModPerl/Registry.pm line 32\n\tModPerl::Registry::handler('Bugzilla::ModPerl::ResponseHandler', 'Apache2::RequestRec=SCALAR(0x55eed3231870)') called at /var/www/html/bugzilla/mod_perl.pl line 139\n\tBugzilla::ModPerl::ResponseHandler::handler('Bugzilla::ModPerl::ResponseHandler', 'Apache2::RequestRec=SCALAR(0x55eed3231870)') called at (eval 5435) line 0\n\teval {...} called at (eval 5435) line 0\n\n\u003c/pre\u003e at /var/www/html/bugzilla/Bugzilla/Object.pm line 754.\n at /var/www/html/bugzilla/Bugzilla/Object.pm line 754.\n\tBugzilla::Object::insert_create_data('Bugzilla::Extension::ExternalBugs::Bug', 'HASH(0x55eec2747a30)') called at /loader/0x55eec2720cc0/Bugzilla/Extension/ExternalBugs/Bug.pm line 118\n\tBugzilla::Extension::ExternalBugs::Bug::create('Bugzilla::Extension::ExternalBugs::Bug', 'HASH(0x55eed47b6d20)') called at /var/www/html/bugzilla/extensions/ExternalBugs/Extension.pm line 858\n\tBugzilla::Extension::ExternalBugs::bug_start_of_update('Bugzilla::Extension::ExternalBugs=HASH(0x55eecf484038)', 'HASH(0x55eed09302e8)') called at /var/www/html/bugzilla/Bugzilla/Hook.pm line 21\n\tBugzilla::Hook::process('bug_start_of_update', 'HASH(0x55eed09302e8)') called at /var/www/html/bugzilla/Bugzilla/Bug.pm line 1168\n\tBugzilla::Bug::update('Bugzilla::Bug=HASH(0x55eed048b350)') called at /loader/0x55eec2720cc0/Bugzilla/Extension/ExternalBugs/WebService.pm line 80\n\tBugzilla::Extension::ExternalBugs::WebService::add_external_bug('Bugzilla::WebService::Server::JSONRPC::Bugzilla::Extension::E...', 'HASH(0x55eed38bd710)') called at (eval 5435) line 1\n\teval ' $procedure-\u003e{code}-\u003e($self, @params) \n;' called at /usr/share/perl5/vendor_perl/JSON/RPC/Legacy/Server.pm line 220\n\tJSON::RPC::Legacy::Server::_handle('Bugzilla::WebService::Server::JSONRPC::Bugzilla::Extension::E...', 'HASH(0x55eed1990ef0)') called at /var/www/html/bugzilla/Bugzilla/WebService/Server/JSONRPC.pm line 295\n\tBugzilla::WebService::Server::JSONRPC::_handle('Bugzilla::WebService::Server::JSONRPC::Bugzilla::Extension::E...', 'HASH(0x55eed1990ef0)') called at /usr/share/perl5/vendor_perl/JSON/RPC/Legacy/Server.pm line 126\n\tJSON::RPC::Legacy::Server::handle('Bugzilla::WebService::Server::JSONRPC::Bugzilla::Extension::E...') called at /var/www/html/bugzilla/Bugzilla/WebService/Server/JSONRPC.pm line 70\n\tBugzilla::WebService::Server::JSONRPC::handle('Bugzilla::WebService::Server::JSONRPC::Bugzilla::Extension::E...') called at /var/www/html/bugzilla/jsonrpc.cgi line 31\n\tModPerl::ROOT::Bugzilla::ModPerl::ResponseHandler::var_www_html_bugzilla_jsonrpc_2ecgi::handler('Apache2::RequestRec=SCALAR(0x55eed3231870)') called at /usr/lib64/perl5/vendor_perl/ModPerl/RegistryCooker.pm line 207\n\teval {...} called at /usr/lib64/perl5/vendor_perl/ModPerl/RegistryCooker.pm line 207\n\tModPerl::RegistryCooker::run('Bugzilla::ModPerl::ResponseHandler=HASH(0x55eed023da08)') called at /usr/lib64/perl5/vendor_perl/ModPerl/RegistryCooker.pm line 173\n\tModPerl::RegistryCooker::default_handler('Bugzilla::ModPerl::ResponseHandler=HASH(0x55eed023da08)') called at /usr/lib64/perl5/vendor_perl/ModPerl/Registry.pm line 32\n\tModPerl::Registry::handler('Bugzilla::ModPerl::ResponseHandler', 'Apache2::RequestRec=SCALAR(0x55eed3231870)') called at /var/www/html/bugzilla/mod_perl.pl line 139\n\tBugzilla::ModPerl::ResponseHandler::handler('Bugzilla::ModPerl::ResponseHandler', 'Apache2::RequestRec=SCALAR(0x55eed3231870)') called at (eval 5435) line 0\n\teval {...} called at (eval 5435) line 0"},"id":"identifier","result":null}`,
   632  			expectedError:   false,
   633  			expectedChanged: false,
   634  		},
   635  	}
   636  	testServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   637  		if r.Header.Get("Content-Type") != "application/json" {
   638  			t.Error("did not correctly set content-type header for JSON")
   639  			http.Error(w, "403 Forbidden", http.StatusForbidden)
   640  			return
   641  		}
   642  		if r.Method != http.MethodPost {
   643  			t.Errorf("incorrect method to use the JSONRPC API: %s", r.Method)
   644  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   645  			return
   646  		}
   647  		if r.URL.Path != "/jsonrpc.cgi" {
   648  			t.Errorf("incorrect path to use the JSONRPC API: %s", r.URL.Path)
   649  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   650  			return
   651  		}
   652  		var payload struct {
   653  			// Version is the version of JSONRPC to use. All Bugzilla servers
   654  			// support 1.0. Some support 1.1 and some support 2.0
   655  			Version string `json:"jsonrpc"`
   656  			Method  string `json:"method"`
   657  			// Parameters must be specified in JSONRPC 1.0 as a structure in the first
   658  			// index of this slice
   659  			Parameters []AddExternalBugParameters `json:"params"`
   660  			ID         string                     `json:"id"`
   661  		}
   662  		raw, err := io.ReadAll(r.Body)
   663  		if err != nil {
   664  			t.Errorf("failed to read request body: %v", err)
   665  			http.Error(w, "500 Server Error", http.StatusInternalServerError)
   666  			return
   667  		}
   668  		if err := json.Unmarshal(raw, &payload); err != nil {
   669  			t.Errorf("malformed JSONRPC payload: %s", string(raw))
   670  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   671  			return
   672  		}
   673  		for _, testCase := range testCases {
   674  			if payload.Parameters[0].BugIDs[0] == testCase.id {
   675  				if diff := cmp.Diff(string(raw), testCase.expectedPayload); diff != "" {
   676  					t.Errorf("%s: got incorrect JSONRPC payload: %v", testCase.name, diff)
   677  				}
   678  				if _, err := w.Write([]byte(testCase.response)); err != nil {
   679  					t.Fatalf("%s: failed to send JSONRPC response: %v", testCase.name, err)
   680  				}
   681  				return
   682  			}
   683  		}
   684  		http.Error(w, "404 Not Found", http.StatusNotFound)
   685  	}))
   686  	defer testServer.Close()
   687  	client := clientForUrl(testServer.URL)
   688  
   689  	for _, testCase := range testCases {
   690  		t.Run(testCase.name, func(t *testing.T) {
   691  			client.githubExternalTrackerId = testCase.trackerId
   692  			changed, err := client.AddPullRequestAsExternalBug(testCase.id, "org", "repo", 1)
   693  			if !testCase.expectedError && err != nil {
   694  				t.Errorf("%s: expected no error, but got one: %v", testCase.name, err)
   695  			}
   696  			if testCase.expectedError && err == nil {
   697  				t.Errorf("%s: expected an error, but got none", testCase.name)
   698  			}
   699  			if testCase.expectedChanged != changed {
   700  				t.Errorf("%s: got incorrect state change", testCase.name)
   701  			}
   702  		})
   703  	}
   704  
   705  	// this should 404
   706  	changed, err := client.AddPullRequestAsExternalBug(1, "org", "repo", 1)
   707  	if err == nil {
   708  		t.Error("expected an error, but got none")
   709  	} else if !IsNotFound(err) {
   710  		t.Errorf("expected a not found error, got %v", err)
   711  	}
   712  	if changed {
   713  		t.Error("expected not to change state, but did")
   714  	}
   715  }
   716  
   717  func TestRemovePullRequestAsExternalBug(t *testing.T) {
   718  	var testCases = []struct {
   719  		name            string
   720  		id              int
   721  		expectedPayload string
   722  		response        string
   723  		expectedError   bool
   724  		expectedChanged bool
   725  	}{
   726  		{
   727  			name:            "update succeeds, makes a change",
   728  			id:              1705243,
   729  			expectedPayload: `{"jsonrpc":"1.0","method":"ExternalBugs.remove_external_bug","params":[{"bug_ids":[1705243],"ext_type_url":"https://github.com/","ext_bz_bug_id":"org/repo/pull/1"}],"id":"identifier"}`,
   730  			response:        `{"error":null,"id":"identifier","result":{"external_bugs":[{"ext_type_url":"https://github.com/","ext_bz_bug_id":"org/repo/pull/1"}]}}`,
   731  			expectedError:   false,
   732  			expectedChanged: true,
   733  		},
   734  		{
   735  			name:            "update succeeds, makes a change as part of multiple changes reported",
   736  			id:              1705244,
   737  			expectedPayload: `{"jsonrpc":"1.0","method":"ExternalBugs.remove_external_bug","params":[{"bug_ids":[1705244],"ext_type_url":"https://github.com/","ext_bz_bug_id":"org/repo/pull/1"}],"id":"identifier"}`,
   738  			response:        `{"error":null,"id":"identifier","result":{"external_bugs":[{"ext_type_url":"https://github.com/","ext_bz_bug_id":"org/repo/pull/1"},{"ext_type_url":"https://github.com/","ext_bz_bug_id":"org/repo/pull/2"}]}}`,
   739  			expectedError:   false,
   740  			expectedChanged: true,
   741  		},
   742  		{
   743  			name:            "update succeeds, makes no change",
   744  			id:              1705245,
   745  			expectedPayload: `{"jsonrpc":"1.0","method":"ExternalBugs.remove_external_bug","params":[{"bug_ids":[1705245],"ext_type_url":"https://github.com/","ext_bz_bug_id":"org/repo/pull/1"}],"id":"identifier"}`,
   746  			response:        `{"error":null,"id":"identifier","result":{"external_bugs":[]}}`,
   747  			expectedError:   false,
   748  			expectedChanged: false,
   749  		},
   750  		{
   751  			name:            "update fails, makes no change",
   752  			id:              1705246,
   753  			expectedPayload: `{"jsonrpc":"1.0","method":"ExternalBugs.remove_external_bug","params":[{"bug_ids":[1705246],"ext_type_url":"https://github.com/","ext_bz_bug_id":"org/repo/pull/1"}],"id":"identifier"}`,
   754  			response:        `{"error":{"code": 100400,"message":"Invalid params for JSONRPC 1.0."},"id":"identifier","result":null}`,
   755  			expectedError:   true,
   756  			expectedChanged: false,
   757  		},
   758  		{
   759  			name:            "get unrelated JSONRPC response",
   760  			id:              1705247,
   761  			expectedPayload: `{"jsonrpc":"1.0","method":"ExternalBugs.remove_external_bug","params":[{"bug_ids":[1705247],"ext_type_url":"https://github.com/","ext_bz_bug_id":"org/repo/pull/1"}],"id":"identifier"}`,
   762  			response:        `{"error":null,"id":"oops","result":{"external_bugs":[]}}`,
   763  			expectedError:   true,
   764  			expectedChanged: false,
   765  		},
   766  	}
   767  	testServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   768  		if r.Header.Get("Content-Type") != "application/json" {
   769  			t.Error("did not correctly set content-type header for JSON")
   770  			http.Error(w, "403 Forbidden", http.StatusForbidden)
   771  			return
   772  		}
   773  		if r.Method != http.MethodPost {
   774  			t.Errorf("incorrect method to use the JSONRPC API: %s", r.Method)
   775  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   776  			return
   777  		}
   778  		if r.URL.Path != "/jsonrpc.cgi" {
   779  			t.Errorf("incorrect path to use the JSONRPC API: %s", r.URL.Path)
   780  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   781  			return
   782  		}
   783  		var payload struct {
   784  			// Version is the version of JSONRPC to use. All Bugzilla servers
   785  			// support 1.0. Some support 1.1 and some support 2.0
   786  			Version string `json:"jsonrpc"`
   787  			Method  string `json:"method"`
   788  			// Parameters must be specified in JSONRPC 1.0 as a structure in the first
   789  			// index of this slice
   790  			Parameters []RemoveExternalBugParameters `json:"params"`
   791  			ID         string                        `json:"id"`
   792  		}
   793  		raw, err := io.ReadAll(r.Body)
   794  		if err != nil {
   795  			t.Errorf("failed to read request body: %v", err)
   796  			http.Error(w, "500 Server Error", http.StatusInternalServerError)
   797  			return
   798  		}
   799  		if err := json.Unmarshal(raw, &payload); err != nil {
   800  			t.Errorf("malformed JSONRPC payload: %s", string(raw))
   801  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
   802  			return
   803  		}
   804  		for _, testCase := range testCases {
   805  			if payload.Parameters[0].BugIDs[0] == testCase.id {
   806  				if actual, expected := string(raw), testCase.expectedPayload; actual != expected {
   807  					t.Errorf("%s: got incorrect JSONRPC payload: %v", testCase.name, diff.ObjectReflectDiff(expected, actual))
   808  				}
   809  				if _, err := w.Write([]byte(testCase.response)); err != nil {
   810  					t.Fatalf("%s: failed to send JSONRPC response: %v", testCase.name, err)
   811  				}
   812  				return
   813  			}
   814  		}
   815  		http.Error(w, "404 Not Found", http.StatusNotFound)
   816  	}))
   817  	defer testServer.Close()
   818  	client := clientForUrl(testServer.URL)
   819  
   820  	for _, testCase := range testCases {
   821  		t.Run(testCase.name, func(t *testing.T) {
   822  			changed, err := client.RemovePullRequestAsExternalBug(testCase.id, "org", "repo", 1)
   823  			if !testCase.expectedError && err != nil {
   824  				t.Errorf("%s: expected no error, but got one: %v", testCase.name, err)
   825  			}
   826  			if testCase.expectedError && err == nil {
   827  				t.Errorf("%s: expected an error, but got none", testCase.name)
   828  			}
   829  			if testCase.expectedChanged != changed {
   830  				t.Errorf("%s: got incorrect state change", testCase.name)
   831  			}
   832  		})
   833  	}
   834  
   835  	// this should 404
   836  	changed, err := client.AddPullRequestAsExternalBug(1, "org", "repo", 1)
   837  	if err == nil {
   838  		t.Error("expected an error, but got none")
   839  	} else if !IsNotFound(err) {
   840  		t.Errorf("expected a not found error, got %v", err)
   841  	}
   842  	if changed {
   843  		t.Error("expected not to change state, but did")
   844  	}
   845  }
   846  
   847  func TestIdentifierForPull(t *testing.T) {
   848  	var testCases = []struct {
   849  		name      string
   850  		org, repo string
   851  		num       int
   852  		expected  string
   853  	}{
   854  		{
   855  			name:     "normal works as expected",
   856  			org:      "organization",
   857  			repo:     "repository",
   858  			num:      1234,
   859  			expected: "organization/repository/pull/1234",
   860  		},
   861  	}
   862  
   863  	for _, testCase := range testCases {
   864  		if actual, expected := IdentifierForPull(testCase.org, testCase.repo, testCase.num), testCase.expected; actual != expected {
   865  			t.Errorf("%s: got incorrect identifier, expected %s but got %s", testCase.name, expected, actual)
   866  		}
   867  	}
   868  }
   869  
   870  func TestPullFromIdentifier(t *testing.T) {
   871  	var testCases = []struct {
   872  		name                      string
   873  		identifier                string
   874  		expectedOrg, expectedRepo string
   875  		expectedNum               int
   876  		expectedErr               bool
   877  		expectedNotPullErr        bool
   878  	}{
   879  		{
   880  			name:         "normal works as expected",
   881  			identifier:   "organization/repository/pull/1234",
   882  			expectedOrg:  "organization",
   883  			expectedRepo: "repository",
   884  			expectedNum:  1234,
   885  		},
   886  		{
   887  			name:         "extra `/` at end works correctly",
   888  			identifier:   "organization/repository/pull/1234/",
   889  			expectedOrg:  "organization",
   890  			expectedRepo: "repository",
   891  			expectedNum:  1234,
   892  		},
   893  		{
   894  			name:         "extra `/files` included works correctly",
   895  			identifier:   "organization/repository/pull/1234/files",
   896  			expectedOrg:  "organization",
   897  			expectedRepo: "repository",
   898  			expectedNum:  1234,
   899  		},
   900  		{
   901  			name:         "extra `/files/` included works correctly",
   902  			identifier:   "organization/repository/pull/1234/files/",
   903  			expectedOrg:  "organization",
   904  			expectedRepo: "repository",
   905  			expectedNum:  1234,
   906  		},
   907  		{
   908  			name:        "wrong number of parts fails",
   909  			identifier:  "organization/repository",
   910  			expectedErr: true,
   911  		},
   912  		{
   913  			name:               "not a pull fails but in an identifiable way",
   914  			identifier:         "organization/repository/issue/1234",
   915  			expectedErr:        true,
   916  			expectedNotPullErr: true,
   917  		},
   918  		{
   919  			name:        "not a number fails",
   920  			identifier:  "organization/repository/pull/abcd",
   921  			expectedErr: true,
   922  		},
   923  	}
   924  
   925  	for _, testCase := range testCases {
   926  		org, repo, num, err := PullFromIdentifier(testCase.identifier)
   927  		if testCase.expectedErr && err == nil {
   928  			t.Errorf("%s: expected an error but got none", testCase.name)
   929  		}
   930  		if !testCase.expectedErr && err != nil {
   931  			t.Errorf("%s: expected no error but got one: %v", testCase.name, err)
   932  		}
   933  		if testCase.expectedNotPullErr && !IsIdentifierNotForPullErr(err) {
   934  			t.Errorf("%s: expected a notForPull error but got: %T", testCase.name, err)
   935  		}
   936  		if org != testCase.expectedOrg {
   937  			t.Errorf("%s: got incorrect org, expected %s but got %s", testCase.name, testCase.expectedOrg, org)
   938  		}
   939  		if repo != testCase.expectedRepo {
   940  			t.Errorf("%s: got incorrect repo, expected %s but got %s", testCase.name, testCase.expectedRepo, repo)
   941  		}
   942  		if num != testCase.expectedNum {
   943  			t.Errorf("%s: got incorrect num, expected %d but got %d", testCase.name, testCase.expectedNum, num)
   944  		}
   945  	}
   946  }
   947  
   948  func TestGetExternalBugPRsOnBug(t *testing.T) {
   949  	var testCases = []struct {
   950  		name          string
   951  		id            int
   952  		response      string
   953  		expectedError bool
   954  		expectedPRs   []ExternalBug
   955  	}{
   956  		{
   957  			name:          "no external bugs returns empty list",
   958  			id:            1705243,
   959  			response:      `{"bugs":[{"external_bugs":[]}],"faults":[]}`,
   960  			expectedError: false,
   961  		},
   962  		{
   963  			name:          "one external bug pointing to PR is found",
   964  			id:            1705244,
   965  			response:      `{"bugs":[{"external_bugs":[{"bug_id": 1705244,"ext_bz_bug_id":"org/repo/pull/1","type":{"url":"https://github.com/"}}]}],"faults":[]}`,
   966  			expectedError: false,
   967  			expectedPRs:   []ExternalBug{{Type: ExternalBugType{URL: "https://github.com/"}, BugzillaBugID: 1705244, ExternalBugID: "org/repo/pull/1", Org: "org", Repo: "repo", Num: 1}},
   968  		},
   969  		{
   970  			name:          "multiple external bugs pointing to PRs are found",
   971  			id:            1705245,
   972  			response:      `{"bugs":[{"external_bugs":[{"bug_id": 1705245,"ext_bz_bug_id":"org/repo/pull/1","type":{"url":"https://github.com/"}},{"bug_id": 1705245,"ext_bz_bug_id":"org/repo/pull/2","type":{"url":"https://github.com/"}}]}],"faults":[]}`,
   973  			expectedError: false,
   974  			expectedPRs:   []ExternalBug{{Type: ExternalBugType{URL: "https://github.com/"}, BugzillaBugID: 1705245, ExternalBugID: "org/repo/pull/1", Org: "org", Repo: "repo", Num: 1}, {Type: ExternalBugType{URL: "https://github.com/"}, BugzillaBugID: 1705245, ExternalBugID: "org/repo/pull/2", Org: "org", Repo: "repo", Num: 2}},
   975  		},
   976  		{
   977  			name:          "external bugs pointing to issues are ignored",
   978  			id:            1705246,
   979  			response:      `{"bugs":[{"external_bugs":[{"bug_id": 1705246,"ext_bz_bug_id":"org/repo/issues/1","type":{"url":"https://github.com/"}}]}],"faults":[]}`,
   980  			expectedError: false,
   981  		},
   982  		{
   983  			name:          "external bugs pointing to other Bugzilla bugs are ignored",
   984  			id:            1705247,
   985  			response:      `{"bugs":[{"external_bugs":[{"bug_id": 3,"ext_bz_bug_id":"org/repo/pull/1","type":{"url":"https://github.com/"}}]}],"faults":[]}`,
   986  			expectedError: false,
   987  		},
   988  		{
   989  			name:          "external bugs pointing to other trackers are ignored",
   990  			id:            1705248,
   991  			response:      `{"bugs":[{"external_bugs":[{"bug_id": 1705248,"ext_bz_bug_id":"something","type":{"url":"https://bugs.tracker.com/"}}]}],"faults":[]}`,
   992  			expectedError: false,
   993  		},
   994  		{
   995  			name:          "external bugs pointing to invalid pulls cause an error",
   996  			id:            1705249,
   997  			response:      `{"bugs":[{"external_bugs":[{"bug_id": 1705249,"ext_bz_bug_id":"org/repo/pull/c","type":{"url":"https://github.com/"}}]}],"faults":[]}`,
   998  			expectedError: true,
   999  		},
  1000  	}
  1001  	testServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1002  		if r.Header.Get("X-BUGZILLA-API-KEY") != "api-key" {
  1003  			t.Error("did not get api-key passed in X-BUGZILLA-API-KEY header")
  1004  			http.Error(w, "403 Forbidden", http.StatusForbidden)
  1005  			return
  1006  		}
  1007  		if r.URL.Query().Get("include_fields") != "external_bugs" {
  1008  			t.Error("did not get external bugs passed in include_fields query parameter")
  1009  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
  1010  			return
  1011  		}
  1012  		if r.Method != http.MethodGet {
  1013  			t.Errorf("incorrect method to get a bug: %s", r.Method)
  1014  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
  1015  			return
  1016  		}
  1017  		if !strings.HasPrefix(r.URL.Path, "/rest/bug/") {
  1018  			t.Errorf("incorrect path to get a bug: %s", r.URL.Path)
  1019  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
  1020  			return
  1021  		}
  1022  		id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/rest/bug/"))
  1023  		if err != nil {
  1024  			t.Errorf("malformed bug id: %s", r.URL.Path)
  1025  			http.Error(w, "400 Bad Request", http.StatusBadRequest)
  1026  			return
  1027  		}
  1028  		for _, testCase := range testCases {
  1029  			if id == testCase.id {
  1030  				if _, err := w.Write([]byte(testCase.response)); err != nil {
  1031  					t.Fatalf("%s: failed to send response: %v", testCase.name, err)
  1032  				}
  1033  				return
  1034  			}
  1035  		}
  1036  
  1037  	}))
  1038  	defer testServer.Close()
  1039  	client := clientForUrl(testServer.URL)
  1040  
  1041  	for _, testCase := range testCases {
  1042  		t.Run(testCase.name, func(t *testing.T) {
  1043  			prs, err := client.GetExternalBugPRsOnBug(testCase.id)
  1044  			if !testCase.expectedError && err != nil {
  1045  				t.Errorf("%s: expected no error, but got one: %v", testCase.name, err)
  1046  			}
  1047  			if testCase.expectedError && err == nil {
  1048  				t.Errorf("%s: expected an error, but got none", testCase.name)
  1049  			}
  1050  			if diff := cmp.Diff(prs, testCase.expectedPRs); diff != "" {
  1051  				t.Errorf("%s: got incorrect prs: %v", testCase.name, diff)
  1052  			}
  1053  		})
  1054  	}
  1055  }
  1056  func errorChecker(err error, t *testing.T) {
  1057  	if err != nil {
  1058  		t.Fatalf("Error while creating bugs for testing while calling the mocked endpoint!!")
  1059  	}
  1060  }
  1061  func TestGetAllClones(t *testing.T) {
  1062  
  1063  	testcases := []struct {
  1064  		name            string
  1065  		bugs            []Bug
  1066  		bugToBeSearched Bug
  1067  		expectedClones  sets.Set[int]
  1068  	}{
  1069  		{
  1070  			name: "Clones for the root node",
  1071  			bugs: []Bug{
  1072  				{Summary: "", ID: 1, Blocks: []int{2, 5}},
  1073  				{Summary: "", ID: 2, DependsOn: []int{1}, Blocks: []int{3}},
  1074  				{Summary: "", ID: 3, DependsOn: []int{2}},
  1075  				{Summary: "Not a clone", ID: 4, DependsOn: []int{1}},
  1076  				{Summary: "", ID: 5, DependsOn: []int{1}},
  1077  			},
  1078  			bugToBeSearched: Bug{Summary: "", ID: 1, Blocks: []int{2, 5}},
  1079  			expectedClones:  sets.New[int](1, 2, 3, 5),
  1080  		},
  1081  		{
  1082  			name: "Clones for child of root",
  1083  			bugs: []Bug{
  1084  				{Summary: "", ID: 1, Blocks: []int{2, 5}},
  1085  				{Summary: "", ID: 2, DependsOn: []int{1}, Blocks: []int{3}},
  1086  				{Summary: "", ID: 3, DependsOn: []int{2}},
  1087  				{Summary: "Not a clone", ID: 4, DependsOn: []int{1}},
  1088  				{Summary: "", ID: 5, DependsOn: []int{1}},
  1089  			},
  1090  			bugToBeSearched: Bug{Summary: "", ID: 2, DependsOn: []int{1}, Blocks: []int{3}},
  1091  			expectedClones:  sets.New[int](1, 2, 3, 5),
  1092  		},
  1093  		{
  1094  			name: "Clones for grandchild of root",
  1095  			bugs: []Bug{
  1096  				{Summary: "", ID: 1, Blocks: []int{2, 5}},
  1097  				{Summary: "", ID: 2, DependsOn: []int{1}, Blocks: []int{3}},
  1098  				{Summary: "", ID: 3, DependsOn: []int{2}},
  1099  				{Summary: "Not a clone", ID: 4, DependsOn: []int{1}},
  1100  				{Summary: "", ID: 5, DependsOn: []int{1}},
  1101  			},
  1102  			bugToBeSearched: Bug{Summary: "", ID: 3, DependsOn: []int{2}},
  1103  			expectedClones:  sets.New[int](1, 2, 3, 5),
  1104  		},
  1105  		{
  1106  			name: "Clones when no clone is expected",
  1107  			bugs: []Bug{
  1108  				{Summary: "", ID: 1, Blocks: []int{2, 5}},
  1109  				{Summary: "", ID: 2, DependsOn: []int{1}, Blocks: []int{3}},
  1110  				{Summary: "", ID: 3, DependsOn: []int{2}},
  1111  				{Summary: "Not a clone", ID: 4, DependsOn: []int{1}},
  1112  				{Summary: "", ID: 5, DependsOn: []int{1}},
  1113  			},
  1114  			bugToBeSearched: Bug{Summary: "Not a clone", ID: 4, DependsOn: []int{1}},
  1115  			expectedClones:  sets.New[int](4),
  1116  		},
  1117  	}
  1118  	for _, tc := range testcases {
  1119  		t.Run(tc.name, func(t *testing.T) {
  1120  			fake := &Fake{
  1121  				Bugs:        map[int]Bug{},
  1122  				BugComments: map[int][]Comment{},
  1123  			}
  1124  			for _, bug := range tc.bugs {
  1125  				fake.Bugs[bug.ID] = bug
  1126  			}
  1127  			bugCache := newBugDetailsCache()
  1128  			clones, err := getAllClones(fake, &tc.bugToBeSearched, bugCache)
  1129  			if err != nil {
  1130  				t.Errorf("Error occurred when none was expected: %v", err)
  1131  			}
  1132  			actualCloneSet := sets.New[int]()
  1133  			for _, clone := range clones {
  1134  				actualCloneSet.Insert(clone.ID)
  1135  			}
  1136  			if !tc.expectedClones.Equal(actualCloneSet) {
  1137  				t.Errorf("clones mismatch - expected %v, got %v", tc.expectedClones, actualCloneSet)
  1138  			}
  1139  
  1140  		})
  1141  
  1142  	}
  1143  
  1144  }
  1145  
  1146  func TestGetRootForClone(t *testing.T) {
  1147  	fake := &Fake{}
  1148  	fake.Bugs = map[int]Bug{}
  1149  	fake.BugComments = map[int][]Comment{}
  1150  	bug1Create := &BugCreate{
  1151  		Summary: "Dummy bug to test getAllClones",
  1152  	}
  1153  	bugDiffCreate := &BugCreate{
  1154  		Summary: "Different bug",
  1155  	}
  1156  	diffBugID, err := fake.CreateBug(bugDiffCreate)
  1157  	errorChecker(err, t)
  1158  	bug1ID, err := fake.CreateBug(bug1Create)
  1159  	if err != nil {
  1160  		t.Fatalf("Error while creating bug in Fake!\n")
  1161  	}
  1162  	idUpdate := &IDUpdate{
  1163  		Add: []int{diffBugID},
  1164  	}
  1165  	update := BugUpdate{
  1166  		DependsOn: idUpdate,
  1167  	}
  1168  	fake.UpdateBug(bug1ID, update)
  1169  	bug1, err := fake.GetBug(bug1ID)
  1170  	errorChecker(err, t)
  1171  	bug2ID, err := fake.CloneBug(bug1)
  1172  	errorChecker(err, t)
  1173  	bug2, err := fake.GetBug(bug2ID)
  1174  	errorChecker(err, t)
  1175  	bug3ID, err := fake.CloneBug(bug2)
  1176  	errorChecker(err, t)
  1177  	bug1, err = fake.GetBug(bug1ID)
  1178  	errorChecker(err, t)
  1179  	bug2, err = fake.GetBug(bug2ID)
  1180  	errorChecker(err, t)
  1181  	bug3, err := fake.GetBug(bug3ID)
  1182  	errorChecker(err, t)
  1183  	testcases := []struct {
  1184  		name         string
  1185  		bugPtr       *Bug
  1186  		expectedRoot int
  1187  	}{
  1188  		{
  1189  			"Root is itself",
  1190  			bug1,
  1191  			bug1ID,
  1192  		},
  1193  		{
  1194  			"Root is immediate parent",
  1195  			bug2,
  1196  			bug1ID,
  1197  		},
  1198  		{
  1199  			"Root is grandparent",
  1200  			bug3,
  1201  			bug1ID,
  1202  		},
  1203  	}
  1204  	for _, tc := range testcases {
  1205  		t.Run(tc.name, func(t *testing.T) {
  1206  			// this should run get the root
  1207  			root, err := getRootForClone(fake, tc.bugPtr)
  1208  			if err != nil {
  1209  				t.Errorf("Error occurred when error not expected: %v", err)
  1210  			}
  1211  			if root.ID != tc.expectedRoot {
  1212  				t.Errorf("ID of root incorrect.")
  1213  			}
  1214  		})
  1215  	}
  1216  }
  1217  
  1218  func TestClone(t *testing.T) {
  1219  	t.Parallel()
  1220  	testCases := []struct {
  1221  		name      string
  1222  		mutations []func(bug *BugCreate)
  1223  		original  *Bug
  1224  		expected  Bug
  1225  	}{
  1226  		{
  1227  			name:     "Simple",
  1228  			original: &Bug{ID: 1},
  1229  			expected: Bug{DependsOn: []int{1}},
  1230  		},
  1231  		{
  1232  			name: "Simple with mutation",
  1233  			mutations: []func(bug *BugCreate){
  1234  				func(bug *BugCreate) {
  1235  					bug.TargetRelease = []string{"1.2"}
  1236  				},
  1237  			},
  1238  			original: &Bug{ID: 1},
  1239  			expected: Bug{
  1240  				DependsOn:     []int{1},
  1241  				TargetRelease: []string{"1.2"},
  1242  			},
  1243  		},
  1244  		{
  1245  			name: "Simple with multiple mutations",
  1246  			mutations: []func(bug *BugCreate){
  1247  				func(bug *BugCreate) {
  1248  					bug.TargetRelease = []string{"1.2"}
  1249  				},
  1250  				func(bug *BugCreate) {
  1251  					bug.CC = []string{"test@test.com", "foo@bar.com"}
  1252  				},
  1253  			},
  1254  			original: &Bug{ID: 1},
  1255  			expected: Bug{
  1256  				DependsOn:     []int{1},
  1257  				TargetRelease: []string{"1.2"},
  1258  				CC:            []string{"test@test.com", "foo@bar.com"},
  1259  			},
  1260  		},
  1261  		{
  1262  			name:     "Copy blocks field",
  1263  			original: &Bug{ID: 1, Blocks: []int{0}},
  1264  			expected: Bug{DependsOn: []int{1}, Blocks: []int{0}},
  1265  		},
  1266  	}
  1267  
  1268  	for _, tc := range testCases {
  1269  		t.Run(tc.name, func(t *testing.T) {
  1270  			client := &Fake{Bugs: map[int]Bug{0: {}}, BugComments: map[int][]Comment{1: {{}}}}
  1271  			newID, err := clone(client, tc.original, tc.mutations)
  1272  			if err != nil {
  1273  				t.Fatalf("cloning failed: %v", err)
  1274  			}
  1275  			tc.expected.ID = newID
  1276  			if diff := cmp.Diff(tc.expected, client.Bugs[newID]); diff != "" {
  1277  				t.Errorf("expected clone differs from actual clone: %s", diff)
  1278  			}
  1279  		})
  1280  	}
  1281  }