github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/cmd/deck/main_test.go (about)

     1  /*
     2  Copyright 2016 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 main
    18  
    19  import (
    20  	"bufio"
    21  	"bytes"
    22  	"context"
    23  	"encoding/json"
    24  	"errors"
    25  	"flag"
    26  	"fmt"
    27  	"io"
    28  	"net/http"
    29  	"net/http/httptest"
    30  	"net/url"
    31  	"os"
    32  	"reflect"
    33  	"strconv"
    34  	"testing"
    35  	"time"
    36  
    37  	"github.com/google/go-cmp/cmp"
    38  	"github.com/sirupsen/logrus"
    39  	coreapi "k8s.io/api/core/v1"
    40  	"k8s.io/apimachinery/pkg/api/equality"
    41  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    42  	"k8s.io/apimachinery/pkg/util/diff"
    43  	"k8s.io/apimachinery/pkg/util/sets"
    44  	ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client"
    45  	"sigs.k8s.io/yaml"
    46  
    47  	prowapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1"
    48  	"sigs.k8s.io/prow/pkg/client/clientset/versioned/fake"
    49  	"sigs.k8s.io/prow/pkg/config"
    50  	"sigs.k8s.io/prow/pkg/deck/jobs"
    51  	"sigs.k8s.io/prow/pkg/flagutil"
    52  	configflagutil "sigs.k8s.io/prow/pkg/flagutil/config"
    53  	pluginsflagutil "sigs.k8s.io/prow/pkg/flagutil/plugins"
    54  	"sigs.k8s.io/prow/pkg/pluginhelp"
    55  	"sigs.k8s.io/prow/pkg/plugins"
    56  	_ "sigs.k8s.io/prow/pkg/spyglass/lenses/buildlog"
    57  	"sigs.k8s.io/prow/pkg/spyglass/lenses/common"
    58  	_ "sigs.k8s.io/prow/pkg/spyglass/lenses/junit"
    59  	_ "sigs.k8s.io/prow/pkg/spyglass/lenses/metadata"
    60  	"sigs.k8s.io/prow/pkg/tide"
    61  	"sigs.k8s.io/prow/pkg/tide/history"
    62  )
    63  
    64  type fkc []prowapi.ProwJob
    65  
    66  func (f fkc) List(ctx context.Context, pjs *prowapi.ProwJobList, _ ...ctrlruntimeclient.ListOption) error {
    67  	pjs.Items = f
    68  	return nil
    69  }
    70  
    71  type fca struct {
    72  	c config.Config
    73  }
    74  
    75  func (ca fca) Config() *config.Config {
    76  	return &ca.c
    77  }
    78  
    79  func TestOptions_Validate(t *testing.T) {
    80  	setTenantIDs := flagutil.Strings{}
    81  	setTenantIDs.Set("Test")
    82  	var testCases = []struct {
    83  		name        string
    84  		input       options
    85  		expectedErr bool
    86  	}{
    87  		{
    88  			name: "minimal set ok",
    89  			input: options{
    90  				config: configflagutil.ConfigOptions{ConfigPath: "test"},
    91  				controllerManager: flagutil.ControllerManagerOptions{
    92  					TimeoutListingProwJobsDefault: 30 * time.Second,
    93  				},
    94  			},
    95  			expectedErr: false,
    96  		},
    97  		{
    98  			name:        "missing configpath",
    99  			input:       options{},
   100  			expectedErr: true,
   101  		},
   102  		{
   103  			name: "ok with oauth",
   104  			input: options{
   105  				config: configflagutil.ConfigOptions{ConfigPath: "test"},
   106  				controllerManager: flagutil.ControllerManagerOptions{
   107  					TimeoutListingProwJobsDefault: 30 * time.Second,
   108  				},
   109  				oauthURL:              "website",
   110  				githubOAuthConfigFile: "something",
   111  				cookieSecretFile:      "yum",
   112  			},
   113  			expectedErr: false,
   114  		},
   115  		{
   116  			name: "missing github config with oauth",
   117  			input: options{
   118  				config: configflagutil.ConfigOptions{ConfigPath: "test"},
   119  				controllerManager: flagutil.ControllerManagerOptions{
   120  					TimeoutListingProwJobsDefault: 30 * time.Second,
   121  				},
   122  				oauthURL:         "website",
   123  				cookieSecretFile: "yum",
   124  			},
   125  			expectedErr: true,
   126  		},
   127  		{
   128  			name: "missing cookie with oauth",
   129  			input: options{
   130  				config: configflagutil.ConfigOptions{ConfigPath: "test"},
   131  				controllerManager: flagutil.ControllerManagerOptions{
   132  					TimeoutListingProwJobsDefault: 30 * time.Second,
   133  				},
   134  				oauthURL:              "website",
   135  				githubOAuthConfigFile: "something",
   136  			},
   137  			expectedErr: true,
   138  		},
   139  		{
   140  			name: "hidden only and show hidden are mutually exclusive",
   141  			input: options{
   142  				config: configflagutil.ConfigOptions{ConfigPath: "test"},
   143  				controllerManager: flagutil.ControllerManagerOptions{
   144  					TimeoutListingProwJobsDefault: 30 * time.Second,
   145  				},
   146  				hiddenOnly: true,
   147  				showHidden: true,
   148  			},
   149  			expectedErr: true,
   150  		},
   151  		{
   152  			name: "show hidden and tenantIds are mutually exclusive",
   153  			input: options{
   154  				config: configflagutil.ConfigOptions{ConfigPath: "test"},
   155  				controllerManager: flagutil.ControllerManagerOptions{
   156  					TimeoutListingProwJobsDefault: 30 * time.Second,
   157  				},
   158  				hiddenOnly: false,
   159  				showHidden: true,
   160  				tenantIDs:  setTenantIDs,
   161  			},
   162  			expectedErr: true,
   163  		},
   164  		{
   165  			name: "hiddenOnly and tenantIds are mutually exclusive",
   166  			input: options{
   167  				config: configflagutil.ConfigOptions{ConfigPath: "test"},
   168  				controllerManager: flagutil.ControllerManagerOptions{
   169  					TimeoutListingProwJobsDefault: 30 * time.Second,
   170  				},
   171  				hiddenOnly: true,
   172  				showHidden: false,
   173  				tenantIDs:  setTenantIDs,
   174  			},
   175  			expectedErr: true,
   176  		},
   177  	}
   178  
   179  	for _, testCase := range testCases {
   180  		err := testCase.input.Validate()
   181  		if testCase.expectedErr && err == nil {
   182  			t.Errorf("%s: expected an error but got none", testCase.name)
   183  		}
   184  		if !testCase.expectedErr && err != nil {
   185  			t.Errorf("%s: expected no error but got one: %v", testCase.name, err)
   186  		}
   187  	}
   188  }
   189  
   190  type flc int
   191  
   192  func (f flc) GetJobLog(job, id, container string) ([]byte, error) {
   193  	if job == "job" && id == "123" {
   194  		return []byte("hello"), nil
   195  	}
   196  	return nil, errors.New("muahaha")
   197  }
   198  
   199  func TestHandleLog(t *testing.T) {
   200  	var testcases = []struct {
   201  		name string
   202  		path string
   203  		code int
   204  	}{
   205  		{
   206  			name: "no job name",
   207  			path: "",
   208  			code: http.StatusBadRequest,
   209  		},
   210  		{
   211  			name: "job but no id",
   212  			path: "?job=job",
   213  			code: http.StatusBadRequest,
   214  		},
   215  		{
   216  			name: "id but no job",
   217  			path: "?id=123",
   218  			code: http.StatusBadRequest,
   219  		},
   220  		{
   221  			name: "id and job, found",
   222  			path: "?job=job&id=123",
   223  			code: http.StatusOK,
   224  		},
   225  		{
   226  			name: "id and job, not found",
   227  			path: "?job=ohno&id=123",
   228  			code: http.StatusNotFound,
   229  		},
   230  	}
   231  	handler := handleLog(flc(0), logrus.WithField("handler", "/log"))
   232  	for _, tc := range testcases {
   233  		req, err := http.NewRequest(http.MethodGet, "", nil)
   234  		if err != nil {
   235  			t.Fatalf("Error making request: %v", err)
   236  		}
   237  		u, err := url.Parse(tc.path)
   238  		if err != nil {
   239  			t.Fatalf("Error parsing URL: %v", err)
   240  		}
   241  		var follow = false
   242  		if ok, _ := strconv.ParseBool(u.Query().Get("follow")); ok {
   243  			follow = true
   244  		}
   245  		req.URL = u
   246  		rr := httptest.NewRecorder()
   247  		handler.ServeHTTP(rr, req)
   248  		if rr.Code != tc.code {
   249  			t.Errorf("Wrong error code. Got %v, want %v", rr.Code, tc.code)
   250  		} else if rr.Code == http.StatusOK {
   251  			if follow {
   252  				//wait a little to get the chunks
   253  				time.Sleep(2 * time.Millisecond)
   254  				reader := bufio.NewReader(rr.Body)
   255  				var buf bytes.Buffer
   256  				for {
   257  					line, err := reader.ReadBytes('\n')
   258  					if err == io.EOF {
   259  						break
   260  					}
   261  					if err != nil {
   262  						t.Fatalf("Expecting reply with content but got error: %v", err)
   263  					}
   264  					buf.Write(line)
   265  				}
   266  				if !bytes.Contains(buf.Bytes(), []byte("hello")) {
   267  					t.Errorf("Unexpected body: got %s.", buf.String())
   268  				}
   269  			} else {
   270  				resp := rr.Result()
   271  				defer resp.Body.Close()
   272  				if body, err := io.ReadAll(resp.Body); err != nil {
   273  					t.Errorf("Error reading response body: %v", err)
   274  				} else if string(body) != "hello" {
   275  					t.Errorf("Unexpected body: got %s.", string(body))
   276  				}
   277  			}
   278  		}
   279  	}
   280  }
   281  
   282  // TestHandleProwJobs just checks that the results can be unmarshaled properly, have the same
   283  func TestHandleProwJobs(t *testing.T) {
   284  	kc := fkc{
   285  		prowapi.ProwJob{
   286  			ObjectMeta: metav1.ObjectMeta{
   287  				Annotations: map[string]string{
   288  					"hello": "world",
   289  				},
   290  				Labels: map[string]string{
   291  					"goodbye": "world",
   292  				},
   293  			},
   294  			Spec: prowapi.ProwJobSpec{
   295  				Agent:            prowapi.KubernetesAgent,
   296  				Job:              "job",
   297  				DecorationConfig: &prowapi.DecorationConfig{},
   298  				PodSpec: &coreapi.PodSpec{
   299  					Containers: []coreapi.Container{
   300  						{
   301  							Name:  "test-1",
   302  							Image: "tester1",
   303  						},
   304  						{
   305  							Name:  "test-2",
   306  							Image: "tester2",
   307  						},
   308  					},
   309  				},
   310  			},
   311  		},
   312  		prowapi.ProwJob{
   313  			ObjectMeta: metav1.ObjectMeta{
   314  				Annotations: map[string]string{
   315  					"hello": "world",
   316  				},
   317  				Labels: map[string]string{
   318  					"goodbye": "world",
   319  				},
   320  			},
   321  			Spec: prowapi.ProwJobSpec{
   322  				Agent:            prowapi.KubernetesAgent,
   323  				Job:              "missing-podspec-job",
   324  				DecorationConfig: &prowapi.DecorationConfig{},
   325  			},
   326  		},
   327  	}
   328  
   329  	fakeJa := jobs.NewJobAgent(context.Background(), kc, false, true, []string{}, map[string]jobs.PodLogClient{}, fca{}.Config)
   330  	fakeJa.Start()
   331  
   332  	handler := handleProwJobs(fakeJa, logrus.WithField("handler", "/prowjobs.js"))
   333  	req, err := http.NewRequest(http.MethodGet, "/prowjobs.js?omit=annotations,labels,decoration_config,pod_spec", nil)
   334  	if err != nil {
   335  		t.Fatalf("Error making request: %v", err)
   336  	}
   337  	rr := httptest.NewRecorder()
   338  	handler.ServeHTTP(rr, req)
   339  	if rr.Code != http.StatusOK {
   340  		t.Fatalf("Bad error code: %d", rr.Code)
   341  	}
   342  	resp := rr.Result()
   343  	defer resp.Body.Close()
   344  	body, err := io.ReadAll(resp.Body)
   345  	if err != nil {
   346  		t.Fatalf("Error reading response body: %v", err)
   347  	}
   348  	type prowjobItems struct {
   349  		Items []prowapi.ProwJob `json:"items"`
   350  	}
   351  	var res prowjobItems
   352  	if err := json.Unmarshal(body, &res); err != nil {
   353  		t.Fatalf("Error unmarshaling: %v", err)
   354  	}
   355  	if res.Items[0].Annotations != nil {
   356  		t.Errorf("Failed to omit annotations correctly, expected: nil, got %v", res.Items[0].Annotations)
   357  	}
   358  	if res.Items[0].Labels != nil {
   359  		t.Errorf("Failed to omit labels correctly, expected: nil, got %v", res.Items[0].Labels)
   360  	}
   361  	if res.Items[0].Spec.DecorationConfig != nil {
   362  		t.Errorf("Failed to omit decoration config correctly, expected: nil, got %v", res.Items[0].Spec.DecorationConfig)
   363  	}
   364  
   365  	// this tests the behavior for filling a podspec with empty containers when asked to omit it
   366  	emptyPodspec := &coreapi.PodSpec{
   367  		Containers: []coreapi.Container{{}, {}},
   368  	}
   369  	if !equality.Semantic.DeepEqual(res.Items[0].Spec.PodSpec, emptyPodspec) {
   370  		t.Errorf("Failed to omit podspec correctly\n%s", diff.ObjectReflectDiff(res.Items[0].Spec.PodSpec, emptyPodspec))
   371  	}
   372  
   373  	if res.Items[1].Spec.PodSpec != nil {
   374  		t.Errorf("Failed to omit podspec correctly, expected: nil, got %v", res.Items[0].Spec.PodSpec)
   375  	}
   376  }
   377  
   378  // TestProwJob just checks that the result can be unmarshaled properly, has
   379  // the same status, and has equal spec.
   380  func TestProwJob(t *testing.T) {
   381  	fakeProwJobClient := fake.NewSimpleClientset(&prowapi.ProwJob{
   382  		ObjectMeta: metav1.ObjectMeta{
   383  			Name:      "wowsuch",
   384  			Namespace: "prowjobs",
   385  		},
   386  		Spec: prowapi.ProwJobSpec{
   387  			Job:  "whoa",
   388  			Type: prowapi.PresubmitJob,
   389  			Refs: &prowapi.Refs{
   390  				Org:  "org",
   391  				Repo: "repo",
   392  				Pulls: []prowapi.Pull{
   393  					{Number: 1},
   394  				},
   395  			},
   396  		},
   397  		Status: prowapi.ProwJobStatus{
   398  			State: prowapi.PendingState,
   399  		},
   400  	})
   401  	handler := handleProwJob(fakeProwJobClient.ProwV1().ProwJobs("prowjobs"), logrus.WithField("handler", "/prowjob"))
   402  	req, err := http.NewRequest(http.MethodGet, "/prowjob?prowjob=wowsuch", nil)
   403  	if err != nil {
   404  		t.Fatalf("Error making request: %v", err)
   405  	}
   406  	rr := httptest.NewRecorder()
   407  	handler.ServeHTTP(rr, req)
   408  	if rr.Code != http.StatusOK {
   409  		t.Fatalf("Bad error code: %d", rr.Code)
   410  	}
   411  	resp := rr.Result()
   412  	defer resp.Body.Close()
   413  	body, err := io.ReadAll(resp.Body)
   414  	if err != nil {
   415  		t.Fatalf("Error reading response body: %v", err)
   416  	}
   417  	var res prowapi.ProwJob
   418  	if err := yaml.Unmarshal(body, &res); err != nil {
   419  		t.Fatalf("Error unmarshaling: %v", err)
   420  	}
   421  	if res.Spec.Job != "whoa" {
   422  		t.Errorf("Wrong job, expected \"whoa\", got \"%s\"", res.Spec.Job)
   423  	}
   424  	if res.Status.State != prowapi.PendingState {
   425  		t.Errorf("Wrong state, expected \"%v\", got \"%v\"", prowapi.PendingState, res.Status.State)
   426  	}
   427  }
   428  
   429  type fakeAuthenticatedUserIdentifier struct {
   430  	login string
   431  }
   432  
   433  func (a *fakeAuthenticatedUserIdentifier) LoginForRequester(requester, token string) (string, error) {
   434  	return a.login, nil
   435  }
   436  
   437  func TestTide(t *testing.T) {
   438  	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   439  		pools := []tide.Pool{
   440  			{
   441  				Org: "o",
   442  			},
   443  		}
   444  		b, err := json.Marshal(pools)
   445  		if err != nil {
   446  			t.Fatalf("Marshaling: %v", err)
   447  		}
   448  		fmt.Fprint(w, string(b))
   449  	}))
   450  	ca := &config.Agent{}
   451  	ca.Set(&config.Config{
   452  		ProwConfig: config.ProwConfig{
   453  			Tide: config.Tide{
   454  				TideGitHubConfig: config.TideGitHubConfig{
   455  					Queries: []config.TideQuery{
   456  						{Repos: []string{"prowapi.netes/test-infra"}},
   457  					},
   458  				},
   459  			},
   460  		},
   461  	})
   462  	ta := tideAgent{
   463  		path: s.URL,
   464  		hiddenRepos: func() []string {
   465  			return []string{}
   466  		},
   467  		updatePeriod: func() time.Duration { return time.Minute },
   468  		cfg:          func() *config.Config { return &config.Config{} },
   469  	}
   470  	if err := ta.updatePools(); err != nil {
   471  		t.Fatalf("Updating: %v", err)
   472  	}
   473  	if len(ta.pools) != 1 {
   474  		t.Fatalf("Wrong number of pools. Got %d, expected 1 in %v", len(ta.pools), ta.pools)
   475  	}
   476  	if ta.pools[0].Org != "o" {
   477  		t.Errorf("Wrong org in pool. Got %s, expected o in %v", ta.pools[0].Org, ta.pools)
   478  	}
   479  	handler := handleTidePools(ca.Config, &ta, logrus.WithField("handler", "/tide.js"))
   480  	req, err := http.NewRequest(http.MethodGet, "/tide.js", nil)
   481  	if err != nil {
   482  		t.Fatalf("Error making request: %v", err)
   483  	}
   484  	rr := httptest.NewRecorder()
   485  	handler.ServeHTTP(rr, req)
   486  	if rr.Code != http.StatusOK {
   487  		t.Fatalf("Bad error code: %d", rr.Code)
   488  	}
   489  	resp := rr.Result()
   490  	defer resp.Body.Close()
   491  	body, err := io.ReadAll(resp.Body)
   492  	if err != nil {
   493  		t.Fatalf("Error reading response body: %v", err)
   494  	}
   495  	res := tidePools{}
   496  	if err := json.Unmarshal(body, &res); err != nil {
   497  		t.Fatalf("Error unmarshaling: %v", err)
   498  	}
   499  	if len(res.Pools) != 1 {
   500  		t.Fatalf("Wrong number of pools. Got %d, expected 1 in %v", len(res.Pools), res.Pools)
   501  	}
   502  	if res.Pools[0].Org != "o" {
   503  		t.Errorf("Wrong org in pool. Got %s, expected o in %v", res.Pools[0].Org, res.Pools)
   504  	}
   505  	if len(res.Queries) != 1 {
   506  		t.Fatalf("Wrong number of pools. Got %d, expected 1 in %v", len(res.Queries), res.Queries)
   507  	}
   508  	if expected := "is:pr state:open archived:false repo:\"prowapi.netes/test-infra\""; res.Queries[0] != expected {
   509  		t.Errorf("Wrong query. Got %s, expected %s", res.Queries[0], expected)
   510  	}
   511  }
   512  
   513  func TestTideHistory(t *testing.T) {
   514  	testHist := map[string][]history.Record{
   515  		"o/r:b": {
   516  			{Action: "MERGE"}, {Action: "TRIGGER"},
   517  		},
   518  	}
   519  	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   520  		b, err := json.Marshal(testHist)
   521  		if err != nil {
   522  			t.Fatalf("Marshaling: %v", err)
   523  		}
   524  		fmt.Fprint(w, string(b))
   525  	}))
   526  
   527  	ta := tideAgent{
   528  		path: s.URL,
   529  		hiddenRepos: func() []string {
   530  			return []string{}
   531  		},
   532  		updatePeriod: func() time.Duration { return time.Minute },
   533  		cfg:          func() *config.Config { return &config.Config{} },
   534  	}
   535  	if err := ta.updateHistory(); err != nil {
   536  		t.Fatalf("Updating: %v", err)
   537  	}
   538  	if !reflect.DeepEqual(ta.history, testHist) {
   539  		t.Fatalf("Expected tideAgent history:\n%#v\n,but got:\n%#v\n", testHist, ta.history)
   540  	}
   541  
   542  	handler := handleTideHistory(&ta, logrus.WithField("handler", "/tide-history.js"))
   543  	req, err := http.NewRequest(http.MethodGet, "/tide-history.js", nil)
   544  	if err != nil {
   545  		t.Fatalf("Error making request: %v", err)
   546  	}
   547  	rr := httptest.NewRecorder()
   548  	handler.ServeHTTP(rr, req)
   549  	if rr.Code != http.StatusOK {
   550  		t.Fatalf("Bad error code: %d", rr.Code)
   551  	}
   552  	resp := rr.Result()
   553  	defer resp.Body.Close()
   554  	body, err := io.ReadAll(resp.Body)
   555  	if err != nil {
   556  		t.Fatalf("Error reading response body: %v", err)
   557  	}
   558  	var res tideHistory
   559  	if err := json.Unmarshal(body, &res); err != nil {
   560  		t.Fatalf("Error unmarshaling: %v", err)
   561  	}
   562  	if !reflect.DeepEqual(res.History, testHist) {
   563  		t.Fatalf("Expected /tide-history.js:\n%#v\n,but got:\n%#v\n", testHist, res.History)
   564  	}
   565  }
   566  
   567  func TestHelp(t *testing.T) {
   568  	hitCount := 0
   569  	help := pluginhelp.Help{
   570  		AllRepos:            []string{"org/repo"},
   571  		RepoPlugins:         map[string][]string{"org": {"plugin"}},
   572  		RepoExternalPlugins: map[string][]string{"org/repo": {"external-plugin"}},
   573  		PluginHelp:          map[string]pluginhelp.PluginHelp{"plugin": {Description: "plugin"}},
   574  		ExternalPluginHelp:  map[string]pluginhelp.PluginHelp{"external-plugin": {Description: "external-plugin"}},
   575  	}
   576  	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   577  		hitCount++
   578  		b, err := json.Marshal(help)
   579  		if err != nil {
   580  			t.Fatalf("Marshaling: %v", err)
   581  		}
   582  		fmt.Fprint(w, string(b))
   583  	}))
   584  	ha := &helpAgent{
   585  		path: s.URL,
   586  	}
   587  	handler := handlePluginHelp(ha, logrus.WithField("handler", "/plugin-help.js"))
   588  	handleAndCheck := func() {
   589  		req, err := http.NewRequest(http.MethodGet, "/plugin-help.js", nil)
   590  		if err != nil {
   591  			t.Fatalf("Error making request: %v", err)
   592  		}
   593  		rr := httptest.NewRecorder()
   594  		handler.ServeHTTP(rr, req)
   595  		if rr.Code != http.StatusOK {
   596  			t.Fatalf("Bad error code: %d", rr.Code)
   597  		}
   598  		resp := rr.Result()
   599  		defer resp.Body.Close()
   600  		body, err := io.ReadAll(resp.Body)
   601  		if err != nil {
   602  			t.Fatalf("Error reading response body: %v", err)
   603  		}
   604  		var res pluginhelp.Help
   605  		if err := yaml.Unmarshal(body, &res); err != nil {
   606  			t.Fatalf("Error unmarshaling: %v", err)
   607  		}
   608  		if !reflect.DeepEqual(help, res) {
   609  			t.Errorf("Invalid plugin help. Got %v, expected %v", res, help)
   610  		}
   611  		if hitCount != 1 {
   612  			t.Errorf("Expected fake hook endpoint to be hit once, but endpoint was hit %d times.", hitCount)
   613  		}
   614  	}
   615  	handleAndCheck()
   616  	handleAndCheck()
   617  }
   618  
   619  func Test_gatherOptions(t *testing.T) {
   620  	cases := []struct {
   621  		name       string
   622  		args       map[string]string
   623  		del        sets.Set[string]
   624  		koDataPath string
   625  		expected   func(*options)
   626  		err        bool
   627  	}{
   628  		{
   629  			name: "minimal flags work",
   630  			expected: func(o *options) {
   631  				o.controllerManager.TimeoutListingProwJobs = 30 * time.Second
   632  				o.controllerManager.TimeoutListingProwJobsDefault = 30 * time.Second
   633  			},
   634  		},
   635  		{
   636  			name: "default static files location",
   637  			expected: func(o *options) {
   638  				o.controllerManager.TimeoutListingProwJobs = 30 * time.Second
   639  				o.controllerManager.TimeoutListingProwJobsDefault = 30 * time.Second
   640  				o.spyglassFilesLocation = "/lenses"
   641  				o.staticFilesLocation = "/static"
   642  				o.templateFilesLocation = "/template"
   643  			},
   644  		},
   645  		{
   646  			name:       "ko data path",
   647  			koDataPath: "ko-data",
   648  			expected: func(o *options) {
   649  				o.controllerManager.TimeoutListingProwJobs = 30 * time.Second
   650  				o.controllerManager.TimeoutListingProwJobsDefault = 30 * time.Second
   651  				o.spyglassFilesLocation = "ko-data/lenses"
   652  				o.staticFilesLocation = "ko-data/static"
   653  				o.templateFilesLocation = "ko-data/template"
   654  			},
   655  		},
   656  		{
   657  			name: "explicitly set --config-path",
   658  			args: map[string]string{
   659  				"--config-path": "/random/value",
   660  			},
   661  			expected: func(o *options) {
   662  				o.config.ConfigPath = "/random/value"
   663  				o.controllerManager.TimeoutListingProwJobs = 30 * time.Second
   664  				o.controllerManager.TimeoutListingProwJobsDefault = 30 * time.Second
   665  			},
   666  		},
   667  		{
   668  			name: "explicitly set both --hidden-only and --show-hidden to true",
   669  			args: map[string]string{
   670  				"--hidden-only": "true",
   671  				"--show-hidden": "true",
   672  			},
   673  			err: true,
   674  		},
   675  		{
   676  			name: "explicitly set --plugin-config",
   677  			args: map[string]string{
   678  				"--hidden-only": "true",
   679  				"--show-hidden": "true",
   680  			},
   681  			err: true,
   682  		},
   683  	}
   684  	for _, tc := range cases {
   685  		fs := flag.NewFlagSet("fake-flags", flag.PanicOnError)
   686  		ghoptions := flagutil.GitHubOptions{}
   687  		ghoptions.AddFlags(fs)
   688  		ghoptions.AllowAnonymous = true
   689  		ghoptions.AllowDirectAccess = true
   690  		t.Run(tc.name, func(t *testing.T) {
   691  			oldKoDataPath := os.Getenv("KO_DATA_PATH")
   692  			if err := os.Setenv("KO_DATA_PATH", tc.koDataPath); err != nil {
   693  				t.Fatalf("Failed set env var KO_DATA_PATH: %v", err)
   694  			}
   695  			defer os.Setenv("KO_DATA_PATH", oldKoDataPath)
   696  
   697  			expected := &options{
   698  				config: configflagutil.ConfigOptions{
   699  					ConfigPathFlagName:                    "config-path",
   700  					JobConfigPathFlagName:                 "job-config-path",
   701  					ConfigPath:                            "yo",
   702  					SupplementalProwConfigsFileNameSuffix: "_prowconfig.yaml",
   703  					InRepoConfigCacheSize:                 200,
   704  				},
   705  				pluginsConfig: pluginsflagutil.PluginOptions{
   706  					SupplementalPluginsConfigsFileNameSuffix: "_pluginconfig.yaml",
   707  				},
   708  				githubOAuthConfigFile: "/etc/github/secret",
   709  				cookieSecretFile:      "",
   710  				staticFilesLocation:   "/static",
   711  				templateFilesLocation: "/template",
   712  				spyglassFilesLocation: "/lenses",
   713  				github:                ghoptions,
   714  				instrumentation:       flagutil.DefaultInstrumentationOptions(),
   715  			}
   716  			if tc.expected != nil {
   717  				tc.expected(expected)
   718  			}
   719  
   720  			argMap := map[string]string{
   721  				"--config-path": "yo",
   722  			}
   723  			for k, v := range tc.args {
   724  				argMap[k] = v
   725  			}
   726  			for k := range tc.del {
   727  				delete(argMap, k)
   728  			}
   729  
   730  			var args []string
   731  			for k, v := range argMap {
   732  				args = append(args, k+"="+v)
   733  			}
   734  			fs := flag.NewFlagSet("fake-flags", flag.PanicOnError)
   735  			actual := gatherOptions(fs, args...)
   736  			switch err := actual.Validate(); {
   737  			case err != nil:
   738  				if !tc.err {
   739  					t.Errorf("unexpected error: %v", err)
   740  				}
   741  			case tc.err:
   742  				t.Errorf("failed to receive expected error")
   743  			case !reflect.DeepEqual(*expected, actual):
   744  				t.Errorf("actual differs from expected: %s", cmp.Diff(actual, *expected, cmp.Exporter(func(_ reflect.Type) bool { return true })))
   745  			}
   746  		})
   747  	}
   748  
   749  }
   750  
   751  func TestHandleConfig(t *testing.T) {
   752  	trueVal := true
   753  	c := config.Config{
   754  		JobConfig: config.JobConfig{
   755  			PresubmitsStatic: map[string][]config.Presubmit{
   756  				"org/repo": {
   757  					{
   758  						Reporter: config.Reporter{
   759  							Context: "gce",
   760  						},
   761  						AlwaysRun: true,
   762  					},
   763  					{
   764  						Reporter: config.Reporter{
   765  							Context: "unit",
   766  						},
   767  						AlwaysRun: true,
   768  					},
   769  				},
   770  			},
   771  		},
   772  		ProwConfig: config.ProwConfig{
   773  			BranchProtection: config.BranchProtection{
   774  				Orgs: map[string]config.Org{
   775  					"kubernetes": {
   776  						Policy: config.Policy{
   777  							Protect: &trueVal,
   778  							RequiredStatusChecks: &config.ContextPolicy{
   779  								Strict: &trueVal,
   780  							},
   781  						},
   782  					},
   783  				},
   784  			},
   785  			Tide: config.Tide{
   786  				TideGitHubConfig: config.TideGitHubConfig{
   787  					Queries: []config.TideQuery{
   788  						{Repos: []string{"prowapi.netes/test-infra"}},
   789  					},
   790  				},
   791  			},
   792  		},
   793  	}
   794  	cWithDisabledCluster := config.Config{
   795  		ProwConfig: config.ProwConfig{
   796  			DisabledClusters: []string{"build08", "build08", "build01"},
   797  		},
   798  	}
   799  	dataC, err := yaml.Marshal(c)
   800  	if err != nil {
   801  		t.Fatalf("Error unmarshaling: %v", err)
   802  	}
   803  
   804  	testcases := []struct {
   805  		name                string
   806  		config              config.Config
   807  		url                 string
   808  		expectedBody        []byte
   809  		expectedStatus      int
   810  		expectedContentType string
   811  	}{
   812  		{
   813  			name:                "general case",
   814  			config:              c,
   815  			url:                 "/config",
   816  			expectedBody:        dataC,
   817  			expectedStatus:      http.StatusOK,
   818  			expectedContentType: "text/plain",
   819  		},
   820  		{
   821  			name:                "unsupported key",
   822  			config:              c,
   823  			url:                 "/config?key=some",
   824  			expectedBody:        []byte("getting config for key some is not supported\n"),
   825  			expectedStatus:      http.StatusInternalServerError,
   826  			expectedContentType: `text/plain; charset=utf-8`,
   827  		},
   828  		{
   829  			name:   "no disabled clusters",
   830  			config: c,
   831  			url:    "/config?key=disabled-clusters",
   832  			expectedBody: []byte(`[]
   833  `),
   834  			expectedStatus:      http.StatusOK,
   835  			expectedContentType: `text/plain`,
   836  		},
   837  		{
   838  			name:   "disabled clusters",
   839  			config: cWithDisabledCluster,
   840  			url:    "/config?key=disabled-clusters",
   841  			expectedBody: []byte(`- build01
   842  - build08
   843  `),
   844  			expectedStatus:      http.StatusOK,
   845  			expectedContentType: `text/plain`,
   846  		},
   847  	}
   848  
   849  	for _, tc := range testcases {
   850  		t.Run(tc.name, func(t *testing.T) {
   851  			configGetter := func() *config.Config {
   852  				return &tc.config
   853  			}
   854  			handler := handleConfig(configGetter, logrus.WithField("handler", "/config"))
   855  			req, err := http.NewRequest(http.MethodGet, tc.url, nil)
   856  			if err != nil {
   857  				t.Fatalf("Error making request: %v", err)
   858  			}
   859  			rr := httptest.NewRecorder()
   860  			handler.ServeHTTP(rr, req)
   861  
   862  			if rr.Code != tc.expectedStatus {
   863  				t.Fatalf("Bad error code: %d", rr.Code)
   864  			}
   865  			if h := rr.Header().Get("Content-Type"); h != tc.expectedContentType {
   866  				t.Fatalf("Bad Content-Type, expected: 'text/plain', got: %v", h)
   867  			}
   868  			resp := rr.Result()
   869  			defer resp.Body.Close()
   870  			body, err := io.ReadAll(resp.Body)
   871  			if err != nil {
   872  				t.Fatalf("Error reading response body: %v", err)
   873  			}
   874  			if diff := cmp.Diff(string(tc.expectedBody), string(body)); diff != "" {
   875  				t.Errorf("Error differs from expected:\n%s", diff)
   876  			}
   877  		})
   878  	}
   879  
   880  }
   881  
   882  func TestHandlePluginConfig(t *testing.T) {
   883  	c := plugins.Configuration{
   884  		Plugins: plugins.Plugins{
   885  			"org/repo": {Plugins: []string{
   886  				"approve",
   887  				"lgtm",
   888  			}},
   889  		},
   890  		Blunderbuss: plugins.Blunderbuss{
   891  			ExcludeApprovers: true,
   892  		},
   893  	}
   894  	pluginAgent := &plugins.ConfigAgent{}
   895  	pluginAgent.Set(&c)
   896  	handler := handlePluginConfig(pluginAgent, logrus.WithField("handler", "/plugin-config"))
   897  	req, err := http.NewRequest(http.MethodGet, "/config", nil)
   898  	if err != nil {
   899  		t.Fatalf("Error making request: %v", err)
   900  	}
   901  	rr := httptest.NewRecorder()
   902  	handler.ServeHTTP(rr, req)
   903  	if rr.Code != http.StatusOK {
   904  		t.Fatalf("Bad error code: %d", rr.Code)
   905  	}
   906  	if h := rr.Header().Get("Content-Type"); h != "text/plain" {
   907  		t.Fatalf("Bad Content-Type, expected: 'text/plain', got: %v", h)
   908  	}
   909  	resp := rr.Result()
   910  	defer resp.Body.Close()
   911  	body, err := io.ReadAll(resp.Body)
   912  	if err != nil {
   913  		t.Fatalf("Error reading response body: %v", err)
   914  	}
   915  	var res plugins.Configuration
   916  	if err := yaml.Unmarshal(body, &res); err != nil {
   917  		t.Fatalf("Error unmarshaling: %v", err)
   918  	}
   919  	if !reflect.DeepEqual(c, res) {
   920  		t.Errorf("Invalid config. Got %v, expected %v", res, c)
   921  	}
   922  }
   923  
   924  func cfgWithLensNamed(lensName string) *config.Config {
   925  	return &config.Config{
   926  		ProwConfig: config.ProwConfig{
   927  			Deck: config.Deck{
   928  				Spyglass: config.Spyglass{
   929  					Lenses: []config.LensFileConfig{{
   930  						Lens: config.LensConfig{
   931  							Name: lensName,
   932  						},
   933  					}},
   934  				},
   935  			},
   936  		},
   937  	}
   938  }
   939  
   940  func verifyCfgHasRemoteForLens(lensName string) func(*config.Config, error) error {
   941  	return func(c *config.Config, err error) error {
   942  		if err != nil {
   943  			return fmt.Errorf("got unexpected error: %w", err)
   944  		}
   945  
   946  		var found bool
   947  		for _, lens := range c.Deck.Spyglass.Lenses {
   948  			if lens.Lens.Name != lensName {
   949  				continue
   950  			}
   951  			found = true
   952  
   953  			if lens.RemoteConfig == nil {
   954  				return errors.New("remoteConfig for lens was nil")
   955  			}
   956  
   957  			if lens.RemoteConfig.Endpoint == "" {
   958  				return errors.New("endpoint was unset")
   959  			}
   960  
   961  			if lens.RemoteConfig.ParsedEndpoint == nil {
   962  				return errors.New("parsedEndpoint was nil")
   963  			}
   964  			if expected := common.DyanmicPathForLens(lensName); lens.RemoteConfig.ParsedEndpoint.Path != expected {
   965  				return fmt.Errorf("expected parsedEndpoint.Path to be %q, was %q", expected, lens.RemoteConfig.ParsedEndpoint.Path)
   966  			}
   967  			if lens.RemoteConfig.ParsedEndpoint.Scheme != "http" {
   968  				return fmt.Errorf("expected parsedEndpoint.scheme to be 'http', was %q", lens.RemoteConfig.ParsedEndpoint.Scheme)
   969  			}
   970  			if lens.RemoteConfig.ParsedEndpoint.Host != spyglassLocalLensListenerAddr {
   971  				return fmt.Errorf("expected parsedEndpoint.Host to be %q, was %q", spyglassLocalLensListenerAddr, lens.RemoteConfig.ParsedEndpoint.Host)
   972  			}
   973  			if lens.RemoteConfig.Title == "" {
   974  				return errors.New("expected title to be set")
   975  			}
   976  			if lens.RemoteConfig.Priority == nil {
   977  				return errors.New("expected priority to be set")
   978  			}
   979  			if lens.RemoteConfig.HideTitle == nil {
   980  				return errors.New("expected HideTitle to be set")
   981  			}
   982  		}
   983  
   984  		if !found {
   985  			return fmt.Errorf("no config found for lens %q", lensName)
   986  		}
   987  
   988  		return nil
   989  	}
   990  
   991  }
   992  
   993  func TestSpyglassConfigDefaulting(t *testing.T) {
   994  	t.Parallel()
   995  
   996  	testCases := []struct {
   997  		name   string
   998  		in     *config.Config
   999  		verify func(*config.Config, error) error
  1000  	}{
  1001  		{
  1002  			name:   "buildlog lens gets defaulted",
  1003  			in:     cfgWithLensNamed("buildlog"),
  1004  			verify: verifyCfgHasRemoteForLens("buildlog"),
  1005  		},
  1006  		{
  1007  			name:   "coverage lens gets defaulted",
  1008  			in:     cfgWithLensNamed("coverage"),
  1009  			verify: verifyCfgHasRemoteForLens("coverage"),
  1010  		},
  1011  		{
  1012  			name:   "junit lens gets defaulted",
  1013  			in:     cfgWithLensNamed("junit"),
  1014  			verify: verifyCfgHasRemoteForLens("junit"),
  1015  		},
  1016  		{
  1017  			name:   "metadata lens gets defaulted",
  1018  			in:     cfgWithLensNamed("metadata"),
  1019  			verify: verifyCfgHasRemoteForLens("metadata"),
  1020  		},
  1021  		{
  1022  			name:   "podinfo lens gets defaulted",
  1023  			in:     cfgWithLensNamed("podinfo"),
  1024  			verify: verifyCfgHasRemoteForLens("podinfo"),
  1025  		},
  1026  		{
  1027  			name:   "restcoverage lens gets defaulted",
  1028  			in:     cfgWithLensNamed("restcoverage"),
  1029  			verify: verifyCfgHasRemoteForLens("restcoverage"),
  1030  		},
  1031  		{
  1032  			name: "undef lens defaulting fails",
  1033  			in:   cfgWithLensNamed("undef"),
  1034  			verify: func(_ *config.Config, err error) error {
  1035  				expectedErrMsg := `lens "undef" has no remote_config and could not get default: invalid lens name`
  1036  				if err == nil || err.Error() != expectedErrMsg {
  1037  					return fmt.Errorf("expected err to be %q, was %w", expectedErrMsg, err)
  1038  				}
  1039  				return nil
  1040  			},
  1041  		},
  1042  	}
  1043  
  1044  	for _, tc := range testCases {
  1045  		t.Run(tc.name, func(t *testing.T) {
  1046  			if err := tc.verify(tc.in, spglassConfigDefaulting(tc.in)); err != nil {
  1047  				t.Error(err)
  1048  			}
  1049  		})
  1050  	}
  1051  }
  1052  
  1053  func TestHandleGitHubLink(t *testing.T) {
  1054  	ghoptions := flagutil.GitHubOptions{Host: "github.mycompany.com"}
  1055  	org, repo := "org", "repo"
  1056  	handler := HandleGitHubLink(ghoptions.Host, true)
  1057  	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/github-link?dest=%s/%s", org, repo), nil)
  1058  	if err != nil {
  1059  		t.Fatalf("Error making request: %v", err)
  1060  	}
  1061  	rr := httptest.NewRecorder()
  1062  	handler.ServeHTTP(rr, req)
  1063  	if rr.Code != http.StatusFound {
  1064  		t.Fatalf("Bad error code: %d", rr.Code)
  1065  	}
  1066  	resp := rr.Result()
  1067  	defer resp.Body.Close()
  1068  	actual := resp.Header.Get("Location")
  1069  	expected := fmt.Sprintf("https://%s/%s/%s", ghoptions.Host, org, repo)
  1070  	if expected != actual {
  1071  		t.Fatalf("%v", actual)
  1072  	}
  1073  }
  1074  
  1075  func TestHandleGitProviderLink(t *testing.T) {
  1076  	tests := []struct {
  1077  		name  string
  1078  		query string
  1079  		want  string
  1080  	}{
  1081  		{
  1082  			name:  "github-commit",
  1083  			query: "target=commit&repo=bar&commit=abc123",
  1084  			want:  "https://github.mycompany.com/bar/commit/abc123",
  1085  		},
  1086  		{
  1087  			name:  "github-branch",
  1088  			query: "target=branch&repo=bar&branch=main",
  1089  			want:  "https://github.mycompany.com/bar/tree/main",
  1090  		},
  1091  		{
  1092  			name:  "github-pr",
  1093  			query: "target=pr&repo=bar&number=2",
  1094  			want:  "https://github.mycompany.com/bar/pull/2",
  1095  		},
  1096  		{
  1097  			name:  "github-pr-with-quote",
  1098  			query: "target=pr&repo='bar'&number=2",
  1099  			want:  "https://github.mycompany.com/bar/pull/2",
  1100  		},
  1101  		{
  1102  			name:  "github-author",
  1103  			query: "target=author&author=chaodaiG",
  1104  			want:  "https://github.mycompany.com/chaodaiG",
  1105  		},
  1106  		{
  1107  			name:  "github-author-withquote",
  1108  			query: "target=author&repo='bar'&author=chaodaiG",
  1109  			want:  "https://github.mycompany.com/chaodaiG",
  1110  		},
  1111  		{
  1112  			name:  "github-invalid",
  1113  			query: "target=invalid&repo=bar&commit=abc123",
  1114  			want:  "/",
  1115  		},
  1116  		{
  1117  			name:  "gerrit-commit",
  1118  			query: "target=commit&repo='https://foo-review.abc/bar'&commit=abc123",
  1119  			want:  "https://foo.abc/bar/+/abc123",
  1120  		},
  1121  		{
  1122  			name:  "gerrit-commit",
  1123  			query: "target=prcommit&repo='https://foo-review.abc/bar'&commit=abc123",
  1124  			want:  "https://foo.abc/bar/+/abc123",
  1125  		},
  1126  		{
  1127  			name:  "gerrit-branch",
  1128  			query: "target=branch&repo='https://foo-review.abc/bar'&branch=main",
  1129  			want:  "https://foo.abc/bar/+/refs/heads/main",
  1130  		},
  1131  		{
  1132  			name:  "gerrit-pr",
  1133  			query: "target=pr&repo='https://foo-review.abc/bar'&number=2",
  1134  			want:  "https://foo-review.abc/c/bar/+/2",
  1135  		},
  1136  		{
  1137  			name:  "gerrit-invalid",
  1138  			query: "target=invalid&repo='https://foo-review.abc/bar'&commit=abc123",
  1139  			want:  "/",
  1140  		},
  1141  	}
  1142  
  1143  	ghoptions := flagutil.GitHubOptions{Host: "github.mycompany.com"}
  1144  
  1145  	for _, tc := range tests {
  1146  		t.Run(tc.name, func(t *testing.T) {
  1147  			url := fmt.Sprintf("/git-provider-link?%s", tc.query)
  1148  			req, err := http.NewRequest(http.MethodGet, url, nil)
  1149  			if err != nil {
  1150  				t.Fatalf("Error making request: %v", err)
  1151  			}
  1152  
  1153  			handler := HandleGitProviderLink(ghoptions.Host, true)
  1154  			rr := httptest.NewRecorder()
  1155  			handler.ServeHTTP(rr, req)
  1156  			if rr.Code != http.StatusFound {
  1157  				t.Fatalf("Bad error code: %d", rr.Code)
  1158  			}
  1159  			resp := rr.Result()
  1160  			defer resp.Body.Close()
  1161  			if want, got := tc.want, resp.Header.Get("Location"); want != got {
  1162  				t.Fatalf("Wrong URL. Want: %s, got: %s", want, got)
  1163  			}
  1164  		})
  1165  	}
  1166  }
  1167  
  1168  func TestHttpStatusForError(t *testing.T) {
  1169  	testCases := []struct {
  1170  		name           string
  1171  		input          error
  1172  		expectedStatus int
  1173  	}{
  1174  		{
  1175  			name:           "normal_error",
  1176  			input:          errors.New("some error message"),
  1177  			expectedStatus: http.StatusInternalServerError,
  1178  		},
  1179  		{
  1180  			name: "httpError",
  1181  			input: httpError{
  1182  				error:      errors.New("some error message"),
  1183  				statusCode: http.StatusGone,
  1184  			},
  1185  			expectedStatus: http.StatusGone,
  1186  		},
  1187  		{
  1188  			name: "httpError_wrapped",
  1189  			input: fmt.Errorf("wrapped error: %w", httpError{
  1190  				error:      errors.New("some error message"),
  1191  				statusCode: http.StatusGone,
  1192  			}),
  1193  			expectedStatus: http.StatusGone,
  1194  		},
  1195  	}
  1196  	for _, tc := range testCases {
  1197  		t.Run(tc.name, func(nested *testing.T) {
  1198  			actual := httpStatusForError(tc.input)
  1199  			if actual != tc.expectedStatus {
  1200  				t.Fatalf("unexpected HTTP status (expected=%v, actual=%v) for error: %v", tc.expectedStatus, actual, tc.input)
  1201  			}
  1202  		})
  1203  	}
  1204  }
  1205  
  1206  func TestPRHistLink(t *testing.T) {
  1207  	tests := []struct {
  1208  		name    string
  1209  		tmpl    string
  1210  		org     string
  1211  		repo    string
  1212  		number  int
  1213  		want    string
  1214  		wantErr bool
  1215  	}{
  1216  		{
  1217  			name:    "default",
  1218  			tmpl:    defaultPRHistLinkTemplate,
  1219  			org:     "org",
  1220  			repo:    "repo",
  1221  			number:  0,
  1222  			want:    "/pr-history?org=org&repo=repo&pr=0",
  1223  			wantErr: false,
  1224  		},
  1225  		{
  1226  			name:    "different-template",
  1227  			tmpl:    "/pull={{.Number}}",
  1228  			org:     "org",
  1229  			repo:    "repo",
  1230  			number:  0,
  1231  			want:    "/pull=0",
  1232  			wantErr: false,
  1233  		},
  1234  		{
  1235  			name:    "invalid-template",
  1236  			tmpl:    "doesn't matter {{.NotExist}}",
  1237  			org:     "org",
  1238  			repo:    "repo",
  1239  			number:  0,
  1240  			want:    "",
  1241  			wantErr: true,
  1242  		},
  1243  	}
  1244  
  1245  	for _, tc := range tests {
  1246  		t.Run(tc.name, func(t *testing.T) {
  1247  			got, gotErr := prHistLinkFromTemplate(tc.tmpl, tc.org, tc.repo, tc.number)
  1248  			if (tc.wantErr && (gotErr == nil)) || (!tc.wantErr && (gotErr != nil)) {
  1249  				t.Fatalf("Error mismatch. Want: %v, got: %v", tc.wantErr, gotErr)
  1250  			}
  1251  			if diff := cmp.Diff(tc.want, got); diff != "" {
  1252  				t.Fatalf("Template mismatch. Want: (-), got: (+). \n%s", diff)
  1253  			}
  1254  		})
  1255  	}
  1256  }