github.com/abayer/test-infra@v0.0.5/prow/cmd/branchprotector/protect_test.go (about)

     1  /*
     2  Copyright 2018 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  	"errors"
    21  	"fmt"
    22  	"reflect"
    23  	"sort"
    24  	"strings"
    25  	"testing"
    26  
    27  	"github.com/ghodss/yaml"
    28  	"k8s.io/apimachinery/pkg/util/diff"
    29  
    30  	"k8s.io/test-infra/prow/config"
    31  	"k8s.io/test-infra/prow/flagutil"
    32  	"k8s.io/test-infra/prow/github"
    33  )
    34  
    35  func TestOptions_Validate(t *testing.T) {
    36  	var testCases = []struct {
    37  		name        string
    38  		opt         options
    39  		expectedErr bool
    40  	}{
    41  		{
    42  			name: "all ok",
    43  			opt: options{
    44  				config:   "dummy",
    45  				token:    "fake",
    46  				endpoint: flagutil.NewStrings("https://api.github.com"),
    47  			},
    48  			expectedErr: false,
    49  		},
    50  		{
    51  			name: "no config",
    52  			opt: options{
    53  				config:   "",
    54  				token:    "fake",
    55  				endpoint: flagutil.NewStrings("https://api.github.com"),
    56  			},
    57  			expectedErr: true,
    58  		},
    59  		{
    60  			name: "no token",
    61  			opt: options{
    62  				config:   "dummy",
    63  				token:    "",
    64  				endpoint: flagutil.NewStrings("https://api.github.com"),
    65  			},
    66  			expectedErr: true,
    67  		},
    68  		{
    69  			name: "invalid endpoint",
    70  			opt: options{
    71  				config:   "dummy",
    72  				token:    "fake",
    73  				endpoint: flagutil.NewStrings(":"),
    74  			},
    75  			expectedErr: true,
    76  		},
    77  	}
    78  
    79  	for _, testCase := range testCases {
    80  		err := testCase.opt.Validate()
    81  		if testCase.expectedErr && err == nil {
    82  			t.Errorf("%s: expected an error but got none", testCase.name)
    83  		}
    84  		if !testCase.expectedErr && err != nil {
    85  			t.Errorf("%s: expected no error but got one: %v", testCase.name, err)
    86  		}
    87  	}
    88  }
    89  
    90  type fakeClient struct {
    91  	repos    map[string][]github.Repo
    92  	branches map[string][]github.Branch
    93  	deleted  map[string]bool
    94  	updated  map[string]github.BranchProtectionRequest
    95  }
    96  
    97  func (c fakeClient) GetRepos(org string, user bool) ([]github.Repo, error) {
    98  	r, ok := c.repos[org]
    99  	if !ok {
   100  		return nil, fmt.Errorf("Unknown org: %s", org)
   101  	}
   102  	return r, nil
   103  }
   104  
   105  func (c fakeClient) GetBranches(org, repo string, onlyProtected bool) ([]github.Branch, error) {
   106  	b, ok := c.branches[org+"/"+repo]
   107  	if !ok {
   108  		return nil, fmt.Errorf("Unknown repo: %s/%s", org, repo)
   109  	}
   110  	var out []github.Branch
   111  	if onlyProtected {
   112  		for _, item := range b {
   113  			if !item.Protected {
   114  				continue
   115  			}
   116  			out = append(out, item)
   117  		}
   118  	} else {
   119  		// when !onlyProtected, github does not set Protected
   120  		// match that behavior here to ensure we handle this correctly
   121  		for _, item := range b {
   122  			item.Protected = false
   123  			out = append(out, item)
   124  		}
   125  	}
   126  	return b, nil
   127  }
   128  
   129  func (c *fakeClient) UpdateBranchProtection(org, repo, branch string, config github.BranchProtectionRequest) error {
   130  	if branch == "error" {
   131  		return errors.New("failed to update branch protection")
   132  	}
   133  	if c.updated == nil {
   134  		c.updated = map[string]github.BranchProtectionRequest{}
   135  	}
   136  	ctx := org + "/" + repo + "=" + branch
   137  	c.updated[ctx] = config
   138  	return nil
   139  }
   140  
   141  func (c *fakeClient) RemoveBranchProtection(org, repo, branch string) error {
   142  	if branch == "error" {
   143  		return errors.New("failed to remove branch protection")
   144  	}
   145  	if c.deleted == nil {
   146  		c.deleted = map[string]bool{}
   147  	}
   148  	ctx := org + "/" + repo + "=" + branch
   149  	c.deleted[ctx] = true
   150  	return nil
   151  }
   152  
   153  func TestConfigureBranches(t *testing.T) {
   154  	yes := true
   155  
   156  	prot := github.BranchProtectionRequest{}
   157  	diffprot := github.BranchProtectionRequest{
   158  		EnforceAdmins: &yes,
   159  	}
   160  
   161  	cases := []struct {
   162  		name    string
   163  		updates []requirements
   164  		deletes map[string]bool
   165  		sets    map[string]github.BranchProtectionRequest
   166  		errors  int
   167  	}{
   168  		{
   169  			name: "remove-protection",
   170  			updates: []requirements{
   171  				{Org: "one", Repo: "1", Branch: "delete", Request: nil},
   172  				{Org: "one", Repo: "1", Branch: "remove", Request: nil},
   173  				{Org: "two", Repo: "2", Branch: "remove", Request: nil},
   174  			},
   175  			deletes: map[string]bool{
   176  				"one/1=delete": true,
   177  				"one/1=remove": true,
   178  				"two/2=remove": true,
   179  			},
   180  		},
   181  		{
   182  			name: "error-remove-protection",
   183  			updates: []requirements{
   184  				{Org: "one", Repo: "1", Branch: "error", Request: nil},
   185  			},
   186  			errors: 1,
   187  		},
   188  		{
   189  			name: "update-protection-context",
   190  			updates: []requirements{
   191  				{
   192  					Org:     "one",
   193  					Repo:    "1",
   194  					Branch:  "master",
   195  					Request: &prot,
   196  				},
   197  				{
   198  					Org:     "one",
   199  					Repo:    "1",
   200  					Branch:  "other",
   201  					Request: &diffprot,
   202  				},
   203  			},
   204  			sets: map[string]github.BranchProtectionRequest{
   205  				"one/1=master": prot,
   206  				"one/1=other":  diffprot,
   207  			},
   208  		},
   209  		{
   210  			name: "complex",
   211  			updates: []requirements{
   212  				{Org: "update", Repo: "1", Branch: "master", Request: &prot},
   213  				{Org: "update", Repo: "2", Branch: "error", Request: &prot},
   214  				{Org: "remove", Repo: "3", Branch: "master", Request: nil},
   215  				{Org: "remove", Repo: "4", Branch: "error", Request: nil},
   216  			},
   217  			errors: 2, // four and five
   218  			deletes: map[string]bool{
   219  				"remove/3=master": true,
   220  			},
   221  			sets: map[string]github.BranchProtectionRequest{
   222  				"update/1=master": prot,
   223  			},
   224  		},
   225  	}
   226  
   227  	for _, tc := range cases {
   228  		fc := fakeClient{}
   229  		p := protector{
   230  			client:  &fc,
   231  			updates: make(chan requirements),
   232  			done:    make(chan []error),
   233  		}
   234  		go p.configureBranches()
   235  		for _, u := range tc.updates {
   236  			p.updates <- u
   237  		}
   238  		close(p.updates)
   239  		errs := <-p.done
   240  		if len(errs) != tc.errors {
   241  			t.Errorf("%s: %d errors != expected %d: %v", tc.name, len(errs), tc.errors, errs)
   242  		}
   243  		if !reflect.DeepEqual(fc.deleted, tc.deletes) {
   244  			t.Errorf("%s: deletes %v != expected %v", tc.name, fc.deleted, tc.deletes)
   245  		}
   246  		if !reflect.DeepEqual(fc.updated, tc.sets) {
   247  			t.Errorf("%s: updates %v != expected %v", tc.name, fc.updated, tc.sets)
   248  		}
   249  
   250  	}
   251  }
   252  
   253  func split(branch string) (string, string, string) {
   254  	parts := strings.Split(branch, "=")
   255  	b := parts[1]
   256  	parts = strings.Split(parts[0], "/")
   257  	return parts[0], parts[1], b
   258  }
   259  
   260  func TestProtect(t *testing.T) {
   261  	yes := true
   262  
   263  	cases := []struct {
   264  		name             string
   265  		branches         []string
   266  		startUnprotected bool
   267  		config           string
   268  		expected         []requirements
   269  		errors           int
   270  	}{
   271  		{
   272  			name: "nothing",
   273  		},
   274  		{
   275  			name: "unknown org",
   276  			config: `
   277  branch-protection:
   278    protect-by-default: true
   279    orgs:
   280      unknown:
   281  `,
   282  			errors: 1,
   283  		},
   284  		{
   285  			name:     "protect org via config default",
   286  			branches: []string{"cfgdef/repo1=master", "cfgdef/repo1=branch", "cfgdef/repo2=master"},
   287  			config: `
   288  branch-protection:
   289    protect-by-default: true
   290    orgs:
   291      cfgdef:
   292  `,
   293  			expected: []requirements{
   294  				{
   295  					Org:     "cfgdef",
   296  					Repo:    "repo1",
   297  					Branch:  "master",
   298  					Request: &github.BranchProtectionRequest{},
   299  				},
   300  				{
   301  					Org:     "cfgdef",
   302  					Repo:    "repo1",
   303  					Branch:  "branch",
   304  					Request: &github.BranchProtectionRequest{},
   305  				},
   306  				{
   307  					Org:     "cfgdef",
   308  					Repo:    "repo2",
   309  					Branch:  "master",
   310  					Request: &github.BranchProtectionRequest{},
   311  				},
   312  			},
   313  		},
   314  		{
   315  			name:     "protect this but not that org",
   316  			branches: []string{"this/yes=master", "that/no=master"},
   317  			config: `
   318  branch-protection:
   319    protect-by-default: false
   320    orgs:
   321      this:
   322        protect-by-default: true
   323      that:
   324  `,
   325  			expected: []requirements{
   326  				{
   327  					Org:     "this",
   328  					Repo:    "yes",
   329  					Branch:  "master",
   330  					Request: &github.BranchProtectionRequest{},
   331  				},
   332  				{
   333  					Org:     "that",
   334  					Repo:    "no",
   335  					Branch:  "master",
   336  					Request: nil,
   337  				},
   338  			},
   339  		},
   340  		{
   341  			name:     "require a defined branch to make a protection decision",
   342  			branches: []string{"org/repo=branch"},
   343  			config: `
   344  branch-protection:
   345    orgs:
   346      org:
   347        repos:
   348          repo:
   349            branches:
   350              branch: # empty
   351  `,
   352  			errors: 1,
   353  		},
   354  		{
   355  			name:     "require pushers to set protection",
   356  			branches: []string{"org/repo=push"},
   357  			config: `
   358  branch-protection:
   359    protect-by-default: false
   360    allow-push:
   361    - oncall
   362    orgs:
   363      org:
   364  `,
   365  			errors: 1,
   366  		},
   367  		{
   368  			name:     "required contexts must set protection",
   369  			branches: []string{"org/repo=context"},
   370  			config: `
   371  branch-protection:
   372    protect-by-default: false
   373    require-contexts:
   374    - test-foo
   375    orgs:
   376      org:
   377  `,
   378  			errors: 1,
   379  		},
   380  		{
   381  			name:     "protect org but skip a repo",
   382  			branches: []string{"org/repo1=master", "org/repo1=branch", "org/skip=master"},
   383  			config: `
   384  branch-protection:
   385    protect-by-default: false
   386    orgs:
   387      org:
   388        protect-by-default: true
   389        repos:
   390          skip:
   391            protect-by-default: false
   392  `,
   393  			expected: []requirements{
   394  				{
   395  					Org:     "org",
   396  					Repo:    "repo1",
   397  					Branch:  "master",
   398  					Request: &github.BranchProtectionRequest{},
   399  				},
   400  				{
   401  					Org:     "org",
   402  					Repo:    "repo1",
   403  					Branch:  "branch",
   404  					Request: &github.BranchProtectionRequest{},
   405  				},
   406  				{
   407  					Org:     "org",
   408  					Repo:    "skip",
   409  					Branch:  "master",
   410  					Request: nil,
   411  				},
   412  			},
   413  		},
   414  		{
   415  			name:     "append contexts",
   416  			branches: []string{"org/repo=master"},
   417  			config: `
   418  branch-protection:
   419    protect-by-default: true
   420    require-contexts:
   421    - config-presubmit
   422    orgs:
   423      org:
   424        require-contexts:
   425        - org-presubmit
   426        repos:
   427          repo:
   428            require-contexts:
   429            - repo-presubmit
   430            branches:
   431              master:
   432                require-contexts:
   433                - branch-presubmit
   434  `,
   435  			expected: []requirements{
   436  				{
   437  					Org:    "org",
   438  					Repo:   "repo",
   439  					Branch: "master",
   440  					Request: &github.BranchProtectionRequest{
   441  						RequiredStatusChecks: &github.RequiredStatusChecks{
   442  							Contexts: []string{"config-presubmit", "org-presubmit", "repo-presubmit", "branch-presubmit"},
   443  						},
   444  					},
   445  				},
   446  			},
   447  		},
   448  		{
   449  			name:     "append pushers",
   450  			branches: []string{"org/repo=master"},
   451  			config: `
   452  branch-protection:
   453    protect-by-default: true
   454    allow-push:
   455    - config-team
   456    orgs:
   457      org:
   458        allow-push:
   459        - org-team
   460        repos:
   461          repo:
   462            allow-push:
   463            - repo-team
   464            branches:
   465              master:
   466                allow-push:
   467                - branch-team
   468  `,
   469  			expected: []requirements{
   470  				{
   471  					Org:    "org",
   472  					Repo:   "repo",
   473  					Branch: "master",
   474  					Request: &github.BranchProtectionRequest{
   475  						Restrictions: &github.Restrictions{
   476  							Users: &[]string{},
   477  							Teams: &[]string{"config-team", "org-team", "repo-team", "branch-team"},
   478  						},
   479  					},
   480  				},
   481  			},
   482  		},
   483  		{
   484  			name:     "all modern fields",
   485  			branches: []string{"all/modern=master"},
   486  			config: `
   487  branch-protection:
   488    protect: true
   489    enforce_admins: true
   490    required_status_checks:
   491      contexts:
   492      - config-presubmit
   493      strict: true
   494    required_pull_request_reviews:
   495      required_approving_review_count: 3
   496      dismiss_stale: false
   497      require_code_owner_reviews: true
   498      dismissal_restrictions:
   499        users:
   500        - bob
   501        - jane
   502        teams:
   503        - oncall
   504        - sres
   505    restrictions:
   506      teams:
   507      - config-team
   508      users:
   509      - cindy
   510    orgs:
   511      all:
   512        required_status_checks:
   513          contexts:
   514          - org-presubmit
   515        restrictions:
   516          teams:
   517          - org-team
   518  `,
   519  			expected: []requirements{
   520  				{
   521  					Org:    "all",
   522  					Repo:   "modern",
   523  					Branch: "master",
   524  					Request: &github.BranchProtectionRequest{
   525  						EnforceAdmins: &yes,
   526  						RequiredStatusChecks: &github.RequiredStatusChecks{
   527  							Strict:   true,
   528  							Contexts: []string{"config-presubmit", "org-presubmit"},
   529  						},
   530  						RequiredPullRequestReviews: &github.RequiredPullRequestReviews{
   531  							DismissStaleReviews:          false,
   532  							RequireCodeOwnerReviews:      true,
   533  							RequiredApprovingReviewCount: 3,
   534  							DismissalRestrictions: github.Restrictions{
   535  								Users: &[]string{"bob", "jane"},
   536  								Teams: &[]string{"oncall", "sres"},
   537  							},
   538  						},
   539  						Restrictions: &github.Restrictions{
   540  							Users: &[]string{"cindy"},
   541  							Teams: &[]string{"config-team", "org-team"},
   542  						},
   543  					},
   544  				},
   545  			},
   546  		},
   547  		{
   548  			name:     "child cannot disable parent policy by default",
   549  			branches: []string{"parent/child=unprotected"},
   550  			config: `
   551  branch-protection:
   552    protect: true
   553    enforce_admins: true
   554    orgs:
   555      parent:
   556        protect: false
   557  `,
   558  			errors: 1,
   559  		},
   560  		{
   561  			name:     "child disables parent",
   562  			branches: []string{"parent/child=unprotected"},
   563  			config: `
   564  branch-protection:
   565    allow_disabled_policies: true
   566    protect: true
   567    enforce_admins: true
   568    orgs:
   569      parent:
   570        protect: false
   571  `,
   572  			expected: []requirements{
   573  				{
   574  					Org:    "parent",
   575  					Repo:   "child",
   576  					Branch: "unprotected",
   577  				},
   578  			},
   579  		},
   580  		{
   581  			name:     "modern/deprecated mixed",
   582  			branches: []string{"modern/deprecated=mixed"},
   583  			config: `
   584  branch-protection:
   585    protect: false
   586    required_status_checks:
   587      contexts:
   588      - config-presubmit
   589    restrictions:
   590      teams:
   591      - config-team
   592    orgs:
   593      modern:
   594        protect-by-default: true
   595        allow-push:
   596        - org-team
   597        require-contexts:
   598        - org-presubmit
   599  `,
   600  			expected: []requirements{
   601  				{
   602  					Org:    "modern",
   603  					Repo:   "deprecated",
   604  					Branch: "mixed",
   605  					Request: &github.BranchProtectionRequest{
   606  						RequiredStatusChecks: &github.RequiredStatusChecks{
   607  							Contexts: []string{"config-presubmit", "org-presubmit"},
   608  						},
   609  						Restrictions: &github.Restrictions{
   610  							Users: &[]string{},
   611  							Teams: &[]string{"config-team", "org-team"},
   612  						},
   613  					},
   614  				},
   615  			},
   616  		},
   617  		{
   618  			name:     "do not unprotect unprotected",
   619  			branches: []string{"protect/update=master", "unprotected/skip=master"},
   620  			config: `
   621  branch-protection:
   622    protect: true
   623    orgs:
   624      protect:
   625        protect: true
   626      unprotected:
   627        protect: false
   628  `,
   629  			startUnprotected: true,
   630  			expected: []requirements{
   631  				{
   632  					Org:     "protect",
   633  					Repo:    "update",
   634  					Branch:  "master",
   635  					Request: &github.BranchProtectionRequest{},
   636  				},
   637  			},
   638  		},
   639  	}
   640  
   641  	for _, tc := range cases {
   642  		t.Run(tc.name, func(t *testing.T) {
   643  			repos := map[string]map[string]bool{}
   644  			branches := map[string][]github.Branch{}
   645  			for _, b := range tc.branches {
   646  				org, repo, branch := split(b)
   647  				k := org + "/" + repo
   648  				branches[k] = append(branches[k], github.Branch{
   649  					Name:      branch,
   650  					Protected: !tc.startUnprotected,
   651  				})
   652  				r := repos[org]
   653  				if r == nil {
   654  					repos[org] = make(map[string]bool)
   655  				}
   656  				repos[org][repo] = true
   657  			}
   658  			fc := fakeClient{
   659  				branches: branches,
   660  				repos:    map[string][]github.Repo{},
   661  			}
   662  			for org, r := range repos {
   663  				for rname := range r {
   664  					fc.repos[org] = append(fc.repos[org], github.Repo{Name: rname, FullName: org + "/" + rname})
   665  				}
   666  			}
   667  
   668  			var cfg config.Config
   669  			if err := yaml.Unmarshal([]byte(tc.config), &cfg); err != nil {
   670  				t.Fatalf("failed to parse config: %v", err)
   671  			}
   672  			p := protector{
   673  				client:         &fc,
   674  				cfg:            &cfg,
   675  				errors:         Errors{},
   676  				updates:        make(chan requirements),
   677  				done:           make(chan []error),
   678  				completedRepos: make(map[string]bool),
   679  			}
   680  			go func() {
   681  				p.protect()
   682  				close(p.updates)
   683  			}()
   684  
   685  			var actual []requirements
   686  			for r := range p.updates {
   687  				actual = append(actual, r)
   688  			}
   689  			errors := p.errors.errs
   690  			if len(errors) != tc.errors {
   691  				t.Errorf("actual errors %d != expected %d: %v", len(errors), tc.errors, errors)
   692  			}
   693  			switch {
   694  			case len(actual) != len(tc.expected):
   695  				t.Errorf("%+v %+v", cfg.BranchProtection, actual)
   696  				t.Errorf("actual updates %v != expected %v", actual, tc.expected)
   697  			default:
   698  				for _, a := range actual {
   699  					found := false
   700  					for _, e := range tc.expected {
   701  						if e.Org == a.Org && e.Repo == a.Repo && e.Branch == a.Branch {
   702  							found = true
   703  							fixup(&a)
   704  							fixup(&e)
   705  							if !reflect.DeepEqual(e, a) {
   706  								t.Errorf("actual != expected: %s", diff.ObjectDiff(a.Request, e.Request))
   707  							}
   708  							break
   709  						}
   710  					}
   711  					if !found {
   712  						t.Errorf("actual updates %v not in expected %v", a, tc.expected)
   713  					}
   714  				}
   715  			}
   716  		})
   717  	}
   718  }
   719  
   720  func fixup(r *requirements) {
   721  	if r == nil || r.Request == nil {
   722  		return
   723  	}
   724  	req := r.Request
   725  	if req.RequiredStatusChecks != nil {
   726  		sort.Strings(req.RequiredStatusChecks.Contexts)
   727  	}
   728  	if restr := req.Restrictions; restr != nil {
   729  		sort.Strings(*restr.Teams)
   730  		sort.Strings(*restr.Users)
   731  	}
   732  }