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