go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/rpc/clusters_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 rpc
    16  
    17  import (
    18  	"context"
    19  	"encoding/hex"
    20  	"sort"
    21  	"testing"
    22  	"time"
    23  
    24  	"cloud.google.com/go/bigquery"
    25  	"google.golang.org/protobuf/types/known/timestamppb"
    26  
    27  	"go.chromium.org/luci/common/clock"
    28  	"go.chromium.org/luci/common/data/stringset"
    29  	"go.chromium.org/luci/common/errors"
    30  	"go.chromium.org/luci/gae/impl/memory"
    31  	"go.chromium.org/luci/resultdb/rdbperms"
    32  	"go.chromium.org/luci/server/auth"
    33  	"go.chromium.org/luci/server/auth/authtest"
    34  	"go.chromium.org/luci/server/auth/realms"
    35  	"go.chromium.org/luci/server/caching"
    36  	"go.chromium.org/luci/server/secrets"
    37  	"go.chromium.org/luci/server/secrets/testsecrets"
    38  
    39  	"go.chromium.org/luci/analysis/internal/analysis"
    40  	"go.chromium.org/luci/analysis/internal/analysis/metrics"
    41  	"go.chromium.org/luci/analysis/internal/bugs"
    42  	"go.chromium.org/luci/analysis/internal/clustering"
    43  	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
    44  	"go.chromium.org/luci/analysis/internal/clustering/algorithms/failurereason"
    45  	"go.chromium.org/luci/analysis/internal/clustering/algorithms/rulesalgorithm"
    46  	"go.chromium.org/luci/analysis/internal/clustering/algorithms/testname"
    47  	"go.chromium.org/luci/analysis/internal/clustering/rules"
    48  	"go.chromium.org/luci/analysis/internal/clustering/runs"
    49  	"go.chromium.org/luci/analysis/internal/config"
    50  	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
    51  	"go.chromium.org/luci/analysis/internal/perms"
    52  	"go.chromium.org/luci/analysis/internal/testutil"
    53  	"go.chromium.org/luci/analysis/pbutil"
    54  	configpb "go.chromium.org/luci/analysis/proto/config"
    55  	pb "go.chromium.org/luci/analysis/proto/v1"
    56  
    57  	. "github.com/smartystreets/goconvey/convey"
    58  	. "go.chromium.org/luci/common/testing/assertions"
    59  )
    60  
    61  func TestClusters(t *testing.T) {
    62  	Convey("With a clusters server", t, func() {
    63  		ctx := testutil.IntegrationTestContext(t)
    64  		ctx = caching.WithEmptyProcessCache(ctx)
    65  
    66  		// For user identification.
    67  		ctx = authtest.MockAuthConfig(ctx)
    68  		authState := &authtest.FakeState{
    69  			Identity:       "user:someone@example.com",
    70  			IdentityGroups: []string{"luci-analysis-access"},
    71  		}
    72  		ctx = auth.WithState(ctx, authState)
    73  		ctx = secrets.Use(ctx, &testsecrets.Store{})
    74  
    75  		// Provides datastore implementation needed for project config.
    76  		ctx = memory.Use(ctx)
    77  		analysisClient := newFakeAnalysisClient()
    78  		server := NewClustersServer(analysisClient)
    79  
    80  		configVersion := time.Date(2025, time.August, 12, 0, 1, 2, 3, time.UTC)
    81  		projectCfg := config.CreateConfigWithBothBuganizerAndMonorail(configpb.BugSystem_MONORAIL)
    82  		projectCfg.LastUpdated = timestamppb.New(configVersion)
    83  		projectCfg.BugManagement.Monorail.DisplayPrefix = "crbug.com"
    84  		projectCfg.BugManagement.Monorail.MonorailHostname = "bugs.chromium.org"
    85  		configs := make(map[string]*configpb.ProjectConfig)
    86  		configs["testproject"] = projectCfg
    87  		err := config.SetTestProjectConfig(ctx, configs)
    88  		So(err, ShouldBeNil)
    89  
    90  		compiledTestProjectCfg, err := compiledcfg.NewConfig(projectCfg)
    91  		So(err, ShouldBeNil)
    92  
    93  		// Rules version is in microsecond granularity, consistent with
    94  		// the granularity of Spanner commit timestamps.
    95  		rulesVersion := time.Date(2021, time.February, 12, 1, 2, 4, 5000, time.UTC)
    96  		rs := []*rules.Entry{
    97  			rules.NewRule(0).
    98  				WithProject("testproject").
    99  				WithRuleDefinition(`test LIKE "%TestSuite.TestName%"`).
   100  				WithPredicateLastUpdateTime(rulesVersion.Add(-1 * time.Hour)).
   101  				WithBug(bugs.BugID{
   102  					System: "monorail",
   103  					ID:     "chromium/7654321",
   104  				}).Build(),
   105  			rules.NewRule(1).
   106  				WithProject("testproject").
   107  				WithRuleDefinition(`reason LIKE "my_file.cc(%): Check failed: false."`).
   108  				WithPredicateLastUpdateTime(rulesVersion).
   109  				WithBug(bugs.BugID{
   110  					System: "buganizer",
   111  					ID:     "82828282",
   112  				}).Build(),
   113  			rules.NewRule(2).
   114  				WithProject("testproject").
   115  				WithRuleDefinition(`test LIKE "%Other%"`).
   116  				WithPredicateLastUpdateTime(rulesVersion.Add(-2 * time.Hour)).
   117  				WithBug(bugs.BugID{
   118  					System: "monorail",
   119  					ID:     "chromium/912345",
   120  				}).Build(),
   121  		}
   122  		err = rules.SetForTesting(ctx, rs)
   123  		So(err, ShouldBeNil)
   124  
   125  		Convey("Unauthorized requests are rejected", func() {
   126  			// Ensure no access to luci-analysis-access.
   127  			ctx = auth.WithState(ctx, &authtest.FakeState{
   128  				Identity: "user:someone@example.com",
   129  				// Not a member of luci-analysis-access.
   130  				IdentityGroups: []string{"other-group"},
   131  			})
   132  
   133  			// Make some request (the request should not matter, as
   134  			// a common decorator is used for all requests.)
   135  			request := &pb.ClusterRequest{
   136  				Project: "testproject",
   137  			}
   138  
   139  			rule, err := server.Cluster(ctx, request)
   140  			So(err, ShouldBeRPCPermissionDenied, "not a member of luci-analysis-access")
   141  			So(rule, ShouldBeNil)
   142  		})
   143  		Convey("Cluster", func() {
   144  			authState.IdentityPermissions = []authtest.RealmPermission{
   145  				{
   146  					Realm:      "testproject:@project",
   147  					Permission: perms.PermGetClustersByFailure,
   148  				},
   149  				{
   150  					Realm:      "testproject:@project",
   151  					Permission: perms.PermGetRule,
   152  				},
   153  			}
   154  
   155  			request := &pb.ClusterRequest{
   156  				Project: "testproject",
   157  				TestResults: []*pb.ClusterRequest_TestResult{
   158  					{
   159  						RequestTag: "my tag 1",
   160  						TestId:     "ninja://chrome/test:interactive_ui_tests/TestSuite.TestName",
   161  						FailureReason: &pb.FailureReason{
   162  							PrimaryErrorMessage: "my_file.cc(123): Check failed: false.",
   163  						},
   164  					},
   165  					{
   166  						RequestTag: "my tag 2",
   167  						TestId:     "Other_test",
   168  					},
   169  				},
   170  			}
   171  			Convey("Not authorised to cluster", func() {
   172  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetClustersByFailure)
   173  
   174  				response, err := server.Cluster(ctx, request)
   175  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission analysis.clusters.getByFailure")
   176  				So(response, ShouldBeNil)
   177  			})
   178  			Convey("Not authorised to get rule", func() {
   179  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetRule)
   180  
   181  				response, err := server.Cluster(ctx, request)
   182  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission analysis.rules.get")
   183  				So(response, ShouldBeNil)
   184  			})
   185  			Convey("With a valid request", func() {
   186  				// Run
   187  				response, err := server.Cluster(ctx, request)
   188  
   189  				// Verify
   190  				So(err, ShouldBeNil)
   191  				So(response, ShouldResembleProto, &pb.ClusterResponse{
   192  					ClusteredTestResults: []*pb.ClusterResponse_ClusteredTestResult{
   193  						{
   194  							RequestTag: "my tag 1",
   195  							Clusters: sortClusterEntries([]*pb.ClusterResponse_ClusteredTestResult_ClusterEntry{
   196  								{
   197  									ClusterId: &pb.ClusterId{
   198  										Algorithm: "rules",
   199  										Id:        rs[0].RuleID,
   200  									},
   201  									Bug: &pb.AssociatedBug{
   202  										System:   "monorail",
   203  										Id:       "chromium/7654321",
   204  										LinkText: "crbug.com/7654321",
   205  										Url:      "https://bugs.chromium.org/p/chromium/issues/detail?id=7654321",
   206  									},
   207  								}, {
   208  									ClusterId: &pb.ClusterId{
   209  										Algorithm: "rules",
   210  										Id:        rs[1].RuleID,
   211  									},
   212  									Bug: &pb.AssociatedBug{
   213  										System:   "buganizer",
   214  										Id:       "82828282",
   215  										LinkText: "b/82828282",
   216  										Url:      "https://issuetracker.google.com/issues/82828282",
   217  									},
   218  								},
   219  								failureReasonClusterEntry(compiledTestProjectCfg, "my_file.cc(123): Check failed: false."),
   220  								testNameClusterEntry(compiledTestProjectCfg, "ninja://chrome/test:interactive_ui_tests/TestSuite.TestName"),
   221  							}),
   222  						},
   223  						{
   224  							RequestTag: "my tag 2",
   225  							Clusters: sortClusterEntries([]*pb.ClusterResponse_ClusteredTestResult_ClusterEntry{
   226  								{
   227  									ClusterId: &pb.ClusterId{
   228  										Algorithm: "rules",
   229  										Id:        rs[2].RuleID,
   230  									},
   231  									Bug: &pb.AssociatedBug{
   232  										System:   "monorail",
   233  										Id:       "chromium/912345",
   234  										LinkText: "crbug.com/912345",
   235  										Url:      "https://bugs.chromium.org/p/chromium/issues/detail?id=912345",
   236  									},
   237  								},
   238  								testNameClusterEntry(compiledTestProjectCfg, "Other_test"),
   239  							}),
   240  						},
   241  					},
   242  					ClusteringVersion: &pb.ClusteringVersion{
   243  						AlgorithmsVersion: algorithms.AlgorithmsVersion,
   244  						RulesVersion:      timestamppb.New(rulesVersion),
   245  						ConfigVersion:     timestamppb.New(configVersion),
   246  					},
   247  				})
   248  			})
   249  			Convey("With no monorail configuration", func() {
   250  				// Setup
   251  				projectCfg.BugManagement.Monorail = nil
   252  				configs := make(map[string]*configpb.ProjectConfig)
   253  				configs["testproject"] = projectCfg
   254  				err := config.SetTestProjectConfig(ctx, configs)
   255  				So(err, ShouldBeNil)
   256  				// Run
   257  				response, err := server.Cluster(ctx, request)
   258  				So(err, ShouldBeNil)
   259  				So(response.ClusteredTestResults[0].Clusters[1].Bug.Url, ShouldEqual, "")
   260  			})
   261  			Convey("With missing test ID", func() {
   262  				request.TestResults[1].TestId = ""
   263  
   264  				// Run
   265  				response, err := server.Cluster(ctx, request)
   266  
   267  				// Verify
   268  				So(response, ShouldBeNil)
   269  				So(err, ShouldBeRPCInvalidArgument, "test result 1: test ID must not be empty")
   270  			})
   271  			Convey("With too many test results", func() {
   272  				var testResults []*pb.ClusterRequest_TestResult
   273  				for i := 0; i < 1001; i++ {
   274  					testResults = append(testResults, &pb.ClusterRequest_TestResult{
   275  						TestId: "AnotherTest",
   276  					})
   277  				}
   278  				request.TestResults = testResults
   279  
   280  				// Run
   281  				response, err := server.Cluster(ctx, request)
   282  
   283  				// Verify
   284  				So(response, ShouldBeNil)
   285  				So(err, ShouldBeRPCInvalidArgument, "too many test results: at most 1000 test results can be clustered in one request")
   286  			})
   287  			Convey("With project not configured", func() {
   288  				err := config.SetTestProjectConfig(ctx, map[string]*configpb.ProjectConfig{})
   289  				So(err, ShouldBeNil)
   290  
   291  				// Run
   292  				response, err := server.Cluster(ctx, request)
   293  
   294  				// Verify
   295  				So(response.ClusteringVersion.ConfigVersion.AsTime(), ShouldEqual, config.StartingEpoch)
   296  				So(err, ShouldBeNil)
   297  			})
   298  		})
   299  		Convey("Get", func() {
   300  			authState.IdentityPermissions = []authtest.RealmPermission{
   301  				{
   302  					Realm:      "testproject:@project",
   303  					Permission: perms.PermGetCluster,
   304  				},
   305  				{
   306  					Realm:      "testproject:realm1",
   307  					Permission: rdbperms.PermListTestResults,
   308  				},
   309  				{
   310  					Realm:      "testproject:realm3",
   311  					Permission: rdbperms.PermListTestResults,
   312  				},
   313  			}
   314  
   315  			example := &clustering.Failure{
   316  				TestID: "TestID_Example",
   317  				Reason: &pb.FailureReason{
   318  					PrimaryErrorMessage: "Example failure reason 123.",
   319  				},
   320  			}
   321  			a := &failurereason.Algorithm{}
   322  			reasonClusterID := a.Cluster(compiledTestProjectCfg, example)
   323  
   324  			analysisClient.clustersByProject["testproject"] = []*analysis.Cluster{}
   325  
   326  			request := &pb.GetClusterRequest{
   327  				Name: "projects/testproject/clusters/rules/22222200000000000000000000000000",
   328  			}
   329  
   330  			Convey("Not authorised to get cluster", func() {
   331  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetCluster)
   332  
   333  				response, err := server.Get(ctx, request)
   334  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission analysis.clusters.get")
   335  				So(response, ShouldBeNil)
   336  			})
   337  			Convey("With a valid request", func() {
   338  				analysisClient.clustersByProject["testproject"] = []*analysis.Cluster{
   339  					{
   340  						ClusterID: clustering.ClusterID{
   341  							Algorithm: rulesalgorithm.AlgorithmName,
   342  							ID:        "11111100000000000000000000000000",
   343  						},
   344  						MetricValues: map[metrics.ID]metrics.TimewiseCounts{
   345  							metrics.HumanClsFailedPresubmit.ID: {
   346  								OneDay:   metrics.Counts{Nominal: 1},
   347  								ThreeDay: metrics.Counts{Nominal: 2},
   348  								SevenDay: metrics.Counts{Nominal: 3},
   349  							},
   350  							metrics.CriticalFailuresExonerated.ID: {
   351  								OneDay:   metrics.Counts{Nominal: 4},
   352  								ThreeDay: metrics.Counts{Nominal: 5},
   353  								SevenDay: metrics.Counts{Nominal: 6},
   354  							},
   355  							metrics.Failures.ID: {
   356  								OneDay:   metrics.Counts{Nominal: 7},
   357  								ThreeDay: metrics.Counts{Nominal: 8},
   358  								SevenDay: metrics.Counts{Nominal: 9},
   359  							},
   360  						},
   361  						DistinctUserCLsWithFailures7d:  metrics.Counts{Nominal: 13},
   362  						PostsubmitBuildsWithFailures7d: metrics.Counts{Nominal: 14},
   363  						ExampleFailureReason:           bigquery.NullString{Valid: true, StringVal: "Example failure reason."},
   364  						TopTestIDs: []analysis.TopCount{
   365  							{Value: "TestID 1", Count: 2},
   366  							{Value: "TestID 2", Count: 1},
   367  						},
   368  						Realms: []string{"testproject:realm1", "testproject:realm2"},
   369  					},
   370  				}
   371  				request := &pb.GetClusterRequest{
   372  					Name: "projects/testproject/clusters/rules/11111100000000000000000000000000",
   373  				}
   374  				expectedResponse := &pb.Cluster{
   375  					Name:       "projects/testproject/clusters/rules/11111100000000000000000000000000",
   376  					HasExample: true,
   377  					Metrics: map[string]*pb.Cluster_TimewiseCounts{
   378  						metrics.HumanClsFailedPresubmit.ID.String(): {
   379  							OneDay:   &pb.Cluster_Counts{Nominal: 1},
   380  							ThreeDay: &pb.Cluster_Counts{Nominal: 2},
   381  							SevenDay: &pb.Cluster_Counts{Nominal: 3},
   382  						},
   383  						metrics.CriticalFailuresExonerated.ID.String(): {
   384  							OneDay:   &pb.Cluster_Counts{Nominal: 4},
   385  							ThreeDay: &pb.Cluster_Counts{Nominal: 5},
   386  							SevenDay: &pb.Cluster_Counts{Nominal: 6},
   387  						},
   388  						metrics.Failures.ID.String(): {
   389  							OneDay:   &pb.Cluster_Counts{Nominal: 7},
   390  							ThreeDay: &pb.Cluster_Counts{Nominal: 8},
   391  							SevenDay: &pb.Cluster_Counts{Nominal: 9},
   392  						},
   393  					},
   394  				}
   395  
   396  				Convey("Rule with clustered failures", func() {
   397  					// Run
   398  					response, err := server.Get(ctx, request)
   399  
   400  					// Verify
   401  					So(err, ShouldBeNil)
   402  					So(response, ShouldResembleProto, expectedResponse)
   403  				})
   404  				Convey("Rule without clustered failures", func() {
   405  					analysisClient.clustersByProject["testproject"] = []*analysis.Cluster{}
   406  
   407  					expectedResponse.HasExample = false
   408  					expectedResponse.Metrics = map[string]*pb.Cluster_TimewiseCounts{}
   409  					for _, metric := range metrics.ComputedMetrics {
   410  						expectedResponse.Metrics[metric.ID.String()] = emptyMetricValues()
   411  					}
   412  
   413  					// Run
   414  					response, err := server.Get(ctx, request)
   415  
   416  					// Verify
   417  					So(err, ShouldBeNil)
   418  					So(response, ShouldResembleProto, expectedResponse)
   419  				})
   420  				Convey("Suggested cluster with example failure matching cluster definition", func() {
   421  					// Suggested cluster for which there are clustered failures, and
   422  					// the cluster ID matches the example provided for the cluster.
   423  					analysisClient.clustersByProject["testproject"] = []*analysis.Cluster{
   424  						{
   425  							ClusterID: clustering.ClusterID{
   426  								Algorithm: failurereason.AlgorithmName,
   427  								ID:        hex.EncodeToString(reasonClusterID),
   428  							},
   429  							MetricValues: map[metrics.ID]metrics.TimewiseCounts{
   430  								metrics.HumanClsFailedPresubmit.ID: {
   431  									SevenDay: metrics.Counts{Nominal: 15},
   432  								},
   433  							},
   434  							ExampleFailureReason: bigquery.NullString{Valid: true, StringVal: "Example failure reason 123."},
   435  							TopTestIDs: []analysis.TopCount{
   436  								{Value: "TestID_Example", Count: 10},
   437  							},
   438  							Realms: []string{"testproject:realm1", "testproject:realm3"},
   439  						},
   440  					}
   441  
   442  					request := &pb.GetClusterRequest{
   443  						Name: "projects/testproject/clusters/" + failurereason.AlgorithmName + "/" + hex.EncodeToString(reasonClusterID),
   444  					}
   445  					expectedResponse := &pb.Cluster{
   446  						Name:       "projects/testproject/clusters/" + failurereason.AlgorithmName + "/" + hex.EncodeToString(reasonClusterID),
   447  						Title:      "Example failure reason %.",
   448  						HasExample: true,
   449  						Metrics: map[string]*pb.Cluster_TimewiseCounts{
   450  							metrics.HumanClsFailedPresubmit.ID.String(): {
   451  								OneDay:   &pb.Cluster_Counts{},
   452  								ThreeDay: &pb.Cluster_Counts{},
   453  								SevenDay: &pb.Cluster_Counts{Nominal: 15},
   454  							},
   455  						},
   456  						EquivalentFailureAssociationRule: `reason LIKE "Example failure reason %."`,
   457  					}
   458  
   459  					// Run
   460  					response, err := server.Get(ctx, request)
   461  
   462  					// Verify
   463  					So(err, ShouldBeNil)
   464  					So(response, ShouldResembleProto, expectedResponse)
   465  
   466  					Convey("No test result list permission", func() {
   467  						authState.IdentityPermissions = removePermission(authState.IdentityPermissions, rdbperms.PermListTestResults)
   468  
   469  						// Run
   470  						response, err := server.Get(ctx, request)
   471  
   472  						// Verify
   473  						expectedResponse.Title = ""
   474  						expectedResponse.EquivalentFailureAssociationRule = ""
   475  						So(err, ShouldBeNil)
   476  						So(response, ShouldResembleProto, expectedResponse)
   477  					})
   478  				})
   479  				Convey("Suggested cluster with example failure not matching cluster definition", func() {
   480  					// Suggested cluster for which there are clustered failures,
   481  					// but cluster ID mismatches the example provided for the cluster.
   482  					// This could be because clustering configuration has changed and
   483  					// re-clustering is not yet complete.
   484  					analysisClient.clustersByProject["testproject"] = []*analysis.Cluster{
   485  						{
   486  							ClusterID: clustering.ClusterID{
   487  								Algorithm: testname.AlgorithmName,
   488  								ID:        "cccccc00000000000000000000000001",
   489  							},
   490  							MetricValues: map[metrics.ID]metrics.TimewiseCounts{
   491  								metrics.HumanClsFailedPresubmit.ID: {
   492  									SevenDay: metrics.Counts{Nominal: 11},
   493  								},
   494  							},
   495  							ExampleFailureReason: bigquery.NullString{Valid: true, StringVal: "Example failure reason 2."},
   496  							TopTestIDs: []analysis.TopCount{
   497  								{Value: "TestID 3", Count: 2},
   498  							},
   499  							Realms: []string{"testproject:realm2", "testproject:realm3"},
   500  						},
   501  					}
   502  
   503  					request := &pb.GetClusterRequest{
   504  						Name: "projects/testproject/clusters/" + testname.AlgorithmName + "/cccccc00000000000000000000000001",
   505  					}
   506  					expectedResponse := &pb.Cluster{
   507  						Name:       "projects/testproject/clusters/" + testname.AlgorithmName + "/cccccc00000000000000000000000001",
   508  						Title:      "(definition unavailable due to ongoing reclustering)",
   509  						HasExample: true,
   510  						Metrics: map[string]*pb.Cluster_TimewiseCounts{
   511  							metrics.HumanClsFailedPresubmit.ID.String(): {
   512  								OneDay:   &pb.Cluster_Counts{},
   513  								ThreeDay: &pb.Cluster_Counts{},
   514  								SevenDay: &pb.Cluster_Counts{Nominal: 11},
   515  							},
   516  						},
   517  						EquivalentFailureAssociationRule: ``,
   518  					}
   519  
   520  					// Run
   521  					response, err := server.Get(ctx, request)
   522  
   523  					// Verify
   524  					So(err, ShouldBeNil)
   525  					So(response, ShouldResembleProto, expectedResponse)
   526  				})
   527  				Convey("Suggested cluster without clustered failures", func() {
   528  					// Suggested cluster for which no impact data exists.
   529  					request := &pb.GetClusterRequest{
   530  						Name: "projects/testproject/clusters/reason-v3/cccccc0000000000000000000000ffff",
   531  					}
   532  					expectedResponse := &pb.Cluster{
   533  						Name:       "projects/testproject/clusters/reason-v3/cccccc0000000000000000000000ffff",
   534  						HasExample: false,
   535  						Metrics:    map[string]*pb.Cluster_TimewiseCounts{},
   536  					}
   537  					for _, metric := range metrics.ComputedMetrics {
   538  						expectedResponse.Metrics[metric.ID.String()] = emptyMetricValues()
   539  					}
   540  
   541  					// Run
   542  					response, err := server.Get(ctx, request)
   543  
   544  					// Verify
   545  					So(err, ShouldBeNil)
   546  					So(response, ShouldResembleProto, expectedResponse)
   547  				})
   548  				Convey("With project not configured", func() {
   549  					err := config.SetTestProjectConfig(ctx, map[string]*configpb.ProjectConfig{})
   550  					So(err, ShouldBeNil)
   551  
   552  					// Run
   553  					response, err := server.Get(ctx, request)
   554  
   555  					// Verify
   556  					So(response, ShouldResembleProto, expectedResponse)
   557  					So(err, ShouldBeNil)
   558  				})
   559  			})
   560  			Convey("With invalid request", func() {
   561  				Convey("No name specified", func() {
   562  					request.Name = ""
   563  
   564  					// Run
   565  					response, err := server.Get(ctx, request)
   566  
   567  					// Verify
   568  					So(response, ShouldBeNil)
   569  					So(err, ShouldBeRPCInvalidArgument, "name: must be specified")
   570  				})
   571  				Convey("Invalid name", func() {
   572  					request.Name = "invalid"
   573  
   574  					// Run
   575  					response, err := server.Get(ctx, request)
   576  
   577  					// Verify
   578  					So(response, ShouldBeNil)
   579  					So(err, ShouldBeRPCInvalidArgument, "name: invalid cluster name, expected format: projects/{project}/clusters/{cluster_alg}/{cluster_id}")
   580  				})
   581  				Convey("Invalid cluster algorithm in name", func() {
   582  					request.Name = "projects/blah/clusters/reason/cccccc00000000000000000000000001"
   583  
   584  					// Run
   585  					response, err := server.Get(ctx, request)
   586  
   587  					// Verify
   588  					So(response, ShouldBeNil)
   589  					So(err, ShouldBeRPCInvalidArgument, "name: invalid cluster identity: algorithm not valid")
   590  				})
   591  				Convey("Invalid cluster ID in name", func() {
   592  					request.Name = "projects/blah/clusters/reason-v3/123"
   593  
   594  					// Run
   595  					response, err := server.Get(ctx, request)
   596  
   597  					// Verify
   598  					So(response, ShouldBeNil)
   599  					So(err, ShouldBeRPCInvalidArgument, "name: invalid cluster identity: ID is not valid lowercase hexadecimal bytes")
   600  				})
   601  			})
   602  		})
   603  		Convey("QueryClusterSummaries", func() {
   604  			authState.IdentityPermissions = listTestResultsPermissions(
   605  				"testproject:realm1",
   606  				"testproject:realm2",
   607  				"otherproject:realm3",
   608  			)
   609  			authState.IdentityPermissions = append(authState.IdentityPermissions, []authtest.RealmPermission{
   610  				{
   611  					Realm:      "testproject:@project",
   612  					Permission: perms.PermListClusters,
   613  				},
   614  				{
   615  					Realm:      "testproject:@project",
   616  					Permission: perms.PermGetRule,
   617  				},
   618  				{
   619  					Realm:      "testproject:@project",
   620  					Permission: perms.PermGetRuleDefinition,
   621  				},
   622  			}...)
   623  
   624  			analysisClient.clusterMetricsByProject["testproject"] = []*analysis.ClusterSummary{
   625  				{
   626  					ClusterID: clustering.ClusterID{
   627  						Algorithm: rulesalgorithm.AlgorithmName,
   628  						ID:        rs[0].RuleID,
   629  					},
   630  					MetricValues: map[metrics.ID]*analysis.MetricValue{
   631  						metrics.HumanClsFailedPresubmit.ID: {
   632  							Value:          1,
   633  							DailyBreakdown: []int64{1, 0, 0, 0, 0, 0, 0},
   634  						},
   635  						metrics.CriticalFailuresExonerated.ID: {
   636  							Value:          2,
   637  							DailyBreakdown: []int64{1, 1, 0, 0, 0, 0, 0},
   638  						},
   639  						metrics.Failures.ID: {
   640  							Value:          3,
   641  							DailyBreakdown: []int64{1, 1, 1, 0, 0, 0, 0},
   642  						},
   643  					},
   644  					ExampleFailureReason: bigquery.NullString{Valid: true, StringVal: "Example failure reason."},
   645  					ExampleTestID:        "TestID 1",
   646  				},
   647  				{
   648  					ClusterID: clustering.ClusterID{
   649  						Algorithm: "reason-v3",
   650  						ID:        "cccccc00000000000000000000000001",
   651  					},
   652  					MetricValues: map[metrics.ID]*analysis.MetricValue{
   653  						metrics.HumanClsFailedPresubmit.ID: {
   654  							Value:          4,
   655  							DailyBreakdown: []int64{1, 1, 1, 1, 0, 0, 0},
   656  						},
   657  						metrics.CriticalFailuresExonerated.ID: {
   658  							Value:          5,
   659  							DailyBreakdown: []int64{1, 1, 1, 1, 1, 0, 0},
   660  						},
   661  						metrics.Failures.ID: {
   662  							Value:          6,
   663  							DailyBreakdown: []int64{1, 1, 1, 1, 1, 1, 0},
   664  						},
   665  					},
   666  					ExampleFailureReason: bigquery.NullString{Valid: true, StringVal: "Example failure reason 2."},
   667  					ExampleTestID:        "TestID 3",
   668  				},
   669  				{
   670  					ClusterID: clustering.ClusterID{
   671  						// Rule that is no longer active.
   672  						Algorithm: rulesalgorithm.AlgorithmName,
   673  						ID:        "01234567890abcdef01234567890abcdef",
   674  					},
   675  					MetricValues: map[metrics.ID]*analysis.MetricValue{
   676  						metrics.HumanClsFailedPresubmit.ID: {
   677  							Value:          7,
   678  							DailyBreakdown: []int64{1, 1, 1, 1, 1, 1, 1},
   679  						},
   680  						metrics.CriticalFailuresExonerated.ID: {
   681  							Value:          8,
   682  							DailyBreakdown: []int64{2, 1, 1, 1, 1, 1, 1},
   683  						},
   684  						metrics.Failures.ID: {
   685  							Value:          9,
   686  							DailyBreakdown: []int64{2, 2, 1, 1, 1, 1, 1},
   687  						},
   688  					},
   689  					ExampleFailureReason: bigquery.NullString{Valid: true, StringVal: "Example failure reason."},
   690  					ExampleTestID:        "TestID 1",
   691  				},
   692  			}
   693  			analysisClient.expectedRealmsQueried = []string{"testproject:realm1", "testproject:realm2"}
   694  
   695  			now := clock.Now(ctx)
   696  			request := &pb.QueryClusterSummariesRequest{
   697  				Project:       "testproject",
   698  				FailureFilter: "test_id:\"pita.Boot\" failure_reason:\"failed to boot\"",
   699  				OrderBy:       "metrics.`human-cls-failed-presubmit`.value desc, metrics.`critical-failures-exonerated`.value desc, metrics.failures.value desc",
   700  				Metrics: []string{
   701  					"projects/testproject/metrics/human-cls-failed-presubmit",
   702  					"projects/testproject/metrics/critical-failures-exonerated",
   703  					"projects/testproject/metrics/failures",
   704  				},
   705  				TimeRange: &pb.TimeRange{
   706  					Earliest: timestamppb.New(now.Add(-24 * time.Hour)),
   707  					Latest:   timestamppb.New(now),
   708  				},
   709  			}
   710  			Convey("Invalid time range", func() {
   711  				request.TimeRange = &pb.TimeRange{
   712  					Earliest: timestamppb.New(now),
   713  					Latest:   timestamppb.New(now.Add(-24 * time.Hour)),
   714  				}
   715  
   716  				response, err := server.QueryClusterSummaries(ctx, request)
   717  				So(err, ShouldBeRPCInvalidArgument, "time_range: earliest must be before latest")
   718  				So(response, ShouldBeNil)
   719  			})
   720  			Convey("Not authorised to list clusters", func() {
   721  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermListClusters)
   722  
   723  				response, err := server.QueryClusterSummaries(ctx, request)
   724  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission analysis.clusters.list")
   725  				So(response, ShouldBeNil)
   726  			})
   727  			Convey("Not authorised to get rules", func() {
   728  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetRule)
   729  
   730  				response, err := server.QueryClusterSummaries(ctx, request)
   731  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission analysis.rules.get")
   732  				So(response, ShouldBeNil)
   733  			})
   734  			Convey("Not authorised to list test results in any realm", func() {
   735  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, rdbperms.PermListTestResults)
   736  
   737  				response, err := server.QueryClusterSummaries(ctx, request)
   738  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permissions [resultdb.testResults.list resultdb.testExonerations.list] in any realm in project \"testproject\"")
   739  				So(response, ShouldBeNil)
   740  			})
   741  			Convey("Not authorised to list test exonerations in any realm", func() {
   742  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, rdbperms.PermListTestExonerations)
   743  
   744  				response, err := server.QueryClusterSummaries(ctx, request)
   745  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permissions [resultdb.testResults.list resultdb.testExonerations.list] in any realm in project \"testproject\"")
   746  				So(response, ShouldBeNil)
   747  			})
   748  			Convey("Valid request", func() {
   749  				expectedResponse := &pb.QueryClusterSummariesResponse{
   750  					ClusterSummaries: []*pb.ClusterSummary{
   751  						{
   752  							ClusterId: &pb.ClusterId{
   753  								Algorithm: "rules",
   754  								Id:        rs[0].RuleID,
   755  							},
   756  							Title: rs[0].RuleDefinition,
   757  							Bug: &pb.AssociatedBug{
   758  								System:   "monorail",
   759  								Id:       "chromium/7654321",
   760  								LinkText: "crbug.com/7654321",
   761  								Url:      "https://bugs.chromium.org/p/chromium/issues/detail?id=7654321",
   762  							},
   763  							Metrics: map[string]*pb.ClusterSummary_MetricValue{
   764  								metrics.HumanClsFailedPresubmit.ID.String(): {
   765  									Value: 1,
   766  								},
   767  								metrics.CriticalFailuresExonerated.ID.String(): {
   768  									Value: 2,
   769  								},
   770  								metrics.Failures.ID.String(): {
   771  									Value: 3,
   772  								},
   773  							},
   774  						},
   775  						{
   776  							ClusterId: &pb.ClusterId{
   777  								Algorithm: "reason-v3",
   778  								Id:        "cccccc00000000000000000000000001",
   779  							},
   780  							Title: `Example failure reason 2.`,
   781  							Metrics: map[string]*pb.ClusterSummary_MetricValue{
   782  								metrics.HumanClsFailedPresubmit.ID.String(): {
   783  									Value: 4,
   784  								},
   785  								metrics.CriticalFailuresExonerated.ID.String(): {
   786  									Value: 5,
   787  								},
   788  								metrics.Failures.ID.String(): {
   789  									Value: 6,
   790  								},
   791  							},
   792  						},
   793  						{
   794  							ClusterId: &pb.ClusterId{
   795  								Algorithm: "rules",
   796  								Id:        "01234567890abcdef01234567890abcdef",
   797  							},
   798  							Title: `(rule archived)`,
   799  							Metrics: map[string]*pb.ClusterSummary_MetricValue{
   800  								metrics.HumanClsFailedPresubmit.ID.String(): {
   801  									Value: 7,
   802  								},
   803  								metrics.CriticalFailuresExonerated.ID.String(): {
   804  									Value: 8,
   805  								},
   806  								metrics.Failures.ID.String(): {
   807  									Value: 9,
   808  								},
   809  							},
   810  						},
   811  					},
   812  				}
   813  
   814  				Convey("With filters and order by", func() {
   815  					response, err := server.QueryClusterSummaries(ctx, request)
   816  					So(err, ShouldBeNil)
   817  					So(response, ShouldResembleProto, expectedResponse)
   818  				})
   819  				Convey("Without filters or order", func() {
   820  					request.FailureFilter = ""
   821  					request.OrderBy = ""
   822  
   823  					response, err := server.QueryClusterSummaries(ctx, request)
   824  					So(err, ShouldBeNil)
   825  					So(response, ShouldResembleProto, expectedResponse)
   826  				})
   827  				Convey("With full view", func() {
   828  					request.View = pb.ClusterSummaryView_FULL
   829  
   830  					expectedFullResponse := &pb.QueryClusterSummariesResponse{
   831  						ClusterSummaries: []*pb.ClusterSummary{
   832  							{
   833  								ClusterId: &pb.ClusterId{
   834  									Algorithm: "rules",
   835  									Id:        rs[0].RuleID,
   836  								},
   837  								Title: rs[0].RuleDefinition,
   838  								Bug: &pb.AssociatedBug{
   839  									System:   "monorail",
   840  									Id:       "chromium/7654321",
   841  									LinkText: "crbug.com/7654321",
   842  									Url:      "https://bugs.chromium.org/p/chromium/issues/detail?id=7654321",
   843  								},
   844  								Metrics: map[string]*pb.ClusterSummary_MetricValue{
   845  									metrics.HumanClsFailedPresubmit.ID.String(): {
   846  										Value:          1,
   847  										DailyBreakdown: []int64{1, 0, 0, 0, 0, 0, 0},
   848  									},
   849  									metrics.CriticalFailuresExonerated.ID.String(): {
   850  										Value:          2,
   851  										DailyBreakdown: []int64{1, 1, 0, 0, 0, 0, 0},
   852  									},
   853  									metrics.Failures.ID.String(): {
   854  										Value:          3,
   855  										DailyBreakdown: []int64{1, 1, 1, 0, 0, 0, 0},
   856  									},
   857  								},
   858  							},
   859  							{
   860  								ClusterId: &pb.ClusterId{
   861  									Algorithm: "reason-v3",
   862  									Id:        "cccccc00000000000000000000000001",
   863  								},
   864  								Title: `Example failure reason 2.`,
   865  								Metrics: map[string]*pb.ClusterSummary_MetricValue{
   866  									metrics.HumanClsFailedPresubmit.ID.String(): {
   867  										Value:          4,
   868  										DailyBreakdown: []int64{1, 1, 1, 1, 0, 0, 0},
   869  									},
   870  									metrics.CriticalFailuresExonerated.ID.String(): {
   871  										Value:          5,
   872  										DailyBreakdown: []int64{1, 1, 1, 1, 1, 0, 0},
   873  									},
   874  									metrics.Failures.ID.String(): {
   875  										Value:          6,
   876  										DailyBreakdown: []int64{1, 1, 1, 1, 1, 1, 0},
   877  									},
   878  								},
   879  							},
   880  							{
   881  								ClusterId: &pb.ClusterId{
   882  									Algorithm: "rules",
   883  									Id:        "01234567890abcdef01234567890abcdef",
   884  								},
   885  								Title: `(rule archived)`,
   886  								Metrics: map[string]*pb.ClusterSummary_MetricValue{
   887  									metrics.HumanClsFailedPresubmit.ID.String(): {
   888  										Value:          7,
   889  										DailyBreakdown: []int64{1, 1, 1, 1, 1, 1, 1},
   890  									},
   891  									metrics.CriticalFailuresExonerated.ID.String(): {
   892  										Value:          8,
   893  										DailyBreakdown: []int64{2, 1, 1, 1, 1, 1, 1},
   894  									},
   895  									metrics.Failures.ID.String(): {
   896  										Value:          9,
   897  										DailyBreakdown: []int64{2, 2, 1, 1, 1, 1, 1},
   898  									},
   899  								},
   900  							},
   901  						},
   902  					}
   903  
   904  					response, err := server.QueryClusterSummaries(ctx, request)
   905  					So(err, ShouldBeNil)
   906  					So(response, ShouldResembleProto, expectedFullResponse)
   907  				})
   908  				Convey("Without rule definition get permission", func() {
   909  					authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetRuleDefinition)
   910  
   911  					// The RPC cannot return the rule definition as the
   912  					// cluster title as the user is not authorised to see it.
   913  					// Instead, it should generate a description of the
   914  					// content of the cluster based on what the user can see.
   915  					expectedResponse.ClusterSummaries[0].Title = "Selected failures in TestID 1"
   916  
   917  					response, err := server.QueryClusterSummaries(ctx, request)
   918  					So(err, ShouldBeNil)
   919  					So(response, ShouldResembleProto, expectedResponse)
   920  				})
   921  				Convey("Without metrics", func() {
   922  					request.Metrics = []string{}
   923  					request.OrderBy = ""
   924  
   925  					for _, item := range expectedResponse.ClusterSummaries {
   926  						item.Metrics = make(map[string]*pb.ClusterSummary_MetricValue)
   927  					}
   928  
   929  					response, err := server.QueryClusterSummaries(ctx, request)
   930  					So(err, ShouldBeNil)
   931  					So(response, ShouldResembleProto, expectedResponse)
   932  				})
   933  			})
   934  			Convey("Invalid request", func() {
   935  				Convey("Failure filter syntax is invalid", func() {
   936  					request.FailureFilter = "test_id::"
   937  
   938  					// Run
   939  					response, err := server.QueryClusterSummaries(ctx, request)
   940  
   941  					// Verify
   942  					So(response, ShouldBeNil)
   943  					So(err, ShouldBeRPCInvalidArgument, "failure_filter: expected arg after :")
   944  				})
   945  				Convey("Failure filter references non-existant column", func() {
   946  					request.FailureFilter = `test:"pita.Boot"`
   947  
   948  					// Run
   949  					response, err := server.QueryClusterSummaries(ctx, request)
   950  
   951  					// Verify
   952  					So(response, ShouldBeNil)
   953  					So(err, ShouldBeRPCInvalidArgument, `failure_filter: no filterable field "test"`)
   954  				})
   955  				Convey("Failure filter references unimplemented feature", func() {
   956  					request.FailureFilter = "test_id<=\"blah\""
   957  
   958  					// Run
   959  					response, err := server.QueryClusterSummaries(ctx, request)
   960  
   961  					// Verify
   962  					So(response, ShouldBeNil)
   963  					So(err, ShouldBeRPCInvalidArgument, "failure_filter: comparator operator not implemented yet")
   964  				})
   965  				Convey("Metrics references non-existent metric", func() {
   966  					request.Metrics = []string{"projects/testproject/metrics/not-exists"}
   967  					// Run
   968  					response, err := server.QueryClusterSummaries(ctx, request)
   969  
   970  					// Verify
   971  					So(response, ShouldBeNil)
   972  					So(err, ShouldBeRPCInvalidArgument, `metrics: no metric with ID "not-exists"`)
   973  				})
   974  				Convey("Metrics references metric in another project", func() {
   975  					request.Metrics = []string{"projects/anotherproject/metrics/failures"}
   976  					// Run
   977  					response, err := server.QueryClusterSummaries(ctx, request)
   978  
   979  					// Verify
   980  					So(response, ShouldBeNil)
   981  					So(err, ShouldBeRPCInvalidArgument, `metrics: metric projects/anotherproject/metrics/failures cannot be used as it is from a different LUCI Project`)
   982  				})
   983  				Convey("Order by references metric that is not selected", func() {
   984  					request.Metrics = []string{"projects/testproject/metrics/failures"}
   985  					request.OrderBy = "metrics.`human-cls-failed-presubmit`.value desc"
   986  
   987  					// Run
   988  					response, err := server.QueryClusterSummaries(ctx, request)
   989  
   990  					// Verify
   991  					So(response, ShouldBeNil)
   992  					So(err, ShouldBeRPCInvalidArgument, "order_by: no sortable field named \"metrics.`human-cls-failed-presubmit`.value\", valid fields are metrics.failures.value")
   993  				})
   994  				Convey("Order by syntax invalid", func() {
   995  					// To sort in ascending order, "desc" should be omittted. "asc" is not valid syntax.
   996  					request.OrderBy = "metrics.`human-cls-failed-presubmit`.value asc"
   997  
   998  					// Run
   999  					response, err := server.QueryClusterSummaries(ctx, request)
  1000  
  1001  					// Verify
  1002  					So(response, ShouldBeNil)
  1003  					So(err, ShouldBeRPCInvalidArgument, `order_by: syntax error: 1:44: unexpected token "asc"`)
  1004  				})
  1005  				Convey("Order by syntax references invalid column", func() {
  1006  					request.OrderBy = "not_exists desc"
  1007  
  1008  					// Run
  1009  					response, err := server.QueryClusterSummaries(ctx, request)
  1010  
  1011  					// Verify
  1012  					So(response, ShouldBeNil)
  1013  					So(err, ShouldBeRPCInvalidArgument, `order_by: no sortable field named "not_exists"`)
  1014  				})
  1015  			})
  1016  		})
  1017  		Convey("GetReclusteringProgress", func() {
  1018  			authState.IdentityPermissions = []authtest.RealmPermission{{
  1019  				Realm:      "testproject:@project",
  1020  				Permission: perms.PermGetCluster,
  1021  			}}
  1022  
  1023  			request := &pb.GetReclusteringProgressRequest{
  1024  				Name: "projects/testproject/reclusteringProgress",
  1025  			}
  1026  			Convey("Not authorised to get cluster", func() {
  1027  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetCluster)
  1028  
  1029  				response, err := server.GetReclusteringProgress(ctx, request)
  1030  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission analysis.clusters.get")
  1031  				So(response, ShouldBeNil)
  1032  			})
  1033  			Convey("With a valid request", func() {
  1034  				rulesVersion := time.Date(2021, time.January, 1, 1, 0, 0, 0, time.UTC)
  1035  				reference := time.Date(2020, time.February, 1, 1, 0, 0, 0, time.UTC)
  1036  				configVersion := time.Date(2019, time.March, 1, 1, 0, 0, 0, time.UTC)
  1037  				rns := []*runs.ReclusteringRun{
  1038  					runs.NewRun(0).
  1039  						WithProject("testproject").
  1040  						WithAttemptTimestamp(reference.Add(-5 * time.Minute)).
  1041  						WithRulesVersion(rulesVersion).
  1042  						WithAlgorithmsVersion(2).
  1043  						WithConfigVersion(configVersion).
  1044  						WithNoReportedProgress().
  1045  						Build(),
  1046  					runs.NewRun(1).
  1047  						WithProject("testproject").
  1048  						WithAttemptTimestamp(reference.Add(-10 * time.Minute)).
  1049  						WithRulesVersion(rulesVersion).
  1050  						WithAlgorithmsVersion(2).
  1051  						WithConfigVersion(configVersion).
  1052  						WithReportedProgress(500).
  1053  						Build(),
  1054  					runs.NewRun(2).
  1055  						WithProject("testproject").
  1056  						WithAttemptTimestamp(reference.Add(-20 * time.Minute)).
  1057  						WithRulesVersion(rulesVersion.Add(-1 * time.Hour)).
  1058  						WithAlgorithmsVersion(1).
  1059  						WithConfigVersion(configVersion.Add(-1 * time.Hour)).
  1060  						WithCompletedProgress().
  1061  						Build(),
  1062  				}
  1063  				err := runs.SetRunsForTesting(ctx, rns)
  1064  				So(err, ShouldBeNil)
  1065  
  1066  				// Run
  1067  				response, err := server.GetReclusteringProgress(ctx, request)
  1068  
  1069  				// Verify.
  1070  				So(err, ShouldBeNil)
  1071  				So(response, ShouldResembleProto, &pb.ReclusteringProgress{
  1072  					Name:             "projects/testproject/reclusteringProgress",
  1073  					ProgressPerMille: 500,
  1074  					Last: &pb.ClusteringVersion{
  1075  						AlgorithmsVersion: 1,
  1076  						ConfigVersion:     timestamppb.New(configVersion.Add(-1 * time.Hour)),
  1077  						RulesVersion:      timestamppb.New(rulesVersion.Add(-1 * time.Hour)),
  1078  					},
  1079  					Next: &pb.ClusteringVersion{
  1080  						AlgorithmsVersion: 2,
  1081  						ConfigVersion:     timestamppb.New(configVersion),
  1082  						RulesVersion:      timestamppb.New(rulesVersion),
  1083  					},
  1084  				})
  1085  			})
  1086  			Convey("With an invalid request", func() {
  1087  				Convey("Invalid name", func() {
  1088  					request.Name = "invalid"
  1089  
  1090  					// Run
  1091  					response, err := server.GetReclusteringProgress(ctx, request)
  1092  
  1093  					// Verify
  1094  					So(response, ShouldBeNil)
  1095  					So(err, ShouldBeRPCInvalidArgument, "name: invalid reclustering progress name, expected format: projects/{project}/reclusteringProgress")
  1096  				})
  1097  			})
  1098  		})
  1099  		Convey("QueryClusterFailures", func() {
  1100  			authState.IdentityPermissions = listTestResultsPermissions(
  1101  				"testproject:realm1",
  1102  				"testproject:realm2",
  1103  				"otherproject:realm3",
  1104  			)
  1105  			authState.IdentityPermissions = append(authState.IdentityPermissions, authtest.RealmPermission{
  1106  				Realm:      "testproject:@project",
  1107  				Permission: perms.PermGetCluster,
  1108  			})
  1109  
  1110  			request := &pb.QueryClusterFailuresRequest{
  1111  				Parent: "projects/testproject/clusters/reason-v1/cccccc00000000000000000000000001/failures",
  1112  			}
  1113  			Convey("Not authorised to get cluster", func() {
  1114  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetCluster)
  1115  
  1116  				response, err := server.QueryClusterFailures(ctx, request)
  1117  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission analysis.clusters.get")
  1118  				So(response, ShouldBeNil)
  1119  			})
  1120  			Convey("Not authorised to list test results in any realm", func() {
  1121  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, rdbperms.PermListTestResults)
  1122  
  1123  				response, err := server.QueryClusterFailures(ctx, request)
  1124  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permissions [resultdb.testResults.list resultdb.testExonerations.list] in any realm in project \"testproject\"")
  1125  				So(response, ShouldBeNil)
  1126  			})
  1127  			Convey("Not authorised to list test exonerations in any realm", func() {
  1128  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, rdbperms.PermListTestExonerations)
  1129  
  1130  				response, err := server.QueryClusterFailures(ctx, request)
  1131  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permissions [resultdb.testResults.list resultdb.testExonerations.list] in any realm in project \"testproject\"")
  1132  				So(response, ShouldBeNil)
  1133  			})
  1134  			Convey("With a valid request", func() {
  1135  				analysisClient.expectedRealmsQueried = []string{"testproject:realm1", "testproject:realm2"}
  1136  				analysisClient.failuresByProjectAndCluster["testproject"] = map[clustering.ClusterID][]*analysis.ClusterFailure{
  1137  					{
  1138  						Algorithm: "reason-v1",
  1139  						ID:        "cccccc00000000000000000000000001",
  1140  					}: {
  1141  						{
  1142  							TestID: bqString("testID-1"),
  1143  							Variant: []*analysis.Variant{
  1144  								{
  1145  									Key:   bqString("key1"),
  1146  									Value: bqString("value1"),
  1147  								},
  1148  								{
  1149  									Key:   bqString("key2"),
  1150  									Value: bqString("value2"),
  1151  								},
  1152  							},
  1153  							PresubmitRunID: &analysis.PresubmitRunID{
  1154  								System: bqString("luci-cv"),
  1155  								ID:     bqString("123456789"),
  1156  							},
  1157  							PresubmitRunOwner: bqString("user"),
  1158  							PresubmitRunMode:  bqString(analysis.ToBQPresubmitRunMode(pb.PresubmitRunMode_QUICK_DRY_RUN)),
  1159  							Changelists: []*analysis.Changelist{
  1160  								{
  1161  									Host:     bqString("testproject.googlesource.com"),
  1162  									Change:   bigquery.NullInt64{Int64: 100006, Valid: true},
  1163  									Patchset: bigquery.NullInt64{Int64: 106, Valid: true},
  1164  								},
  1165  								{
  1166  									Host:     bqString("testproject-internal.googlesource.com"),
  1167  									Change:   bigquery.NullInt64{Int64: 100007, Valid: true},
  1168  									Patchset: bigquery.NullInt64{Int64: 107, Valid: true},
  1169  								},
  1170  							},
  1171  							PartitionTime: bigquery.NullTimestamp{Timestamp: time.Date(2123, time.April, 1, 2, 3, 4, 5, time.UTC), Valid: true},
  1172  							Exonerations: []*analysis.Exoneration{
  1173  								{
  1174  									Reason: bqString(pb.ExonerationReason_OCCURS_ON_MAINLINE.String()),
  1175  								},
  1176  								{
  1177  									Reason: bqString(pb.ExonerationReason_NOT_CRITICAL.String()),
  1178  								},
  1179  							},
  1180  							BuildStatus:                 bqString(analysis.ToBQBuildStatus(pb.BuildStatus_BUILD_STATUS_FAILURE)),
  1181  							IsBuildCritical:             bigquery.NullBool{Bool: true, Valid: true},
  1182  							IngestedInvocationID:        bqString("build-1234567890"),
  1183  							IsIngestedInvocationBlocked: bigquery.NullBool{Bool: true, Valid: true},
  1184  							Count:                       15,
  1185  						},
  1186  						{
  1187  							TestID: bigquery.NullString{StringVal: "testID-2"},
  1188  							Variant: []*analysis.Variant{
  1189  								{
  1190  									Key:   bqString("key1"),
  1191  									Value: bqString("value2"),
  1192  								},
  1193  								{
  1194  									Key:   bqString("key3"),
  1195  									Value: bqString("value3"),
  1196  								},
  1197  							},
  1198  							PresubmitRunID:              nil,
  1199  							PresubmitRunOwner:           bigquery.NullString{},
  1200  							PresubmitRunMode:            bigquery.NullString{},
  1201  							Changelists:                 nil,
  1202  							PartitionTime:               bigquery.NullTimestamp{Timestamp: time.Date(2124, time.May, 2, 3, 4, 5, 6, time.UTC), Valid: true},
  1203  							BuildStatus:                 bqString(analysis.ToBQBuildStatus(pb.BuildStatus_BUILD_STATUS_CANCELED)),
  1204  							IsBuildCritical:             bigquery.NullBool{},
  1205  							IngestedInvocationID:        bqString("build-9888887771"),
  1206  							IsIngestedInvocationBlocked: bigquery.NullBool{Bool: true, Valid: true},
  1207  							Count:                       1,
  1208  						},
  1209  					},
  1210  				}
  1211  
  1212  				expectedResponse := &pb.QueryClusterFailuresResponse{
  1213  					Failures: []*pb.DistinctClusterFailure{
  1214  						{
  1215  							TestId:        "testID-1",
  1216  							Variant:       pbutil.Variant("key1", "value1", "key2", "value2"),
  1217  							PartitionTime: timestamppb.New(time.Date(2123, time.April, 1, 2, 3, 4, 5, time.UTC)),
  1218  							PresubmitRun: &pb.DistinctClusterFailure_PresubmitRun{
  1219  								PresubmitRunId: &pb.PresubmitRunId{
  1220  									System: "luci-cv",
  1221  									Id:     "123456789",
  1222  								},
  1223  								Owner: "user",
  1224  								Mode:  pb.PresubmitRunMode_QUICK_DRY_RUN,
  1225  							},
  1226  							IsBuildCritical: true,
  1227  							Exonerations: []*pb.DistinctClusterFailure_Exoneration{{
  1228  								Reason: pb.ExonerationReason_OCCURS_ON_MAINLINE,
  1229  							}, {
  1230  								Reason: pb.ExonerationReason_NOT_CRITICAL,
  1231  							}},
  1232  							BuildStatus:                 pb.BuildStatus_BUILD_STATUS_FAILURE,
  1233  							IngestedInvocationId:        "build-1234567890",
  1234  							IsIngestedInvocationBlocked: true,
  1235  							Changelists: []*pb.Changelist{
  1236  								{
  1237  									Host:     "testproject.googlesource.com",
  1238  									Change:   100006,
  1239  									Patchset: 106,
  1240  								},
  1241  								{
  1242  									Host:     "testproject-internal.googlesource.com",
  1243  									Change:   100007,
  1244  									Patchset: 107,
  1245  								},
  1246  							},
  1247  							Count: 15,
  1248  						},
  1249  						{
  1250  							TestId:                      "testID-2",
  1251  							Variant:                     pbutil.Variant("key1", "value2", "key3", "value3"),
  1252  							PartitionTime:               timestamppb.New(time.Date(2124, time.May, 2, 3, 4, 5, 6, time.UTC)),
  1253  							PresubmitRun:                nil,
  1254  							IsBuildCritical:             false,
  1255  							Exonerations:                nil,
  1256  							BuildStatus:                 pb.BuildStatus_BUILD_STATUS_CANCELED,
  1257  							IngestedInvocationId:        "build-9888887771",
  1258  							IsIngestedInvocationBlocked: true,
  1259  							Count:                       1,
  1260  						},
  1261  					},
  1262  				}
  1263  
  1264  				Convey("Without metric filter", func() {
  1265  					// Run
  1266  					response, err := server.QueryClusterFailures(ctx, request)
  1267  
  1268  					// Verify.
  1269  					So(err, ShouldBeNil)
  1270  					So(response, ShouldResembleProto, expectedResponse)
  1271  				})
  1272  				Convey("With metric filter", func() {
  1273  					request.MetricFilter = "projects/testproject/metrics/human-cls-failed-presubmit"
  1274  					metric, err := metrics.ByID(metrics.HumanClsFailedPresubmit.ID)
  1275  					So(err, ShouldBeNil)
  1276  					analysisClient.expectedMetricFilter = &metrics.Definition{}
  1277  					*analysisClient.expectedMetricFilter = metric.AdaptToProject("testproject", projectCfg.Metrics)
  1278  
  1279  					// Run
  1280  					response, err := server.QueryClusterFailures(ctx, request)
  1281  
  1282  					// Verify.
  1283  					So(err, ShouldBeNil)
  1284  					So(response, ShouldResembleProto, expectedResponse)
  1285  
  1286  				})
  1287  			})
  1288  			Convey("With an invalid request", func() {
  1289  				Convey("Invalid parent", func() {
  1290  					request.Parent = "blah"
  1291  
  1292  					// Run
  1293  					response, err := server.QueryClusterFailures(ctx, request)
  1294  
  1295  					// Verify
  1296  					So(response, ShouldBeNil)
  1297  					So(err, ShouldBeRPCInvalidArgument, "parent: invalid cluster failures name, expected format: projects/{project}/clusters/{cluster_alg}/{cluster_id}/failures")
  1298  				})
  1299  				Convey("Invalid cluster algorithm in parent", func() {
  1300  					request.Parent = "projects/blah/clusters/reason/cccccc00000000000000000000000001/failures"
  1301  
  1302  					// Run
  1303  					response, err := server.QueryClusterFailures(ctx, request)
  1304  
  1305  					// Verify
  1306  					So(response, ShouldBeNil)
  1307  					So(err, ShouldBeRPCInvalidArgument, "parent: cluster id: algorithm not valid")
  1308  				})
  1309  				Convey("Invalid cluster ID in parent", func() {
  1310  					request.Parent = "projects/blah/clusters/reason-v3/123/failures"
  1311  
  1312  					// Run
  1313  					response, err := server.QueryClusterFailures(ctx, request)
  1314  
  1315  					// Verify
  1316  					So(response, ShouldBeNil)
  1317  					So(err, ShouldBeRPCInvalidArgument, "parent: cluster id: ID is not valid lowercase hexadecimal bytes")
  1318  				})
  1319  				Convey("Invalid metric ID format", func() {
  1320  					request.MetricFilter = "metrics/human-cls-failed-presubmit"
  1321  
  1322  					// Run
  1323  					response, err := server.QueryClusterFailures(ctx, request)
  1324  
  1325  					// Verify
  1326  					So(response, ShouldBeNil)
  1327  					So(err, ShouldBeRPCInvalidArgument, "filter_metric: invalid project metric name, expected format: projects/{project}/metrics/{metric_id}")
  1328  				})
  1329  				Convey("Filter metric references non-existant metric", func() {
  1330  					request.MetricFilter = "projects/testproject/metrics/not-exists"
  1331  
  1332  					// Run
  1333  					response, err := server.QueryClusterFailures(ctx, request)
  1334  
  1335  					// Verify
  1336  					So(response, ShouldBeNil)
  1337  					So(err, ShouldBeRPCInvalidArgument, `filter_metric: no metric with ID "not-exists"`)
  1338  				})
  1339  			})
  1340  		})
  1341  
  1342  		Convey("QueryExoneratedTestVariants", func() {
  1343  			authState.IdentityPermissions = listTestResultsPermissions(
  1344  				"testproject:realm1",
  1345  				"testproject:realm2",
  1346  				"otherproject:realm3",
  1347  			)
  1348  			authState.IdentityPermissions = append(authState.IdentityPermissions, authtest.RealmPermission{
  1349  				Realm:      "testproject:@project",
  1350  				Permission: perms.PermGetCluster,
  1351  			})
  1352  
  1353  			request := &pb.QueryClusterExoneratedTestVariantsRequest{
  1354  				Parent: "projects/testproject/clusters/reason-v1/cccccc00000000000000000000000001/exoneratedTestVariants",
  1355  			}
  1356  			Convey("Not authorised to get cluster", func() {
  1357  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetCluster)
  1358  
  1359  				response, err := server.QueryExoneratedTestVariants(ctx, request)
  1360  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission analysis.clusters.get")
  1361  				So(response, ShouldBeNil)
  1362  			})
  1363  			Convey("Not authorised to list test results in any realm", func() {
  1364  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, rdbperms.PermListTestResults)
  1365  
  1366  				response, err := server.QueryExoneratedTestVariants(ctx, request)
  1367  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permissions [resultdb.testResults.list resultdb.testExonerations.list] in any realm in project \"testproject\"")
  1368  				So(response, ShouldBeNil)
  1369  			})
  1370  			Convey("Not authorised to list test exonerations in any realm", func() {
  1371  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, rdbperms.PermListTestExonerations)
  1372  
  1373  				response, err := server.QueryExoneratedTestVariants(ctx, request)
  1374  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permissions [resultdb.testResults.list resultdb.testExonerations.list] in any realm in project \"testproject\"")
  1375  				So(response, ShouldBeNil)
  1376  			})
  1377  			Convey("With a valid request", func() {
  1378  				analysisClient.expectedRealmsQueried = []string{"testproject:realm1", "testproject:realm2"}
  1379  				analysisClient.exoneratedTVsByProjectAndCluster["testproject"] = map[clustering.ClusterID][]*analysis.ExoneratedTestVariant{
  1380  					{
  1381  						Algorithm: "reason-v1",
  1382  						ID:        "cccccc00000000000000000000000001",
  1383  					}: {
  1384  						{
  1385  							TestID: bqString("testID-1"),
  1386  							Variant: []*analysis.Variant{
  1387  								{
  1388  									Key:   bqString("key1"),
  1389  									Value: bqString("value1"),
  1390  								},
  1391  								{
  1392  									Key:   bqString("key2"),
  1393  									Value: bqString("value2"),
  1394  								},
  1395  							},
  1396  							CriticalFailuresExonerated: 51,
  1397  							LastExoneration:            bigquery.NullTimestamp{Timestamp: time.Date(2123, time.April, 1, 2, 3, 4, 5, time.UTC), Valid: true},
  1398  						},
  1399  						{
  1400  							TestID: bigquery.NullString{StringVal: "testID-2"},
  1401  							Variant: []*analysis.Variant{
  1402  								{
  1403  									Key:   bqString("key1"),
  1404  									Value: bqString("value2"),
  1405  								},
  1406  								{
  1407  									Key:   bqString("key3"),
  1408  									Value: bqString("value3"),
  1409  								},
  1410  							},
  1411  							CriticalFailuresExonerated: 172,
  1412  							LastExoneration:            bigquery.NullTimestamp{Timestamp: time.Date(2124, time.May, 2, 3, 4, 5, 6, time.UTC), Valid: true},
  1413  						},
  1414  					},
  1415  				}
  1416  
  1417  				expectedResponse := &pb.QueryClusterExoneratedTestVariantsResponse{
  1418  					TestVariants: []*pb.ClusterExoneratedTestVariant{
  1419  						{
  1420  							TestId:                     "testID-1",
  1421  							Variant:                    pbutil.Variant("key1", "value1", "key2", "value2"),
  1422  							CriticalFailuresExonerated: 51,
  1423  							LastExoneration:            timestamppb.New(time.Date(2123, time.April, 1, 2, 3, 4, 5, time.UTC)),
  1424  						},
  1425  						{
  1426  							TestId:                     "testID-2",
  1427  							Variant:                    pbutil.Variant("key1", "value2", "key3", "value3"),
  1428  							CriticalFailuresExonerated: 172,
  1429  							LastExoneration:            timestamppb.New(time.Date(2124, time.May, 2, 3, 4, 5, 6, time.UTC)),
  1430  						},
  1431  					},
  1432  				}
  1433  
  1434  				// Run
  1435  				response, err := server.QueryExoneratedTestVariants(ctx, request)
  1436  
  1437  				// Verify.
  1438  				So(err, ShouldBeNil)
  1439  				So(response, ShouldResembleProto, expectedResponse)
  1440  			})
  1441  			Convey("With an invalid request", func() {
  1442  				Convey("Invalid parent", func() {
  1443  					request.Parent = "blah"
  1444  
  1445  					// Run
  1446  					response, err := server.QueryExoneratedTestVariants(ctx, request)
  1447  
  1448  					// Verify
  1449  					So(response, ShouldBeNil)
  1450  					So(err, ShouldBeRPCInvalidArgument, "parent: invalid resource name, expected format: projects/{project}/clusters/{cluster_alg}/{cluster_id}/exoneratedTestVariants")
  1451  				})
  1452  				Convey("Invalid cluster algorithm in parent", func() {
  1453  					request.Parent = "projects/blah/clusters/reason/cccccc00000000000000000000000001/exoneratedTestVariants"
  1454  
  1455  					// Run
  1456  					response, err := server.QueryExoneratedTestVariants(ctx, request)
  1457  
  1458  					// Verify
  1459  					So(response, ShouldBeNil)
  1460  					So(err, ShouldBeRPCInvalidArgument, "parent: cluster id: algorithm not valid")
  1461  				})
  1462  				Convey("Invalid cluster ID in parent", func() {
  1463  					request.Parent = "projects/blah/clusters/reason-v3/123/exoneratedTestVariants"
  1464  
  1465  					// Run
  1466  					response, err := server.QueryExoneratedTestVariants(ctx, request)
  1467  
  1468  					// Verify
  1469  					So(response, ShouldBeNil)
  1470  					So(err, ShouldBeRPCInvalidArgument, "parent: cluster id: ID is not valid lowercase hexadecimal bytes")
  1471  				})
  1472  			})
  1473  		})
  1474  
  1475  		Convey("QueryExoneratedTestVariantBranches", func() {
  1476  			authState.IdentityPermissions = listTestResultsPermissions(
  1477  				"testproject:realm1",
  1478  				"testproject:realm2",
  1479  				"otherproject:realm3",
  1480  			)
  1481  			authState.IdentityPermissions = append(authState.IdentityPermissions, authtest.RealmPermission{
  1482  				Realm:      "testproject:@project",
  1483  				Permission: perms.PermGetCluster,
  1484  			})
  1485  
  1486  			request := &pb.QueryClusterExoneratedTestVariantBranchesRequest{
  1487  				Parent: "projects/testproject/clusters/reason-v1/cccccc00000000000000000000000001/exoneratedTestVariantBranches",
  1488  			}
  1489  			Convey("Not authorised to get cluster", func() {
  1490  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetCluster)
  1491  
  1492  				response, err := server.QueryExoneratedTestVariantBranches(ctx, request)
  1493  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission analysis.clusters.get")
  1494  				So(response, ShouldBeNil)
  1495  			})
  1496  			Convey("Not authorised to list test results in any realm", func() {
  1497  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, rdbperms.PermListTestResults)
  1498  
  1499  				response, err := server.QueryExoneratedTestVariantBranches(ctx, request)
  1500  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permissions [resultdb.testResults.list resultdb.testExonerations.list] in any realm in project \"testproject\"")
  1501  				So(response, ShouldBeNil)
  1502  			})
  1503  			Convey("Not authorised to list test exonerations in any realm", func() {
  1504  				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, rdbperms.PermListTestExonerations)
  1505  
  1506  				response, err := server.QueryExoneratedTestVariantBranches(ctx, request)
  1507  				So(err, ShouldBeRPCPermissionDenied, "caller does not have permissions [resultdb.testResults.list resultdb.testExonerations.list] in any realm in project \"testproject\"")
  1508  				So(response, ShouldBeNil)
  1509  			})
  1510  			Convey("With a valid request", func() {
  1511  				analysisClient.expectedRealmsQueried = []string{"testproject:realm1", "testproject:realm2"}
  1512  				analysisClient.exoneratedTVBsByProjectAndCluster["testproject"] = map[clustering.ClusterID][]*analysis.ExoneratedTestVariantBranch{
  1513  					{
  1514  						Algorithm: "reason-v1",
  1515  						ID:        "cccccc00000000000000000000000001",
  1516  					}: {
  1517  						{
  1518  							Project: bqString("testproject"),
  1519  							TestID:  bqString("testID-1"),
  1520  							Variant: []*analysis.Variant{
  1521  								{
  1522  									Key:   bqString("key1"),
  1523  									Value: bqString("value1"),
  1524  								},
  1525  								{
  1526  									Key:   bqString("key2"),
  1527  									Value: bqString("value2"),
  1528  								},
  1529  							},
  1530  							SourceRef: analysis.SourceRef{
  1531  								Gitiles: &analysis.GitilesRef{
  1532  									Host:    bqString("myproject.googlesource.com"),
  1533  									Project: bqString("myproject/src"),
  1534  									Ref:     bqString("refs/heads/main"),
  1535  								},
  1536  							},
  1537  							CriticalFailuresExonerated: 51,
  1538  							LastExoneration:            bigquery.NullTimestamp{Timestamp: time.Date(2123, time.April, 1, 2, 3, 4, 5, time.UTC), Valid: true},
  1539  						},
  1540  						{
  1541  							Project: bqString("testproject"),
  1542  							TestID:  bigquery.NullString{StringVal: "testID-2"},
  1543  							Variant: []*analysis.Variant{
  1544  								{
  1545  									Key:   bqString("key1"),
  1546  									Value: bqString("value2"),
  1547  								},
  1548  								{
  1549  									Key:   bqString("key3"),
  1550  									Value: bqString("value3"),
  1551  								},
  1552  							},
  1553  							SourceRef: analysis.SourceRef{
  1554  								Gitiles: &analysis.GitilesRef{
  1555  									Host:    bqString("myproject2.googlesource.com"),
  1556  									Project: bqString("myproject2/src"),
  1557  									Ref:     bqString("refs/heads/main2"),
  1558  								},
  1559  							},
  1560  							CriticalFailuresExonerated: 172,
  1561  							LastExoneration:            bigquery.NullTimestamp{Timestamp: time.Date(2124, time.May, 2, 3, 4, 5, 6, time.UTC), Valid: true},
  1562  						},
  1563  					},
  1564  				}
  1565  
  1566  				expectedResponse := &pb.QueryClusterExoneratedTestVariantBranchesResponse{
  1567  					TestVariantBranches: []*pb.ClusterExoneratedTestVariantBranch{
  1568  						{
  1569  							Project: "testproject",
  1570  							TestId:  "testID-1",
  1571  							Variant: pbutil.Variant("key1", "value1", "key2", "value2"),
  1572  							SourceRef: &pb.SourceRef{
  1573  								System: &pb.SourceRef_Gitiles{
  1574  									Gitiles: &pb.GitilesRef{
  1575  										Host:    "myproject.googlesource.com",
  1576  										Project: "myproject/src",
  1577  										Ref:     "refs/heads/main",
  1578  									},
  1579  								},
  1580  							},
  1581  							CriticalFailuresExonerated: 51,
  1582  							LastExoneration:            timestamppb.New(time.Date(2123, time.April, 1, 2, 3, 4, 5, time.UTC)),
  1583  						},
  1584  						{
  1585  							Project: "testproject",
  1586  							TestId:  "testID-2",
  1587  							Variant: pbutil.Variant("key1", "value2", "key3", "value3"),
  1588  							SourceRef: &pb.SourceRef{
  1589  								System: &pb.SourceRef_Gitiles{
  1590  									Gitiles: &pb.GitilesRef{
  1591  										Host:    "myproject2.googlesource.com",
  1592  										Project: "myproject2/src",
  1593  										Ref:     "refs/heads/main2",
  1594  									},
  1595  								},
  1596  							},
  1597  							CriticalFailuresExonerated: 172,
  1598  							LastExoneration:            timestamppb.New(time.Date(2124, time.May, 2, 3, 4, 5, 6, time.UTC)),
  1599  						},
  1600  					},
  1601  				}
  1602  
  1603  				// Run
  1604  				response, err := server.QueryExoneratedTestVariantBranches(ctx, request)
  1605  
  1606  				// Verify.
  1607  				So(err, ShouldBeNil)
  1608  				So(response, ShouldResembleProto, expectedResponse)
  1609  			})
  1610  			Convey("With an invalid request", func() {
  1611  				Convey("Invalid parent", func() {
  1612  					request.Parent = "blah"
  1613  
  1614  					// Run
  1615  					response, err := server.QueryExoneratedTestVariantBranches(ctx, request)
  1616  
  1617  					// Verify
  1618  					So(response, ShouldBeNil)
  1619  					So(err, ShouldBeRPCInvalidArgument, "parent: invalid resource name, expected format: projects/{project}/clusters/{cluster_alg}/{cluster_id}/exoneratedTestVariantBranches")
  1620  				})
  1621  				Convey("Invalid cluster algorithm in parent", func() {
  1622  					request.Parent = "projects/blah/clusters/reason/cccccc00000000000000000000000001/exoneratedTestVariantBranches"
  1623  
  1624  					// Run
  1625  					response, err := server.QueryExoneratedTestVariantBranches(ctx, request)
  1626  
  1627  					// Verify
  1628  					So(response, ShouldBeNil)
  1629  					So(err, ShouldBeRPCInvalidArgument, "parent: cluster id: algorithm not valid")
  1630  				})
  1631  				Convey("Invalid cluster ID in parent", func() {
  1632  					request.Parent = "projects/blah/clusters/reason-v3/123/exoneratedTestVariantBranches"
  1633  
  1634  					// Run
  1635  					response, err := server.QueryExoneratedTestVariantBranches(ctx, request)
  1636  
  1637  					// Verify
  1638  					So(response, ShouldBeNil)
  1639  					So(err, ShouldBeRPCInvalidArgument, "parent: cluster id: ID is not valid lowercase hexadecimal bytes")
  1640  				})
  1641  			})
  1642  		})
  1643  	})
  1644  }
  1645  
  1646  func bqString(value string) bigquery.NullString {
  1647  	return bigquery.NullString{StringVal: value, Valid: true}
  1648  }
  1649  
  1650  func listTestResultsPermissions(realms ...string) []authtest.RealmPermission {
  1651  	var result []authtest.RealmPermission
  1652  	for _, r := range realms {
  1653  		result = append(result, authtest.RealmPermission{
  1654  			Realm:      r,
  1655  			Permission: rdbperms.PermListTestResults,
  1656  		})
  1657  		result = append(result, authtest.RealmPermission{
  1658  			Realm:      r,
  1659  			Permission: rdbperms.PermListTestExonerations,
  1660  		})
  1661  	}
  1662  	return result
  1663  }
  1664  
  1665  func removePermission(perms []authtest.RealmPermission, permission realms.Permission) []authtest.RealmPermission {
  1666  	var result []authtest.RealmPermission
  1667  	for _, p := range perms {
  1668  		if p.Permission != permission {
  1669  			result = append(result, p)
  1670  		}
  1671  	}
  1672  	return result
  1673  }
  1674  
  1675  func emptyMetricValues() *pb.Cluster_TimewiseCounts {
  1676  	return &pb.Cluster_TimewiseCounts{
  1677  		OneDay:   &pb.Cluster_Counts{},
  1678  		ThreeDay: &pb.Cluster_Counts{},
  1679  		SevenDay: &pb.Cluster_Counts{},
  1680  	}
  1681  }
  1682  
  1683  func failureReasonClusterEntry(projectcfg *compiledcfg.ProjectConfig, primaryErrorMessage string) *pb.ClusterResponse_ClusteredTestResult_ClusterEntry {
  1684  	alg := &failurereason.Algorithm{}
  1685  	clusterID := alg.Cluster(projectcfg, &clustering.Failure{
  1686  		Reason: &pb.FailureReason{
  1687  			PrimaryErrorMessage: primaryErrorMessage,
  1688  		},
  1689  	})
  1690  	return &pb.ClusterResponse_ClusteredTestResult_ClusterEntry{
  1691  		ClusterId: &pb.ClusterId{
  1692  			Algorithm: failurereason.AlgorithmName,
  1693  			Id:        hex.EncodeToString(clusterID),
  1694  		},
  1695  	}
  1696  }
  1697  
  1698  func testNameClusterEntry(projectcfg *compiledcfg.ProjectConfig, testID string) *pb.ClusterResponse_ClusteredTestResult_ClusterEntry {
  1699  	alg := &testname.Algorithm{}
  1700  	clusterID := alg.Cluster(projectcfg, &clustering.Failure{
  1701  		TestID: testID,
  1702  	})
  1703  	return &pb.ClusterResponse_ClusteredTestResult_ClusterEntry{
  1704  		ClusterId: &pb.ClusterId{
  1705  			Algorithm: testname.AlgorithmName,
  1706  			Id:        hex.EncodeToString(clusterID),
  1707  		},
  1708  	}
  1709  }
  1710  
  1711  // sortClusterEntries sorts clusters by ascending Cluster ID.
  1712  func sortClusterEntries(entries []*pb.ClusterResponse_ClusteredTestResult_ClusterEntry) []*pb.ClusterResponse_ClusteredTestResult_ClusterEntry {
  1713  	result := make([]*pb.ClusterResponse_ClusteredTestResult_ClusterEntry, len(entries))
  1714  	copy(result, entries)
  1715  	sort.Slice(result, func(i, j int) bool {
  1716  		if result[i].ClusterId.Algorithm != result[j].ClusterId.Algorithm {
  1717  			return result[i].ClusterId.Algorithm < result[j].ClusterId.Algorithm
  1718  		}
  1719  		return result[i].ClusterId.Id < result[j].ClusterId.Id
  1720  	})
  1721  	return result
  1722  }
  1723  
  1724  type fakeAnalysisClient struct {
  1725  	clustersByProject                 map[string][]*analysis.Cluster
  1726  	failuresByProjectAndCluster       map[string]map[clustering.ClusterID][]*analysis.ClusterFailure
  1727  	exoneratedTVsByProjectAndCluster  map[string]map[clustering.ClusterID][]*analysis.ExoneratedTestVariant
  1728  	exoneratedTVBsByProjectAndCluster map[string]map[clustering.ClusterID][]*analysis.ExoneratedTestVariantBranch
  1729  	clusterMetricsByProject           map[string][]*analysis.ClusterSummary
  1730  	clusterMetricBreakdownsByProject  map[string][]*analysis.ClusterMetricBreakdown
  1731  	expectedRealmsQueried             []string
  1732  	expectedMetricFilter              *metrics.Definition
  1733  }
  1734  
  1735  func newFakeAnalysisClient() *fakeAnalysisClient {
  1736  	return &fakeAnalysisClient{
  1737  		clustersByProject:                 make(map[string][]*analysis.Cluster),
  1738  		failuresByProjectAndCluster:       make(map[string]map[clustering.ClusterID][]*analysis.ClusterFailure),
  1739  		exoneratedTVsByProjectAndCluster:  make(map[string]map[clustering.ClusterID][]*analysis.ExoneratedTestVariant),
  1740  		exoneratedTVBsByProjectAndCluster: make(map[string]map[clustering.ClusterID][]*analysis.ExoneratedTestVariantBranch),
  1741  		clusterMetricsByProject:           make(map[string][]*analysis.ClusterSummary),
  1742  		clusterMetricBreakdownsByProject:  make(map[string][]*analysis.ClusterMetricBreakdown),
  1743  	}
  1744  }
  1745  
  1746  func (f *fakeAnalysisClient) ReadCluster(ctx context.Context, project string, clusterID clustering.ClusterID) (*analysis.Cluster, error) {
  1747  	clusters, ok := f.clustersByProject[project]
  1748  	if !ok {
  1749  		return nil, nil
  1750  	}
  1751  
  1752  	var result *analysis.Cluster
  1753  	for _, c := range clusters {
  1754  		if c.ClusterID == clusterID {
  1755  			result = c
  1756  			break
  1757  		}
  1758  	}
  1759  	if result == nil {
  1760  		return analysis.EmptyCluster(clusterID), nil
  1761  	}
  1762  	return result, nil
  1763  }
  1764  
  1765  func (f *fakeAnalysisClient) QueryClusterSummaries(ctx context.Context, project string, options *analysis.QueryClusterSummariesOptions) ([]*analysis.ClusterSummary, error) {
  1766  	clusters, ok := f.clusterMetricsByProject[project]
  1767  	if !ok {
  1768  		return nil, nil
  1769  	}
  1770  
  1771  	set := stringset.NewFromSlice(options.Realms...)
  1772  	if set.Len() != len(f.expectedRealmsQueried) || !set.HasAll(f.expectedRealmsQueried...) {
  1773  		panic("realms passed to QueryClusterSummaries do not match expected")
  1774  	}
  1775  
  1776  	_, _, err := analysis.ClusteredFailuresTable.WhereClause(options.FailureFilter, "w_")
  1777  	if err != nil {
  1778  		return nil, analysis.InvalidArgumentTag.Apply(errors.Annotate(err, "failure_filter").Err())
  1779  	}
  1780  	_, err = analysis.ClusterSummariesTable(options.Metrics).OrderByClause(options.OrderBy)
  1781  	if err != nil {
  1782  		return nil, analysis.InvalidArgumentTag.Apply(errors.Annotate(err, "order_by").Err())
  1783  	}
  1784  
  1785  	var results []*analysis.ClusterSummary
  1786  	for _, c := range clusters {
  1787  		results = append(results, copyClusterSummary(c, options.Metrics, options.IncludeMetricBreakdown))
  1788  	}
  1789  	return results, nil
  1790  }
  1791  
  1792  func copyClusterSummary(cs *analysis.ClusterSummary, queriedMetrics []metrics.Definition, includeMetricBreakdown bool) *analysis.ClusterSummary {
  1793  	result := &analysis.ClusterSummary{
  1794  		ClusterID:            cs.ClusterID,
  1795  		ExampleFailureReason: cs.ExampleFailureReason,
  1796  		ExampleTestID:        cs.ExampleTestID,
  1797  		UniqueTestIDs:        cs.UniqueTestIDs,
  1798  		MetricValues:         make(map[metrics.ID]*analysis.MetricValue),
  1799  	}
  1800  	for _, m := range queriedMetrics {
  1801  		metricValue := &analysis.MetricValue{
  1802  			Value: cs.MetricValues[m.ID].Value,
  1803  		}
  1804  		if includeMetricBreakdown {
  1805  			metricValue.DailyBreakdown = cs.MetricValues[m.ID].DailyBreakdown
  1806  		}
  1807  		result.MetricValues[m.ID] = metricValue
  1808  	}
  1809  	return result
  1810  }
  1811  
  1812  func (f *fakeAnalysisClient) ReadClusterFailures(ctx context.Context, options analysis.ReadClusterFailuresOptions) ([]*analysis.ClusterFailure, error) {
  1813  	failuresByCluster, ok := f.failuresByProjectAndCluster[options.Project]
  1814  	if !ok {
  1815  		return nil, nil
  1816  	}
  1817  	if f.expectedMetricFilter != nil && (options.MetricFilter == nil || *options.MetricFilter != *f.expectedMetricFilter) ||
  1818  		f.expectedMetricFilter == nil && options.MetricFilter != nil {
  1819  		panic("filter metric passed to ReadClusterFailures does not match expected")
  1820  	}
  1821  
  1822  	set := stringset.NewFromSlice(options.Realms...)
  1823  	if set.Len() != len(f.expectedRealmsQueried) || !set.HasAll(f.expectedRealmsQueried...) {
  1824  		panic("realms passed to ReadClusterFailures do not match expected")
  1825  	}
  1826  
  1827  	return failuresByCluster[options.ClusterID], nil
  1828  }
  1829  
  1830  func (f *fakeAnalysisClient) ReadClusterExoneratedTestVariants(ctx context.Context, options analysis.ReadClusterExoneratedTestVariantsOptions) ([]*analysis.ExoneratedTestVariant, error) {
  1831  	exoneratedTVsByCluster, ok := f.exoneratedTVsByProjectAndCluster[options.Project]
  1832  	if !ok {
  1833  		return nil, nil
  1834  	}
  1835  
  1836  	set := stringset.NewFromSlice(options.Realms...)
  1837  	if set.Len() != len(f.expectedRealmsQueried) || !set.HasAll(f.expectedRealmsQueried...) {
  1838  		panic("realms passed to ReadClusterExoneratedTestVariants do not match expected")
  1839  	}
  1840  
  1841  	return exoneratedTVsByCluster[options.ClusterID], nil
  1842  }
  1843  
  1844  func (f *fakeAnalysisClient) ReadClusterExoneratedTestVariantBranches(ctx context.Context, options analysis.ReadClusterExoneratedTestVariantBranchesOptions) ([]*analysis.ExoneratedTestVariantBranch, error) {
  1845  	exoneratedTVBsByCluster, ok := f.exoneratedTVBsByProjectAndCluster[options.Project]
  1846  	if !ok {
  1847  		return nil, nil
  1848  	}
  1849  
  1850  	set := stringset.NewFromSlice(options.Realms...)
  1851  	if set.Len() != len(f.expectedRealmsQueried) || !set.HasAll(f.expectedRealmsQueried...) {
  1852  		panic("realms passed to ReadClusterExoneratedTestVariantBranches do not match expected")
  1853  	}
  1854  
  1855  	return exoneratedTVBsByCluster[options.ClusterID], nil
  1856  }
  1857  
  1858  func (f *fakeAnalysisClient) ReadClusterHistory(ctx context.Context, options analysis.ReadClusterHistoryOptions) (ret []*analysis.ReadClusterHistoryDay, err error) {
  1859  	return nil, nil
  1860  }