github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/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 "k8s.io/apimachinery/pkg/util/diff" 28 "sigs.k8s.io/yaml" 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 github: flagutil.GitHubOptions{TokenPath: "fake"}, 46 }, 47 expectedErr: false, 48 }, 49 { 50 name: "no config", 51 opt: options{ 52 config: "", 53 github: flagutil.GitHubOptions{TokenPath: "fake"}, 54 }, 55 expectedErr: true, 56 }, 57 { 58 name: "no token, allow", 59 opt: options{ 60 config: "dummy", 61 }, 62 expectedErr: false, 63 }, 64 } 65 66 for _, testCase := range testCases { 67 err := testCase.opt.Validate() 68 if testCase.expectedErr && err == nil { 69 t.Errorf("%s: expected an error but got none", testCase.name) 70 } 71 if !testCase.expectedErr && err != nil { 72 t.Errorf("%s: expected no error but got one: %v", testCase.name, err) 73 } 74 } 75 } 76 77 type fakeClient struct { 78 repos map[string][]github.Repo 79 branches map[string][]github.Branch 80 deleted map[string]bool 81 updated map[string]github.BranchProtectionRequest 82 } 83 84 func (c fakeClient) GetRepo(org string, repo string) (github.Repo, error) { 85 r, ok := c.repos[org] 86 if !ok { 87 return github.Repo{}, fmt.Errorf("Unknown org: %s", org) 88 } 89 for _, item := range r { 90 if item.Name == repo { 91 return item, nil 92 } 93 } 94 return github.Repo{}, fmt.Errorf("Unknown repo: %s", repo) 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 archived string 269 expected []requirements 270 errors int 271 }{ 272 { 273 name: "nothing", 274 }, 275 { 276 name: "unknown org", 277 config: ` 278 branch-protection: 279 protect: true 280 orgs: 281 unknown: 282 `, 283 errors: 1, 284 }, 285 { 286 name: "protect org via config default", 287 branches: []string{"cfgdef/repo1=master", "cfgdef/repo1=branch", "cfgdef/repo2=master"}, 288 config: ` 289 branch-protection: 290 protect: true 291 orgs: 292 cfgdef: 293 `, 294 expected: []requirements{ 295 { 296 Org: "cfgdef", 297 Repo: "repo1", 298 Branch: "master", 299 Request: &github.BranchProtectionRequest{}, 300 }, 301 { 302 Org: "cfgdef", 303 Repo: "repo1", 304 Branch: "branch", 305 Request: &github.BranchProtectionRequest{}, 306 }, 307 { 308 Org: "cfgdef", 309 Repo: "repo2", 310 Branch: "master", 311 Request: &github.BranchProtectionRequest{}, 312 }, 313 }, 314 }, 315 { 316 name: "protect this but not that org", 317 branches: []string{"this/yes=master", "that/no=master"}, 318 config: ` 319 branch-protection: 320 protect: false 321 orgs: 322 this: 323 protect: true 324 that: 325 `, 326 expected: []requirements{ 327 { 328 Org: "this", 329 Repo: "yes", 330 Branch: "master", 331 Request: &github.BranchProtectionRequest{}, 332 }, 333 { 334 Org: "that", 335 Repo: "no", 336 Branch: "master", 337 Request: nil, 338 }, 339 }, 340 }, 341 { 342 name: "protect all repos when protection configured at org level", 343 branches: []string{"kubernetes/test-infra=master", "kubernetes/publishing-bot=master"}, 344 config: ` 345 branch-protection: 346 orgs: 347 kubernetes: 348 protect: true 349 repos: 350 test-infra: 351 required_status_checks: 352 contexts: 353 - hello-world 354 `, 355 expected: []requirements{ 356 { 357 Org: "kubernetes", 358 Repo: "test-infra", 359 Branch: "master", 360 Request: &github.BranchProtectionRequest{ 361 RequiredStatusChecks: &github.RequiredStatusChecks{ 362 Contexts: []string{"hello-world"}, 363 }, 364 }, 365 }, 366 { 367 Org: "kubernetes", 368 Repo: "publishing-bot", 369 Branch: "master", 370 Request: &github.BranchProtectionRequest{}, 371 }, 372 }, 373 }, 374 { 375 name: "require a defined branch to make a protection decision", 376 branches: []string{"org/repo=branch"}, 377 config: ` 378 branch-protection: 379 orgs: 380 org: 381 repos: 382 repo: 383 branches: 384 branch: # empty 385 `, 386 errors: 1, 387 }, 388 { 389 name: "require pushers to set protection", 390 branches: []string{"org/repo=push"}, 391 config: ` 392 branch-protection: 393 protect: false 394 restrictions: 395 teams: 396 - oncall 397 orgs: 398 org: 399 `, 400 errors: 1, 401 }, 402 { 403 name: "required contexts must set protection", 404 branches: []string{"org/repo=context"}, 405 config: ` 406 branch-protection: 407 protect: false 408 required_status_checks: 409 contexts: 410 - test-foo 411 orgs: 412 org: 413 `, 414 errors: 1, 415 }, 416 { 417 name: "protect org but skip a repo", 418 branches: []string{"org/repo1=master", "org/repo1=branch", "org/skip=master"}, 419 config: ` 420 branch-protection: 421 protect: false 422 orgs: 423 org: 424 protect: true 425 repos: 426 skip: 427 protect: false 428 `, 429 expected: []requirements{ 430 { 431 Org: "org", 432 Repo: "repo1", 433 Branch: "master", 434 Request: &github.BranchProtectionRequest{}, 435 }, 436 { 437 Org: "org", 438 Repo: "repo1", 439 Branch: "branch", 440 Request: &github.BranchProtectionRequest{}, 441 }, 442 { 443 Org: "org", 444 Repo: "skip", 445 Branch: "master", 446 Request: nil, 447 }, 448 }, 449 }, 450 { 451 name: "protect org but skip a repo due to archival", 452 branches: []string{"org/repo1=master", "org/repo1=branch", "org/skip=master"}, 453 config: ` 454 branch-protection: 455 protect: false 456 orgs: 457 org: 458 protect: true 459 `, 460 archived: "skip", 461 expected: []requirements{ 462 { 463 Org: "org", 464 Repo: "repo1", 465 Branch: "master", 466 Request: &github.BranchProtectionRequest{}, 467 }, 468 { 469 Org: "org", 470 Repo: "repo1", 471 Branch: "branch", 472 Request: &github.BranchProtectionRequest{}, 473 }, 474 }, 475 }, 476 { 477 name: "collapse duplicated contexts", 478 branches: []string{"org/repo=master"}, 479 config: ` 480 branch-protection: 481 protect: true 482 required_status_checks: 483 contexts: 484 - hello-world 485 - duplicate-context 486 - duplicate-context 487 - hello-world 488 orgs: 489 org: 490 `, 491 expected: []requirements{ 492 { 493 Org: "org", 494 Repo: "repo", 495 Branch: "master", 496 Request: &github.BranchProtectionRequest{ 497 RequiredStatusChecks: &github.RequiredStatusChecks{ 498 Contexts: []string{"duplicate-context", "hello-world"}, 499 }, 500 }, 501 }, 502 }, 503 }, 504 { 505 name: "append contexts", 506 branches: []string{"org/repo=master"}, 507 config: ` 508 branch-protection: 509 protect: true 510 required_status_checks: 511 contexts: 512 - config-presubmit 513 orgs: 514 org: 515 required_status_checks: 516 contexts: 517 - org-presubmit 518 repos: 519 repo: 520 required_status_checks: 521 contexts: 522 - repo-presubmit 523 branches: 524 master: 525 required_status_checks: 526 contexts: 527 - branch-presubmit 528 `, 529 expected: []requirements{ 530 { 531 Org: "org", 532 Repo: "repo", 533 Branch: "master", 534 Request: &github.BranchProtectionRequest{ 535 RequiredStatusChecks: &github.RequiredStatusChecks{ 536 Contexts: []string{"config-presubmit", "org-presubmit", "repo-presubmit", "branch-presubmit"}, 537 }, 538 }, 539 }, 540 }, 541 }, 542 { 543 name: "append pushers", 544 branches: []string{"org/repo=master"}, 545 config: ` 546 branch-protection: 547 protect: true 548 restrictions: 549 teams: 550 - config-team 551 orgs: 552 org: 553 restrictions: 554 teams: 555 - org-team 556 repos: 557 repo: 558 restrictions: 559 teams: 560 - repo-team 561 branches: 562 master: 563 restrictions: 564 teams: 565 - branch-team 566 `, 567 expected: []requirements{ 568 { 569 Org: "org", 570 Repo: "repo", 571 Branch: "master", 572 Request: &github.BranchProtectionRequest{ 573 Restrictions: &github.Restrictions{ 574 Users: &[]string{}, 575 Teams: &[]string{"config-team", "org-team", "repo-team", "branch-team"}, 576 }, 577 }, 578 }, 579 }, 580 }, 581 { 582 name: "all modern fields", 583 branches: []string{"all/modern=master"}, 584 config: ` 585 branch-protection: 586 protect: true 587 enforce_admins: true 588 required_status_checks: 589 contexts: 590 - config-presubmit 591 strict: true 592 required_pull_request_reviews: 593 required_approving_review_count: 3 594 dismiss_stale: false 595 require_code_owner_reviews: true 596 dismissal_restrictions: 597 users: 598 - bob 599 - jane 600 teams: 601 - oncall 602 - sres 603 restrictions: 604 teams: 605 - config-team 606 users: 607 - cindy 608 orgs: 609 all: 610 required_status_checks: 611 contexts: 612 - org-presubmit 613 restrictions: 614 teams: 615 - org-team 616 `, 617 expected: []requirements{ 618 { 619 Org: "all", 620 Repo: "modern", 621 Branch: "master", 622 Request: &github.BranchProtectionRequest{ 623 EnforceAdmins: &yes, 624 RequiredStatusChecks: &github.RequiredStatusChecks{ 625 Strict: true, 626 Contexts: []string{"config-presubmit", "org-presubmit"}, 627 }, 628 RequiredPullRequestReviews: &github.RequiredPullRequestReviews{ 629 DismissStaleReviews: false, 630 RequireCodeOwnerReviews: true, 631 RequiredApprovingReviewCount: 3, 632 DismissalRestrictions: github.Restrictions{ 633 Users: &[]string{"bob", "jane"}, 634 Teams: &[]string{"oncall", "sres"}, 635 }, 636 }, 637 Restrictions: &github.Restrictions{ 638 Users: &[]string{"cindy"}, 639 Teams: &[]string{"config-team", "org-team"}, 640 }, 641 }, 642 }, 643 }, 644 }, 645 { 646 name: "child cannot disable parent policy by default", 647 branches: []string{"parent/child=unprotected"}, 648 config: ` 649 branch-protection: 650 protect: true 651 enforce_admins: true 652 orgs: 653 parent: 654 protect: false 655 `, 656 errors: 1, 657 }, 658 { 659 name: "child disables parent", 660 branches: []string{"parent/child=unprotected"}, 661 config: ` 662 branch-protection: 663 allow_disabled_policies: true 664 protect: true 665 enforce_admins: true 666 orgs: 667 parent: 668 protect: false 669 `, 670 expected: []requirements{ 671 { 672 Org: "parent", 673 Repo: "child", 674 Branch: "unprotected", 675 }, 676 }, 677 }, 678 { 679 name: "do not unprotect unprotected", 680 branches: []string{"protect/update=master", "unprotected/skip=master"}, 681 config: ` 682 branch-protection: 683 protect: true 684 orgs: 685 protect: 686 protect: true 687 unprotected: 688 protect: false 689 `, 690 startUnprotected: true, 691 expected: []requirements{ 692 { 693 Org: "protect", 694 Repo: "update", 695 Branch: "master", 696 Request: &github.BranchProtectionRequest{}, 697 }, 698 }, 699 }, 700 } 701 702 for _, tc := range cases { 703 t.Run(tc.name, func(t *testing.T) { 704 repos := map[string]map[string]bool{} 705 branches := map[string][]github.Branch{} 706 for _, b := range tc.branches { 707 org, repo, branch := split(b) 708 k := org + "/" + repo 709 branches[k] = append(branches[k], github.Branch{ 710 Name: branch, 711 Protected: !tc.startUnprotected, 712 }) 713 r := repos[org] 714 if r == nil { 715 repos[org] = make(map[string]bool) 716 } 717 repos[org][repo] = true 718 } 719 fc := fakeClient{ 720 branches: branches, 721 repos: map[string][]github.Repo{}, 722 } 723 for org, r := range repos { 724 for rname := range r { 725 fc.repos[org] = append(fc.repos[org], github.Repo{Name: rname, FullName: org + "/" + rname, Archived: rname == tc.archived}) 726 } 727 } 728 729 var cfg config.Config 730 if err := yaml.Unmarshal([]byte(tc.config), &cfg); err != nil { 731 t.Fatalf("failed to parse config: %v", err) 732 } 733 p := protector{ 734 client: &fc, 735 cfg: &cfg, 736 errors: Errors{}, 737 updates: make(chan requirements), 738 done: make(chan []error), 739 completedRepos: make(map[string]bool), 740 } 741 go func() { 742 p.protect() 743 close(p.updates) 744 }() 745 746 var actual []requirements 747 for r := range p.updates { 748 actual = append(actual, r) 749 } 750 errors := p.errors.errs 751 if len(errors) != tc.errors { 752 t.Errorf("actual errors %d != expected %d: %v", len(errors), tc.errors, errors) 753 } 754 switch { 755 case len(actual) != len(tc.expected): 756 t.Errorf("%+v %+v", cfg.BranchProtection, actual) 757 t.Errorf("actual updates %v != expected %v", actual, tc.expected) 758 default: 759 for _, a := range actual { 760 found := false 761 for _, e := range tc.expected { 762 if e.Org == a.Org && e.Repo == a.Repo && e.Branch == a.Branch { 763 found = true 764 fixup(&a) 765 fixup(&e) 766 if !reflect.DeepEqual(e, a) { 767 t.Errorf("actual != expected: %s", diff.ObjectDiff(a.Request, e.Request)) 768 } 769 break 770 } 771 } 772 if !found { 773 t.Errorf("actual updates %v not in expected %v", a, tc.expected) 774 } 775 } 776 } 777 }) 778 } 779 } 780 781 func fixup(r *requirements) { 782 if r == nil || r.Request == nil { 783 return 784 } 785 req := r.Request 786 if req.RequiredStatusChecks != nil { 787 sort.Strings(req.RequiredStatusChecks.Contexts) 788 } 789 if restr := req.Restrictions; restr != nil { 790 sort.Strings(*restr.Teams) 791 sort.Strings(*restr.Users) 792 } 793 } 794 795 func TestIgnoreArchivedRepos(t *testing.T) { 796 repos := map[string]map[string]bool{} 797 branches := map[string][]github.Branch{} 798 org, repo, branch := "organization", "repository", "branch" 799 k := org + "/" + repo 800 branches[k] = append(branches[k], github.Branch{ 801 Name: branch, 802 }) 803 r := repos[org] 804 if r == nil { 805 repos[org] = make(map[string]bool) 806 } 807 repos[org][repo] = true 808 fc := fakeClient{ 809 branches: branches, 810 repos: map[string][]github.Repo{}, 811 } 812 for org, r := range repos { 813 for rname := range r { 814 fc.repos[org] = append(fc.repos[org], github.Repo{Name: rname, FullName: org + "/" + rname, Archived: true}) 815 } 816 } 817 818 var cfg config.Config 819 if err := yaml.Unmarshal([]byte(` 820 branch-protection: 821 protect-by-default: true 822 orgs: 823 organization: 824 `), &cfg); err != nil { 825 t.Fatalf("failed to parse config: %v", err) 826 } 827 p := protector{ 828 client: &fc, 829 cfg: &cfg, 830 errors: Errors{}, 831 updates: make(chan requirements), 832 done: make(chan []error), 833 completedRepos: make(map[string]bool), 834 } 835 go func() { 836 p.protect() 837 close(p.updates) 838 }() 839 840 protectionErrors := p.errors.errs 841 if len(protectionErrors) != 0 { 842 t.Errorf("expected no errors, got %d errors: %v", len(protectionErrors), protectionErrors) 843 } 844 var actual []requirements 845 for r := range p.updates { 846 actual = append(actual, r) 847 } 848 if len(actual) != 0 { 849 t.Errorf("expected no updates, got: %v", actual) 850 } 851 }