go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/rpc/test_variant_branches_test.go (about)

     1  // Copyright 2023 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 rpc
    16  
    17  import (
    18  	"context"
    19  	"encoding/hex"
    20  	"fmt"
    21  	"testing"
    22  	"time"
    23  
    24  	"cloud.google.com/go/bigquery"
    25  	"google.golang.org/grpc/codes"
    26  	"google.golang.org/protobuf/types/known/anypb"
    27  	"google.golang.org/protobuf/types/known/durationpb"
    28  	"google.golang.org/protobuf/types/known/timestamppb"
    29  
    30  	"go.chromium.org/luci/common/proto/git"
    31  	"go.chromium.org/luci/resultdb/rdbperms"
    32  	"go.chromium.org/luci/server/auth"
    33  	"go.chromium.org/luci/server/auth/authtest"
    34  
    35  	"go.chromium.org/luci/analysis/internal/changepoints/inputbuffer"
    36  	cpb "go.chromium.org/luci/analysis/internal/changepoints/proto"
    37  	"go.chromium.org/luci/analysis/internal/changepoints/testvariantbranch"
    38  	"go.chromium.org/luci/analysis/internal/gitiles"
    39  	"go.chromium.org/luci/analysis/internal/pagination"
    40  	"go.chromium.org/luci/analysis/internal/testutil"
    41  	"go.chromium.org/luci/analysis/internal/testverdicts"
    42  	"go.chromium.org/luci/analysis/pbutil"
    43  	pb "go.chromium.org/luci/analysis/proto/v1"
    44  
    45  	. "github.com/smartystreets/goconvey/convey"
    46  	. "go.chromium.org/luci/common/testing/assertions"
    47  )
    48  
    49  func TestTestVariantAnalysesServer(t *testing.T) {
    50  	Convey("TestVariantAnalysesServer", t, func() {
    51  		ctx := testutil.IntegrationTestContext(t)
    52  
    53  		tvc := testverdicts.FakeReadClient{}
    54  		server := NewTestVariantBranchesServer(&tvc)
    55  		Convey("GetRaw", func() {
    56  			Convey("permission denied", func() {
    57  				ctx = auth.WithState(ctx, &authtest.FakeState{
    58  					Identity: "anonymous:anonymous",
    59  				})
    60  				req := &pb.GetRawTestVariantBranchRequest{}
    61  				res, err := server.GetRaw(ctx, req)
    62  				So(err, ShouldNotBeNil)
    63  				So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
    64  				So(res, ShouldBeNil)
    65  			})
    66  
    67  			Convey("invalid request", func() {
    68  				ctx = adminContext(ctx)
    69  				req := &pb.GetRawTestVariantBranchRequest{
    70  					Name: "Project/abc/xyz",
    71  				}
    72  				res, err := server.GetRaw(ctx, req)
    73  				So(err, ShouldNotBeNil)
    74  				So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
    75  				So(res, ShouldBeNil)
    76  			})
    77  
    78  			Convey("not found", func() {
    79  				ctx = adminContext(ctx)
    80  				req := &pb.GetRawTestVariantBranchRequest{
    81  					Name: "projects/project/tests/test/variants/abababababababab/refs/abababababababab",
    82  				}
    83  				res, err := server.GetRaw(ctx, req)
    84  				So(err, ShouldNotBeNil)
    85  				So(err, ShouldHaveGRPCStatus, codes.NotFound)
    86  				So(res, ShouldBeNil)
    87  			})
    88  
    89  			Convey("invalid ref_hash", func() {
    90  				ctx = adminContext(ctx)
    91  				req := &pb.GetRawTestVariantBranchRequest{
    92  					Name: "projects/project/tests/this//is/a/test/variants/abababababababab/refs/abababababababgh",
    93  				}
    94  				res, err := server.GetRaw(ctx, req)
    95  				So(err, ShouldNotBeNil)
    96  				So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
    97  				So(res, ShouldBeNil)
    98  			})
    99  
   100  			Convey("invalid test id", func() {
   101  				ctx = adminContext(ctx)
   102  				Convey("bad structure", func() {
   103  					ctx = adminContext(ctx)
   104  					req := &pb.GetRawTestVariantBranchRequest{
   105  						Name: "projects/project/tests/a/variants/0123456789abcdef/refs/7265665f68617368/bad/subpath",
   106  					}
   107  					res, err := server.GetRaw(ctx, req)
   108  					So(err, ShouldNotBeNil)
   109  					So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   110  					So(err, ShouldErrLike, "name must be of format projects/{PROJECT}/tests/{URL_ESCAPED_TEST_ID}/variants/{VARIANT_HASH}/refs/{REF_HASH}")
   111  					So(res, ShouldBeNil)
   112  				})
   113  				Convey("bad URL escaping", func() {
   114  					req := &pb.GetRawTestVariantBranchRequest{
   115  						Name: "projects/project/tests/abcdef%test/variants/0123456789abcdef/refs/7265665f68617368",
   116  					}
   117  					res, err := server.GetRaw(ctx, req)
   118  					So(err, ShouldNotBeNil)
   119  					So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   120  					So(err, ShouldErrLike, "malformed test id: invalid URL escape \"%te\"")
   121  					So(res, ShouldBeNil)
   122  				})
   123  				Convey("bad value", func() {
   124  					req := &pb.GetRawTestVariantBranchRequest{
   125  						Name: "projects/project/tests/\u0001atest/variants/0123456789abcdef/refs/7265665f68617368",
   126  					}
   127  					res, err := server.GetRaw(ctx, req)
   128  					So(err, ShouldNotBeNil)
   129  					So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   130  					So(err, ShouldErrLike, `test id "\x01atest": non-printable rune`)
   131  					So(res, ShouldBeNil)
   132  				})
   133  			})
   134  			Convey("ok", func() {
   135  				ctx = adminContext(ctx)
   136  				// Insert test variant branch to Spanner.
   137  				tvb := &testvariantbranch.Entry{
   138  					IsNew:       true,
   139  					Project:     "project",
   140  					TestID:      "this//is/a/test",
   141  					VariantHash: "0123456789abcdef",
   142  					RefHash:     []byte("ref_hash"),
   143  					SourceRef: &pb.SourceRef{
   144  						System: &pb.SourceRef_Gitiles{
   145  							Gitiles: &pb.GitilesRef{
   146  								Host:    "host",
   147  								Project: "proj",
   148  								Ref:     "ref",
   149  							},
   150  						},
   151  					},
   152  					Variant: &pb.Variant{
   153  						Def: map[string]string{
   154  							"k": "v",
   155  						},
   156  					},
   157  					InputBuffer: &inputbuffer.Buffer{
   158  						HotBuffer: inputbuffer.History{
   159  							Verdicts: []inputbuffer.PositionVerdict{
   160  								{
   161  									CommitPosition:       20,
   162  									IsSimpleExpectedPass: true,
   163  									Hour:                 time.Unix(3600, 0),
   164  								},
   165  							},
   166  						},
   167  						ColdBuffer: inputbuffer.History{
   168  							Verdicts: []inputbuffer.PositionVerdict{
   169  								{
   170  									CommitPosition: 30,
   171  									Hour:           time.Unix(7200, 0),
   172  									Details: inputbuffer.VerdictDetails{
   173  										IsExonerated: true,
   174  										Runs: []inputbuffer.Run{
   175  											{
   176  												Expected: inputbuffer.ResultCounts{
   177  													PassCount: 1,
   178  													FailCount: 2,
   179  												},
   180  												Unexpected: inputbuffer.ResultCounts{
   181  													CrashCount: 3,
   182  													AbortCount: 4,
   183  												},
   184  												IsDuplicate: true,
   185  											},
   186  											{
   187  												Expected: inputbuffer.ResultCounts{
   188  													CrashCount: 5,
   189  													AbortCount: 6,
   190  												},
   191  												Unexpected: inputbuffer.ResultCounts{
   192  													PassCount:  7,
   193  													AbortCount: 8,
   194  												},
   195  											},
   196  										},
   197  									},
   198  								},
   199  							},
   200  						},
   201  					},
   202  					FinalizingSegment: &cpb.Segment{
   203  						State:                        cpb.SegmentState_FINALIZING,
   204  						HasStartChangepoint:          true,
   205  						StartPosition:                100,
   206  						StartHour:                    timestamppb.New(time.Unix(3600, 0)),
   207  						StartPositionLowerBound_99Th: 95,
   208  						StartPositionUpperBound_99Th: 105,
   209  						FinalizedCounts: &cpb.Counts{
   210  							UnexpectedResults: 1,
   211  						},
   212  					},
   213  					FinalizedSegments: &cpb.Segments{
   214  						Segments: []*cpb.Segment{
   215  							{
   216  								State:                        cpb.SegmentState_FINALIZED,
   217  								StartPosition:                50,
   218  								StartHour:                    timestamppb.New(time.Unix(3600, 0)),
   219  								StartPositionLowerBound_99Th: 45,
   220  								StartPositionUpperBound_99Th: 55,
   221  								FinalizedCounts: &cpb.Counts{
   222  									UnexpectedResults: 2,
   223  								},
   224  							},
   225  						},
   226  					},
   227  					Statistics: &cpb.Statistics{
   228  						HourlyBuckets: []*cpb.Statistics_HourBucket{
   229  							{
   230  								Hour:               123456,
   231  								UnexpectedVerdicts: 1,
   232  								FlakyVerdicts:      3,
   233  								TotalVerdicts:      12,
   234  							},
   235  							{
   236  								Hour:               123500,
   237  								UnexpectedVerdicts: 3,
   238  								FlakyVerdicts:      7,
   239  								TotalVerdicts:      93,
   240  							},
   241  						},
   242  					},
   243  				}
   244  				var hs inputbuffer.HistorySerializer
   245  				mutation, err := tvb.ToMutation(&hs)
   246  				So(err, ShouldBeNil)
   247  				testutil.MustApply(ctx, mutation)
   248  
   249  				hexStr := "7265665f68617368" // hex string of "ref_hash".
   250  				req := &pb.GetRawTestVariantBranchRequest{
   251  					Name: "projects/project/tests/this%2F%2Fis%2Fa%2Ftest/variants/0123456789abcdef/refs/7265665f68617368",
   252  				}
   253  				res, err := server.GetRaw(ctx, req)
   254  				So(err, ShouldBeNil)
   255  
   256  				expectedFinalizingSegment, err := anypb.New(tvb.FinalizingSegment)
   257  				So(err, ShouldBeNil)
   258  
   259  				expectedFinalizedSegments, err := anypb.New(tvb.FinalizedSegments)
   260  				So(err, ShouldBeNil)
   261  
   262  				expectedStatistics, err := anypb.New(tvb.Statistics)
   263  				So(err, ShouldBeNil)
   264  
   265  				So(res, ShouldResembleProto, &pb.TestVariantBranchRaw{
   266  					Name:              "projects/project/tests/this%2F%2Fis%2Fa%2Ftest/variants/0123456789abcdef/refs/7265665f68617368",
   267  					Project:           "project",
   268  					TestId:            "this//is/a/test",
   269  					VariantHash:       "0123456789abcdef",
   270  					RefHash:           hexStr,
   271  					Variant:           tvb.Variant,
   272  					Ref:               tvb.SourceRef,
   273  					FinalizingSegment: expectedFinalizingSegment,
   274  					FinalizedSegments: expectedFinalizedSegments,
   275  					Statistics:        expectedStatistics,
   276  					HotBuffer: &pb.InputBuffer{
   277  						Length: 1,
   278  						Verdicts: []*pb.PositionVerdict{
   279  							{
   280  								CommitPosition: 20,
   281  								Hour:           timestamppb.New(time.Unix(3600, 0)),
   282  								Runs: []*pb.PositionVerdict_Run{
   283  									{
   284  										ExpectedPassCount: 1,
   285  									},
   286  								},
   287  							},
   288  						},
   289  					},
   290  					ColdBuffer: &pb.InputBuffer{
   291  						Length: 1,
   292  						Verdicts: []*pb.PositionVerdict{
   293  							{
   294  								CommitPosition: 30,
   295  								Hour:           timestamppb.New(time.Unix(7200, 0)),
   296  								IsExonerated:   true,
   297  								Runs: []*pb.PositionVerdict_Run{
   298  									{
   299  										ExpectedPassCount:    1,
   300  										ExpectedFailCount:    2,
   301  										UnexpectedCrashCount: 3,
   302  										UnexpectedAbortCount: 4,
   303  										IsDuplicate:          true,
   304  									},
   305  									{
   306  										ExpectedCrashCount:   5,
   307  										ExpectedAbortCount:   6,
   308  										UnexpectedPassCount:  7,
   309  										UnexpectedAbortCount: 8,
   310  									},
   311  								},
   312  							},
   313  						},
   314  					},
   315  				})
   316  			})
   317  		})
   318  
   319  		Convey("BatchGet", func() {
   320  			Convey("permission denied", func() {
   321  				ctx = auth.WithState(ctx, &authtest.FakeState{
   322  					Identity:       "user:someone@example.com",
   323  					IdentityGroups: []string{"luci-analysis-access"},
   324  				})
   325  				req := &pb.BatchGetTestVariantBranchRequest{}
   326  
   327  				res, err := server.BatchGet(ctx, req)
   328  				So(err, ShouldNotBeNil)
   329  				So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
   330  				So(res, ShouldBeNil)
   331  			})
   332  
   333  			Convey("invalid request", func() {
   334  				ctx = auth.WithState(ctx, &authtest.FakeState{
   335  					Identity:       "user:someone@example.com",
   336  					IdentityGroups: []string{"googlers", "luci-analysis-access"},
   337  				})
   338  				Convey("invalid name", func() {
   339  					req := &pb.BatchGetTestVariantBranchRequest{
   340  						Names: []string{"projects/abc/xyz"},
   341  					}
   342  
   343  					res, err := server.BatchGet(ctx, req)
   344  					So(err, ShouldNotBeNil)
   345  					So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   346  					So(res, ShouldBeNil)
   347  				})
   348  
   349  				Convey("too many test variant branch requested", func() {
   350  					names := []string{}
   351  					for i := 0; i < 200; i++ {
   352  						names = append(names, "projects/project/tests/this%2F%2Fis%2Fa%2Ftest/variants/0123456789abcdef/refs/7265665f68617368")
   353  					}
   354  					req := &pb.BatchGetTestVariantBranchRequest{
   355  						Names: names,
   356  					}
   357  
   358  					res, err := server.BatchGet(ctx, req)
   359  					So(err, ShouldNotBeNil)
   360  					So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   361  					So(err, ShouldErrLike, "names: no more than 100 may be queried at a time")
   362  					So(res, ShouldBeNil)
   363  				})
   364  			})
   365  
   366  			Convey("e2e", func() {
   367  				ctx = auth.WithState(ctx, &authtest.FakeState{
   368  					Identity:       "user:someone@example.com",
   369  					IdentityGroups: []string{"googlers", "luci-analysis-access"},
   370  				})
   371  				// Insert test variant branch to Spanner.
   372  				tvb := &testvariantbranch.Entry{
   373  					IsNew:       true,
   374  					Project:     "project",
   375  					TestID:      "this//is/a/test",
   376  					VariantHash: "0123456789abcdef",
   377  					RefHash:     []byte("ref_hash"),
   378  					SourceRef: &pb.SourceRef{
   379  						System: &pb.SourceRef_Gitiles{
   380  							Gitiles: &pb.GitilesRef{
   381  								Host:    "host",
   382  								Project: "proj",
   383  								Ref:     "ref",
   384  							},
   385  						},
   386  					},
   387  					Variant: &pb.Variant{
   388  						Def: map[string]string{
   389  							"k": "v",
   390  						},
   391  					},
   392  					InputBuffer: &inputbuffer.Buffer{
   393  						HotBuffer: inputbuffer.History{
   394  							Verdicts: []inputbuffer.PositionVerdict{
   395  								{
   396  									CommitPosition:       200,
   397  									IsSimpleExpectedPass: true,
   398  									Hour:                 time.Unix(3700, 0),
   399  								},
   400  							},
   401  						},
   402  						ColdBuffer: inputbuffer.History{
   403  							Verdicts: []inputbuffer.PositionVerdict{},
   404  						},
   405  					},
   406  					FinalizingSegment: &cpb.Segment{
   407  						State:                        cpb.SegmentState_FINALIZING,
   408  						HasStartChangepoint:          true,
   409  						StartPosition:                100,
   410  						StartHour:                    timestamppb.New(time.Unix(3600, 0)),
   411  						StartPositionLowerBound_99Th: 95,
   412  						StartPositionUpperBound_99Th: 105,
   413  						FinalizedCounts: &cpb.Counts{
   414  							UnexpectedVerdicts: 1,
   415  							TotalVerdicts:      1,
   416  						},
   417  					},
   418  					FinalizedSegments: &cpb.Segments{
   419  						Segments: []*cpb.Segment{
   420  							{
   421  								State:                        cpb.SegmentState_FINALIZED,
   422  								StartHour:                    timestamppb.New(time.Unix(3600, 0)),
   423  								StartPositionLowerBound_99Th: 45,
   424  								StartPositionUpperBound_99Th: 55,
   425  								FinalizedCounts: &cpb.Counts{
   426  									UnexpectedVerdicts: 2,
   427  									TotalVerdicts:      2,
   428  								},
   429  							},
   430  						},
   431  					},
   432  				}
   433  				var hs inputbuffer.HistorySerializer
   434  				mutation, err := tvb.ToMutation(&hs)
   435  				So(err, ShouldBeNil)
   436  				testutil.MustApply(ctx, mutation)
   437  				req := &pb.BatchGetTestVariantBranchRequest{
   438  					Names: []string{
   439  						"projects/project/tests/not%2Fexist%2Ftest/variants/0123456789abcdef/refs/7265665f68617368",
   440  						"projects/project/tests/this%2F%2Fis%2Fa%2Ftest/variants/0123456789abcdef/refs/7265665f68617368",
   441  					},
   442  				}
   443  
   444  				res, err := server.BatchGet(ctx, req)
   445  				So(err, ShouldBeNil)
   446  				So(res.TestVariantBranches, ShouldHaveLength, 2)
   447  				So(res.TestVariantBranches[0], ShouldBeNil)
   448  				So(res.TestVariantBranches[1], ShouldResembleProto, &pb.TestVariantBranch{
   449  					Name:        "projects/project/tests/this%2F%2Fis%2Fa%2Ftest/variants/0123456789abcdef/refs/7265665f68617368",
   450  					Project:     "project",
   451  					TestId:      "this//is/a/test",
   452  					VariantHash: "0123456789abcdef",
   453  					RefHash:     "7265665f68617368",
   454  					Ref: &pb.SourceRef{
   455  						System: &pb.SourceRef_Gitiles{
   456  							Gitiles: &pb.GitilesRef{
   457  								Host:    "host",
   458  								Project: "proj",
   459  								Ref:     "ref",
   460  							},
   461  						},
   462  					},
   463  					Variant: &pb.Variant{
   464  						Def: map[string]string{
   465  							"k": "v",
   466  						},
   467  					},
   468  					Segments: []*pb.Segment{
   469  						{
   470  							HasStartChangepoint:          true,
   471  							StartPosition:                100,
   472  							StartPositionLowerBound_99Th: 95,
   473  							StartPositionUpperBound_99Th: 105,
   474  							StartHour:                    timestamppb.New(time.Unix(3600, 0)),
   475  							EndPosition:                  200,
   476  							EndHour:                      timestamppb.New(time.Unix(3600, 0)),
   477  							Counts: &pb.Segment_Counts{
   478  								UnexpectedVerdicts: 1,
   479  								FlakyVerdicts:      0,
   480  								TotalVerdicts:      2,
   481  							},
   482  						},
   483  						{
   484  							StartPositionLowerBound_99Th: 45,
   485  							StartPositionUpperBound_99Th: 55,
   486  							StartHour:                    timestamppb.New(time.Unix(3600, 0)),
   487  							EndHour:                      timestamppb.New(time.Unix(0, 0)),
   488  							Counts: &pb.Segment_Counts{
   489  								UnexpectedVerdicts: 2,
   490  								FlakyVerdicts:      0,
   491  								TotalVerdicts:      2,
   492  							},
   493  						},
   494  					},
   495  				})
   496  			})
   497  		})
   498  
   499  		Convey("QuerySourcePositions", func() {
   500  			ctx = auth.WithState(ctx, &authtest.FakeState{
   501  				Identity: "user:someone@example.com",
   502  				IdentityPermissions: []authtest.RealmPermission{
   503  					{
   504  						Realm:      "project:realm",
   505  						Permission: rdbperms.PermListTestResults,
   506  					},
   507  					{
   508  						Realm:      "project:realm",
   509  						Permission: rdbperms.PermListTestExonerations,
   510  					},
   511  				},
   512  				IdentityGroups: []string{"luci-analysis-access"},
   513  			})
   514  			var1 := pbutil.Variant("key1", "val1", "key2", "val1")
   515  			ref := &pb.SourceRef{
   516  				System: &pb.SourceRef_Gitiles{
   517  					Gitiles: &pb.GitilesRef{
   518  						Host:    "host",
   519  						Project: "project",
   520  						Ref:     "ref",
   521  					},
   522  				},
   523  			}
   524  			refhash := hex.EncodeToString(pbutil.SourceRefHash(ref))
   525  			req := &pb.QuerySourcePositionsRequest{
   526  				Project:             "project",
   527  				TestId:              "testid",
   528  				VariantHash:         pbutil.VariantHash(var1),
   529  				RefHash:             refhash,
   530  				StartSourcePosition: 1100,
   531  				PageToken:           "",
   532  				PageSize:            111,
   533  			}
   534  
   535  			Convey("unauthorised requests are rejected", func() {
   536  				ctx = auth.WithState(ctx, &authtest.FakeState{
   537  					Identity:       "user:someone@example.com",
   538  					IdentityGroups: []string{"luci-analysis-access"},
   539  				})
   540  				res, err := server.QuerySourcePositions(ctx, req)
   541  				So(err, ShouldErrLike, `caller does not have permission`, `in any realm in project "project"`)
   542  				So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
   543  				So(res, ShouldBeNil)
   544  			})
   545  
   546  			Convey("invalid requests are rejected", func() {
   547  				req.PageSize = -1
   548  				res, err := server.QuerySourcePositions(ctx, req)
   549  				So(err, ShouldNotBeNil)
   550  				So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   551  				So(res, ShouldBeNil)
   552  			})
   553  
   554  			bqRef := &testverdicts.Ref{
   555  				Gitiles: &testverdicts.Gitiles{
   556  					Host:    bigquery.NullString{StringVal: "chromium.googlesource.com", Valid: true},
   557  					Project: bigquery.NullString{StringVal: "project", Valid: true},
   558  					Ref:     bigquery.NullString{StringVal: "ref", Valid: true},
   559  				},
   560  			}
   561  			Convey("no test verdicts that is close enough to start_source_position", func() {
   562  				tvc.CommitsWithVerdicts = []*testverdicts.CommitWithVerdicts{
   563  					// Verdict at position 10990.
   564  					// This is the smallest position that is greater than the requested position.
   565  					{
   566  						Position:     10990, // we need 10990 - 1100 + 111 (10001) commits from gitiles.
   567  						CommitHash:   "commithash",
   568  						Ref:          bqRef,
   569  						TestVerdicts: []*testverdicts.TestVerdict{},
   570  					},
   571  					// Verdict at position 1002.
   572  					{
   573  						Position:     1002,
   574  						CommitHash:   "commithash",
   575  						Ref:          bqRef,
   576  						TestVerdicts: []*testverdicts.TestVerdict{},
   577  					},
   578  				}
   579  
   580  				res, err := server.QuerySourcePositions(ctx, req)
   581  				So(err, ShouldNotBeNil)
   582  				So(err, ShouldErrLike, `cannot find source positions because test verdicts is too sparse`)
   583  				So(err, ShouldHaveGRPCStatus, codes.NotFound)
   584  				So(res, ShouldBeNil)
   585  			})
   586  
   587  			Convey("no test verdicts after start_source_position", func() {
   588  				tvc.CommitsWithVerdicts = []*testverdicts.CommitWithVerdicts{
   589  					// Verdict at position 1002.
   590  					{
   591  						Position:     1002,
   592  						CommitHash:   "commithash",
   593  						Ref:          bqRef,
   594  						TestVerdicts: []*testverdicts.TestVerdict{},
   595  					},
   596  				}
   597  
   598  				res, err := server.QuerySourcePositions(ctx, req)
   599  				So(err, ShouldNotBeNil)
   600  				So(err, ShouldErrLike, `no commit at or after the requested start position`)
   601  				So(err, ShouldHaveGRPCStatus, codes.NotFound)
   602  				So(res, ShouldBeNil)
   603  			})
   604  
   605  			Convey("e2e", func() {
   606  				tvc.CommitsWithVerdicts = []*testverdicts.CommitWithVerdicts{
   607  					// Verdict at position 1200.
   608  					// This is the smallest position that is greater than the requested position.
   609  					// We use its commit hash to query gitiles.
   610  					{
   611  						Position:     1200,
   612  						CommitHash:   "commithash",
   613  						Ref:          bqRef,
   614  						TestVerdicts: []*testverdicts.TestVerdict{},
   615  					},
   616  					// Verdict at position 1002.
   617  					// The caller doesn't have access to this verdict, this verdict will be excluded from the response.
   618  					{
   619  						Position:   1002,
   620  						CommitHash: "commithash",
   621  						Ref:        bqRef,
   622  						TestVerdicts: []*testverdicts.TestVerdict{
   623  							{
   624  								TestID:        "testid",
   625  								VariantHash:   pbutil.VariantHash(var1),
   626  								RefHash:       refhash,
   627  								InvocationID:  "invocation-123",
   628  								Status:        "EXPECTED",
   629  								PartitionTime: time.Unix(1000, 0),
   630  								PassedAvgDurationUsec: bigquery.NullFloat64{
   631  									Float64: 0.001,
   632  									Valid:   true,
   633  								},
   634  								Changelists: []*testverdicts.Changelist{},
   635  								HasAccess:   false,
   636  							},
   637  						},
   638  					},
   639  					// Verdict at position 1001.
   640  					// This is within the queried range, verdict will be included in the response.
   641  					{
   642  						Position:   1001,
   643  						CommitHash: "commithash",
   644  						Ref:        bqRef,
   645  						TestVerdicts: []*testverdicts.TestVerdict{
   646  							{
   647  								TestID:        "testid",
   648  								VariantHash:   pbutil.VariantHash(var1),
   649  								RefHash:       refhash,
   650  								InvocationID:  "invocation-123",
   651  								Status:        "EXPECTED",
   652  								PartitionTime: time.Unix(1000, 0),
   653  								PassedAvgDurationUsec: bigquery.NullFloat64{
   654  									Float64: 0.001,
   655  									Valid:   true,
   656  								},
   657  								Changelists: []*testverdicts.Changelist{},
   658  								HasAccess:   true,
   659  							},
   660  						},
   661  					},
   662  				}
   663  				makeCommit := func(i int32) *git.Commit {
   664  					return &git.Commit{
   665  						Id:      fmt.Sprintf("id %d", i),
   666  						Tree:    "tree",
   667  						Parents: []string{},
   668  						Author: &git.Commit_User{
   669  							Name:  "userX",
   670  							Email: "userx@google.com",
   671  							Time:  timestamppb.New(time.Unix(1000, 0)),
   672  						},
   673  						Committer: &git.Commit_User{
   674  							Name:  "userY",
   675  							Email: "usery@google.com",
   676  							Time:  timestamppb.New(time.Unix(1100, 0)),
   677  						},
   678  						Message: fmt.Sprintf("message %d", i),
   679  					}
   680  				}
   681  				ctx := gitiles.UseFakeClient(ctx, makeCommit)
   682  
   683  				res, err := server.QuerySourcePositions(ctx, req)
   684  				So(err, ShouldBeNil)
   685  				cwvs := []*pb.SourcePosition{}
   686  				for i := req.StartSourcePosition; i > req.StartSourcePosition-int64(req.PageSize); i-- {
   687  					cwv := &pb.SourcePosition{
   688  						Commit:   makeCommit(int32(i - req.StartSourcePosition + int64(req.PageSize))),
   689  						Position: i,
   690  					}
   691  					// Attach verdicts.
   692  					if i == 1001 {
   693  						cwv.Verdicts = []*pb.TestVerdict{{
   694  							TestId:            "testid",
   695  							VariantHash:       pbutil.VariantHash(var1),
   696  							InvocationId:      "invocation-123",
   697  							Status:            pb.TestVerdictStatus_EXPECTED,
   698  							PartitionTime:     timestamppb.New(time.Unix(1000, 0)),
   699  							PassedAvgDuration: durationpb.New(time.Duration(1) * time.Millisecond),
   700  							Changelists:       []*pb.Changelist{},
   701  						}}
   702  					}
   703  					cwvs = append(cwvs, cwv)
   704  				}
   705  				// Query commits 1100 to 990 (111 commits). Next page will start from 989.
   706  				nextPageToken := pagination.Token(fmt.Sprintf("%d", 989))
   707  				So(res, ShouldResembleProto, &pb.QuerySourcePositionsResponse{
   708  					SourcePositions: cwvs,
   709  					NextPageToken:   nextPageToken,
   710  				})
   711  			})
   712  
   713  		})
   714  
   715  	})
   716  }
   717  
   718  func TestValidateQuerySourcePositionsRequest(t *testing.T) {
   719  	t.Parallel()
   720  
   721  	Convey("validateQuerySourcePositionsRequest", t, func() {
   722  		ref := &pb.SourceRef{
   723  			System: &pb.SourceRef_Gitiles{
   724  				Gitiles: &pb.GitilesRef{
   725  					Host:    "host",
   726  					Project: "project",
   727  					Ref:     "ref",
   728  				},
   729  			},
   730  		}
   731  		refhash := hex.EncodeToString(pbutil.SourceRefHash(ref))
   732  		req := &pb.QuerySourcePositionsRequest{
   733  			Project:             "project",
   734  			TestId:              "testid",
   735  			VariantHash:         pbutil.VariantHash(pbutil.Variant("key1", "val1", "key2", "val1")),
   736  			RefHash:             refhash,
   737  			StartSourcePosition: 110,
   738  			PageToken:           "",
   739  			PageSize:            1,
   740  		}
   741  
   742  		Convey("valid", func() {
   743  			err := validateQuerySourcePositionsRequest(req)
   744  			So(err, ShouldBeNil)
   745  		})
   746  
   747  		Convey("no project", func() {
   748  			req.Project = ""
   749  			err := validateQuerySourcePositionsRequest(req)
   750  			So(err, ShouldErrLike, "project: unspecified")
   751  		})
   752  
   753  		Convey("invalid project", func() {
   754  			req.Project = "project:realm"
   755  			err := validateQuerySourcePositionsRequest(req)
   756  			So(err, ShouldErrLike, `project: must match ^[a-z0-9\-]{1,40}$`)
   757  		})
   758  
   759  		Convey("no test id", func() {
   760  			req.TestId = ""
   761  			err := validateQuerySourcePositionsRequest(req)
   762  			So(err, ShouldErrLike, "test_id: unspecified")
   763  		})
   764  
   765  		Convey("invalid test id", func() {
   766  			req.TestId = "\xFF"
   767  			err := validateQuerySourcePositionsRequest(req)
   768  			So(err, ShouldErrLike, "test_id: not a valid utf8 string")
   769  		})
   770  
   771  		Convey("invalid variant hash", func() {
   772  			req.VariantHash = "invalid"
   773  			err := validateQuerySourcePositionsRequest(req)
   774  			So(err, ShouldErrLike, "variant_hash", "must match ^[0-9a-f]{16}$")
   775  		})
   776  
   777  		Convey("invalid ref hash", func() {
   778  			req.RefHash = "invalid"
   779  			err := validateQuerySourcePositionsRequest(req)
   780  			So(err, ShouldErrLike, "ref_hash:", "must match ^[0-9a-f]{16}$")
   781  		})
   782  
   783  		Convey("invalid start commit position", func() {
   784  			req.StartSourcePosition = 0
   785  			err := validateQuerySourcePositionsRequest(req)
   786  			So(err, ShouldErrLike, "start_source_position: must be a positive number")
   787  		})
   788  
   789  		Convey("no page size", func() {
   790  			req.PageSize = 0
   791  			err := validateQuerySourcePositionsRequest(req)
   792  			So(err, ShouldBeNil)
   793  		})
   794  
   795  		Convey("negative page size", func() {
   796  			req.PageSize = -1
   797  			err := validateQuerySourcePositionsRequest(req)
   798  			So(err, ShouldErrLike, "page_size", "negative")
   799  		})
   800  	})
   801  }
   802  
   803  func adminContext(ctx context.Context) context.Context {
   804  	return auth.WithState(ctx, &authtest.FakeState{
   805  		Identity:       "user:admin@example.com",
   806  		IdentityGroups: []string{"service-luci-analysis-admins", "luci-analysis-access"},
   807  	})
   808  }