go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/acls/run_create_test.go (about)

     1  // Copyright 2022 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package acls
    16  
    17  import (
    18  	"fmt"
    19  	"testing"
    20  
    21  	"go.chromium.org/luci/auth/identity"
    22  	"go.chromium.org/luci/gae/service/datastore"
    23  	"go.chromium.org/luci/server/auth"
    24  	"go.chromium.org/luci/server/auth/authtest"
    25  
    26  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    27  
    28  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    29  	"go.chromium.org/luci/cv/internal/changelist"
    30  	"go.chromium.org/luci/cv/internal/common"
    31  	"go.chromium.org/luci/cv/internal/configs/prjcfg"
    32  	"go.chromium.org/luci/cv/internal/cvtesting"
    33  	"go.chromium.org/luci/cv/internal/run"
    34  
    35  	. "github.com/smartystreets/goconvey/convey"
    36  )
    37  
    38  func TestCheckRunCLs(t *testing.T) {
    39  	t.Parallel()
    40  
    41  	const (
    42  		lProject   = "chromium"
    43  		gerritHost = "chromium-review.googlesource.com"
    44  		committers = "committer-group"
    45  		dryRunners = "dry-runner-group"
    46  		npRunners  = "new-patchset-runner-group"
    47  	)
    48  
    49  	Convey("CheckRunCreate", t, func() {
    50  		ct := cvtesting.Test{}
    51  		ctx, cancel := ct.SetUp(t)
    52  		defer cancel()
    53  		cg := prjcfg.ConfigGroup{
    54  			Content: &cfgpb.ConfigGroup{
    55  				Verifiers: &cfgpb.Verifiers{
    56  					GerritCqAbility: &cfgpb.Verifiers_GerritCQAbility{
    57  						CommitterList:            []string{committers},
    58  						DryRunAccessList:         []string{dryRunners},
    59  						NewPatchsetRunAccessList: []string{npRunners},
    60  					},
    61  				},
    62  			},
    63  		}
    64  
    65  		authState := &authtest.FakeState{FakeDB: authtest.NewFakeDB()}
    66  		ctx = auth.WithState(ctx, authState)
    67  		addMember := func(email, grp string) {
    68  			id, err := identity.MakeIdentity(fmt.Sprintf("%s:%s", identity.User, email))
    69  			So(err, ShouldBeNil)
    70  			authState.FakeDB.(*authtest.FakeDB).AddMocks(authtest.MockMembership(id, grp))
    71  		}
    72  		addCommitter := func(email string) {
    73  			addMember(email, committers)
    74  		}
    75  		addDryRunner := func(email string) {
    76  			addMember(email, dryRunners)
    77  		}
    78  		addNPRunner := func(email string) {
    79  			addMember(email, npRunners)
    80  		}
    81  
    82  		// test helpers
    83  		var cls []*changelist.CL
    84  		var trs []*run.Trigger
    85  		var clid int64
    86  		addCL := func(triggerer, owner string, m run.Mode) (*changelist.CL, *run.Trigger) {
    87  			clid++
    88  			cl := &changelist.CL{
    89  				ID:         common.CLID(clid),
    90  				ExternalID: changelist.MustGobID(gerritHost, clid),
    91  				Snapshot: &changelist.Snapshot{
    92  					Kind: &changelist.Snapshot_Gerrit{
    93  						Gerrit: &changelist.Gerrit{
    94  							Host: gerritHost,
    95  							Info: &gerritpb.ChangeInfo{
    96  								Owner: &gerritpb.AccountInfo{
    97  									Email: owner,
    98  								},
    99  							},
   100  						},
   101  					},
   102  				},
   103  			}
   104  			So(datastore.Put(ctx, cl), ShouldBeNil)
   105  			cls = append(cls, cl)
   106  			tr := &run.Trigger{
   107  				Email: triggerer,
   108  				Mode:  string(m),
   109  			}
   110  			trs = append(trs, tr)
   111  			return cl, tr
   112  		}
   113  		addDep := func(base *changelist.CL, owner string) *changelist.CL {
   114  			clid++
   115  			dep := &changelist.CL{
   116  				ID:         common.CLID(clid),
   117  				ExternalID: changelist.MustGobID(gerritHost, clid),
   118  				Snapshot: &changelist.Snapshot{
   119  					Kind: &changelist.Snapshot_Gerrit{
   120  						Gerrit: &changelist.Gerrit{
   121  							Host: gerritHost,
   122  							Info: &gerritpb.ChangeInfo{
   123  								Owner: &gerritpb.AccountInfo{
   124  									Email: owner,
   125  								},
   126  							},
   127  						},
   128  					},
   129  				},
   130  			}
   131  			So(datastore.Put(ctx, dep), ShouldBeNil)
   132  			base.Snapshot.Deps = append(base.Snapshot.Deps, &changelist.Dep{Clid: clid})
   133  			return dep
   134  		}
   135  
   136  		mustOK := func() {
   137  			res, err := CheckRunCreate(ctx, &cg, trs, cls)
   138  			So(err, ShouldBeNil)
   139  			So(res.FailuresSummary(), ShouldBeEmpty)
   140  			So(res.OK(), ShouldBeTrue)
   141  		}
   142  		mustFailWith := func(cl *changelist.CL, format string, args ...any) CheckResult {
   143  			res, err := CheckRunCreate(ctx, &cg, trs, cls)
   144  			So(err, ShouldBeNil)
   145  			So(res.OK(), ShouldBeFalse)
   146  			So(res.Failure(cl), ShouldContainSubstring, fmt.Sprintf(format, args...))
   147  			return res
   148  		}
   149  		markCLSubmittable := func(cl *changelist.CL) {
   150  			cl.Snapshot.GetGerrit().GetInfo().Submittable = true
   151  			So(datastore.Put(ctx, cl), ShouldBeNil)
   152  		}
   153  		submitCL := func(cl *changelist.CL) {
   154  			cl.Snapshot.GetGerrit().GetInfo().Status = gerritpb.ChangeStatus_MERGED
   155  			So(datastore.Put(ctx, cl), ShouldBeNil)
   156  		}
   157  		setAllowOwner := func(action cfgpb.Verifiers_GerritCQAbility_CQAction) {
   158  			cg.Content.Verifiers.GerritCqAbility.AllowOwnerIfSubmittable = action
   159  		}
   160  		setTrustDryRunnerDeps := func(trust bool) {
   161  			cg.Content.Verifiers.GerritCqAbility.TrustDryRunnerDeps = trust
   162  		}
   163  		setAllowNonOwnerDryRunner := func(allow bool) {
   164  			cg.Content.Verifiers.GerritCqAbility.AllowNonOwnerDryRunner = allow
   165  		}
   166  
   167  		addSubmitReq := func(cl *changelist.CL, name string, st gerritpb.SubmitRequirementResultInfo_Status) {
   168  			ci := cl.Snapshot.Kind.(*changelist.Snapshot_Gerrit).Gerrit.Info
   169  			ci.SubmitRequirements = append(ci.SubmitRequirements,
   170  				&gerritpb.SubmitRequirementResultInfo{Name: name, Status: st})
   171  			So(datastore.Put(ctx, cl), ShouldBeNil)
   172  		}
   173  		satisfiedReq := func(cl *changelist.CL, name string) {
   174  			addSubmitReq(cl, name, gerritpb.SubmitRequirementResultInfo_SATISFIED)
   175  		}
   176  		unsatisfiedReq := func(cl *changelist.CL, name string) {
   177  			addSubmitReq(cl, name, gerritpb.SubmitRequirementResultInfo_UNSATISFIED)
   178  		}
   179  		naReq := func(cl *changelist.CL, name string) {
   180  			addSubmitReq(cl, name, gerritpb.SubmitRequirementResultInfo_NOT_APPLICABLE)
   181  		}
   182  
   183  		Convey("mode == FullRun", func() {
   184  			m := run.FullRun
   185  
   186  			Convey("triggerer == owner", func() {
   187  				tr, owner := "t@example.org", "t@example.org"
   188  				cl, _ := addCL(tr, owner, m)
   189  
   190  				Convey("triggerer is a committer", func() {
   191  					addCommitter(tr)
   192  
   193  					// Should succeed when CL becomes submittable.
   194  					mustFailWith(cl, notSubmittable)
   195  					markCLSubmittable(cl)
   196  					mustOK()
   197  				})
   198  				Convey("triggerer is a dry-runner", func() {
   199  					addDryRunner(tr)
   200  
   201  					// Dry-runner can trigger a full-run for own submittable CL.
   202  					unsatisfiedReq(cl, "Code-Review")
   203  
   204  					mustFailWith(cl, "CV cannot start a Run because this CL is not satisfying the `Code-Review` submit requirement. Please hover over the corresponding entry in the Submit Requirements section to check what is missing.")
   205  					markCLSubmittable(cl)
   206  					mustOK()
   207  				})
   208  				Convey("triggerer is neither dry-runner nor committer", func() {
   209  					Convey("CL submittable", func() {
   210  						// Should fail, even if it was submittable.
   211  						markCLSubmittable(cl)
   212  						mustFailWith(cl, "CV cannot start a Run for `%s` because the user is not a committer", tr)
   213  						// unless AllowOwnerIfSubmittable == COMMIT
   214  						setAllowOwner(cfgpb.Verifiers_GerritCQAbility_COMMIT)
   215  						mustOK()
   216  					})
   217  					Convey("CL not submittable", func() {
   218  						// Should fail always.
   219  						mustFailWith(cl, "CV cannot start a Run for `%s` because the user is not a committer", tr)
   220  						setAllowOwner(cfgpb.Verifiers_GerritCQAbility_COMMIT)
   221  						mustFailWith(cl, notSubmittable)
   222  					})
   223  				})
   224  				Convey("suspiciously not submittable", func() {
   225  					addDryRunner(tr)
   226  					addSubmitReq(cl, "Code-Review", gerritpb.SubmitRequirementResultInfo_SATISFIED)
   227  					mustFailWith(cl, notSubmittableSuspicious)
   228  				})
   229  			})
   230  
   231  			Convey("triggerer != owner", func() {
   232  				tr, owner := "t@example.org", "o@example.org"
   233  				cl, _ := addCL(tr, owner, m)
   234  
   235  				Convey("triggerer is a committer", func() {
   236  					addCommitter(tr)
   237  
   238  					// Should succeed when CL becomes submittable.
   239  					mustFailWith(cl, notSubmittable)
   240  					markCLSubmittable(cl)
   241  					mustOK()
   242  				})
   243  				Convey("triggerer is a dry-runner", func() {
   244  					addDryRunner(tr)
   245  
   246  					// Dry-runner cannot trigger a full-run for someone else' CL,
   247  					// whether it is submittable or not
   248  					mustFailWith(cl, "neither the CL owner nor a committer")
   249  					markCLSubmittable(cl)
   250  					mustFailWith(cl, "neither the CL owner nor a committer")
   251  
   252  					// AllowOwnerIfSubmittable doesn't change the decision, either.
   253  					setAllowOwner(cfgpb.Verifiers_GerritCQAbility_COMMIT)
   254  					mustFailWith(cl, "neither the CL owner nor a committer")
   255  
   256  					// TrustDryRunnerDeps doesn't change the decision, either.
   257  					setTrustDryRunnerDeps(true)
   258  					mustFailWith(cl, "neither the CL owner nor a committer")
   259  
   260  					// AllowNonOwnerDryRunner doesn't change the decision, either.
   261  					setAllowNonOwnerDryRunner(true)
   262  					mustFailWith(cl, "neither the CL owner nor a committer")
   263  				})
   264  				Convey("triggerer is neither dry-runner nor committer", func() {
   265  					// Should fail always.
   266  					mustFailWith(cl, "neither the CL owner nor a committer")
   267  					markCLSubmittable(cl)
   268  					setAllowOwner(cfgpb.Verifiers_GerritCQAbility_COMMIT)
   269  					mustFailWith(cl, "neither the CL owner nor a committer")
   270  				})
   271  				Convey("suspiciously not submittable", func() {
   272  					addCommitter(tr)
   273  					addSubmitReq(cl, "Code-Review", gerritpb.SubmitRequirementResultInfo_SATISFIED)
   274  					mustFailWith(cl, notSubmittableSuspicious)
   275  				})
   276  			})
   277  		})
   278  
   279  		Convey("mode == DryRun", func() {
   280  			m := run.DryRun
   281  
   282  			Convey("triggerer == owner", func() {
   283  				tr, owner := "t@example.org", "t@example.org"
   284  				cl, _ := addCL(tr, owner, m)
   285  
   286  				Convey("triggerer is a committer", func() {
   287  					// Committers can trigger a dry-run for someone else' CL
   288  					// even if the CL is not submittable
   289  					addCommitter(tr)
   290  					mustOK()
   291  				})
   292  				Convey("triggerer is a dry-runner", func() {
   293  					// Should succeed even if the CL is not submittable.
   294  					addDryRunner(tr)
   295  					mustOK()
   296  				})
   297  				Convey("triggerer is neither dry-runner nor committer", func() {
   298  					Convey("CL submittable", func() {
   299  						// Should fail, even if the CL is submittable.
   300  						markCLSubmittable(cl)
   301  						mustFailWith(cl, "CV cannot start a Run for `%s` because the user is not a dry-runner", owner)
   302  						// Unless AllowOwnerIfSubmittable == DRY_RUN
   303  						setAllowOwner(cfgpb.Verifiers_GerritCQAbility_DRY_RUN)
   304  						mustOK()
   305  						// Or, COMMIT
   306  						setAllowOwner(cfgpb.Verifiers_GerritCQAbility_COMMIT)
   307  						mustOK()
   308  					})
   309  					Convey("CL not submittable", func() {
   310  						// Should fail always.
   311  						mustFailWith(cl, "CV cannot start a Run for `%s` because the user is not a dry-runner", owner)
   312  						setAllowOwner(cfgpb.Verifiers_GerritCQAbility_COMMIT)
   313  						mustFailWith(cl, notSubmittable)
   314  					})
   315  				})
   316  			})
   317  
   318  			Convey("triggerer != owner", func() {
   319  				tr, owner := "t@example.org", "o@example.org"
   320  				cl, _ := addCL(tr, owner, m)
   321  
   322  				Convey("triggerer is a committer", func() {
   323  					// Should succeed whether CL is submittable or not.
   324  					addCommitter(tr)
   325  					mustOK()
   326  					markCLSubmittable(cl)
   327  					mustOK()
   328  				})
   329  				Convey("triggerer is a dry-runner", func() {
   330  					// Only committers can trigger a dry-run for someone else's CL.
   331  					addDryRunner(tr)
   332  					mustFailWith(cl, "neither the CL owner nor a committer")
   333  					markCLSubmittable(cl)
   334  					mustFailWith(cl, "neither the CL owner nor a committer")
   335  					// AllowOwnerIfSubmittable doesn't change the decision, either.
   336  					setAllowOwner(cfgpb.Verifiers_GerritCQAbility_COMMIT)
   337  					mustFailWith(cl, "neither the CL owner nor a committer")
   338  					setAllowOwner(cfgpb.Verifiers_GerritCQAbility_DRY_RUN)
   339  					mustFailWith(cl, "neither the CL owner nor a committer")
   340  					// TrustDryRunnerDeps doesn't change the decision, either.
   341  					setTrustDryRunnerDeps(true)
   342  					mustFailWith(cl, "neither the CL owner nor a committer")
   343  				})
   344  				Convey("triggerer is a dry-runner (with allow_non_owner_dry_runner)", func() {
   345  					// With allow_non_owner_dry_runner, dry-runners can trigger a dry-run for someone else's CL.
   346  					addDryRunner(tr)
   347  					setAllowNonOwnerDryRunner(true)
   348  					mustOK()
   349  					markCLSubmittable(cl)
   350  					mustOK()
   351  				})
   352  				Convey("triggerer is neither dry-runner nor committer", func() {
   353  					// Only committers can trigger a dry-run for someone else' CL.
   354  					mustFailWith(cl, "neither the CL owner nor a committer")
   355  					markCLSubmittable(cl)
   356  					mustFailWith(cl, "neither the CL owner nor a committer")
   357  					// AllowOwnerIfSubmittable doesn't change the decision, either.
   358  					setAllowOwner(cfgpb.Verifiers_GerritCQAbility_COMMIT)
   359  					mustFailWith(cl, "neither the CL owner nor a committer")
   360  					setAllowOwner(cfgpb.Verifiers_GerritCQAbility_DRY_RUN)
   361  					mustFailWith(cl, "neither the CL owner nor a committer")
   362  				})
   363  			})
   364  
   365  			Convey("w/ dependencies", func() {
   366  				// if triggerer is not the owner, but a
   367  				// committer/dry-runner, then untrusted deps
   368  				// should be checked.
   369  				tr, owner := "t@example.org", "o@example.org"
   370  				cl, _ := addCL(tr, owner, m)
   371  
   372  				dep1 := addDep(cl, "dep_owner1@example.org")
   373  				dep2 := addDep(cl, "dep_owner2@example.org")
   374  				dep1URL := dep1.ExternalID.MustURL()
   375  				dep2URL := dep2.ExternalID.MustURL()
   376  
   377  				testCases := func() {
   378  					Convey("untrusted", func() {
   379  						res := mustFailWith(cl, untrustedDeps)
   380  						So(res.Failure(cl), ShouldContainSubstring, dep1URL)
   381  						So(res.Failure(cl), ShouldContainSubstring, dep2URL)
   382  						// if the deps have no submit requirements, the rejection message
   383  						// shouldn't contain a warning for suspicious CLs.
   384  						So(res.Failure(cl), ShouldNotContainSubstring, untrustedDepsSuspicious)
   385  
   386  						Convey("with TrustDryRunnerDeps", func() {
   387  							setTrustDryRunnerDeps(true)
   388  							mustFailWith(cl, untrustedDepsTrustDryRunnerDeps)
   389  						})
   390  
   391  						Convey("but dep2 satisfies all the SubmitRequirements", func() {
   392  							naReq(dep1, "Code-Review")
   393  							unsatisfiedReq(dep1, "Code-Owner")
   394  							satisfiedReq(dep2, "Code-Review")
   395  							satisfiedReq(dep2, "Code-Owner")
   396  							res := mustFailWith(cl, untrustedDeps)
   397  							So(res.Failure(cl), ShouldContainSubstring, fmt.Sprintf(""+
   398  								"- %s: not submittable, although submit requirements `Code-Review` and `Code-Owner` are satisfied",
   399  								dep2URL,
   400  							))
   401  							So(res.Failure(cl), ShouldContainSubstring, untrustedDepsSuspicious)
   402  						})
   403  
   404  						Convey("because all the deps have unsatisfied requirements", func() {
   405  							dep3 := addDep(cl, "dep_owner3@example.org")
   406  							dep3URL := dep3.ExternalID.MustURL()
   407  
   408  							unsatisfiedReq(dep1, "Code-Review")
   409  							unsatisfiedReq(dep2, "Code-Review")
   410  							unsatisfiedReq(dep2, "Code-Owner")
   411  							unsatisfiedReq(dep3, "Code-Review")
   412  							unsatisfiedReq(dep3, "Code-Owner")
   413  							unsatisfiedReq(dep3, "Code-Quiz")
   414  
   415  							res := mustFailWith(cl, untrustedDeps)
   416  							So(res.Failure(cl), ShouldNotContainSubstring, untrustedDepsSuspicious)
   417  							So(res.Failure(cl), ShouldContainSubstring,
   418  								dep1URL+": not satisfying the `Code-Review` submit requirement")
   419  							So(res.Failure(cl), ShouldContainSubstring,
   420  								dep2URL+": not satisfying the `Code-Review` and `Code-Owner` submit requirement")
   421  							So(res.Failure(cl), ShouldContainSubstring,
   422  								dep3URL+": not satisfying the `Code-Review`, `Code-Owner`, and `Code-Quiz` submit requirement")
   423  						})
   424  					})
   425  					Convey("trusted because it's apart of the Run", func() {
   426  						cls = append(cls, dep1, dep2)
   427  						trs = append(trs, &run.Trigger{Email: tr, Mode: string(m)})
   428  						trs = append(trs, &run.Trigger{Email: tr, Mode: string(m)})
   429  						mustOK()
   430  					})
   431  					Convey("trusted because of submittable", func() {
   432  						markCLSubmittable(dep1)
   433  						markCLSubmittable(dep2)
   434  						mustOK()
   435  					})
   436  					Convey("trusterd because they have been merged already", func() {
   437  						submitCL(dep1)
   438  						submitCL(dep2)
   439  						mustOK()
   440  					})
   441  					Convey("trusted because the owner is a committer", func() {
   442  						addCommitter("dep_owner1@example.org")
   443  						addCommitter("dep_owner2@example.org")
   444  						mustOK()
   445  					})
   446  					Convey("trusted because the owner is a dry-runner", func() {
   447  						addDryRunner("dep_owner1@example.org")
   448  						addDryRunner("dep_owner2@example.org")
   449  
   450  						// Not allowed without TrustDryRunnerDeps.
   451  						res := mustFailWith(cl, untrustedDeps)
   452  						So(res.Failure(cl), ShouldContainSubstring, dep1URL)
   453  						So(res.Failure(cl), ShouldContainSubstring, dep2URL)
   454  
   455  						setTrustDryRunnerDeps(true)
   456  						mustOK()
   457  					})
   458  					Convey("a mix of untrusted and trusted deps", func() {
   459  						addCommitter("dep_owner1@example.org")
   460  						res := mustFailWith(cl, untrustedDeps)
   461  						So(res.Failure(cl), ShouldNotContainSubstring, dep1URL)
   462  						So(res.Failure(cl), ShouldContainSubstring, dep2URL)
   463  					})
   464  				}
   465  
   466  				Convey("committer", func() {
   467  					addCommitter(tr)
   468  					testCases()
   469  				})
   470  
   471  				Convey("dry-runner (with allow_non_owner_dry_runner)", func() {
   472  					addDryRunner(tr)
   473  					setAllowNonOwnerDryRunner(true)
   474  					testCases()
   475  				})
   476  			})
   477  		})
   478  
   479  		Convey("mode == NewPatchsetRun", func() {
   480  			tr, owner := "t@example.org", "t@example.org"
   481  			cl, _ := addCL(tr, owner, run.NewPatchsetRun)
   482  			Convey("owner is disallowed", func() {
   483  				mustFailWith(cl, "CL owner is not in the allowlist.")
   484  			})
   485  			Convey("owner is allowed", func() {
   486  				addNPRunner(owner)
   487  				mustOK()
   488  			})
   489  		})
   490  
   491  		Convey("mode is non standard mode", func() {
   492  			tr, owner := "t@example.org", "t@example.org"
   493  			cl, trigger := addCL(tr, owner, "CUSTOM_RUN")
   494  			trigger.ModeDefinition = &cfgpb.Mode{
   495  				Name:            "CUSTOM_RUN",
   496  				TriggeringLabel: "CUSTOM",
   497  				TriggeringValue: 1,
   498  			}
   499  			Convey("dry", func() {
   500  				trigger.ModeDefinition.CqLabelValue = 1
   501  				Convey("disallowed", func() {
   502  					mustFailWith(cl, "CV cannot start a Run for `%s` because the user is not a dry-runner", owner)
   503  				})
   504  				Convey("allowed", func() {
   505  					addDryRunner(owner)
   506  					mustOK()
   507  				})
   508  			})
   509  			Convey("full", func() {
   510  				trigger.ModeDefinition.CqLabelValue = 2
   511  				markCLSubmittable(cl)
   512  				Convey("disallowed", func() {
   513  					mustFailWith(cl, "CV cannot start a Run for `%s` because the user is not a committer", owner)
   514  				})
   515  				Convey("allowed", func() {
   516  					addCommitter(owner)
   517  					mustOK()
   518  				})
   519  			})
   520  		})
   521  
   522  		Convey("multiple CLs", func() {
   523  			m := run.DryRun
   524  			tr, owner := "t@example.org", "t@example.org"
   525  			cl1, _ := addCL(tr, owner, m)
   526  			cl2, _ := addCL(tr, owner, m)
   527  			setAllowOwner(cfgpb.Verifiers_GerritCQAbility_DRY_RUN)
   528  
   529  			Convey("all CLs passed", func() {
   530  				markCLSubmittable(cl1)
   531  				markCLSubmittable(cl2)
   532  				mustOK()
   533  			})
   534  			Convey("all CLs failed", func() {
   535  				mustFailWith(cl1, notSubmittable)
   536  				mustFailWith(cl2, notSubmittable)
   537  			})
   538  			Convey("Some CLs failed", func() {
   539  				markCLSubmittable(cl1)
   540  				mustFailWith(cl1, "CV cannot start a Run due to errors in the following CL(s)")
   541  				mustFailWith(cl2, notSubmittable)
   542  			})
   543  		})
   544  	})
   545  }