go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/gerrit/updater/updater_test.go (about)

     1  // Copyright 2020 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 updater
    16  
    17  import (
    18  	"context"
    19  	"sort"
    20  	"sync/atomic"
    21  	"testing"
    22  	"time"
    23  
    24  	"google.golang.org/grpc/codes"
    25  	"google.golang.org/grpc/status"
    26  	"google.golang.org/protobuf/proto"
    27  	"google.golang.org/protobuf/types/known/timestamppb"
    28  
    29  	"go.chromium.org/luci/common/errors"
    30  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    31  	"go.chromium.org/luci/server/tq"
    32  
    33  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    34  	"go.chromium.org/luci/cv/internal/changelist"
    35  	"go.chromium.org/luci/cv/internal/common"
    36  	"go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest"
    37  	"go.chromium.org/luci/cv/internal/cvtesting"
    38  	"go.chromium.org/luci/cv/internal/gerrit"
    39  	gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake"
    40  	"go.chromium.org/luci/cv/internal/gerrit/gobmap/gobmaptest"
    41  
    42  	. "github.com/smartystreets/goconvey/convey"
    43  	. "go.chromium.org/luci/common/testing/assertions"
    44  )
    45  
    46  func TestUpdaterBackend(t *testing.T) {
    47  	t.Parallel()
    48  
    49  	Convey("updaterBackend methods work, except Fetch()", t, func() {
    50  		// Fetch() is covered in TestUpdaterBackendFetch.
    51  		ct := cvtesting.Test{}
    52  		ctx, cancel := ct.SetUp(t)
    53  		defer cancel()
    54  
    55  		gu := &updaterBackend{
    56  			clUpdater: changelist.NewUpdater(ct.TQDispatcher, changelist.NewMutator(ct.TQDispatcher, &pmMock{}, &rmMock{}, &tjMock{})),
    57  			gFactory:  ct.GFactory(),
    58  		}
    59  
    60  		Convey("Kind", func() {
    61  			So(gu.Kind(), ShouldEqual, "gerrit")
    62  		})
    63  		Convey("TQErrorSpec", func() {
    64  			tqSpec := gu.TQErrorSpec()
    65  			err := errors.Annotate(gerrit.ErrStaleData, "retry, don't ignore").Err()
    66  			So(tq.Ignore.In(tqSpec.Error(ctx, err)), ShouldBeFalse)
    67  		})
    68  		Convey("LookupApplicableConfig", func() {
    69  			const gHost = "x-review.example.com"
    70  			prjcfgtest.Create(ctx, "luci-project-x", singleRepoConfig(gHost, "x"))
    71  			gobmaptest.Update(ctx, "luci-project-x")
    72  			xConfigGroupID := string(prjcfgtest.MustExist(ctx, "luci-project-x").ConfigGroupIDs[0])
    73  
    74  			// Setup valid CL snapshot; it'll be modified in tests below.
    75  			cl := &changelist.CL{
    76  				ID: 11111,
    77  				Snapshot: &changelist.Snapshot{
    78  					Kind: &changelist.Snapshot_Gerrit{Gerrit: &changelist.Gerrit{
    79  						Host: gHost,
    80  						Info: &gerritpb.ChangeInfo{
    81  							Number:  456,
    82  							Ref:     "refs/heads/main",
    83  							Project: "x",
    84  						},
    85  					}},
    86  				},
    87  			}
    88  
    89  			Convey("Happy path", func() {
    90  				acfg, err := gu.LookupApplicableConfig(ctx, cl)
    91  				So(err, ShouldBeNil)
    92  				So(acfg, ShouldResembleProto, &changelist.ApplicableConfig{
    93  					Projects: []*changelist.ApplicableConfig_Project{
    94  						{Name: "luci-project-x", ConfigGroupIds: []string{string(xConfigGroupID)}},
    95  					},
    96  				})
    97  			})
    98  
    99  			Convey("CL without Gerrit Snapshot can't be decided on", func() {
   100  				cl.Snapshot.Kind = nil
   101  				acfg, err := gu.LookupApplicableConfig(ctx, cl)
   102  				So(err, ShouldBeNil)
   103  				So(acfg, ShouldBeNil)
   104  			})
   105  
   106  			Convey("No watching projects", func() {
   107  				cl.Snapshot.GetGerrit().GetInfo().Ref = "ref/un/watched"
   108  				acfg, err := gu.LookupApplicableConfig(ctx, cl)
   109  				So(err, ShouldBeNil)
   110  				// Must be empty, but not nil per updaterBackend interface contract.
   111  				So(acfg, ShouldNotBeNil)
   112  				So(acfg, ShouldResembleProto, &changelist.ApplicableConfig{})
   113  			})
   114  
   115  			Convey("Works with >1 LUCI project watching the same CL", func() {
   116  				prjcfgtest.Create(ctx, "luci-project-dupe", singleRepoConfig(gHost, "x"))
   117  				gobmaptest.Update(ctx, "luci-project-dupe")
   118  				acfg, err := gu.LookupApplicableConfig(ctx, cl)
   119  				So(err, ShouldBeNil)
   120  				So(acfg.GetProjects(), ShouldHaveLength, 2)
   121  			})
   122  		})
   123  
   124  		Convey("HasChanged", func() {
   125  			ciInCV := gf.CI(1, gf.Updated(ct.Clock.Now()), gf.MetaRevID("deadbeef"))
   126  			ciInGerrit := proto.Clone(ciInCV).(*gerritpb.ChangeInfo)
   127  			snapshotInCV := &changelist.Snapshot{
   128  				Kind: &changelist.Snapshot_Gerrit{
   129  					Gerrit: &changelist.Gerrit{
   130  						Info: ciInCV,
   131  					},
   132  				},
   133  			}
   134  			snapshotInGerrit := &changelist.Snapshot{
   135  				Kind: &changelist.Snapshot_Gerrit{
   136  					Gerrit: &changelist.Gerrit{
   137  						Info: ciInGerrit,
   138  					},
   139  				},
   140  			}
   141  			Convey("Returns false for exact same snapshot", func() {
   142  				So(gu.HasChanged(snapshotInCV, snapshotInGerrit), ShouldBeFalse)
   143  			})
   144  			Convey("Returns true for greater update time at backend", func() {
   145  				ct.Clock.Add(1 * time.Minute)
   146  				gf.Updated(ct.Clock.Now())(ciInGerrit)
   147  				So(gu.HasChanged(snapshotInCV, snapshotInGerrit), ShouldBeTrue)
   148  			})
   149  			Convey("Returns false for greater update time in CV", func() {
   150  				ct.Clock.Add(1 * time.Minute)
   151  				gf.Updated(ct.Clock.Now())(ciInCV)
   152  				So(gu.HasChanged(snapshotInCV, snapshotInGerrit), ShouldBeFalse)
   153  			})
   154  			Convey("Returns true if meta rev id is different", func() {
   155  				gf.MetaRevID("cafecafe")(ciInGerrit)
   156  				So(gu.HasChanged(snapshotInCV, snapshotInGerrit), ShouldBeTrue)
   157  			})
   158  		})
   159  	})
   160  }
   161  
   162  func TestUpdaterBackendFetch(t *testing.T) {
   163  	t.Parallel()
   164  
   165  	Convey("updaterBackend.Fetch() works", t, func() {
   166  		ct := cvtesting.Test{}
   167  		ctx, cancel := ct.SetUp(t)
   168  		defer cancel()
   169  
   170  		const (
   171  			lProject      = "proj-1"
   172  			gHost         = "chromium-review.example.com"
   173  			gHostInternal = "internal-review.example.com"
   174  			gRepo         = "depot_tools"
   175  			gChange       = 123
   176  		)
   177  		task := &changelist.UpdateCLTask{
   178  			LuciProject: lProject,
   179  			Hint:        &changelist.UpdateCLTask_Hint{ExternalUpdateTime: timestamppb.New(time.Time{})},
   180  			Requester:   changelist.UpdateCLTask_RUN_POKE,
   181  		}
   182  
   183  		prjcfgtest.Create(ctx, lProject, singleRepoConfig(gHost, gRepo))
   184  		gobmaptest.Update(ctx, lProject)
   185  		externalID := changelist.MustGobID(gHost, gChange)
   186  
   187  		// NOTE: this test doesn't actually care about pmMock/rmMock, but they are
   188  		// provided because they are used by the changelist.Updater to process deps
   189  		// on our (backend's) behalf.
   190  		gu := &updaterBackend{
   191  			clUpdater: changelist.NewUpdater(ct.TQDispatcher, changelist.NewMutator(ct.TQDispatcher, &pmMock{}, &rmMock{}, &tjMock{})),
   192  			gFactory:  ct.GFactory(),
   193  		}
   194  		gu.clUpdater.RegisterBackend(gu)
   195  
   196  		assertUpdateCLScheduledFor := func(expectedChanges ...int) {
   197  			var actual []int
   198  			for _, p := range ct.TQ.Tasks().Payloads() {
   199  				if t, ok := p.(*changelist.UpdateCLTask); ok {
   200  					_, changeNumber, err := changelist.ExternalID(t.GetExternalId()).ParseGobID()
   201  					So(err, ShouldBeNil)
   202  					actual = append(actual, int(changeNumber))
   203  				}
   204  			}
   205  			sort.Ints(actual)
   206  			sort.Ints(expectedChanges)
   207  			So(actual, ShouldResemble, expectedChanges)
   208  		}
   209  
   210  		// Most of the code doesn't care if CL exists, so for simplicity we test it
   211  		// with non yet existing CL input.
   212  		newCL := changelist.CL{ExternalID: externalID}
   213  
   214  		Convey("happy path: fetches CL which has no deps and produces correct Snapshot", func() {
   215  			expUpdateTime := ct.Clock.Now().Add(-time.Minute)
   216  			ci := gf.CI(
   217  				gChange,
   218  				gf.Project(gRepo),
   219  				gf.Ref("refs/heads/main"),
   220  				gf.PS(2),
   221  				gf.Updated(expUpdateTime),
   222  				gf.Desc("Title\n\nMeta: data\nNo-Try: true\nChange-Id: Ideadbeef"),
   223  				gf.Files("z.cpp", "dir/a.py"),
   224  			)
   225  			ct.GFake.AddFrom(gf.WithCIs(gHost, gf.ACLPublic(), ci))
   226  
   227  			Convey("works if parent commits are empty", func() {
   228  				ct.GFake.MutateChange(gHost, gChange, func(c *gf.Change) {
   229  					gf.ParentCommits(nil)(c.Info)
   230  				})
   231  			})
   232  
   233  			res, err := gu.Fetch(ctx, changelist.NewFetchInput(&newCL, task))
   234  			So(err, ShouldBeNil)
   235  
   236  			So(res.AddDependentMeta, ShouldBeNil)
   237  			So(res.ApplicableConfig, ShouldResembleProto, &changelist.ApplicableConfig{
   238  				Projects: []*changelist.ApplicableConfig_Project{
   239  					{
   240  						Name: lProject,
   241  						ConfigGroupIds: []string{
   242  							string(prjcfgtest.MustExist(ctx, lProject).ConfigGroupIDs[0]),
   243  						},
   244  					},
   245  				},
   246  			})
   247  			// Verify Snapshot in two separate steps for easier debugging: high level
   248  			// fields and the noisy Gerrit Change Info.
   249  			// But first, backup Snapshot for later use.
   250  			So(res.Snapshot, ShouldNotBeNil)
   251  			backedupSnapshot := proto.Clone(res.Snapshot).(*changelist.Snapshot)
   252  			// save Gerrit CI portion for later check and check high-level fields first.
   253  			ci = res.Snapshot.GetGerrit().GetInfo()
   254  			res.Snapshot.GetGerrit().Info = nil
   255  			So(res.Snapshot, ShouldResembleProto, &changelist.Snapshot{
   256  				Deps:                  nil,
   257  				ExternalUpdateTime:    timestamppb.New(expUpdateTime),
   258  				LuciProject:           lProject,
   259  				MinEquivalentPatchset: 2,
   260  				Patchset:              2,
   261  				Metadata: []*changelist.StringPair{
   262  					{Key: "Change-Id", Value: "Ideadbeef"},
   263  					{Key: "Meta", Value: "data"},
   264  					{Key: "No-Try", Value: "true"},
   265  				},
   266  				Kind: &changelist.Snapshot_Gerrit{Gerrit: &changelist.Gerrit{
   267  					Host:  gHost,
   268  					Files: []string{"dir/a.py", "z.cpp"},
   269  				}},
   270  			})
   271  			expectedCI := ct.GFake.GetChange(gHost, gChange).Info
   272  			changelist.RemoveUnusedGerritInfo(expectedCI)
   273  			So(ci, ShouldResembleProto, expectedCI)
   274  
   275  			Convey("may re-uses files & related changes of the existing Snapshot", func() {
   276  				// Simulate previously saved CL.
   277  				existingCL := changelist.CL{
   278  					ID:               123123213,
   279  					ExternalID:       newCL.ExternalID,
   280  					ApplicableConfig: res.ApplicableConfig,
   281  					Snapshot:         backedupSnapshot,
   282  				}
   283  				ct.Clock.Add(time.Minute)
   284  
   285  				Convey("possible", func() {
   286  					// Simulate a new message posted to the Gerrit CL.
   287  					ct.GFake.MutateChange(gHost, gChange, func(c *gf.Change) {
   288  						c.Info.Messages = append(c.Info.Messages, &gerritpb.ChangeMessageInfo{
   289  							Message: "this message is ignored by CV",
   290  						})
   291  						c.Info.Updated = timestamppb.New(ct.Clock.Now())
   292  					})
   293  
   294  					res2, err := gu.Fetch(ctx, changelist.NewFetchInput(&existingCL, task))
   295  					So(err, ShouldBeNil)
   296  					// Only the ChangeInfo & ExternalUpdateTime must change.
   297  					expectedCI := ct.GFake.GetChange(gHost, gChange).Info
   298  					changelist.RemoveUnusedGerritInfo(expectedCI)
   299  					So(res2.Snapshot.GetGerrit().GetInfo(), ShouldResembleProto, expectedCI)
   300  					// NOTE: the prior result Snapshot already has nil ChangeInfo due to
   301  					// the assertions done above. Now modify it to match what we expect to
   302  					// get in res2.
   303  					res.Snapshot.ExternalUpdateTime = timestamppb.New(ct.Clock.Now())
   304  					res2.Snapshot.GetGerrit().Info = nil
   305  					So(res2.Snapshot, ShouldResembleProto, res.Snapshot)
   306  				})
   307  
   308  				Convey("not possible", func() {
   309  					// Simulate a new revision with new file set.
   310  					ct.GFake.MutateChange(gHost, gChange, func(c *gf.Change) {
   311  						gf.PS(4)(c.Info)
   312  						gf.Files("new.file")(c.Info)
   313  						gf.Desc("See footer.\n\nCq-Depend: 444")(c.Info)
   314  						gf.Updated(ct.Clock.Now())(c.Info)
   315  					})
   316  
   317  					res2, err := gu.Fetch(ctx, changelist.NewFetchInput(&existingCL, task))
   318  					So(err, ShouldBeNil)
   319  					So(res2.Snapshot.GetGerrit().GetFiles(), ShouldResemble, []string{"new.file"})
   320  					So(res2.Snapshot.GetGerrit().GetSoftDeps(), ShouldResemble, []*changelist.GerritSoftDep{{Change: 444, Host: gHost}})
   321  				})
   322  			})
   323  		})
   324  
   325  		Convey("happy path: fetches CL with deps", func() {
   326  			// This test focuses on deps only. The rest is tested by the no deps case.
   327  
   328  			// Simulate a CL with 2 deps via Cq-Depend -- 444 on internal host and 55,
   329  			// and with 2 parents -- 54, 55 (yes, 55 can be both).
   330  			ci := gf.CI(
   331  				gChange, gf.Project(gRepo), gf.Ref("refs/heads/main"), gf.PS(2),
   332  				gf.Desc("Title\n\nCq-Depend: 55,internal:444"),
   333  				gf.Files("a.cpp"),
   334  			)
   335  			ct.GFake.AddFrom(gf.WithCIs(gHost, gf.ACLPublic(),
   336  				ci, // this CL
   337  				gf.CI(55, gf.Project(gRepo), gf.Ref("refs/heads/main"), gf.PS(1)),
   338  				gf.CI(54, gf.Project(gRepo), gf.Ref("refs/heads/main"), gf.PS(3)),
   339  			))
   340  			// Make this CL depend on 55 ps#1, which in turn depends on 54 ps#3.
   341  			ct.GFake.SetDependsOn(gHost, ci, "55_1")
   342  			ct.GFake.SetDependsOn(gHost, "55_1", "54_3")
   343  
   344  			res, err := gu.Fetch(ctx, changelist.NewFetchInput(&newCL, task))
   345  			So(err, ShouldBeNil)
   346  
   347  			So(res.Snapshot.GetGerrit().GetGitDeps(), ShouldResembleProto, []*changelist.GerritGitDep{
   348  				{Change: 55, Immediate: true},
   349  				{Change: 54, Immediate: false},
   350  			})
   351  			So(res.Snapshot.GetGerrit().GetSoftDeps(), ShouldResembleProto, []*changelist.GerritSoftDep{
   352  				{Change: 55, Host: gHost},
   353  				{Change: 444, Host: gHostInternal},
   354  			})
   355  			// The Snapshot.Deps must use internal CV CL IDs.
   356  			cl54 := changelist.MustGobID(gHost, 54).MustCreateIfNotExists(ctx)
   357  			cl55 := changelist.MustGobID(gHost, 55).MustCreateIfNotExists(ctx)
   358  			cl444 := changelist.MustGobID(gHostInternal, 444).MustCreateIfNotExists(ctx)
   359  			expected := []*changelist.Dep{
   360  				{Clid: int64(cl54.ID), Kind: changelist.DepKind_HARD},
   361  				{Clid: int64(cl55.ID), Kind: changelist.DepKind_HARD},
   362  				{Clid: int64(cl444.ID), Kind: changelist.DepKind_SOFT},
   363  			}
   364  			sort.Slice(expected, func(i, j int) bool { return expected[i].GetClid() < expected[j].GetClid() })
   365  			So(res.Snapshot.GetDeps(), ShouldResembleProto, expected)
   366  		})
   367  
   368  		Convey("happy path: fetches CL in ignorable state", func() {
   369  			for _, s := range []gerritpb.ChangeStatus{gerritpb.ChangeStatus_ABANDONED, gerritpb.ChangeStatus_MERGED} {
   370  				s := s
   371  				Convey(s.String(), func() {
   372  					ct.GFake.AddFrom(gf.WithCIs(gHost, gf.ACLPublic(),
   373  						gf.CI(gChange, gf.Project(gRepo), gf.Ref("refs/heads/main"), gf.Status(s),
   374  							gf.Desc("All deps and files are ignored for such CL.\n\nCq-Depend: 44"),
   375  						)))
   376  					res, err := gu.Fetch(ctx, changelist.NewFetchInput(&newCL, task))
   377  					So(err, ShouldBeNil)
   378  					So(res.Snapshot.GetGerrit().GetInfo().GetStatus(), ShouldResemble, s)
   379  					So(res.Snapshot.GetGerrit().GetFiles(), ShouldBeNil)
   380  					So(res.Snapshot.GetDeps(), ShouldBeNil)
   381  
   382  				})
   383  			}
   384  			Convey("regression: NEW -> ABANDON -> NEW transitions don't lose file list", func() {
   385  				ct.GFake.AddFrom(gf.WithCIs(gHost, gf.ACLPublic(), gf.CI(
   386  					gChange, gf.Project(gRepo), gf.Ref("refs/heads/main"), gf.Files("a.txt"),
   387  					gf.Status(gerritpb.ChangeStatus_NEW),
   388  				)))
   389  				res, err := gu.Fetch(ctx, changelist.NewFetchInput(&newCL, task))
   390  				So(err, ShouldBeNil)
   391  				So(res.Snapshot.GetGerrit().GetFiles(), ShouldResemble, []string{"a.txt"})
   392  
   393  				savedCL := changelist.CL{
   394  					ID:               123123213,
   395  					ExternalID:       newCL.ExternalID,
   396  					Snapshot:         res.Snapshot,
   397  					ApplicableConfig: res.ApplicableConfig,
   398  				}
   399  				ct.Clock.Add(time.Minute)
   400  				ct.GFake.MutateChange(gHost, gChange, func(c *gf.Change) {
   401  					gf.Status(gerritpb.ChangeStatus_ABANDONED)(c.Info)
   402  					gf.Updated(ct.Clock.Now())(c.Info)
   403  				})
   404  				res, err = gu.Fetch(ctx, changelist.NewFetchInput(&newCL, task))
   405  				So(err, ShouldBeNil)
   406  				// CV doesn't care about files of ABANDONED CLs.
   407  				So(res.Snapshot.GetGerrit().GetFiles(), ShouldBeEmpty)
   408  
   409  				// Back to NEW => files must be restored.
   410  				savedCL.Snapshot = res.Snapshot
   411  				ct.Clock.Add(time.Minute)
   412  				ct.GFake.MutateChange(gHost, gChange, func(c *gf.Change) {
   413  					gf.Status(gerritpb.ChangeStatus_NEW)(c.Info)
   414  					gf.Updated(ct.Clock.Now())(c.Info)
   415  				})
   416  				res, err = gu.Fetch(ctx, changelist.NewFetchInput(&newCL, task))
   417  				So(err, ShouldBeNil)
   418  				So(res.Snapshot.GetGerrit().GetFiles(), ShouldResemble, []string{"a.txt"})
   419  			})
   420  		})
   421  
   422  		Convey("stale data", func() {
   423  			staleUpdateTime := ct.Clock.Now().Add(-time.Hour)
   424  			ct.GFake.AddFrom(gf.WithCIs(gHost, gf.ACLPublic(), gf.CI(
   425  				gChange, gf.Project(gRepo), gf.Ref("refs/heads/main"),
   426  				gf.Updated(staleUpdateTime),
   427  			)))
   428  
   429  			Convey("CL's updated timestamp is before updatedHint", func() {
   430  				task.Hint.ExternalUpdateTime = timestamppb.New(staleUpdateTime.Add(time.Minute))
   431  				_, err := gu.Fetch(ctx, changelist.NewFetchInput(&newCL, task))
   432  				So(err, ShouldErrLike, gerrit.ErrStaleData)
   433  			})
   434  
   435  			Convey("CL's existing Snapshot is more recent", func() {
   436  				existingCL := changelist.CL{
   437  					ID:         1231231232,
   438  					ExternalID: newCL.ExternalID,
   439  					Snapshot: &changelist.Snapshot{
   440  						ExternalUpdateTime: timestamppb.New(staleUpdateTime.Add(time.Minute)),
   441  					},
   442  				}
   443  
   444  				_, err := gu.Fetch(ctx, changelist.NewFetchInput(&existingCL, task))
   445  				So(err, ShouldErrLike, gerrit.ErrStaleData)
   446  
   447  				Convey("if MetaRevId was set, skip updating Snapshot", func() {
   448  					task.Hint.MetaRevId = "deadbeef"
   449  					res, err := gu.Fetch(ctx, changelist.NewFetchInput(&existingCL, task))
   450  					// The Fetch() should succeed with nil in toUpdate.Snapshot.
   451  					So(err, ShouldBeNil)
   452  					So(res.Snapshot, ShouldBeNil)
   453  				})
   454  			})
   455  		})
   456  
   457  		Convey("Uncertain lack of access", func() {
   458  			// Simulate Gerrit responding with 404.
   459  			gfResponse := status.New(codes.NotFound, "not found or no access")
   460  			gfACLmock := func(_ gf.Operation, luciProject string) *status.Status {
   461  				return gfResponse
   462  			}
   463  			ct.GFake.AddFrom(gf.WithCIs(gHost, gfACLmock, gf.CI(gChange, gf.Ref("refs/heads/main"), gf.Project(gRepo))))
   464  
   465  			// First time 404 isn't certain.
   466  			res, err := gu.Fetch(ctx, changelist.NewFetchInput(&newCL, task))
   467  			So(err, ShouldBeNil)
   468  			So(res.Snapshot, ShouldBeNil)
   469  			So(res.ApplicableConfig, ShouldBeNil)
   470  			So(res.AddDependentMeta.GetByProject()[lProject], ShouldResembleProto, &changelist.Access_Project{
   471  				NoAccess:     true,
   472  				NoAccessTime: timestamppb.New(ct.Clock.Now().Add(noAccessGraceDuration)),
   473  				UpdateTime:   timestamppb.New(ct.Clock.Now()),
   474  			})
   475  			assertUpdateCLScheduledFor(gChange)
   476  
   477  			// Simulate intermediate save to Datastore.
   478  			cl := changelist.CL{ID: 1123123, ExternalID: externalID, Access: res.AddDependentMeta}
   479  			// For now, access denied isn't certain.
   480  			So(cl.AccessKind(ctx, lProject), ShouldEqual, changelist.AccessDeniedProbably)
   481  
   482  			Convey("403 is treated same as 404, ie potentially stale", func() {
   483  				ct.Clock.Add(noAccessGraceRetryDelay)
   484  				// Because 403 can be caused due to stale ACLs on a stale mirror.
   485  				gfResponse = status.New(codes.PermissionDenied, "403")
   486  				res2, err := gu.Fetch(ctx, changelist.NewFetchInput(&cl, task))
   487  				So(err, ShouldBeNil)
   488  				// res2 must be the same, except for the .UpdateTime.
   489  				So(res2.AddDependentMeta.GetByProject()[lProject].UpdateTime, ShouldResembleProto, timestamppb.New(ct.Clock.Now()))
   490  				res.AddDependentMeta.GetByProject()[lProject].UpdateTime = nil
   491  				res2.AddDependentMeta.GetByProject()[lProject].UpdateTime = nil
   492  				So(res2, cvtesting.SafeShouldResemble, res)
   493  				// And thus, lack of access is still uncertain.
   494  				So(cl.AccessKind(ctx, lProject), ShouldEqual, changelist.AccessDeniedProbably)
   495  			})
   496  
   497  			Convey("still no access after grace duration", func() {
   498  				ct.Clock.Add(noAccessGraceDuration + time.Second)
   499  				res2, err := gu.Fetch(ctx, changelist.NewFetchInput(&cl, task))
   500  				So(err, ShouldBeNil)
   501  				// res2 must be the same, except for the .UpdateTime.
   502  				So(res2.AddDependentMeta.GetByProject()[lProject].UpdateTime, ShouldResembleProto, timestamppb.New(ct.Clock.Now()))
   503  				res.AddDependentMeta.GetByProject()[lProject].UpdateTime = nil
   504  				res2.AddDependentMeta.GetByProject()[lProject].UpdateTime = nil
   505  				So(res2, cvtesting.SafeShouldResemble, res)
   506  				// Nothing new should be scheduled (on top of the existing task).
   507  				assertUpdateCLScheduledFor(gChange)
   508  				// Finally, certainty is reached.
   509  				So(cl.AccessKind(ctx, lProject), ShouldEqual, changelist.AccessDenied)
   510  			})
   511  
   512  			Convey("access not found is forgotten on a successful fetch", func() {
   513  				// At a later time, the CL "magically" appears, e.g. if ACLs are fixed.
   514  				gfResponse = status.New(codes.OK, "OK")
   515  				ct.Clock.Add(time.Minute)
   516  				res2, err := gu.Fetch(ctx, changelist.NewFetchInput(&newCL, task))
   517  				So(err, ShouldBeNil)
   518  				// The previous record of lack of Access must be expunged.
   519  				So(res2.AddDependentMeta, ShouldBeNil)
   520  				So(res2.DelAccess, ShouldResemble, []string{lProject})
   521  				// Exact value of Snapshot and ApplicableConfig is tested in happy path,
   522  				// here we only care that both are set.
   523  				So(res2.Snapshot, ShouldNotBeNil)
   524  				So(res2.ApplicableConfig, ShouldNotBeNil)
   525  
   526  				Convey("can lose access again, which should not erase the snapshot saved before", func() {
   527  					// Simulate saved CL.
   528  					cl.Snapshot = res.Snapshot
   529  					cl.ApplicableConfig = res.ApplicableConfig
   530  
   531  					gfResponse = status.New(codes.NotFound, "not found, again")
   532  					ct.Clock.Add(time.Minute)
   533  					res3, err := gu.Fetch(ctx, changelist.NewFetchInput(&newCL, task))
   534  					So(err, ShouldBeNil)
   535  
   536  					So(res3.Snapshot, ShouldBeNil)         // nothing to update
   537  					So(res3.ApplicableConfig, ShouldBeNil) // nothing to update
   538  					So(res3.AddDependentMeta.GetByProject()[lProject], ShouldResembleProto, &changelist.Access_Project{
   539  						NoAccess:     true,
   540  						NoAccessTime: timestamppb.New(ct.Clock.Now().Add(noAccessGraceDuration)),
   541  						UpdateTime:   timestamppb.New(ct.Clock.Now()),
   542  					})
   543  				})
   544  			})
   545  		})
   546  
   547  		Convey("Not watched CL", func() {
   548  			Convey("Gerrit host is not watched", func() {
   549  				bogusCL := changelist.CL{ExternalID: changelist.MustGobID("404.example.com", 404)}
   550  				res, err := gu.Fetch(ctx, changelist.NewFetchInput(&bogusCL, task))
   551  				So(err, ShouldBeNil)
   552  				So(res.Snapshot, ShouldBeNil)
   553  				So(res.ApplicableConfig, ShouldBeNil)
   554  				So(res.AddDependentMeta.GetByProject()[lProject], ShouldResembleProto, &changelist.Access_Project{
   555  					NoAccess:     true,
   556  					NoAccessTime: timestamppb.New(ct.Clock.Now()), // immediate no access.
   557  					UpdateTime:   timestamppb.New(ct.Clock.Now()),
   558  				})
   559  				bogusCL.Access = res.AddDependentMeta
   560  				So(bogusCL.AccessKind(ctx, lProject), ShouldEqual, changelist.AccessDenied)
   561  			})
   562  			Convey("Only the ref isn't watched", func() {
   563  				ct.GFake.AddFrom(gf.WithCIs(gHost, gf.ACLPublic(), gf.CI(gChange, gf.Ref("refs/un/watched"), gf.Project(gRepo))))
   564  				res, err := gu.Fetch(ctx, changelist.NewFetchInput(&newCL, task))
   565  				So(err, ShouldBeNil)
   566  				// Although technically, LUCI project currently has access,
   567  				// we mark it as lacking access from CV's PoV.
   568  				// TODO(tandrii): this is weird, and ought to be refactored together
   569  				// with weird "AddDependentMeta" field.
   570  				So(res.AddDependentMeta.GetByProject()[lProject], ShouldNotBeNil)
   571  				So(res.ApplicableConfig, ShouldResembleProto, &changelist.ApplicableConfig{
   572  					// No watching projects.
   573  				})
   574  			})
   575  		})
   576  
   577  		Convey("Gerrit errors are propagated", func() {
   578  			Convey("GetChange fails", func() {
   579  				fakeResponseStatus := func(_ gf.Operation, _ string) *status.Status {
   580  					return status.New(codes.ResourceExhausted, "doesn't matter")
   581  				}
   582  				ct.GFake.AddFrom(gf.WithCIs(gHost, fakeResponseStatus, gf.CI(gChange, gf.Ref("refs/heads/main"), gf.Project(gRepo))))
   583  				_, err := gu.Fetch(ctx, changelist.NewFetchInput(&newCL, task))
   584  				So(err, ShouldErrLike, gerrit.ErrOutOfQuota)
   585  			})
   586  			Convey("ListFiles or GetRelatedChanges fails", func() {
   587  				// ListFiles and GetRelatedChanges are done in parallel but after the
   588  				// GetChange call.
   589  				cnt := int32(0)
   590  				fakeResponseStatus := func(_ gf.Operation, _ string) *status.Status {
   591  					if atomic.AddInt32(&cnt, 1) == 2 {
   592  						return status.New(codes.Unavailable, "2nd call failed")
   593  					}
   594  					return status.New(codes.OK, "ok")
   595  				}
   596  				ct.GFake.AddFrom(gf.WithCIs(gHost, fakeResponseStatus, gf.CI(gChange, gf.Ref("refs/heads/main"), gf.Project(gRepo))))
   597  				_, err := gu.Fetch(ctx, changelist.NewFetchInput(&newCL, task))
   598  				So(err, ShouldErrLike, "2nd call failed")
   599  			})
   600  		})
   601  
   602  		Convey("MetaRevID", func() {
   603  			expUpdateTime := ct.Clock.Now().Add(-time.Minute)
   604  			ci := gf.CI(
   605  				gChange,
   606  				gf.Project(gRepo),
   607  				gf.Ref("refs/heads/main"),
   608  				gf.PS(2),
   609  				gf.Updated(expUpdateTime),
   610  				gf.MetaRevID("deadbeef"),
   611  			)
   612  			ct.GFake.AddFrom(gf.WithCIs(gHost, gf.ACLPublic(), ci))
   613  			task.Hint.MetaRevId = "deadbeef"
   614  		})
   615  	})
   616  }
   617  
   618  func singleRepoConfig(gHost string, gRepos ...string) *cfgpb.Config {
   619  	projects := make([]*cfgpb.ConfigGroup_Gerrit_Project, len(gRepos))
   620  	for i, gRepo := range gRepos {
   621  		projects[i] = &cfgpb.ConfigGroup_Gerrit_Project{
   622  			Name:      gRepo,
   623  			RefRegexp: []string{"refs/heads/main"},
   624  		}
   625  	}
   626  	return &cfgpb.Config{
   627  		ConfigGroups: []*cfgpb.ConfigGroup{
   628  			{
   629  				Name: "main",
   630  				Gerrit: []*cfgpb.ConfigGroup_Gerrit{
   631  					{
   632  						Url:      "https://" + gHost + "/",
   633  						Projects: projects,
   634  					},
   635  				},
   636  			},
   637  		},
   638  	}
   639  }
   640  
   641  type pmMock struct {
   642  }
   643  
   644  func (*pmMock) NotifyCLsUpdated(ctx context.Context, project string, cls *changelist.CLUpdatedEvents) error {
   645  	return nil
   646  }
   647  
   648  type rmMock struct {
   649  }
   650  
   651  func (*rmMock) NotifyCLsUpdated(ctx context.Context, rid common.RunID, cls *changelist.CLUpdatedEvents) error {
   652  	return nil
   653  }
   654  
   655  type tjMock struct{}
   656  
   657  func (t *tjMock) ScheduleCancelStale(ctx context.Context, clid common.CLID, prevMinEquivalentPatchset, currentMinEquivalentPatchset int32, eta time.Time) error {
   658  	return nil
   659  }