// Copyright 2023 Harness, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package protection import ( "context" "reflect" "testing" "github.com/harness/gitness/app/services/codeowners" "github.com/harness/gitness/types" "github.com/harness/gitness/types/enum" ) // nolint:gocognit // it's a unit test func TestBranch_MergeVerify(t *testing.T) { user := &types.Principal{ID: 42} admin := &types.Principal{ID: 66, Admin: true} tests := []struct { name string branch Branch in MergeVerifyInput expOut MergeVerifyOutput expVs []types.RuleViolations }{ { name: "empty", branch: Branch{}, in: MergeVerifyInput{Actor: user}, expOut: MergeVerifyOutput{ DeleteSourceBranch: false, AllowedMethods: enum.MergeMethods, }, expVs: []types.RuleViolations{}, }, { name: "admin-no-owner", branch: Branch{ Bypass: DefBypass{}, PullReq: DefPullReq{ StatusChecks: DefStatusChecks{RequireIdentifiers: []string{"abc"}}, Comments: DefComments{RequireResolveAll: true}, Merge: DefMerge{DeleteBranch: true}, }, }, in: MergeVerifyInput{ Actor: admin, IsRepoOwner: false, AllowBypass: true, PullReq: &types.PullReq{UnresolvedCount: 1}, }, expOut: MergeVerifyOutput{ DeleteSourceBranch: true, AllowedMethods: enum.MergeMethods, RequiresCommentResolution: true, }, expVs: []types.RuleViolations{ { Bypassable: false, Bypassed: false, Violations: []types.Violation{ {Code: codePullReqCommentsReqResolveAll}, {Code: codePullReqStatusChecksReqIdentifiers}, }, }, }, }, { name: "user-bypass", branch: Branch{ Bypass: DefBypass{UserIDs: []int64{user.ID}}, PullReq: DefPullReq{ StatusChecks: DefStatusChecks{RequireIdentifiers: []string{"abc"}}, Comments: DefComments{RequireResolveAll: true}, Merge: DefMerge{DeleteBranch: true}, }, }, in: MergeVerifyInput{ Actor: user, AllowBypass: true, PullReq: &types.PullReq{UnresolvedCount: 1}, }, expOut: MergeVerifyOutput{ DeleteSourceBranch: true, AllowedMethods: enum.MergeMethods, RequiresCommentResolution: true, }, expVs: []types.RuleViolations{ { Bypassable: true, Bypassed: true, Violations: []types.Violation{ {Code: codePullReqCommentsReqResolveAll}, {Code: codePullReqStatusChecksReqIdentifiers}, }, }, }, }, { name: "user-no-bypass", branch: Branch{ PullReq: DefPullReq{ StatusChecks: DefStatusChecks{RequireIdentifiers: []string{"abc"}}, Comments: DefComments{RequireResolveAll: true}, Merge: DefMerge{DeleteBranch: true}, }, }, in: MergeVerifyInput{ Actor: user, AllowBypass: true, PullReq: &types.PullReq{UnresolvedCount: 1}, }, expOut: MergeVerifyOutput{ DeleteSourceBranch: true, AllowedMethods: enum.MergeMethods, RequiresCommentResolution: true, }, expVs: []types.RuleViolations{ { Bypassable: false, Bypassed: false, Violations: []types.Violation{ {Code: codePullReqCommentsReqResolveAll}, {Code: codePullReqStatusChecksReqIdentifiers}, }, }, }, }, { name: "merge-methods", branch: Branch{ Bypass: DefBypass{}, PullReq: DefPullReq{ StatusChecks: DefStatusChecks{}, Comments: DefComments{}, Merge: DefMerge{ StrategiesAllowed: []enum.MergeMethod{enum.MergeMethodRebase, enum.MergeMethodSquash}, DeleteBranch: false, }, }, }, in: MergeVerifyInput{ Actor: user, }, expOut: MergeVerifyOutput{ DeleteSourceBranch: false, AllowedMethods: []enum.MergeMethod{enum.MergeMethodRebase, enum.MergeMethodSquash}, }, expVs: []types.RuleViolations{}, }, { name: "verify-output", branch: Branch{ Bypass: DefBypass{}, PullReq: DefPullReq{ StatusChecks: DefStatusChecks{}, Comments: DefComments{ RequireResolveAll: true, }, Approvals: DefApprovals{ RequireCodeOwners: true, RequireMinimumCount: 2, RequireNoChangeRequest: true, }, Merge: DefMerge{ DeleteBranch: true, StrategiesAllowed: []enum.MergeMethod{enum.MergeMethodSquash}, }, }, }, in: MergeVerifyInput{ Actor: user, ResolveUserGroupID: mockUserGroupResolver, CodeOwners: &codeowners.Evaluation{}, PullReq: &types.PullReq{}, Reviewers: []*types.PullReqReviewer{}, }, expOut: MergeVerifyOutput{ DeleteSourceBranch: true, AllowedMethods: []enum.MergeMethod{enum.MergeMethodSquash}, RequiresCodeOwnersApproval: true, RequiresNoChangeRequests: true, RequiresCommentResolution: true, MinimumRequiredApprovalsCount: 2, }, expVs: []types.RuleViolations{ { Violations: []types.Violation{ {Code: codePullReqApprovalReqMinCount}, }, }, }, }, { name: "verify-output-latest", branch: Branch{ Bypass: DefBypass{}, PullReq: DefPullReq{ StatusChecks: DefStatusChecks{}, Comments: DefComments{}, Approvals: DefApprovals{ RequireCodeOwners: true, RequireMinimumCount: 2, RequireNoChangeRequest: true, RequireLatestCommit: true, }, Merge: DefMerge{}, }, }, in: MergeVerifyInput{ Actor: user, CodeOwners: &codeowners.Evaluation{}, PullReq: &types.PullReq{}, Reviewers: []*types.PullReqReviewer{}, }, expOut: MergeVerifyOutput{ AllowedMethods: []enum.MergeMethod{ enum.MergeMethodFastForward, enum.MergeMethodMerge, enum.MergeMethodRebase, enum.MergeMethodSquash, }, RequiresCodeOwnersApprovalLatest: true, RequiresNoChangeRequests: true, MinimumRequiredApprovalsCountLatest: 2, }, expVs: []types.RuleViolations{ { Violations: []types.Violation{ {Code: codePullReqApprovalReqMinCountLatest}, }, }, }, }, } ctx := context.Background() for _, test := range tests { t.Run(test.name, func(t *testing.T) { if err := test.branch.Sanitize(); err != nil { t.Errorf("invalid: %s", err.Error()) return } out, results, err := test.branch.MergeVerify(ctx, test.in) if err != nil { t.Errorf("error: %s", err.Error()) return } if want, got := test.expOut, out; !reflect.DeepEqual(want, got) { t.Errorf("output: want=%+v got=%+v", want, got) } if want, got := len(test.expVs), len(results); want != got { t.Errorf("number of violations mismatch: want=%d got=%d", want, got) return } for i := range results { if want, got := test.expVs[i].Bypassable, results[i].Bypassable; want != got { t.Errorf("rule result %d, bypassable mismatch: want=%t got=%t", i, want, got) return } if want, got := test.expVs[i].Bypassed, results[i].Bypassed; want != got { t.Errorf("rule result %d, bypassed mismatch: want=%t got=%t", i, want, got) return } if want, got := len(test.expVs[i].Violations), len(results[i].Violations); want != got { t.Errorf("rule result %d, violations count mismatch: want=%d got=%d", i, want, got) return } for j := range results[i].Violations { if want, got := test.expVs[i].Violations[j].Code, results[i].Violations[j].Code; want != got { t.Errorf("rule result %d, violation %d, code mismatch: want=%s got=%s", i, j, want, got) } } } }) } } func TestBranch_RequiredChecks(t *testing.T) { user := &types.Principal{ID: 42} admin := &types.Principal{ID: 66, Admin: true} tests := []struct { name string branch Branch in RequiredChecksInput expOut RequiredChecksOutput }{ { name: "empty", branch: Branch{}, in: RequiredChecksInput{Actor: user}, expOut: RequiredChecksOutput{ RequiredIdentifiers: nil, BypassableIdentifiers: nil, }, }, { name: "admin-no-owner", branch: Branch{ Bypass: DefBypass{}, PullReq: DefPullReq{ StatusChecks: DefStatusChecks{RequireIdentifiers: []string{"abc"}}, }, }, in: RequiredChecksInput{ Actor: admin, IsRepoOwner: false, }, expOut: RequiredChecksOutput{ RequiredIdentifiers: map[string]struct{}{"abc": {}}, BypassableIdentifiers: nil, }, }, { name: "user-bypass", branch: Branch{ Bypass: DefBypass{UserIDs: []int64{user.ID}}, PullReq: DefPullReq{ StatusChecks: DefStatusChecks{RequireIdentifiers: []string{"abc"}}, }, }, in: RequiredChecksInput{ Actor: user, }, expOut: RequiredChecksOutput{ RequiredIdentifiers: nil, BypassableIdentifiers: map[string]struct{}{"abc": {}}, }, }, { name: "user-no-bypass", branch: Branch{ PullReq: DefPullReq{ StatusChecks: DefStatusChecks{RequireIdentifiers: []string{"abc"}}, }, }, in: RequiredChecksInput{ Actor: user, }, expOut: RequiredChecksOutput{ RequiredIdentifiers: map[string]struct{}{"abc": {}}, BypassableIdentifiers: nil, }, }, } ctx := context.Background() for _, test := range tests { t.Run(test.name, func(t *testing.T) { if err := test.branch.Sanitize(); err != nil { t.Errorf("invalid: %s", err.Error()) return } out, err := test.branch.RequiredChecks(ctx, test.in) if err != nil { t.Errorf("error: %s", err.Error()) return } if want, got := test.expOut, out; !reflect.DeepEqual(want, got) { t.Errorf("output: want=%+v got=%+v", want, got) } }) } } // nolint:gocognit // it's a unit test func TestBranch_RefChangeVerify(t *testing.T) { user := &types.Principal{ID: 42} admin := &types.Principal{ID: 66, Admin: true} tests := []struct { name string branch Branch in RefChangeVerifyInput expVs []types.RuleViolations }{ { name: "empty", branch: Branch{ Bypass: DefBypass{}, Lifecycle: DefLifecycle{}, }, in: RefChangeVerifyInput{ Actor: user, }, expVs: []types.RuleViolations{}, }, { name: "admin-no-owner", branch: Branch{ Bypass: DefBypass{}, Lifecycle: DefLifecycle{DeleteForbidden: true}, }, in: RefChangeVerifyInput{ Actor: admin, IsRepoOwner: false, AllowBypass: true, RefAction: RefActionDelete, RefType: RefTypeBranch, RefNames: []string{"abc"}, }, expVs: []types.RuleViolations{ { Bypassable: false, Bypassed: false, Violations: []types.Violation{ {Code: codeLifecycleDelete}, }, }, }, }, { name: "owner-bypass", branch: Branch{ Bypass: DefBypass{RepoOwners: true}, Lifecycle: DefLifecycle{DeleteForbidden: true}, }, in: RefChangeVerifyInput{ Actor: user, AllowBypass: true, IsRepoOwner: true, RefAction: RefActionDelete, RefType: RefTypeBranch, RefNames: []string{"abc"}, }, expVs: []types.RuleViolations{ { Bypassable: true, Bypassed: true, Violations: []types.Violation{ {Code: codeLifecycleDelete}, }, }, }, }, { name: "user-no-bypass", branch: Branch{ Bypass: DefBypass{RepoOwners: true}, Lifecycle: DefLifecycle{DeleteForbidden: true}, }, in: RefChangeVerifyInput{ Actor: user, AllowBypass: true, IsRepoOwner: false, RefAction: RefActionDelete, RefType: RefTypeBranch, RefNames: []string{"abc"}, }, expVs: []types.RuleViolations{ { Bypassable: false, Bypassed: false, Violations: []types.Violation{ {Code: codeLifecycleDelete}, }, }, }, }, { name: "usergroup-bypass", branch: Branch{ Bypass: DefBypass{RepoOwners: true}, Lifecycle: DefLifecycle{DeleteForbidden: true}, }, in: RefChangeVerifyInput{ Actor: &types.Principal{ID: 43}, ResolveUserGroupID: mockUserGroupResolver, AllowBypass: true, IsRepoOwner: false, RefAction: RefActionDelete, RefType: RefTypeBranch, RefNames: []string{"abc"}, }, expVs: []types.RuleViolations{ { Bypassable: true, Bypassed: true, Violations: []types.Violation{ {Code: codeLifecycleDelete}, }, }, }, }, } ctx := context.Background() for _, test := range tests { t.Run(test.name, func(t *testing.T) { if err := test.branch.Sanitize(); err != nil { t.Errorf("invalid: %s", err.Error()) return } results, err := test.branch.RefChangeVerify(ctx, test.in) if err != nil { t.Errorf("error: %s", err.Error()) return } if want, got := len(test.expVs), len(results); want != got { t.Errorf("number of violations mismatch: want=%d got=%d", want, got) return } for i := range results { if want, got := test.expVs[i].Bypassable, results[i].Bypassable; want != got { t.Errorf("rule result %d, bypassable mismatch: want=%t got=%t", i, want, got) return } if want, got := test.expVs[i].Bypassed, results[i].Bypassed; want != got { t.Errorf("rule result %d, bypassed mismatch: want=%t got=%t", i, want, got) return } if want, got := len(test.expVs[i].Violations), len(results[i].Violations); want != got { t.Errorf("rule result %d, violations count mismatch: want=%d got=%d", i, want, got) return } for j := range results[i].Violations { if want, got := test.expVs[i].Violations[j].Code, results[i].Violations[j].Code; want != got { t.Errorf("rule result %d, violation %d, code mismatch: want=%s got=%s", i, j, want, got) } } } }) } } func mockUserGroupResolver(_ context.Context, _ []int64) ([]int64, error) { return []int64{43}, nil }