github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/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/google/go-cmp/cmp"
    28  	"k8s.io/apimachinery/pkg/util/diff"
    29  	"sigs.k8s.io/yaml"
    30  
    31  	"sigs.k8s.io/prow/pkg/config"
    32  	"sigs.k8s.io/prow/pkg/flagutil"
    33  	configflagutil "sigs.k8s.io/prow/pkg/flagutil/config"
    34  	"sigs.k8s.io/prow/pkg/github"
    35  )
    36  
    37  func TestOptions_Validate(t *testing.T) {
    38  	var testCases = []struct {
    39  		name        string
    40  		opt         options
    41  		expectedErr bool
    42  	}{
    43  		{
    44  			name: "all ok",
    45  			opt: options{
    46  				config: configflagutil.ConfigOptions{
    47  					ConfigPath: "dummy",
    48  				},
    49  				github: flagutil.GitHubOptions{TokenPath: "fake", ThrottleHourlyTokens: defaultTokens, ThrottleAllowBurst: defaultBurst},
    50  			},
    51  			expectedErr: false,
    52  		},
    53  		{
    54  			name: "no config",
    55  			opt: options{
    56  				github: flagutil.GitHubOptions{TokenPath: "fake", ThrottleHourlyTokens: defaultTokens, ThrottleAllowBurst: defaultBurst},
    57  			},
    58  			expectedErr: true,
    59  		},
    60  		{
    61  			name: "no token, allow",
    62  			opt: options{
    63  				config: configflagutil.ConfigOptions{
    64  					ConfigPath: "dummy",
    65  				},
    66  				github: flagutil.GitHubOptions{ThrottleHourlyTokens: defaultTokens, ThrottleAllowBurst: defaultBurst},
    67  			},
    68  			expectedErr: false,
    69  		},
    70  	}
    71  
    72  	for _, testCase := range testCases {
    73  		err := testCase.opt.Validate()
    74  		if testCase.expectedErr && err == nil {
    75  			t.Errorf("%s: expected an error but got none", testCase.name)
    76  		}
    77  		if !testCase.expectedErr && err != nil {
    78  			t.Errorf("%s: expected no error but got one: %v", testCase.name, err)
    79  		}
    80  	}
    81  }
    82  
    83  type fakeClient struct {
    84  	repos             map[string][]github.Repo
    85  	branches          map[string][]github.Branch
    86  	deleted           map[string]bool
    87  	updated           map[string]github.BranchProtectionRequest
    88  	branchProtections map[string]github.BranchProtection
    89  	appInstallations  []github.AppInstallation
    90  	collaborators     []github.User
    91  	teams             []github.Team
    92  }
    93  
    94  func (c fakeClient) GetRepo(org string, repo string) (github.FullRepo, error) {
    95  	r, ok := c.repos[org]
    96  	if !ok {
    97  		return github.FullRepo{}, fmt.Errorf("Unknown org: %s", org)
    98  	}
    99  	for _, item := range r {
   100  		if item.Name == repo {
   101  			return github.FullRepo{Repo: item}, nil
   102  		}
   103  	}
   104  	return github.FullRepo{}, fmt.Errorf("Unknown repo: %s", repo)
   105  }
   106  
   107  func (c fakeClient) GetRepos(org string, user bool) ([]github.Repo, error) {
   108  	r, ok := c.repos[org]
   109  	if !ok {
   110  		return nil, fmt.Errorf("Unknown org: %s", org)
   111  	}
   112  	return r, nil
   113  }
   114  
   115  func (c fakeClient) GetBranches(org, repo string, onlyProtected bool) ([]github.Branch, error) {
   116  	b, ok := c.branches[org+"/"+repo]
   117  	if !ok {
   118  		return nil, fmt.Errorf("Unknown repo: %s/%s", org, repo)
   119  	}
   120  	if onlyProtected {
   121  		for _, item := range b {
   122  			if !item.Protected {
   123  				continue
   124  			}
   125  		}
   126  	} else {
   127  		// when !onlyProtected, github does not set Protected
   128  		// match that behavior here to ensure we handle this correctly
   129  		for _, item := range b {
   130  			item.Protected = false
   131  		}
   132  	}
   133  	return b, nil
   134  }
   135  
   136  func (c *fakeClient) GetBranchProtection(org, repo, branch string) (*github.BranchProtection, error) {
   137  	ctx := org + "/" + repo + "=" + branch
   138  	if bp, ok := c.branchProtections[ctx]; ok {
   139  		return &bp, nil
   140  	}
   141  	return nil, nil
   142  }
   143  
   144  func (c *fakeClient) UpdateBranchProtection(org, repo, branch string, config github.BranchProtectionRequest) error {
   145  	if branch == "error" {
   146  		return errors.New("failed to update branch protection")
   147  	}
   148  	if c.updated == nil {
   149  		c.updated = map[string]github.BranchProtectionRequest{}
   150  	}
   151  	ctx := org + "/" + repo + "=" + branch
   152  	c.updated[ctx] = config
   153  	return nil
   154  }
   155  
   156  func (c *fakeClient) RemoveBranchProtection(org, repo, branch string) error {
   157  	if branch == "error" {
   158  		return errors.New("failed to remove branch protection")
   159  	}
   160  	if c.deleted == nil {
   161  		c.deleted = map[string]bool{}
   162  	}
   163  	ctx := org + "/" + repo + "=" + branch
   164  	c.deleted[ctx] = true
   165  	return nil
   166  }
   167  
   168  func (c *fakeClient) ListAppInstallationsForOrg(org string) ([]github.AppInstallation, error) {
   169  	return c.appInstallations, nil
   170  }
   171  
   172  func (c *fakeClient) ListCollaborators(org, repo string) ([]github.User, error) {
   173  	return c.collaborators, nil
   174  }
   175  
   176  func (c *fakeClient) ListRepoTeams(org, repo string) ([]github.Team, error) {
   177  	return c.teams, nil
   178  }
   179  
   180  func TestConfigureBranches(t *testing.T) {
   181  	yes := true
   182  
   183  	prot := github.BranchProtectionRequest{}
   184  	diffprot := github.BranchProtectionRequest{
   185  		EnforceAdmins: &yes,
   186  	}
   187  
   188  	cases := []struct {
   189  		name    string
   190  		updates []requirements
   191  		deletes map[string]bool
   192  		sets    map[string]github.BranchProtectionRequest
   193  		errors  int
   194  	}{
   195  		{
   196  			name: "remove-protection",
   197  			updates: []requirements{
   198  				{Org: "one", Repo: "1", Branch: "delete", Request: nil},
   199  				{Org: "one", Repo: "1", Branch: "remove", Request: nil},
   200  				{Org: "two", Repo: "2", Branch: "remove", Request: nil},
   201  			},
   202  			deletes: map[string]bool{
   203  				"one/1=delete": true,
   204  				"one/1=remove": true,
   205  				"two/2=remove": true,
   206  			},
   207  		},
   208  		{
   209  			name: "error-remove-protection",
   210  			updates: []requirements{
   211  				{Org: "one", Repo: "1", Branch: "error", Request: nil},
   212  			},
   213  			errors: 1,
   214  		},
   215  		{
   216  			name: "update-protection-context",
   217  			updates: []requirements{
   218  				{
   219  					Org:     "one",
   220  					Repo:    "1",
   221  					Branch:  "master",
   222  					Request: &prot,
   223  				},
   224  				{
   225  					Org:     "one",
   226  					Repo:    "1",
   227  					Branch:  "other",
   228  					Request: &diffprot,
   229  				},
   230  			},
   231  			sets: map[string]github.BranchProtectionRequest{
   232  				"one/1=master": prot,
   233  				"one/1=other":  diffprot,
   234  			},
   235  		},
   236  		{
   237  			name: "complex",
   238  			updates: []requirements{
   239  				{Org: "update", Repo: "1", Branch: "master", Request: &prot},
   240  				{Org: "update", Repo: "2", Branch: "error", Request: &prot},
   241  				{Org: "remove", Repo: "3", Branch: "master", Request: nil},
   242  				{Org: "remove", Repo: "4", Branch: "error", Request: nil},
   243  			},
   244  			errors: 2, // four and five
   245  			deletes: map[string]bool{
   246  				"remove/3=master": true,
   247  			},
   248  			sets: map[string]github.BranchProtectionRequest{
   249  				"update/1=master": prot,
   250  			},
   251  		},
   252  	}
   253  
   254  	for _, tc := range cases {
   255  		fc := fakeClient{}
   256  		p := protector{
   257  			client:  &fc,
   258  			updates: make(chan requirements),
   259  			done:    make(chan []error),
   260  		}
   261  		go p.configureBranches()
   262  		for _, u := range tc.updates {
   263  			p.updates <- u
   264  		}
   265  		close(p.updates)
   266  		errs := <-p.done
   267  		if len(errs) != tc.errors {
   268  			t.Errorf("%s: %d errors != expected %d: %v", tc.name, len(errs), tc.errors, errs)
   269  		}
   270  		if !reflect.DeepEqual(fc.deleted, tc.deletes) {
   271  			t.Errorf("%s: deletes %v != expected %v", tc.name, fc.deleted, tc.deletes)
   272  		}
   273  		if !reflect.DeepEqual(fc.updated, tc.sets) {
   274  			t.Errorf("%s: updates %v != expected %v", tc.name, fc.updated, tc.sets)
   275  		}
   276  
   277  	}
   278  }
   279  
   280  func split(branch string) (string, string, string) {
   281  	parts := strings.Split(branch, "=")
   282  	b := parts[1]
   283  	parts = strings.Split(parts[0], "/")
   284  	return parts[0], parts[1], b
   285  }
   286  
   287  func TestProtect(t *testing.T) {
   288  	yes := true
   289  	no := false
   290  
   291  	cases := []struct {
   292  		name                   string
   293  		branches               []string
   294  		startUnprotected       bool
   295  		config                 string
   296  		archived               string
   297  		expected               []requirements
   298  		branchProtections      map[string]github.BranchProtection
   299  		appInstallations       []github.AppInstallation
   300  		collaborators          []github.User
   301  		teams                  []github.Team
   302  		skipVerifyRestrictions bool
   303  		enableAppsRestrictions bool
   304  		errors                 int
   305  
   306  		enabled func(org, repo string) bool
   307  	}{
   308  		{
   309  			name: "nothing",
   310  		},
   311  		{
   312  			name: "unknown org",
   313  			config: `
   314  branch-protection:
   315    protect: true
   316    orgs:
   317      unknown:
   318  `,
   319  			errors: 1,
   320  		},
   321  		{
   322  			name:     "protect org via config default",
   323  			branches: []string{"cfgdef/repo1=master", "cfgdef/repo1=branch", "cfgdef/repo2=master"},
   324  			config: `
   325  branch-protection:
   326    protect: true
   327    orgs:
   328      cfgdef:
   329  `,
   330  			expected: []requirements{
   331  				{
   332  					Org:    "cfgdef",
   333  					Repo:   "repo1",
   334  					Branch: "master",
   335  					Request: &github.BranchProtectionRequest{
   336  						EnforceAdmins: &no,
   337  					},
   338  				},
   339  				{
   340  					Org:    "cfgdef",
   341  					Repo:   "repo1",
   342  					Branch: "branch",
   343  					Request: &github.BranchProtectionRequest{
   344  						EnforceAdmins: &no,
   345  					},
   346  				},
   347  				{
   348  					Org:    "cfgdef",
   349  					Repo:   "repo2",
   350  					Branch: "master",
   351  					Request: &github.BranchProtectionRequest{
   352  						EnforceAdmins: &no,
   353  					},
   354  				},
   355  			},
   356  		},
   357  		{
   358  			name:     "protect this but not that org",
   359  			branches: []string{"this/yes=master", "that/no=master"},
   360  			config: `
   361  branch-protection:
   362    protect: false
   363    orgs:
   364      this:
   365        protect: true
   366      that:
   367  `,
   368  			expected: []requirements{
   369  				{
   370  					Org:    "this",
   371  					Repo:   "yes",
   372  					Branch: "master",
   373  					Request: &github.BranchProtectionRequest{
   374  						EnforceAdmins: &no,
   375  					},
   376  				},
   377  				{
   378  					Org:     "that",
   379  					Repo:    "no",
   380  					Branch:  "master",
   381  					Request: nil,
   382  				},
   383  			},
   384  			branchProtections: map[string]github.BranchProtection{"that/no=master": {}},
   385  		},
   386  		{
   387  			name:     "protect all repos when protection configured at org level",
   388  			branches: []string{"kubernetes/test-infra=master", "kubernetes/publishing-bot=master"},
   389  			config: `
   390  branch-protection:
   391    orgs:
   392      kubernetes:
   393        protect: true
   394        repos:
   395          test-infra:
   396            required_status_checks:
   397              contexts:
   398              - hello-world
   399  `,
   400  			expected: []requirements{
   401  				{
   402  					Org:    "kubernetes",
   403  					Repo:   "test-infra",
   404  					Branch: "master",
   405  					Request: &github.BranchProtectionRequest{
   406  						EnforceAdmins: &no,
   407  						RequiredStatusChecks: &github.RequiredStatusChecks{
   408  							Contexts: []string{"hello-world"},
   409  						},
   410  					},
   411  				},
   412  				{
   413  					Org:    "kubernetes",
   414  					Repo:   "publishing-bot",
   415  					Branch: "master",
   416  					Request: &github.BranchProtectionRequest{
   417  						EnforceAdmins: &no,
   418  					},
   419  				},
   420  			},
   421  		},
   422  		{
   423  			name:     "require a defined branch to make a protection decision",
   424  			branches: []string{"org/repo=branch"},
   425  			config: `
   426  branch-protection:
   427    orgs:
   428      org:
   429        repos:
   430          repo:
   431            branches:
   432              branch: # empty
   433  `,
   434  			errors: 1,
   435  		},
   436  		{
   437  			name:     "require pushers to set protection",
   438  			branches: []string{"org/repo=push"},
   439  			config: `
   440  branch-protection:
   441    protect: false
   442    restrictions:
   443      teams:
   444      - oncall
   445    orgs:
   446      org:
   447  `,
   448  			errors: 1,
   449  		},
   450  		{
   451  			name:     "required contexts must set protection",
   452  			branches: []string{"org/repo=context"},
   453  			config: `
   454  branch-protection:
   455    protect: false
   456    required_status_checks:
   457      contexts:
   458      - test-foo
   459    orgs:
   460      org:
   461  `,
   462  			errors: 1,
   463  		},
   464  		{
   465  			name:     "protect org but skip a repo",
   466  			branches: []string{"org/repo1=master", "org/repo1=branch", "org/skip=master"},
   467  			config: `
   468  branch-protection:
   469    protect: false
   470    orgs:
   471      org:
   472        protect: true
   473        repos:
   474          skip:
   475            protect: false
   476  `,
   477  			expected: []requirements{
   478  				{
   479  					Org:    "org",
   480  					Repo:   "repo1",
   481  					Branch: "master",
   482  					Request: &github.BranchProtectionRequest{
   483  						EnforceAdmins: &no,
   484  					},
   485  				},
   486  				{
   487  					Org:    "org",
   488  					Repo:   "repo1",
   489  					Branch: "branch",
   490  					Request: &github.BranchProtectionRequest{
   491  						EnforceAdmins: &no,
   492  					},
   493  				},
   494  				{
   495  					Org:     "org",
   496  					Repo:    "skip",
   497  					Branch:  "master",
   498  					Request: nil,
   499  				},
   500  			},
   501  			branchProtections: map[string]github.BranchProtection{"org/skip=master": {}},
   502  		},
   503  		{
   504  			name:     "protect org but branchprotector is not enabled for this org, nothing happens",
   505  			branches: []string{"org/repo1=master", "org/repo1=branch"},
   506  			config: `
   507  branch-protection:
   508    protect: false
   509    orgs:
   510      org:
   511        protect: true
   512  `,
   513  			enabled: func(org, repo string) bool { return org != "org" },
   514  		},
   515  		{
   516  			name:     "protect org, branchprotector is disabled for different org, org gets protected",
   517  			branches: []string{"org/repo1=master", "org/repo1=branch"},
   518  			config: `
   519  branch-protection:
   520    protect: false
   521    orgs:
   522      org:
   523        protect: true
   524  `,
   525  			expected: []requirements{
   526  				{
   527  					Org:    "org",
   528  					Repo:   "repo1",
   529  					Branch: "master",
   530  					Request: &github.BranchProtectionRequest{
   531  						EnforceAdmins: &no,
   532  					},
   533  				},
   534  				{
   535  					Org:    "org",
   536  					Repo:   "repo1",
   537  					Branch: "branch",
   538  					Request: &github.BranchProtectionRequest{
   539  						EnforceAdmins: &no,
   540  					},
   541  				},
   542  			},
   543  			enabled: func(org, repo string) bool { return org != "other-org" },
   544  		},
   545  		{
   546  			name:     "protect org, branchprotector is disabled for one repo so it gets skipped",
   547  			branches: []string{"org/repo1=master", "org/repo2=master"},
   548  			config: `
   549  branch-protection:
   550    protect: false
   551    orgs:
   552      org:
   553        protect: true
   554  `,
   555  			expected: []requirements{{
   556  				Org:    "org",
   557  				Repo:   "repo1",
   558  				Branch: "master",
   559  				Request: &github.BranchProtectionRequest{
   560  					EnforceAdmins: &no,
   561  				},
   562  			}},
   563  			enabled: func(org, repo string) bool { return org == "org" && repo != "repo2" },
   564  		},
   565  		{
   566  			name:     "protect org but skip a repo due to archival",
   567  			branches: []string{"org/repo1=master", "org/repo1=branch", "org/skip=master"},
   568  			config: `
   569  branch-protection:
   570    protect: false
   571    orgs:
   572      org:
   573        protect: true
   574  `,
   575  			archived: "skip",
   576  			expected: []requirements{
   577  				{
   578  					Org:    "org",
   579  					Repo:   "repo1",
   580  					Branch: "master",
   581  					Request: &github.BranchProtectionRequest{
   582  						EnforceAdmins: &no,
   583  					},
   584  				},
   585  				{
   586  					Org:    "org",
   587  					Repo:   "repo1",
   588  					Branch: "branch",
   589  					Request: &github.BranchProtectionRequest{
   590  						EnforceAdmins: &no,
   591  					},
   592  				},
   593  			},
   594  		},
   595  		{
   596  			name:     "collapse duplicated contexts",
   597  			branches: []string{"org/repo=master"},
   598  			config: `
   599  branch-protection:
   600    protect: true
   601    required_status_checks:
   602      contexts:
   603      - hello-world
   604      - duplicate-context
   605      - duplicate-context
   606      - hello-world
   607    orgs:
   608      org:
   609  `,
   610  			expected: []requirements{
   611  				{
   612  					Org:    "org",
   613  					Repo:   "repo",
   614  					Branch: "master",
   615  					Request: &github.BranchProtectionRequest{
   616  						EnforceAdmins: &no,
   617  						RequiredStatusChecks: &github.RequiredStatusChecks{
   618  							Contexts: []string{"duplicate-context", "hello-world"},
   619  						},
   620  					},
   621  				},
   622  			},
   623  		},
   624  		{
   625  			name:     "append contexts",
   626  			branches: []string{"org/repo=master"},
   627  			config: `
   628  branch-protection:
   629    protect: true
   630    required_status_checks:
   631      contexts:
   632      - config-presubmit
   633    orgs:
   634      org:
   635        required_status_checks:
   636          contexts:
   637          - org-presubmit
   638        repos:
   639          repo:
   640            required_status_checks:
   641              contexts:
   642              - repo-presubmit
   643            branches:
   644              master:
   645                required_status_checks:
   646                  contexts:
   647                  - branch-presubmit
   648  `,
   649  			expected: []requirements{
   650  				{
   651  					Org:    "org",
   652  					Repo:   "repo",
   653  					Branch: "master",
   654  					Request: &github.BranchProtectionRequest{
   655  						EnforceAdmins: &no,
   656  						RequiredStatusChecks: &github.RequiredStatusChecks{
   657  							Contexts: []string{"config-presubmit", "org-presubmit", "repo-presubmit", "branch-presubmit"},
   658  						},
   659  					},
   660  				},
   661  			},
   662  		},
   663  		{
   664  			name:     "append pushers",
   665  			branches: []string{"org/repo=master"},
   666  			teams: []github.Team{
   667  				{
   668  					Slug:       "config-team",
   669  					Permission: github.RepoPush,
   670  				},
   671  				{
   672  					Slug:       "org-team",
   673  					Permission: github.RepoPush,
   674  				},
   675  				{
   676  					Slug:       "repo-team",
   677  					Permission: github.RepoPush,
   678  				},
   679  				{
   680  					Slug:       "branch-team",
   681  					Permission: github.RepoPush,
   682  				},
   683  			},
   684  			config: `
   685  branch-protection:
   686    protect: true
   687    restrictions:
   688      teams:
   689      - config-team
   690    orgs:
   691      org:
   692        restrictions:
   693          teams:
   694          - org-team
   695        repos:
   696          repo:
   697            restrictions:
   698              teams:
   699              - repo-team
   700            branches:
   701              master:
   702                restrictions:
   703                  teams:
   704                  - branch-team
   705  `,
   706  			expected: []requirements{
   707  				{
   708  					Org:    "org",
   709  					Repo:   "repo",
   710  					Branch: "master",
   711  					Request: &github.BranchProtectionRequest{
   712  						EnforceAdmins: &no,
   713  						Restrictions: &github.RestrictionsRequest{
   714  							Users: &[]string{},
   715  							Teams: &[]string{"config-team", "org-team", "repo-team", "branch-team"},
   716  						},
   717  					},
   718  				},
   719  			},
   720  		},
   721  		{
   722  			name:                   "all modern fields",
   723  			branches:               []string{"all/modern=master"},
   724  			enableAppsRestrictions: true,
   725  			appInstallations: []github.AppInstallation{
   726  				{
   727  					AppSlug: "content-app",
   728  					Permissions: github.InstallationPermissions{
   729  						Contents: string(github.Write),
   730  					},
   731  				},
   732  			},
   733  			collaborators: []github.User{
   734  				{
   735  					Login:       "cindy",
   736  					Permissions: github.RepoPermissions{Push: true},
   737  				},
   738  			},
   739  			teams: []github.Team{
   740  				{
   741  					Slug:       "config-team",
   742  					Permission: github.RepoPush,
   743  				},
   744  				{
   745  					Slug:       "org-team",
   746  					Permission: github.RepoPush,
   747  				},
   748  			},
   749  			config: `
   750  branch-protection:
   751    protect: true
   752    enforce_admins: true
   753    required_status_checks:
   754      contexts:
   755      - config-presubmit
   756      strict: true
   757    required_pull_request_reviews:
   758      required_approving_review_count: 3
   759      dismiss_stale: false
   760      require_code_owner_reviews: true
   761      dismissal_restrictions:
   762        users:
   763        - bob
   764        - jane
   765        teams:
   766        - oncall
   767        - sres
   768      bypass_pull_request_allowances:
   769       users:
   770       - bypass_bob
   771       - bypass_jane
   772       teams:
   773       - bypass_oncall
   774       - bypass_sres
   775    restrictions:
   776      apps:
   777      - content-app
   778      teams:
   779      - config-team
   780      users:
   781      - cindy
   782    orgs:
   783      all:
   784        required_status_checks:
   785          contexts:
   786          - org-presubmit
   787        restrictions:
   788          teams:
   789          - org-team
   790  `,
   791  			expected: []requirements{
   792  				{
   793  					Org:    "all",
   794  					Repo:   "modern",
   795  					Branch: "master",
   796  					Request: &github.BranchProtectionRequest{
   797  						EnforceAdmins: &yes,
   798  						RequiredStatusChecks: &github.RequiredStatusChecks{
   799  							Strict:   true,
   800  							Contexts: []string{"config-presubmit", "org-presubmit"},
   801  						},
   802  						RequiredPullRequestReviews: &github.RequiredPullRequestReviewsRequest{
   803  							DismissStaleReviews:          false,
   804  							RequireCodeOwnerReviews:      true,
   805  							RequiredApprovingReviewCount: 3,
   806  							DismissalRestrictions: github.DismissalRestrictionsRequest{
   807  								Users: &[]string{"bob", "jane"},
   808  								Teams: &[]string{"oncall", "sres"},
   809  							},
   810  							BypassRestrictions: github.BypassRestrictionsRequest{
   811  								Users: &[]string{"bypass_bob", "bypass_jane"},
   812  								Teams: &[]string{"bypass_oncall", "bypass_sres"},
   813  							},
   814  						},
   815  						Restrictions: &github.RestrictionsRequest{
   816  							Apps:  &[]string{"content-app"},
   817  							Users: &[]string{"cindy"},
   818  							Teams: &[]string{"config-team", "org-team"},
   819  						},
   820  					},
   821  				},
   822  			},
   823  		},
   824  		{
   825  			name:     "child cannot disable parent policy by default",
   826  			branches: []string{"parent/child=unprotected"},
   827  			config: `
   828  branch-protection:
   829    protect: true
   830    enforce_admins: true
   831    orgs:
   832      parent:
   833        protect: false
   834  `,
   835  			errors: 1,
   836  		},
   837  		{
   838  			name:     "child disables parent",
   839  			branches: []string{"parent/child=unprotected"},
   840  			config: `
   841  branch-protection:
   842    allow_disabled_policies: true
   843    protect: true
   844    enforce_admins: true
   845    orgs:
   846      parent:
   847        protect: false
   848  `,
   849  			expected: []requirements{
   850  				{
   851  					Org:    "parent",
   852  					Repo:   "child",
   853  					Branch: "unprotected",
   854  				},
   855  			},
   856  			branchProtections: map[string]github.BranchProtection{"parent/child=unprotected": {}},
   857  		},
   858  		{
   859  			name:     "do not unprotect unprotected",
   860  			branches: []string{"protect/update=master", "unprotected/skip=master"},
   861  			config: `
   862  branch-protection:
   863    protect: true
   864    orgs:
   865      protect:
   866        protect: true
   867      unprotected:
   868        protect: false
   869  `,
   870  			startUnprotected: true,
   871  			expected: []requirements{
   872  				{
   873  					Org:    "protect",
   874  					Repo:   "update",
   875  					Branch: "master",
   876  					Request: &github.BranchProtectionRequest{
   877  						EnforceAdmins: &no,
   878  					},
   879  				},
   880  			},
   881  		},
   882  		{
   883  			name:                   "do not make update request if the branch is already up-to-date",
   884  			enableAppsRestrictions: true,
   885  			branches:               []string{"kubernetes/test-infra=master"},
   886  			appInstallations: []github.AppInstallation{
   887  				{
   888  					AppSlug: "content-app",
   889  					Permissions: github.InstallationPermissions{
   890  						Contents: string(github.Write),
   891  					},
   892  				},
   893  			},
   894  			collaborators: []github.User{
   895  				{
   896  					Login:       "cindy",
   897  					Permissions: github.RepoPermissions{Push: true},
   898  				},
   899  			},
   900  			teams: []github.Team{
   901  				{
   902  					Slug:       "config-team",
   903  					Permission: github.RepoPush,
   904  				},
   905  			},
   906  			config: `
   907  branch-protection:
   908    enforce_admins: true
   909    required_status_checks:
   910      contexts:
   911      - config-presubmit
   912      strict: true
   913    required_pull_request_reviews:
   914      required_approving_review_count: 3
   915      dismiss_stale: false
   916      require_code_owner_reviews: true
   917      dismissal_restrictions:
   918        users:
   919        - bob
   920        - jane
   921        teams:
   922        - oncall
   923        - sres
   924      bypass_pull_request_allowances:
   925        users:
   926        - bypass_bob
   927        - bypass_jane
   928        teams:
   929        - bypass_oncall
   930        - bypass_sres
   931    restrictions:
   932      apps:
   933      - content-app
   934      teams:
   935      - config-team
   936      users:
   937      - cindy
   938    protect: true
   939    orgs:
   940      kubernetes:
   941        repos:
   942          test-infra:
   943  `,
   944  			branchProtections: map[string]github.BranchProtection{
   945  				"kubernetes/test-infra=master": {
   946  					EnforceAdmins: github.EnforceAdmins{Enabled: true},
   947  					RequiredStatusChecks: &github.RequiredStatusChecks{
   948  						Strict:   true,
   949  						Contexts: []string{"config-presubmit"},
   950  					},
   951  					RequiredPullRequestReviews: &github.RequiredPullRequestReviews{
   952  						DismissStaleReviews:          false,
   953  						RequireCodeOwnerReviews:      true,
   954  						RequiredApprovingReviewCount: 3,
   955  						DismissalRestrictions: &github.DismissalRestrictions{
   956  							Users: []github.User{{Login: "bob"}, {Login: "jane"}},
   957  							Teams: []github.Team{{Slug: "oncall"}, {Slug: "sres"}},
   958  						},
   959  						BypassRestrictions: &github.BypassRestrictions{
   960  							Users: []github.User{{Login: "bypass_bob"}, {Login: "bypass_jane"}},
   961  							Teams: []github.Team{{Slug: "bypass_oncall"}, {Slug: "bypass_sres"}},
   962  						},
   963  					},
   964  					Restrictions: &github.Restrictions{
   965  						Apps:  []github.App{{Slug: "content-app"}},
   966  						Users: []github.User{{Login: "cindy"}},
   967  						Teams: []github.Team{{Slug: "config-team"}},
   968  					},
   969  				},
   970  			},
   971  		},
   972  		// TODO: consider harmonizing apps handling with teams and users
   973  		{
   974  			name:     "do not make update request if the only change is a unspecified app request",
   975  			branches: []string{"kubernetes/test-infra=master"},
   976  			appInstallations: []github.AppInstallation{
   977  				{
   978  					AppSlug: "content-app",
   979  					Permissions: github.InstallationPermissions{
   980  						Contents: string(github.Write),
   981  					},
   982  				},
   983  			},
   984  			collaborators: []github.User{
   985  				{
   986  					Login:       "cindy",
   987  					Permissions: github.RepoPermissions{Push: true},
   988  				},
   989  			},
   990  			teams: []github.Team{
   991  				{
   992  					Slug:       "config-team",
   993  					Permission: github.RepoPush,
   994  				},
   995  			},
   996  			config: `
   997  branch-protection:
   998    enforce_admins: true
   999    required_status_checks:
  1000      contexts:
  1001      - config-presubmit
  1002      strict: true
  1003    required_pull_request_reviews:
  1004      required_approving_review_count: 3
  1005      dismiss_stale: false
  1006      require_code_owner_reviews: true
  1007      dismissal_restrictions:
  1008        users:
  1009        - bob
  1010        - jane
  1011        teams:
  1012        - oncall
  1013        - sres
  1014      bypass_pull_request_allowances:
  1015        users:
  1016        - bypass_bob
  1017        - bypass_jane
  1018        teams:
  1019        - bypass_oncall
  1020        - bypass_sres
  1021    restrictions:
  1022      teams:
  1023      - config-team
  1024      users:
  1025      - cindy
  1026    protect: true
  1027    orgs:
  1028      kubernetes:
  1029        repos:
  1030          test-infra:
  1031  `,
  1032  			branchProtections: map[string]github.BranchProtection{
  1033  				"kubernetes/test-infra=master": {
  1034  					EnforceAdmins: github.EnforceAdmins{Enabled: true},
  1035  					RequiredStatusChecks: &github.RequiredStatusChecks{
  1036  						Strict:   true,
  1037  						Contexts: []string{"config-presubmit"},
  1038  					},
  1039  					RequiredPullRequestReviews: &github.RequiredPullRequestReviews{
  1040  						DismissStaleReviews:          false,
  1041  						RequireCodeOwnerReviews:      true,
  1042  						RequiredApprovingReviewCount: 3,
  1043  						DismissalRestrictions: &github.DismissalRestrictions{
  1044  							Users: []github.User{{Login: "bob"}, {Login: "jane"}},
  1045  							Teams: []github.Team{{Slug: "oncall"}, {Slug: "sres"}},
  1046  						},
  1047  						BypassRestrictions: &github.BypassRestrictions{
  1048  							Users: []github.User{{Login: "bypass_bob"}, {Login: "bypass_jane"}},
  1049  							Teams: []github.Team{{Slug: "bypass_oncall"}, {Slug: "bypass_sres"}},
  1050  						},
  1051  					},
  1052  					Restrictions: &github.Restrictions{
  1053  						Apps:  []github.App{{Slug: "content-app"}},
  1054  						Users: []github.User{{Login: "cindy"}},
  1055  						Teams: []github.Team{{Slug: "config-team"}},
  1056  					},
  1057  				},
  1058  			},
  1059  		},
  1060  		{
  1061  			name:     "make request if branch protection is present, but out of date",
  1062  			branches: []string{"kubernetes/test-infra=master"},
  1063  			config: `
  1064  branch-protection:
  1065    enforce_admins: true
  1066    required_pull_request_reviews:
  1067      required_approving_review_count: 3
  1068    protect: true
  1069    orgs:
  1070      kubernetes:
  1071        repos:
  1072          test-infra:
  1073  `,
  1074  			branchProtections: map[string]github.BranchProtection{
  1075  				"kubernetes/test-infra=master": {
  1076  					EnforceAdmins: github.EnforceAdmins{Enabled: true},
  1077  					RequiredStatusChecks: &github.RequiredStatusChecks{
  1078  						Strict:   true,
  1079  						Contexts: []string{"config-presubmit"},
  1080  					},
  1081  				},
  1082  			},
  1083  			expected: []requirements{
  1084  				{
  1085  					Org:    "kubernetes",
  1086  					Repo:   "test-infra",
  1087  					Branch: "master",
  1088  					Request: &github.BranchProtectionRequest{
  1089  						EnforceAdmins: &yes,
  1090  						RequiredPullRequestReviews: &github.RequiredPullRequestReviewsRequest{
  1091  							RequiredApprovingReviewCount: 3,
  1092  						},
  1093  					},
  1094  				},
  1095  			},
  1096  		},
  1097  		{
  1098  			name:     "excluded branches are not protected",
  1099  			branches: []string{"kubernetes/test-infra=master", "kubernetes/test-infra=skip"},
  1100  			config: `
  1101  branch-protection:
  1102    protect: true
  1103    orgs:
  1104      kubernetes:
  1105        repos:
  1106          test-infra:
  1107            exclude:
  1108            - sk.*
  1109  `,
  1110  
  1111  			expected: []requirements{
  1112  				{
  1113  					Org:     "kubernetes",
  1114  					Repo:    "test-infra",
  1115  					Branch:  "master",
  1116  					Request: &github.BranchProtectionRequest{EnforceAdmins: &no},
  1117  				},
  1118  			},
  1119  		},
  1120  		{
  1121  			name:     "org and repo level branch exclusions are combined",
  1122  			branches: []string{"kubernetes/test-infra=master", "kubernetes/test-infra=skip", "kubernetes/test-infra=foobar1"},
  1123  			config: `
  1124  branch-protection:
  1125    protect: true
  1126    orgs:
  1127      kubernetes:
  1128        exclude:
  1129        - foo.*
  1130        repos:
  1131          test-infra:
  1132            exclude:
  1133            - sk.*
  1134  `,
  1135  			expected: []requirements{
  1136  				{
  1137  					Org:     "kubernetes",
  1138  					Repo:    "test-infra",
  1139  					Branch:  "master",
  1140  					Request: &github.BranchProtectionRequest{EnforceAdmins: &no},
  1141  				},
  1142  			},
  1143  		},
  1144  		{
  1145  			name:     "explicitly specified branches are not affected by Exclude",
  1146  			branches: []string{"kubernetes/test-infra=master"},
  1147  			config: `
  1148  branch-protection:
  1149    protect: true
  1150    orgs:
  1151      kubernetes:
  1152        exclude:
  1153        - master.*
  1154        repos:
  1155          test-infra:
  1156            branches:
  1157              master:
  1158  `,
  1159  			expected: []requirements{
  1160  				{
  1161  					Org:     "kubernetes",
  1162  					Repo:    "test-infra",
  1163  					Branch:  "master",
  1164  					Request: &github.BranchProtectionRequest{EnforceAdmins: &no},
  1165  				},
  1166  			},
  1167  		},
  1168  		{
  1169  			name:                   "do not make update request if the app, team or collaborator is not authorized",
  1170  			branches:               []string{"org/unauthorized-app=master", "org/unauthorized-collaborator=master", "org/unauthorized-team=master"},
  1171  			enableAppsRestrictions: true,
  1172  			config: `
  1173  branch-protection:
  1174    protect: true
  1175    orgs:
  1176      org:
  1177        repos:
  1178          unauthorized-app:
  1179            restrictions:
  1180              apps:
  1181              - nocontent-app  
  1182          unauthorized-collaborator:
  1183            restrictions:
  1184              users:
  1185              - cindy
  1186          unauthorized-team:
  1187            restrictions:
  1188              teams:
  1189              - config-team
  1190  `,
  1191  			errors: 1,
  1192  		},
  1193  		{
  1194  			name:     "make request for unauthorized collaborators/teams if the verify-restrictions feature flag is not set",
  1195  			branches: []string{"org/unauthorized=master"},
  1196  			config: `
  1197  branch-protection:
  1198    restrictions:
  1199      teams:
  1200      - config-team
  1201      users:
  1202      - cindy
  1203    protect: true
  1204    orgs:
  1205      org:
  1206        repos:
  1207          unauthorized:
  1208            protect: true
  1209  `,
  1210  			skipVerifyRestrictions: true,
  1211  			expected: []requirements{
  1212  				{
  1213  					Org:    "org",
  1214  					Repo:   "unauthorized",
  1215  					Branch: "master",
  1216  					Request: &github.BranchProtectionRequest{
  1217  						EnforceAdmins: &no,
  1218  						Restrictions: &github.RestrictionsRequest{
  1219  							Users: &[]string{"cindy"},
  1220  							Teams: &[]string{"config-team"},
  1221  						},
  1222  					},
  1223  				},
  1224  			},
  1225  		},
  1226  		{
  1227  			name:     "protect branches with special characters",
  1228  			branches: []string{"cfgdef/repo1=test_#123"},
  1229  			config: `
  1230  branch-protection:
  1231    protect: true
  1232    orgs:
  1233      cfgdef:
  1234  `,
  1235  			expected: []requirements{
  1236  				{
  1237  					Org:    "cfgdef",
  1238  					Repo:   "repo1",
  1239  					Branch: "test_#123",
  1240  					Request: &github.BranchProtectionRequest{
  1241  						EnforceAdmins: &no,
  1242  					},
  1243  				},
  1244  			},
  1245  		},
  1246  		{
  1247  			name:     "require linear history",
  1248  			branches: []string{"cfgdef/repo1=master", "cfgdef/repo1=branch", "cfgdef/repo2=master"},
  1249  			config: `
  1250  branch-protection:
  1251    protect: true
  1252    required_linear_history: true
  1253    orgs:
  1254      cfgdef:
  1255  `,
  1256  			expected: []requirements{
  1257  				{
  1258  					Org:    "cfgdef",
  1259  					Repo:   "repo1",
  1260  					Branch: "master",
  1261  					Request: &github.BranchProtectionRequest{
  1262  						EnforceAdmins:         &no,
  1263  						RequiredLinearHistory: true,
  1264  					},
  1265  				},
  1266  				{
  1267  					Org:    "cfgdef",
  1268  					Repo:   "repo1",
  1269  					Branch: "branch",
  1270  					Request: &github.BranchProtectionRequest{
  1271  						EnforceAdmins:         &no,
  1272  						RequiredLinearHistory: true,
  1273  					},
  1274  				},
  1275  				{
  1276  					Org:    "cfgdef",
  1277  					Repo:   "repo2",
  1278  					Branch: "master",
  1279  					Request: &github.BranchProtectionRequest{
  1280  						EnforceAdmins:         &no,
  1281  						RequiredLinearHistory: true,
  1282  					},
  1283  				},
  1284  			},
  1285  		},
  1286  		{
  1287  			name:     "allow force pushes",
  1288  			branches: []string{"cfgdef/repo1=master", "cfgdef/repo1=branch", "cfgdef/repo2=master"},
  1289  			config: `
  1290  branch-protection:
  1291    protect: true
  1292    allow_force_pushes: true
  1293    orgs:
  1294      cfgdef:
  1295  `,
  1296  			expected: []requirements{
  1297  				{
  1298  					Org:    "cfgdef",
  1299  					Repo:   "repo1",
  1300  					Branch: "master",
  1301  					Request: &github.BranchProtectionRequest{
  1302  						EnforceAdmins:    &no,
  1303  						AllowForcePushes: true,
  1304  					},
  1305  				},
  1306  				{
  1307  					Org:    "cfgdef",
  1308  					Repo:   "repo1",
  1309  					Branch: "branch",
  1310  					Request: &github.BranchProtectionRequest{
  1311  						EnforceAdmins:    &no,
  1312  						AllowForcePushes: true,
  1313  					},
  1314  				},
  1315  				{
  1316  					Org:    "cfgdef",
  1317  					Repo:   "repo2",
  1318  					Branch: "master",
  1319  					Request: &github.BranchProtectionRequest{
  1320  						EnforceAdmins:    &no,
  1321  						AllowForcePushes: true,
  1322  					},
  1323  				},
  1324  			},
  1325  		},
  1326  		{
  1327  			name:     "allow deletions",
  1328  			branches: []string{"cfgdef/repo1=master", "cfgdef/repo1=branch", "cfgdef/repo2=master"},
  1329  			config: `
  1330  branch-protection:
  1331    protect: true
  1332    allow_deletions: true
  1333    orgs:
  1334      cfgdef:
  1335  `,
  1336  			expected: []requirements{
  1337  				{
  1338  					Org:    "cfgdef",
  1339  					Repo:   "repo1",
  1340  					Branch: "master",
  1341  					Request: &github.BranchProtectionRequest{
  1342  						EnforceAdmins:  &no,
  1343  						AllowDeletions: true,
  1344  					},
  1345  				},
  1346  				{
  1347  					Org:    "cfgdef",
  1348  					Repo:   "repo1",
  1349  					Branch: "branch",
  1350  					Request: &github.BranchProtectionRequest{
  1351  						EnforceAdmins:  &no,
  1352  						AllowDeletions: true,
  1353  					},
  1354  				},
  1355  				{
  1356  					Org:    "cfgdef",
  1357  					Repo:   "repo2",
  1358  					Branch: "master",
  1359  					Request: &github.BranchProtectionRequest{
  1360  						EnforceAdmins:  &no,
  1361  						AllowDeletions: true,
  1362  					},
  1363  				},
  1364  			},
  1365  		},
  1366  		{
  1367  			name:     "Global unmanaged: true makes us not do anything",
  1368  			branches: []string{"cfgdef/repo1=master", "cfgdef/repo1=branch", "cfgdef/repo2=master"},
  1369  			config: `
  1370  branch-protection:
  1371    unmanaged: true
  1372    orgs:
  1373      cfgdef:
  1374        repos:
  1375          repo1:
  1376            required_status_checks:
  1377              contexts:
  1378              - foo
  1379            branches:
  1380              master:
  1381                required_status_checks:
  1382                  contexts:
  1383                  - foo
  1384  `,
  1385  		},
  1386  		{
  1387  			name:     "Org-level unmanaged: true makes us ignore everything in that org",
  1388  			branches: []string{"cfgdef/repo1=master", "cfgdef/repo1=branch", "cfgdef/repo2=master"},
  1389  			config: `
  1390  branch-protection:
  1391    orgs:
  1392      cfgdef:
  1393        unmanaged: true
  1394        repos:
  1395          repo1:
  1396            required_status_checks:
  1397              contexts:
  1398              - foo
  1399            branches:
  1400              master:
  1401                required_status_checks:
  1402                  contexts:
  1403                  - foo
  1404  `,
  1405  		},
  1406  		{
  1407  			name:     "Repo-level unmanaged: true makes us ignore everything in that repo",
  1408  			branches: []string{"cfgdef/repo1=master", "cfgdef/repo1=branch", "cfgdef/repo2=master"},
  1409  			config: `
  1410  branch-protection:
  1411    orgs:
  1412      cfgdef:
  1413        repos:
  1414          repo1:
  1415            unmanaged: true
  1416            required_status_checks:
  1417              contexts:
  1418              - foo
  1419            branches:
  1420              master:
  1421                required_status_checks:
  1422                  contexts:
  1423                  - foo
  1424          repo2:
  1425            required_status_checks:
  1426              contexts:
  1427              - foo
  1428            branches:
  1429              master:
  1430                protect: true
  1431                required_status_checks:
  1432                  contexts:
  1433                  - foo
  1434  `,
  1435  			expected: []requirements{
  1436  				{
  1437  					Org:    "cfgdef",
  1438  					Repo:   "repo2",
  1439  					Branch: "master",
  1440  					Request: &github.BranchProtectionRequest{
  1441  						RequiredStatusChecks: &github.RequiredStatusChecks{Contexts: []string{"foo"}},
  1442  						EnforceAdmins:        &no,
  1443  					},
  1444  				},
  1445  			},
  1446  		},
  1447  		{
  1448  			name:                   "existing app restrictions with apps restrictions feature flag disabled",
  1449  			branches:               []string{"org/apps-restrictions-disabled=master"},
  1450  			enableAppsRestrictions: false,
  1451  			appInstallations: []github.AppInstallation{
  1452  				{
  1453  					AppSlug: "content-app",
  1454  					Permissions: github.InstallationPermissions{
  1455  						Contents: string(github.Write),
  1456  					},
  1457  				},
  1458  			},
  1459  			collaborators: []github.User{
  1460  				{
  1461  					Login:       "cindy",
  1462  					Permissions: github.RepoPermissions{Push: true},
  1463  				},
  1464  			},
  1465  			teams: []github.Team{
  1466  				{
  1467  					Slug:       "org-team",
  1468  					Permission: github.RepoPush,
  1469  				},
  1470  			},
  1471  			config: `
  1472  branch-protection:
  1473    protect: true
  1474    restrictions:
  1475      users:
  1476      - cindy
  1477    orgs:
  1478      org:
  1479        restrictions:
  1480          teams:
  1481          - org-team
  1482  `,
  1483  			expected: []requirements{
  1484  				{
  1485  					Org:    "org",
  1486  					Repo:   "apps-restrictions-disabled",
  1487  					Branch: "master",
  1488  					Request: &github.BranchProtectionRequest{
  1489  						EnforceAdmins: &no,
  1490  						Restrictions: &github.RestrictionsRequest{
  1491  							Apps:  nil,
  1492  							Users: &[]string{"cindy"},
  1493  							Teams: &[]string{"org-team"},
  1494  						},
  1495  					},
  1496  				},
  1497  			},
  1498  		},
  1499  		{
  1500  			name:                   "configured app restrictions with app restriction feature gate disabled",
  1501  			branches:               []string{"org/apps-restrictions-disabled=master"},
  1502  			enableAppsRestrictions: false,
  1503  			config: `
  1504  branch-protection:
  1505    protect: true
  1506    restrictions:
  1507      users:
  1508      - cindy
  1509    orgs:
  1510      org:
  1511        restrictions:
  1512          apps:
  1513          - content-app
  1514  `,
  1515  			errors: 1,
  1516  		},
  1517  	}
  1518  
  1519  	for _, tc := range cases {
  1520  		t.Run(tc.name, func(t *testing.T) {
  1521  			repos := map[string]map[string]bool{}
  1522  			branches := map[string][]github.Branch{}
  1523  			for _, b := range tc.branches {
  1524  				org, repo, branch := split(b)
  1525  				k := org + "/" + repo
  1526  				branches[k] = append(branches[k], github.Branch{
  1527  					Name:      branch,
  1528  					Protected: !tc.startUnprotected,
  1529  				})
  1530  				r := repos[org]
  1531  				if r == nil {
  1532  					repos[org] = make(map[string]bool)
  1533  				}
  1534  				repos[org][repo] = true
  1535  			}
  1536  			fc := fakeClient{
  1537  				branches:          branches,
  1538  				repos:             map[string][]github.Repo{},
  1539  				branchProtections: tc.branchProtections,
  1540  				appInstallations:  tc.appInstallations,
  1541  				collaborators:     tc.collaborators,
  1542  				teams:             tc.teams,
  1543  			}
  1544  			for org, r := range repos {
  1545  				for rname := range r {
  1546  					fc.repos[org] = append(fc.repos[org], github.Repo{Name: rname, FullName: org + "/" + rname, Archived: rname == tc.archived})
  1547  				}
  1548  			}
  1549  
  1550  			var cfg config.Config
  1551  			if err := yaml.Unmarshal([]byte(tc.config), &cfg); err != nil {
  1552  				t.Fatalf("failed to parse config: %v", err)
  1553  			}
  1554  
  1555  			if tc.enabled == nil {
  1556  				tc.enabled = func(org, repo string) bool { return true }
  1557  			}
  1558  			p := protector{
  1559  				client:                 &fc,
  1560  				cfg:                    &cfg,
  1561  				errors:                 Errors{},
  1562  				updates:                make(chan requirements),
  1563  				done:                   make(chan []error),
  1564  				completedRepos:         make(map[string]bool),
  1565  				verifyRestrictions:     !tc.skipVerifyRestrictions,
  1566  				enableAppsRestrictions: tc.enableAppsRestrictions,
  1567  				enabled:                tc.enabled,
  1568  			}
  1569  			go func() {
  1570  				p.protect()
  1571  				close(p.updates)
  1572  			}()
  1573  
  1574  			var actual []requirements
  1575  			for r := range p.updates {
  1576  				actual = append(actual, r)
  1577  			}
  1578  			errors := p.errors.errs
  1579  			if len(errors) != tc.errors {
  1580  				t.Errorf("actual errors %d != expected %d: %v", len(errors), tc.errors, errors)
  1581  			}
  1582  			switch {
  1583  			case len(actual) != len(tc.expected):
  1584  				t.Errorf("%+v %+v", cfg.BranchProtection, actual)
  1585  				t.Errorf("actual updates differ from expected: %s", cmp.Diff(actual, tc.expected))
  1586  			default:
  1587  				for _, a := range actual {
  1588  					found := false
  1589  					for _, e := range tc.expected {
  1590  						if e.Org == a.Org && e.Repo == a.Repo && e.Branch == a.Branch {
  1591  							found = true
  1592  							fixup(&a)
  1593  							fixup(&e)
  1594  							if !reflect.DeepEqual(e, a) {
  1595  								t.Errorf("actual != expected: %s", diff.ObjectDiff(a.Request, e.Request))
  1596  							}
  1597  							break
  1598  						}
  1599  					}
  1600  					if !found {
  1601  						t.Errorf("actual updates %v not in expected %v", a, tc.expected)
  1602  					}
  1603  				}
  1604  			}
  1605  		})
  1606  	}
  1607  }
  1608  
  1609  func fixup(r *requirements) {
  1610  	if r == nil || r.Request == nil {
  1611  		return
  1612  	}
  1613  	req := r.Request
  1614  	if req.RequiredStatusChecks != nil {
  1615  		sort.Strings(req.RequiredStatusChecks.Contexts)
  1616  	}
  1617  	if restr := req.Restrictions; restr != nil {
  1618  		sort.Strings(*restr.Teams)
  1619  		sort.Strings(*restr.Users)
  1620  	}
  1621  }
  1622  
  1623  func TestIgnoreArchivedRepos(t *testing.T) {
  1624  	testBranches := []string{"organization/repository=branch", "organization/archived=branch"}
  1625  	repos := map[string]map[string]bool{}
  1626  	branches := map[string][]github.Branch{}
  1627  	for _, b := range testBranches {
  1628  		org, repo, branch := split(b)
  1629  		k := org + "/" + repo
  1630  		branches[k] = append(branches[k], github.Branch{
  1631  			Name: branch,
  1632  		})
  1633  		r := repos[org]
  1634  		if r == nil {
  1635  			repos[org] = make(map[string]bool)
  1636  		}
  1637  		repos[org][repo] = true
  1638  	}
  1639  	fc := fakeClient{
  1640  		branches: branches,
  1641  		repos:    map[string][]github.Repo{},
  1642  	}
  1643  	for org, r := range repos {
  1644  		for rname := range r {
  1645  			fc.repos[org] = append(fc.repos[org], github.Repo{Name: rname, FullName: org + "/" + rname, Archived: rname == "archived"})
  1646  		}
  1647  	}
  1648  
  1649  	var cfg config.Config
  1650  	if err := yaml.Unmarshal([]byte(`
  1651  branch-protection:
  1652    protect: true
  1653    orgs:
  1654      organization:
  1655  `), &cfg); err != nil {
  1656  		t.Fatalf("failed to parse config: %v", err)
  1657  	}
  1658  	p := protector{
  1659  		client:         &fc,
  1660  		cfg:            &cfg,
  1661  		errors:         Errors{},
  1662  		updates:        make(chan requirements),
  1663  		done:           make(chan []error),
  1664  		completedRepos: make(map[string]bool),
  1665  		enabled:        func(org, repo string) bool { return true },
  1666  	}
  1667  	go func() {
  1668  		p.protect()
  1669  		close(p.updates)
  1670  	}()
  1671  
  1672  	protectionErrors := p.errors.errs
  1673  	if len(protectionErrors) != 0 {
  1674  		t.Errorf("expected no errors, got %d errors: %v", len(protectionErrors), protectionErrors)
  1675  	}
  1676  	var actual []requirements
  1677  	for r := range p.updates {
  1678  		actual = append(actual, r)
  1679  	}
  1680  	if len(actual) != 1 {
  1681  		t.Errorf("expected one update, got: %v", actual)
  1682  	}
  1683  }
  1684  
  1685  func TestIgnorePrivateSecurityRepos(t *testing.T) {
  1686  	testBranches := []string{"organization/repository=branch", "organization/repo-ghsa-1234abcd=branch"}
  1687  	repos := map[string]map[string]bool{}
  1688  	branches := map[string][]github.Branch{}
  1689  	for _, b := range testBranches {
  1690  		org, repo, branch := split(b)
  1691  		k := org + "/" + repo
  1692  		branches[k] = append(branches[k], github.Branch{
  1693  			Name: branch,
  1694  		})
  1695  		r := repos[org]
  1696  		if r == nil {
  1697  			repos[org] = make(map[string]bool)
  1698  		}
  1699  		repos[org][repo] = true
  1700  	}
  1701  	fc := fakeClient{
  1702  		branches: branches,
  1703  		repos:    map[string][]github.Repo{},
  1704  	}
  1705  	for org, r := range repos {
  1706  		for rname := range r {
  1707  			fc.repos[org] = append(fc.repos[org], github.Repo{Name: rname, FullName: org + "/" + rname, Private: true})
  1708  		}
  1709  	}
  1710  
  1711  	var cfg config.Config
  1712  	if err := yaml.Unmarshal([]byte(`
  1713  branch-protection:
  1714    protect: true
  1715    orgs:
  1716      organization:
  1717  `), &cfg); err != nil {
  1718  		t.Fatalf("failed to parse config: %v", err)
  1719  	}
  1720  	p := protector{
  1721  		client:         &fc,
  1722  		cfg:            &cfg,
  1723  		errors:         Errors{},
  1724  		updates:        make(chan requirements),
  1725  		done:           make(chan []error),
  1726  		completedRepos: make(map[string]bool),
  1727  		enabled:        func(org, repo string) bool { return true },
  1728  	}
  1729  	go func() {
  1730  		p.protect()
  1731  		close(p.updates)
  1732  	}()
  1733  
  1734  	protectionErrors := p.errors.errs
  1735  	if len(protectionErrors) != 0 {
  1736  		t.Errorf("expected no errors, got %d errors: %v", len(protectionErrors), protectionErrors)
  1737  	}
  1738  	var actual []requirements
  1739  	for r := range p.updates {
  1740  		actual = append(actual, r)
  1741  	}
  1742  	if len(actual) != 1 {
  1743  		t.Errorf("expected one update, got: %v", actual)
  1744  	}
  1745  }
  1746  
  1747  func TestEqualBranchProtection(t *testing.T) {
  1748  	yes := true
  1749  	var testCases = []struct {
  1750  		name     string
  1751  		state    *github.BranchProtection
  1752  		request  *github.BranchProtectionRequest
  1753  		expected bool
  1754  	}{
  1755  		{
  1756  			name:     "neither set matches",
  1757  			expected: true,
  1758  		},
  1759  		{
  1760  			name:     "request unset doesn't match",
  1761  			state:    &github.BranchProtection{},
  1762  			expected: false,
  1763  		},
  1764  		{
  1765  			name:     "state unset doesn't match",
  1766  			request:  &github.BranchProtectionRequest{},
  1767  			expected: false,
  1768  		},
  1769  		{
  1770  			name: "matching requests work",
  1771  			state: &github.BranchProtection{
  1772  				RequiredStatusChecks: &github.RequiredStatusChecks{
  1773  					Strict:   true,
  1774  					Contexts: []string{"a", "b", "c"},
  1775  				},
  1776  				EnforceAdmins: github.EnforceAdmins{
  1777  					Enabled: true,
  1778  				},
  1779  				RequiredPullRequestReviews: &github.RequiredPullRequestReviews{
  1780  					DismissStaleReviews:          true,
  1781  					RequireCodeOwnerReviews:      true,
  1782  					RequiredApprovingReviewCount: 1,
  1783  					DismissalRestrictions: &github.DismissalRestrictions{
  1784  						Users: []github.User{{Login: "user"}},
  1785  						Teams: []github.Team{{Slug: "team"}},
  1786  					},
  1787  					BypassRestrictions: &github.BypassRestrictions{
  1788  						Users: []github.User{{Login: "user"}},
  1789  						Teams: []github.Team{{Slug: "team"}},
  1790  					},
  1791  				},
  1792  				Restrictions: &github.Restrictions{
  1793  					Apps:  []github.App{{Slug: "app"}},
  1794  					Users: []github.User{{Login: "user"}},
  1795  					Teams: []github.Team{{Slug: "team"}},
  1796  				},
  1797  			},
  1798  			request: &github.BranchProtectionRequest{
  1799  				RequiredStatusChecks: &github.RequiredStatusChecks{
  1800  					Strict:   true,
  1801  					Contexts: []string{"a", "b", "c"},
  1802  				},
  1803  				EnforceAdmins: &yes,
  1804  				RequiredPullRequestReviews: &github.RequiredPullRequestReviewsRequest{
  1805  					DismissStaleReviews:          true,
  1806  					RequireCodeOwnerReviews:      true,
  1807  					RequiredApprovingReviewCount: 1,
  1808  					DismissalRestrictions: github.DismissalRestrictionsRequest{
  1809  						Users: &[]string{"user"},
  1810  						Teams: &[]string{"team"},
  1811  					},
  1812  					BypassRestrictions: github.BypassRestrictionsRequest{
  1813  						Users: &[]string{"user"},
  1814  						Teams: &[]string{"team"},
  1815  					},
  1816  				},
  1817  				Restrictions: &github.RestrictionsRequest{
  1818  					Apps:  &[]string{"app"},
  1819  					Users: &[]string{"user"},
  1820  					Teams: &[]string{"team"},
  1821  				},
  1822  			},
  1823  			expected: true,
  1824  		},
  1825  		{
  1826  			name: "apps unspecified in request is not considered as change",
  1827  			state: &github.BranchProtection{
  1828  				RequiredStatusChecks: &github.RequiredStatusChecks{
  1829  					Strict:   true,
  1830  					Contexts: []string{"a", "b", "c"},
  1831  				},
  1832  				EnforceAdmins: github.EnforceAdmins{
  1833  					Enabled: true,
  1834  				},
  1835  				RequiredPullRequestReviews: &github.RequiredPullRequestReviews{
  1836  					DismissStaleReviews:          true,
  1837  					RequireCodeOwnerReviews:      true,
  1838  					RequiredApprovingReviewCount: 1,
  1839  					DismissalRestrictions: &github.DismissalRestrictions{
  1840  						Users: []github.User{{Login: "user"}},
  1841  						Teams: []github.Team{{Slug: "team"}},
  1842  					},
  1843  					BypassRestrictions: &github.BypassRestrictions{
  1844  						Users: []github.User{{Login: "user"}},
  1845  						Teams: []github.Team{{Slug: "team"}},
  1846  					},
  1847  				},
  1848  				Restrictions: &github.Restrictions{
  1849  					Apps:  []github.App{{Slug: "app"}},
  1850  					Users: []github.User{{Login: "user"}},
  1851  					Teams: []github.Team{{Slug: "team"}},
  1852  				},
  1853  			},
  1854  			request: &github.BranchProtectionRequest{
  1855  				RequiredStatusChecks: &github.RequiredStatusChecks{
  1856  					Strict:   true,
  1857  					Contexts: []string{"a", "b", "c"},
  1858  				},
  1859  				EnforceAdmins: &yes,
  1860  				RequiredPullRequestReviews: &github.RequiredPullRequestReviewsRequest{
  1861  					DismissStaleReviews:          true,
  1862  					RequireCodeOwnerReviews:      true,
  1863  					RequiredApprovingReviewCount: 1,
  1864  					DismissalRestrictions: github.DismissalRestrictionsRequest{
  1865  						Users: &[]string{"user"},
  1866  						Teams: &[]string{"team"},
  1867  					},
  1868  					BypassRestrictions: github.BypassRestrictionsRequest{
  1869  						Users: &[]string{"user"},
  1870  						Teams: &[]string{"team"},
  1871  					},
  1872  				},
  1873  				Restrictions: &github.RestrictionsRequest{
  1874  					Users: &[]string{"user"},
  1875  					Teams: &[]string{"team"},
  1876  				},
  1877  			},
  1878  			expected: true,
  1879  		},
  1880  		{
  1881  			name: "AllowForcePushes is recognized",
  1882  			state: &github.BranchProtection{
  1883  				AllowForcePushes: github.AllowForcePushes{
  1884  					Enabled: false,
  1885  				},
  1886  			},
  1887  			request: &github.BranchProtectionRequest{
  1888  				AllowForcePushes: true,
  1889  			},
  1890  		},
  1891  	}
  1892  
  1893  	for _, testCase := range testCases {
  1894  		if actual, expected := equalBranchProtections(testCase.state, testCase.request), testCase.expected; actual != expected {
  1895  			t.Errorf("%s: didn't compute equality correctly, expected %v got %v", testCase.name, expected, actual)
  1896  		}
  1897  	}
  1898  }
  1899  
  1900  func TestEqualStatusChecks(t *testing.T) {
  1901  	var testCases = []struct {
  1902  		name     string
  1903  		state    *github.RequiredStatusChecks
  1904  		request  *github.RequiredStatusChecks
  1905  		expected bool
  1906  	}{
  1907  		{
  1908  			name:     "neither set matches",
  1909  			expected: true,
  1910  		},
  1911  		{
  1912  			name:     "request unset doesn't match",
  1913  			state:    &github.RequiredStatusChecks{},
  1914  			expected: false,
  1915  		},
  1916  		{
  1917  			name:     "state unset doesn't match",
  1918  			request:  &github.RequiredStatusChecks{},
  1919  			expected: false,
  1920  		},
  1921  		{
  1922  			name: "matching requests work",
  1923  			state: &github.RequiredStatusChecks{
  1924  				Strict:   true,
  1925  				Contexts: []string{"a", "b", "c"},
  1926  			},
  1927  
  1928  			request: &github.RequiredStatusChecks{
  1929  				Strict:   true,
  1930  				Contexts: []string{"a", "b", "c"},
  1931  			},
  1932  			expected: true,
  1933  		},
  1934  		{
  1935  			name: "not matching on strict",
  1936  			state: &github.RequiredStatusChecks{
  1937  				Strict:   true,
  1938  				Contexts: []string{"a", "b", "c"},
  1939  			},
  1940  
  1941  			request: &github.RequiredStatusChecks{
  1942  				Strict:   false,
  1943  				Contexts: []string{"a", "b", "c"},
  1944  			},
  1945  			expected: false,
  1946  		},
  1947  		{
  1948  			name: "not matching on contexts",
  1949  			state: &github.RequiredStatusChecks{
  1950  				Strict:   true,
  1951  				Contexts: []string{"a", "b", "d"},
  1952  			},
  1953  
  1954  			request: &github.RequiredStatusChecks{
  1955  				Strict:   true,
  1956  				Contexts: []string{"a", "b", "c"},
  1957  			},
  1958  			expected: false,
  1959  		},
  1960  	}
  1961  
  1962  	for _, testCase := range testCases {
  1963  		if actual, expected := equalRequiredStatusChecks(testCase.state, testCase.request), testCase.expected; actual != expected {
  1964  			t.Errorf("%s: didn't compute equality correctly, expected %v got %v", testCase.name, expected, actual)
  1965  		}
  1966  	}
  1967  }
  1968  
  1969  func TestEqualStringSlices(t *testing.T) {
  1970  	var testCases = []struct {
  1971  		name     string
  1972  		state    *[]string
  1973  		request  *[]string
  1974  		expected bool
  1975  	}{
  1976  		{
  1977  			name:     "no slices",
  1978  			expected: true,
  1979  		},
  1980  		{
  1981  			name:     "a unset doesn't match",
  1982  			state:    &[]string{},
  1983  			expected: false,
  1984  		},
  1985  		{
  1986  			name:     "b unset doesn't match",
  1987  			request:  &[]string{},
  1988  			expected: false,
  1989  		},
  1990  		{
  1991  			name:     "matching slices work",
  1992  			state:    &[]string{"a", "b", "c"},
  1993  			request:  &[]string{"a", "b", "c"},
  1994  			expected: true,
  1995  		},
  1996  		{
  1997  			name:     "ordering doesn't matter",
  1998  			state:    &[]string{"a", "c", "b"},
  1999  			request:  &[]string{"a", "b", "c"},
  2000  			expected: true,
  2001  		},
  2002  		{
  2003  			name:     "unequal slices don't match",
  2004  			state:    &[]string{"a", "b"},
  2005  			request:  &[]string{"a", "b", "c"},
  2006  			expected: false,
  2007  		},
  2008  		{
  2009  			name:     "disoint slices don't match",
  2010  			state:    &[]string{"e", "f", "g"},
  2011  			request:  &[]string{"a", "b", "c"},
  2012  			expected: false,
  2013  		},
  2014  	}
  2015  
  2016  	for _, testCase := range testCases {
  2017  		if actual, expected := equalStringSlices(testCase.state, testCase.request), testCase.expected; actual != expected {
  2018  			t.Errorf("%s: didn't compute equality correctly, expected %v got %v", testCase.name, expected, actual)
  2019  		}
  2020  	}
  2021  }
  2022  
  2023  func TestEqualAdminEnforcement(t *testing.T) {
  2024  	yes, no := true, false
  2025  	var testCases = []struct {
  2026  		name     string
  2027  		state    github.EnforceAdmins
  2028  		request  *bool
  2029  		expected bool
  2030  	}{
  2031  		{
  2032  			name:     "unset request matches no enforcement",
  2033  			state:    github.EnforceAdmins{Enabled: false},
  2034  			expected: true,
  2035  		},
  2036  		{
  2037  			name:     "set request matches enforcement",
  2038  			state:    github.EnforceAdmins{Enabled: false},
  2039  			request:  &no,
  2040  			expected: true,
  2041  		},
  2042  		{
  2043  			name:     "set request doesn't match enforcement",
  2044  			state:    github.EnforceAdmins{Enabled: false},
  2045  			request:  &yes,
  2046  			expected: false,
  2047  		},
  2048  	}
  2049  
  2050  	for _, testCase := range testCases {
  2051  		if actual, expected := equalAdminEnforcement(testCase.state, testCase.request), testCase.expected; actual != expected {
  2052  			t.Errorf("%s: didn't compute equality correctly, expected %v got %v", testCase.name, expected, actual)
  2053  		}
  2054  	}
  2055  }
  2056  
  2057  func TestEqualRequiredPullRequestReviews(t *testing.T) {
  2058  	var testCases = []struct {
  2059  		name     string
  2060  		state    *github.RequiredPullRequestReviews
  2061  		request  *github.RequiredPullRequestReviewsRequest
  2062  		expected bool
  2063  	}{
  2064  		{
  2065  			name:     "neither set matches",
  2066  			expected: true,
  2067  		},
  2068  		{
  2069  			name:     "request unset doesn't match",
  2070  			state:    &github.RequiredPullRequestReviews{},
  2071  			expected: false,
  2072  		},
  2073  		{
  2074  			name:     "state unset doesn't match",
  2075  			request:  &github.RequiredPullRequestReviewsRequest{},
  2076  			expected: false,
  2077  		},
  2078  		{
  2079  			name: "matching requests work",
  2080  			state: &github.RequiredPullRequestReviews{
  2081  				DismissStaleReviews:          true,
  2082  				RequireCodeOwnerReviews:      true,
  2083  				RequiredApprovingReviewCount: 1,
  2084  				DismissalRestrictions: &github.DismissalRestrictions{
  2085  					Users: []github.User{{Login: "user"}},
  2086  					Teams: []github.Team{{Slug: "team"}},
  2087  				},
  2088  				BypassRestrictions: &github.BypassRestrictions{
  2089  					Users: []github.User{{Login: "user"}},
  2090  					Teams: []github.Team{{Slug: "team"}},
  2091  				},
  2092  			},
  2093  			request: &github.RequiredPullRequestReviewsRequest{
  2094  				DismissStaleReviews:          true,
  2095  				RequireCodeOwnerReviews:      true,
  2096  				RequiredApprovingReviewCount: 1,
  2097  				DismissalRestrictions: github.DismissalRestrictionsRequest{
  2098  					Users: &[]string{"user"},
  2099  					Teams: &[]string{"team"},
  2100  				},
  2101  				BypassRestrictions: github.BypassRestrictionsRequest{
  2102  					Users: &[]string{"user"},
  2103  					Teams: &[]string{"team"},
  2104  				},
  2105  			},
  2106  			expected: true,
  2107  		},
  2108  		{
  2109  			name: "not matching on dismissal",
  2110  			state: &github.RequiredPullRequestReviews{
  2111  				DismissStaleReviews:          true,
  2112  				RequireCodeOwnerReviews:      true,
  2113  				RequiredApprovingReviewCount: 1,
  2114  			},
  2115  			request: &github.RequiredPullRequestReviewsRequest{
  2116  				DismissStaleReviews:          false,
  2117  				RequireCodeOwnerReviews:      true,
  2118  				RequiredApprovingReviewCount: 1,
  2119  			},
  2120  			expected: false,
  2121  		},
  2122  		{
  2123  			name: "not matching on reviews",
  2124  			state: &github.RequiredPullRequestReviews{
  2125  				DismissStaleReviews:          true,
  2126  				RequireCodeOwnerReviews:      true,
  2127  				RequiredApprovingReviewCount: 1,
  2128  			},
  2129  			request: &github.RequiredPullRequestReviewsRequest{
  2130  				DismissStaleReviews:          true,
  2131  				RequireCodeOwnerReviews:      false,
  2132  				RequiredApprovingReviewCount: 1,
  2133  			},
  2134  			expected: false,
  2135  		},
  2136  		{
  2137  			name: "not matching on count",
  2138  			state: &github.RequiredPullRequestReviews{
  2139  				DismissStaleReviews:          true,
  2140  				RequireCodeOwnerReviews:      true,
  2141  				RequiredApprovingReviewCount: 1,
  2142  			},
  2143  			request: &github.RequiredPullRequestReviewsRequest{
  2144  				DismissStaleReviews:          true,
  2145  				RequireCodeOwnerReviews:      true,
  2146  				RequiredApprovingReviewCount: 2,
  2147  			},
  2148  			expected: false,
  2149  		},
  2150  		{
  2151  			name: "not matching on restrictions",
  2152  			state: &github.RequiredPullRequestReviews{
  2153  				DismissStaleReviews:          true,
  2154  				RequireCodeOwnerReviews:      true,
  2155  				RequiredApprovingReviewCount: 1,
  2156  				DismissalRestrictions: &github.DismissalRestrictions{
  2157  					Users: []github.User{{Login: "user"}},
  2158  					Teams: []github.Team{{Slug: "team"}},
  2159  				},
  2160  				BypassRestrictions: &github.BypassRestrictions{
  2161  					Users: []github.User{{Login: "user"}},
  2162  					Teams: []github.Team{{Slug: "team"}},
  2163  				},
  2164  			},
  2165  			request: &github.RequiredPullRequestReviewsRequest{
  2166  				DismissStaleReviews:          true,
  2167  				RequireCodeOwnerReviews:      true,
  2168  				RequiredApprovingReviewCount: 1,
  2169  				DismissalRestrictions: github.DismissalRestrictionsRequest{
  2170  					Users: &[]string{"other"},
  2171  					Teams: &[]string{"team"},
  2172  				},
  2173  				BypassRestrictions: github.BypassRestrictionsRequest{
  2174  					Users: &[]string{"other"},
  2175  					Teams: &[]string{"team"},
  2176  				},
  2177  			},
  2178  			expected: false,
  2179  		},
  2180  	}
  2181  
  2182  	for _, testCase := range testCases {
  2183  		if actual, expected := equalRequiredPullRequestReviews(testCase.state, testCase.request), testCase.expected; actual != expected {
  2184  			t.Errorf("%s: didn't compute equality correctly, expected %v got %v", testCase.name, expected, actual)
  2185  		}
  2186  	}
  2187  }
  2188  
  2189  func TestEqualDismissalRestrictions(t *testing.T) {
  2190  	var testCases = []struct {
  2191  		name     string
  2192  		state    *github.DismissalRestrictions
  2193  		request  *github.DismissalRestrictionsRequest
  2194  		expected bool
  2195  	}{
  2196  		{
  2197  			name:     "neither set matches",
  2198  			expected: true,
  2199  		},
  2200  		{
  2201  			name:     "request unset doesn't match",
  2202  			state:    &github.DismissalRestrictions{},
  2203  			expected: false,
  2204  		},
  2205  		{
  2206  			name: "matching requests work",
  2207  			state: &github.DismissalRestrictions{
  2208  				Users: []github.User{{Login: "user"}},
  2209  				Teams: []github.Team{{Slug: "team"}},
  2210  			},
  2211  			request: &github.DismissalRestrictionsRequest{
  2212  				Users: &[]string{"user"},
  2213  				Teams: &[]string{"team"},
  2214  			},
  2215  			expected: true,
  2216  		},
  2217  		{
  2218  			name: "user login casing is ignored",
  2219  			state: &github.DismissalRestrictions{
  2220  				Users: []github.User{{Login: "User"}, {Login: "OTHer"}},
  2221  				Teams: []github.Team{{Slug: "team"}},
  2222  			},
  2223  			request: &github.DismissalRestrictionsRequest{
  2224  				Users: &[]string{"uSer", "oThER"},
  2225  				Teams: &[]string{"team"},
  2226  			},
  2227  			expected: true,
  2228  		},
  2229  		{
  2230  			name: "not matching on users",
  2231  			state: &github.DismissalRestrictions{
  2232  				Users: []github.User{{Login: "user"}},
  2233  				Teams: []github.Team{{Slug: "team"}},
  2234  			},
  2235  			request: &github.DismissalRestrictionsRequest{
  2236  				Users: &[]string{"other"},
  2237  				Teams: &[]string{"team"},
  2238  			},
  2239  			expected: false,
  2240  		},
  2241  		{
  2242  			name: "not matching on team",
  2243  			state: &github.DismissalRestrictions{
  2244  				Users: []github.User{{Login: "user"}},
  2245  				Teams: []github.Team{{Slug: "team"}},
  2246  			},
  2247  			request: &github.DismissalRestrictionsRequest{
  2248  				Users: &[]string{"user"},
  2249  				Teams: &[]string{"other"},
  2250  			},
  2251  			expected: false,
  2252  		},
  2253  		{
  2254  			name:     "both unset",
  2255  			request:  &github.DismissalRestrictionsRequest{},
  2256  			expected: true,
  2257  		},
  2258  		{
  2259  			name: "partially unset",
  2260  			request: &github.DismissalRestrictionsRequest{
  2261  				Teams: &[]string{"team"},
  2262  			},
  2263  			expected: false,
  2264  		},
  2265  	}
  2266  
  2267  	for _, testCase := range testCases {
  2268  		if actual, expected := equalDismissalRestrictions(testCase.state, testCase.request), testCase.expected; actual != expected {
  2269  			t.Errorf("%s: didn't compute equality correctly, expected %v got %v", testCase.name, expected, actual)
  2270  		}
  2271  	}
  2272  }
  2273  
  2274  func TestEqualBypassRestrictions(t *testing.T) {
  2275  	var testCases = []struct {
  2276  		name     string
  2277  		state    *github.BypassRestrictions
  2278  		request  *github.BypassRestrictionsRequest
  2279  		expected bool
  2280  	}{
  2281  		{
  2282  			name:     "neither set matches",
  2283  			expected: true,
  2284  		},
  2285  		{
  2286  			name:     "request unset doesn't match",
  2287  			state:    &github.BypassRestrictions{},
  2288  			expected: false,
  2289  		},
  2290  		{
  2291  			name: "matching requests work",
  2292  			state: &github.BypassRestrictions{
  2293  				Users: []github.User{{Login: "user"}},
  2294  				Teams: []github.Team{{Slug: "team"}},
  2295  			},
  2296  			request: &github.BypassRestrictionsRequest{
  2297  				Users: &[]string{"user"},
  2298  				Teams: &[]string{"team"},
  2299  			},
  2300  			expected: true,
  2301  		},
  2302  		{
  2303  			name: "user login casing is ignored",
  2304  			state: &github.BypassRestrictions{
  2305  				Users: []github.User{{Login: "User"}, {Login: "OTHer"}},
  2306  				Teams: []github.Team{{Slug: "team"}},
  2307  			},
  2308  			request: &github.BypassRestrictionsRequest{
  2309  				Users: &[]string{"uSer", "oThER"},
  2310  				Teams: &[]string{"team"},
  2311  			},
  2312  			expected: true,
  2313  		},
  2314  		{
  2315  			name: "not matching on users",
  2316  			state: &github.BypassRestrictions{
  2317  				Users: []github.User{{Login: "user"}},
  2318  				Teams: []github.Team{{Slug: "team"}},
  2319  			},
  2320  			request: &github.BypassRestrictionsRequest{
  2321  				Users: &[]string{"other"},
  2322  				Teams: &[]string{"team"},
  2323  			},
  2324  			expected: false,
  2325  		},
  2326  		{
  2327  			name: "not matching on team",
  2328  			state: &github.BypassRestrictions{
  2329  				Users: []github.User{{Login: "user"}},
  2330  				Teams: []github.Team{{Slug: "team"}},
  2331  			},
  2332  			request: &github.BypassRestrictionsRequest{
  2333  				Users: &[]string{"user"},
  2334  				Teams: &[]string{"other"},
  2335  			},
  2336  			expected: false,
  2337  		},
  2338  		{
  2339  			name:     "both unset",
  2340  			request:  &github.BypassRestrictionsRequest{},
  2341  			expected: true,
  2342  		},
  2343  		{
  2344  			name: "partially unset",
  2345  			request: &github.BypassRestrictionsRequest{
  2346  				Teams: &[]string{"team"},
  2347  			},
  2348  			expected: false,
  2349  		},
  2350  	}
  2351  
  2352  	for _, testCase := range testCases {
  2353  		if actual, expected := equalBypassRestrictions(testCase.state, testCase.request), testCase.expected; actual != expected {
  2354  			t.Errorf("%s: didn't compute equality correctly, expected %v got %v", testCase.name, expected, actual)
  2355  		}
  2356  	}
  2357  }
  2358  
  2359  func TestEqualRestrictions(t *testing.T) {
  2360  	var testCases = []struct {
  2361  		name     string
  2362  		state    *github.Restrictions
  2363  		request  *github.RestrictionsRequest
  2364  		expected bool
  2365  	}{
  2366  		{
  2367  			name:     "neither set matches",
  2368  			expected: true,
  2369  		},
  2370  		{
  2371  			name:     "request unset doesn't match",
  2372  			state:    &github.Restrictions{},
  2373  			expected: false,
  2374  		},
  2375  		{
  2376  			name: "matching requests work",
  2377  			state: &github.Restrictions{
  2378  				Apps:  []github.App{{Slug: "app"}},
  2379  				Users: []github.User{{Login: "user"}},
  2380  				Teams: []github.Team{{Slug: "team"}},
  2381  			},
  2382  			request: &github.RestrictionsRequest{
  2383  				Apps:  &[]string{"app"},
  2384  				Users: &[]string{"user"},
  2385  				Teams: &[]string{"team"},
  2386  			},
  2387  			expected: true,
  2388  		},
  2389  		{
  2390  			name: "user login casing is ignored",
  2391  			state: &github.Restrictions{
  2392  				Users: []github.User{{Login: "User"}, {Login: "OTHer"}},
  2393  				Teams: []github.Team{{Slug: "team"}},
  2394  			},
  2395  			request: &github.RestrictionsRequest{
  2396  				Users: &[]string{"uSer", "oThER"},
  2397  				Teams: &[]string{"team"},
  2398  			},
  2399  			expected: true,
  2400  		},
  2401  		{
  2402  			name: "not matching on users",
  2403  			state: &github.Restrictions{
  2404  				Users: []github.User{{Login: "user"}},
  2405  				Teams: []github.Team{{Slug: "team"}},
  2406  			},
  2407  			request: &github.RestrictionsRequest{
  2408  				Users: &[]string{"other"},
  2409  				Teams: &[]string{"team"},
  2410  			},
  2411  			expected: false,
  2412  		},
  2413  		{
  2414  			name: "not matching on team",
  2415  			state: &github.Restrictions{
  2416  				Users: []github.User{{Login: "user"}},
  2417  				Teams: []github.Team{{Slug: "team"}},
  2418  			},
  2419  			request: &github.RestrictionsRequest{
  2420  				Users: &[]string{"user"},
  2421  				Teams: &[]string{"other"},
  2422  			},
  2423  			expected: false,
  2424  		},
  2425  		{
  2426  			name: "not matching on app",
  2427  			state: &github.Restrictions{
  2428  				Apps:  []github.App{{Slug: "app"}},
  2429  				Users: []github.User{{Login: "user"}},
  2430  				Teams: []github.Team{{Slug: "team"}},
  2431  			},
  2432  			request: &github.RestrictionsRequest{
  2433  				Apps:  &[]string{"other"},
  2434  				Users: &[]string{"user"},
  2435  				Teams: &[]string{"team"},
  2436  			},
  2437  			expected: false,
  2438  		},
  2439  		{
  2440  			name:     "both unset",
  2441  			request:  &github.RestrictionsRequest{},
  2442  			expected: true,
  2443  		},
  2444  		{
  2445  			name: "partially unset",
  2446  			request: &github.RestrictionsRequest{
  2447  				Teams: &[]string{"team"},
  2448  			},
  2449  			expected: false,
  2450  		},
  2451  		// TODO: consider harmonizing apps handling with teams and users
  2452  		{
  2453  			name: "app request unset",
  2454  			state: &github.Restrictions{
  2455  				Apps:  []github.App{{Slug: "app"}},
  2456  				Users: []github.User{{Login: "user"}},
  2457  				Teams: []github.Team{{Slug: "team"}},
  2458  			},
  2459  			request: &github.RestrictionsRequest{
  2460  				Users: &[]string{"user"},
  2461  				Teams: &[]string{"team"},
  2462  			},
  2463  			expected: true,
  2464  		},
  2465  		{
  2466  			name: "app request is empty list",
  2467  			state: &github.Restrictions{
  2468  				Apps:  []github.App{{Slug: "app"}},
  2469  				Users: []github.User{{Login: "user"}},
  2470  				Teams: []github.Team{{Slug: "team"}},
  2471  			},
  2472  			request: &github.RestrictionsRequest{
  2473  				Apps:  &[]string{},
  2474  				Users: &[]string{"user"},
  2475  				Teams: &[]string{"team"},
  2476  			},
  2477  			expected: false,
  2478  		},
  2479  	}
  2480  
  2481  	for _, testCase := range testCases {
  2482  		if actual, expected := equalRestrictions(testCase.state, testCase.request), testCase.expected; actual != expected {
  2483  			t.Errorf("%s: didn't compute equality correctly, expected %v got %v", testCase.name, expected, actual)
  2484  		}
  2485  	}
  2486  }
  2487  
  2488  func TestValidateRequest(t *testing.T) {
  2489  	var testCases = []struct {
  2490  		name             string
  2491  		request          *github.BranchProtectionRequest
  2492  		appInstallations []string
  2493  		collaborators    []string
  2494  		teams            []string
  2495  		errs             []error
  2496  	}{
  2497  		{
  2498  			name: "restrict to unauthorized apps results in error",
  2499  			request: &github.BranchProtectionRequest{
  2500  				Restrictions: &github.RestrictionsRequest{
  2501  					Apps: &[]string{"bar"},
  2502  				},
  2503  			},
  2504  			errs: []error{fmt.Errorf("the following apps are not authorized for %s/%s: [%s]", "org", "repo", "bar")},
  2505  		},
  2506  		{
  2507  			name: "restrict to unathorized collaborator results in error",
  2508  			request: &github.BranchProtectionRequest{
  2509  				Restrictions: &github.RestrictionsRequest{
  2510  					Users: &[]string{"foo"},
  2511  				},
  2512  			},
  2513  			errs: []error{fmt.Errorf("the following collaborators are not authorized for %s/%s: [%s]", "org", "repo", "foo")},
  2514  		},
  2515  		{
  2516  			name: "restrict to unauthorized team results in error",
  2517  			request: &github.BranchProtectionRequest{
  2518  				Restrictions: &github.RestrictionsRequest{
  2519  					Teams: &[]string{"bar"},
  2520  				},
  2521  			},
  2522  			errs: []error{fmt.Errorf("the following teams are not authorized for %s/%s: [%s]", "org", "repo", "bar")},
  2523  		},
  2524  		{
  2525  			name: "authorized app, user and team result in no errors",
  2526  			request: &github.BranchProtectionRequest{
  2527  				Restrictions: &github.RestrictionsRequest{
  2528  					Apps:  &[]string{"foobar"},
  2529  					Users: &[]string{"foo"},
  2530  					Teams: &[]string{"bar"},
  2531  				},
  2532  			},
  2533  			appInstallations: []string{"foobar"},
  2534  			collaborators:    []string{"foo"},
  2535  			teams:            []string{"bar"},
  2536  		},
  2537  	}
  2538  
  2539  	for _, tc := range testCases {
  2540  		t.Run(tc.name, func(t *testing.T) {
  2541  			errs := validateRestrictions("org", "repo", tc.request, tc.appInstallations, tc.collaborators, tc.teams)
  2542  			if !reflect.DeepEqual(errs, tc.errs) {
  2543  				t.Errorf("%s: errors %v != expected %v", tc.name, errs, tc.errs)
  2544  			}
  2545  		})
  2546  	}
  2547  }
  2548  
  2549  func TestAuthorizedApps(t *testing.T) {
  2550  	var testCases = []struct {
  2551  		name             string
  2552  		appInstallations []github.AppInstallation
  2553  		expected         []string
  2554  	}{
  2555  		{
  2556  			name: "AppInstallations with content read is not included",
  2557  			appInstallations: []github.AppInstallation{
  2558  				{
  2559  					AppSlug: "foo",
  2560  					Permissions: github.InstallationPermissions{
  2561  						Contents: string(github.Read),
  2562  					},
  2563  				},
  2564  			},
  2565  		},
  2566  		{
  2567  			name: "AppInstallations with content read is included",
  2568  			appInstallations: []github.AppInstallation{
  2569  				{
  2570  					AppSlug: "foo",
  2571  					Permissions: github.InstallationPermissions{
  2572  						Contents: string(github.Write),
  2573  					},
  2574  				},
  2575  			},
  2576  			expected: []string{"foo"},
  2577  		},
  2578  	}
  2579  
  2580  	for _, tc := range testCases {
  2581  		t.Run(tc.name, func(t *testing.T) {
  2582  			fc := fakeClient{appInstallations: tc.appInstallations}
  2583  			p := protector{
  2584  				client: &fc,
  2585  				errors: Errors{},
  2586  			}
  2587  
  2588  			apps, err := p.authorizedApps("org")
  2589  			if err != nil {
  2590  				t.Errorf("Unexpected error: %v", err)
  2591  			}
  2592  			sort.Strings(tc.expected)
  2593  			sort.Strings(apps)
  2594  			if !reflect.DeepEqual(tc.expected, apps) {
  2595  				t.Errorf("expected: %v, got: %v", tc.expected, apps)
  2596  			}
  2597  		})
  2598  	}
  2599  }
  2600  
  2601  func TestAuthorizedCollaborators(t *testing.T) {
  2602  	var testCases = []struct {
  2603  		name          string
  2604  		collaborators []github.User
  2605  		expected      []string
  2606  	}{
  2607  		{
  2608  			name: "Collaborator with pull permission is not included",
  2609  			collaborators: []github.User{
  2610  				{
  2611  					Login: "foo",
  2612  					Permissions: github.RepoPermissions{
  2613  						Pull: true,
  2614  					},
  2615  				},
  2616  			},
  2617  		},
  2618  		{
  2619  			name: "Collaborators with Push or Admin permission are included",
  2620  			collaborators: []github.User{
  2621  				{
  2622  					Login: "foo",
  2623  					Permissions: github.RepoPermissions{
  2624  						Push: true,
  2625  					},
  2626  				},
  2627  				{
  2628  					Login: "bar",
  2629  					Permissions: github.RepoPermissions{
  2630  						Admin: true,
  2631  					},
  2632  				},
  2633  			},
  2634  			expected: []string{"foo", "bar"},
  2635  		},
  2636  	}
  2637  
  2638  	for _, tc := range testCases {
  2639  		t.Run(tc.name, func(t *testing.T) {
  2640  			fc := fakeClient{collaborators: tc.collaborators}
  2641  			p := protector{
  2642  				client: &fc,
  2643  				errors: Errors{},
  2644  			}
  2645  
  2646  			collaborators, err := p.authorizedCollaborators("org", "repo")
  2647  			if err != nil {
  2648  				t.Errorf("Unexpected error: %v", err)
  2649  			}
  2650  			sort.Strings(tc.expected)
  2651  			sort.Strings(collaborators)
  2652  			if !reflect.DeepEqual(tc.expected, collaborators) {
  2653  				t.Errorf("expected: %v, got: %v", tc.expected, collaborators)
  2654  			}
  2655  		})
  2656  	}
  2657  }
  2658  
  2659  func TestAuthorizedTeams(t *testing.T) {
  2660  	var testCases = []struct {
  2661  		name     string
  2662  		teams    []github.Team
  2663  		expected []string
  2664  	}{
  2665  		{
  2666  			name: "Team with pull permission is not included",
  2667  			teams: []github.Team{
  2668  				{
  2669  					Slug:       "foo",
  2670  					Permission: github.RepoPull,
  2671  				},
  2672  			},
  2673  		},
  2674  		{
  2675  			name: "Teams with Push or Admin permission are included",
  2676  			teams: []github.Team{
  2677  				{
  2678  					Slug:       "foo",
  2679  					Permission: github.RepoPush,
  2680  				},
  2681  				{
  2682  					Slug:       "bar",
  2683  					Permission: github.RepoAdmin,
  2684  				},
  2685  			},
  2686  			expected: []string{"foo", "bar"},
  2687  		},
  2688  	}
  2689  
  2690  	for _, tc := range testCases {
  2691  		t.Run(tc.name, func(t *testing.T) {
  2692  			fc := fakeClient{teams: tc.teams}
  2693  			p := protector{
  2694  				client: &fc,
  2695  				errors: Errors{},
  2696  			}
  2697  
  2698  			teams, err := p.authorizedTeams("org", "repo")
  2699  			if err != nil {
  2700  				t.Errorf("Unexpected error: %v", err)
  2701  			}
  2702  			sort.Strings(tc.expected)
  2703  			sort.Strings(teams)
  2704  			if !reflect.DeepEqual(tc.expected, teams) {
  2705  				t.Errorf("expected: %v, got: %v", tc.expected, teams)
  2706  			}
  2707  		})
  2708  	}
  2709  }