go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/scheduler/appengine/task/buildbucket/buildbucket_test.go (about)

     1  // Copyright 2015 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 buildbucket
    16  
    17  import (
    18  	"context"
    19  	"encoding/base64"
    20  	"fmt"
    21  	"math/rand"
    22  	"net/http"
    23  	"sort"
    24  	"strings"
    25  	"sync/atomic"
    26  	"testing"
    27  	"time"
    28  
    29  	"github.com/golang/protobuf/jsonpb"
    30  	"github.com/golang/protobuf/proto"
    31  	"google.golang.org/api/pubsub/v1"
    32  	"google.golang.org/grpc/codes"
    33  	"google.golang.org/grpc/status"
    34  	"google.golang.org/protobuf/types/known/structpb"
    35  
    36  	bbpb "go.chromium.org/luci/buildbucket/proto"
    37  	"go.chromium.org/luci/common/data/rand/mathrand"
    38  	"go.chromium.org/luci/config/validation"
    39  	"go.chromium.org/luci/gae/impl/memory"
    40  	api "go.chromium.org/luci/scheduler/api/scheduler/v1"
    41  	"go.chromium.org/luci/scheduler/appengine/engine/policy"
    42  	"go.chromium.org/luci/scheduler/appengine/internal"
    43  	"go.chromium.org/luci/scheduler/appengine/messages"
    44  	"go.chromium.org/luci/scheduler/appengine/task"
    45  	"go.chromium.org/luci/scheduler/appengine/task/utils/tasktest"
    46  
    47  	. "github.com/smartystreets/goconvey/convey"
    48  	. "go.chromium.org/luci/common/testing/assertions"
    49  )
    50  
    51  var _ task.Manager = (*TaskManager)(nil)
    52  
    53  func TestValidateProtoMessage(t *testing.T) {
    54  	t.Parallel()
    55  
    56  	tm := TaskManager{}
    57  	c := context.Background()
    58  
    59  	Convey("ValidateProtoMessage works", t, func() {
    60  		ctx := &validation.Context{Context: c}
    61  		validate := func(msg proto.Message) error {
    62  			tm.ValidateProtoMessage(ctx, msg, "some-project:some-realm")
    63  			return ctx.Finalize()
    64  		}
    65  
    66  		Convey("ValidateProtoMessage passes good msg", func() {
    67  			So(validate(&messages.BuildbucketTask{
    68  				Server:     "blah.com",
    69  				Bucket:     "bucket",
    70  				Builder:    "builder",
    71  				Tags:       []string{"a:b", "c:d"},
    72  				Properties: []string{"a:b", "c:d"},
    73  			}), ShouldBeNil)
    74  		})
    75  
    76  		Convey("ValidateProtoMessage passes good minimal msg", func() {
    77  			So(validate(&messages.BuildbucketTask{
    78  				Server:  "blah.com",
    79  				Builder: "builder",
    80  			}), ShouldBeNil)
    81  		})
    82  
    83  		Convey("ValidateProtoMessage wrong type", func() {
    84  			So(validate(&messages.NoopTask{}), ShouldErrLike, "wrong type")
    85  		})
    86  
    87  		Convey("ValidateProtoMessage empty", func() {
    88  			So(validate(tm.ProtoMessageType()), ShouldErrLike, "expecting a non-empty BuildbucketTask")
    89  		})
    90  
    91  		Convey("ValidateProtoMessage validates URL", func() {
    92  			call := func(url string) error {
    93  				ctx = &validation.Context{Context: c}
    94  				tm.ValidateProtoMessage(ctx, &messages.BuildbucketTask{
    95  					Server:  url,
    96  					Bucket:  "bucket",
    97  					Builder: "builder",
    98  				}, "some-project:some-realm")
    99  				return ctx.Finalize()
   100  			}
   101  			So(call(""), ShouldErrLike, "field 'server' is required")
   102  			So(call("https://host/not-root"), ShouldErrLike, "field 'server' should be just a host, not a URL")
   103  			So(call("%%%%"), ShouldErrLike, "field 'server' is not a valid hostname")
   104  			So(call("blah.com/abc"), ShouldErrLike, "field 'server' is not a valid hostname")
   105  		})
   106  
   107  		Convey("ValidateProtoMessage needs bucket", func() {
   108  			tm.ValidateProtoMessage(ctx, &messages.BuildbucketTask{
   109  				Server:  "blah.com",
   110  				Builder: "builder",
   111  			}, "some-project:@legacy")
   112  			So(ctx.Finalize(), ShouldErrLike, `'bucket' field for jobs in "@legacy" realm is required`)
   113  		})
   114  
   115  		Convey("ValidateProtoMessage needs builder", func() {
   116  			So(validate(&messages.BuildbucketTask{
   117  				Server: "blah.com",
   118  				Bucket: "bucket",
   119  			}), ShouldErrLike, "'builder' field is required")
   120  		})
   121  
   122  		Convey("ValidateProtoMessage validates properties", func() {
   123  			So(validate(&messages.BuildbucketTask{
   124  				Server:     "blah.com",
   125  				Bucket:     "bucket",
   126  				Builder:    "builder",
   127  				Properties: []string{"not_kv_pair"},
   128  			}), ShouldErrLike, "bad property, not a 'key:value' pair")
   129  		})
   130  
   131  		Convey("ValidateProtoMessage validates tags", func() {
   132  			So(validate(&messages.BuildbucketTask{
   133  				Server:  "blah.com",
   134  				Bucket:  "bucket",
   135  				Builder: "builder",
   136  				Tags:    []string{"not_kv_pair"},
   137  			}), ShouldErrLike, "bad tag, not a 'key:value' pair")
   138  		})
   139  
   140  		Convey("ValidateProtoMessage forbids default tags overwrite", func() {
   141  			So(validate(&messages.BuildbucketTask{
   142  				Server:  "blah.com",
   143  				Bucket:  "bucket",
   144  				Builder: "builder",
   145  				Tags:    []string{"scheduler_job_id:blah"},
   146  			}), ShouldErrLike, "tag \"scheduler_job_id\" is reserved")
   147  		})
   148  	})
   149  }
   150  
   151  func fakeController(testSrvURL string) *tasktest.TestController {
   152  	return &tasktest.TestController{
   153  		TaskMessage: &messages.BuildbucketTask{
   154  			Server:  testSrvURL,
   155  			Bucket:  "test-bucket",
   156  			Builder: "builder",
   157  			Tags:    []string{"a:from-task-def", "b:from-task-def"},
   158  		},
   159  		Req: task.Request{
   160  			IncomingTriggers: []*internal.Trigger{
   161  				{
   162  					Id:    "trigger",
   163  					Title: "Trigger",
   164  					Url:   "https://trigger.example.com",
   165  					Payload: &internal.Trigger_Gitiles{
   166  						Gitiles: &api.GitilesTrigger{
   167  							Repo:     "https://chromium.googlesource.com/chromium/src",
   168  							Ref:      "refs/heads/master",
   169  							Revision: "deadbeef",
   170  						},
   171  					},
   172  				},
   173  			},
   174  		},
   175  		Client:       http.DefaultClient,
   176  		SaveCallback: func() error { return nil },
   177  		PrepareTopicCallback: func(publisher string) (string, string, error) {
   178  			if publisher != testSrvURL {
   179  				panic(fmt.Sprintf("expecting %q, got %q", testSrvURL, publisher))
   180  			}
   181  			return "topic", "auth_token", nil
   182  		},
   183  	}
   184  }
   185  
   186  func TestBuilderID(t *testing.T) {
   187  	t.Parallel()
   188  
   189  	var cases = []struct {
   190  		RealmID string
   191  		Bucket  string
   192  		Output  string
   193  		Error   string
   194  	}{
   195  		{"proj:realm", "", "proj:realm", ""},
   196  		{"proj:@legacy", "", "", "is required"},
   197  		{"proj:@root", "", "", "is required"},
   198  
   199  		{"proj:realm", "another-proj:buck", "another-proj:buck", ""},
   200  		{"proj:realm", "buck", "proj:buck", ""},
   201  		{"proj:realm", "abc.def.123", "proj:abc.def.123", ""},
   202  		{"proj:@legacy", "buck", "proj:buck", ""},
   203  
   204  		{"proj:realm", "luci.proj.buck", "", `use "buck" instead`},
   205  		{"proj:realm", "luci.another-proj.buck", "", `use "another-proj:buck" instead`},
   206  		{"proj:realm", "luci.another-proj", "", "need 3 components"},
   207  	}
   208  
   209  	for _, c := range cases {
   210  		bid, err := builderID(&messages.BuildbucketTask{
   211  			Bucket:  c.Bucket,
   212  			Builder: "some-builder",
   213  		}, c.RealmID)
   214  		if c.Error != "" {
   215  			if err == nil {
   216  				t.Errorf("Expected to fail for %q %q, but did not", c.Bucket, c.RealmID)
   217  			} else if !strings.Contains(err.Error(), c.Error) {
   218  				t.Errorf("Expected to fail with %q, but failed with %q", c.Error, err.Error())
   219  			}
   220  		} else {
   221  			if err != nil {
   222  				t.Errorf("Expected to succeed for %q %q, but failed with %q", c.Bucket, c.RealmID, err.Error())
   223  			} else if got := fmt.Sprintf("%s:%s", bid.Project, bid.Bucket); got != c.Output {
   224  				t.Errorf("Expected to get %q, but got %q", c.Output, got)
   225  			}
   226  		}
   227  	}
   228  }
   229  
   230  func TestFullFlow(t *testing.T) {
   231  	t.Parallel()
   232  
   233  	Convey("LaunchTask and HandleNotification work", t, func(ctx C) {
   234  		scheduleRequest := make(chan *bbpb.ScheduleBuildRequest, 1)
   235  
   236  		buildStatus := atomic.Value{}
   237  		buildStatus.Store(bbpb.Status_STARTED)
   238  
   239  		srv := BuildbucketFake{
   240  			ScheduleBuild: func(req *bbpb.ScheduleBuildRequest) (*bbpb.Build, error) {
   241  				scheduleRequest <- req
   242  				return &bbpb.Build{
   243  					Id:     9025781602559305888,
   244  					Status: bbpb.Status_STARTED,
   245  				}, nil
   246  			},
   247  			GetBuild: func(req *bbpb.GetBuildRequest) (*bbpb.Build, error) {
   248  				if req.Id != 9025781602559305888 {
   249  					return nil, status.Errorf(codes.NotFound, "wrong build ID")
   250  				}
   251  				return &bbpb.Build{
   252  					Id:     req.Id,
   253  					Status: buildStatus.Load().(bbpb.Status),
   254  				}, nil
   255  			},
   256  		}
   257  		srv.Start()
   258  		defer srv.Stop()
   259  
   260  		c := memory.Use(context.Background())
   261  		c = mathrand.Set(c, rand.New(rand.NewSource(1000)))
   262  		mgr := TaskManager{}
   263  		ctl := fakeController(srv.URL())
   264  
   265  		// Launch.
   266  		So(mgr.LaunchTask(c, ctl), ShouldBeNil)
   267  		So(ctl.TaskState, ShouldResemble, task.State{
   268  			Status:   task.StatusRunning,
   269  			TaskData: []byte(`{"build_id":"9025781602559305888"}`),
   270  			ViewURL:  srv.URL() + "/build/9025781602559305888",
   271  		})
   272  
   273  		So(<-scheduleRequest, ShouldResembleProto, &bbpb.ScheduleBuildRequest{
   274  			RequestId: "1",
   275  			Builder: &bbpb.BuilderID{
   276  				Project: "some-project",
   277  				Bucket:  "test-bucket",
   278  				Builder: "builder",
   279  			},
   280  			GitilesCommit: &bbpb.GitilesCommit{
   281  				Host:    "chromium.googlesource.com",
   282  				Project: "chromium/src",
   283  				Id:      "deadbeef",
   284  				Ref:     "refs/heads/master",
   285  			},
   286  			Properties: structFromJSON(`{
   287  				"$recipe_engine/scheduler": {
   288  					"hostname": "app.example.com",
   289  					"job": "some-project/some-job",
   290  					"invocation": "1",
   291  					"triggers": [
   292  						{
   293  							"id": "trigger",
   294  							"title": "Trigger",
   295  							"url": "https://trigger.example.com",
   296  							"gitiles": {
   297  								"repo":     "https://chromium.googlesource.com/chromium/src",
   298  								"ref":      "refs/heads/master",
   299  								"revision": "deadbeef"
   300  							}
   301  						}
   302  					]
   303  				}
   304  			}`),
   305  			Tags: []*bbpb.StringPair{
   306  				{Key: "scheduler_invocation_id", Value: "1"},
   307  				{Key: "scheduler_job_id", Value: "some-project/some-job"},
   308  				{Key: "user_agent", Value: "app"},
   309  				{Key: "a", Value: "from-task-def"},
   310  				{Key: "b", Value: "from-task-def"},
   311  			},
   312  			Notify: &bbpb.NotificationConfig{
   313  				PubsubTopic: "topic",
   314  				UserData:    []byte("auth_token"),
   315  			},
   316  		})
   317  
   318  		// Added the timer.
   319  		So(ctl.Timers, ShouldResemble, []tasktest.TimerSpec{
   320  			{
   321  				Delay: 224 * time.Second, // random
   322  				Name:  statusCheckTimerName,
   323  			},
   324  		})
   325  		ctl.Timers = nil
   326  
   327  		// The timer is called. Checks the state, reschedules itself.
   328  		So(mgr.HandleTimer(c, ctl, statusCheckTimerName, nil), ShouldBeNil)
   329  		So(ctl.Timers, ShouldResemble, []tasktest.TimerSpec{
   330  			{
   331  				Delay: 157 * time.Second, // random
   332  				Name:  statusCheckTimerName,
   333  			},
   334  		})
   335  
   336  		// Process finish notification.
   337  		buildStatus.Store(bbpb.Status_SUCCESS)
   338  		So(mgr.HandleNotification(c, ctl, &pubsub.PubsubMessage{}), ShouldBeNil)
   339  		So(ctl.TaskState.Status, ShouldEqual, task.StatusSucceeded)
   340  	})
   341  }
   342  
   343  func TestAbort(t *testing.T) {
   344  	t.Parallel()
   345  
   346  	Convey("LaunchTask and AbortTask work", t, func(ctx C) {
   347  		srv := BuildbucketFake{
   348  			ScheduleBuild: func(req *bbpb.ScheduleBuildRequest) (*bbpb.Build, error) {
   349  				return &bbpb.Build{
   350  					Id:     9025781602559305888,
   351  					Status: bbpb.Status_STARTED,
   352  				}, nil
   353  			},
   354  			CancelBuild: func(req *bbpb.CancelBuildRequest) (*bbpb.Build, error) {
   355  				if req.Id != 9025781602559305888 {
   356  					return nil, status.Errorf(codes.NotFound, "wrong build ID")
   357  				}
   358  				return &bbpb.Build{
   359  					Id:     req.Id,
   360  					Status: bbpb.Status_CANCELED,
   361  				}, nil
   362  			},
   363  		}
   364  		srv.Start()
   365  		defer srv.Stop()
   366  
   367  		c := memory.Use(context.Background())
   368  		mgr := TaskManager{}
   369  		ctl := fakeController(srv.URL())
   370  
   371  		// Launch and kill.
   372  		So(mgr.LaunchTask(c, ctl), ShouldBeNil)
   373  		So(mgr.AbortTask(c, ctl), ShouldBeNil)
   374  	})
   375  }
   376  
   377  func TestTriggeredFlow(t *testing.T) {
   378  	t.Parallel()
   379  
   380  	Convey("LaunchTask with GitilesTrigger works", t, func(ctx C) {
   381  		scheduleRequest := make(chan *bbpb.ScheduleBuildRequest, 1)
   382  
   383  		srv := BuildbucketFake{
   384  			ScheduleBuild: func(req *bbpb.ScheduleBuildRequest) (*bbpb.Build, error) {
   385  				scheduleRequest <- req
   386  				return &bbpb.Build{
   387  					Id:     9025781602559305888,
   388  					Status: bbpb.Status_STARTED,
   389  				}, nil
   390  			},
   391  			GetBuild: func(req *bbpb.GetBuildRequest) (*bbpb.Build, error) {
   392  				if req.Id != 9025781602559305888 {
   393  					return nil, status.Errorf(codes.NotFound, "wrong build ID")
   394  				}
   395  				return &bbpb.Build{
   396  					Id:     req.Id,
   397  					Status: bbpb.Status_SUCCESS,
   398  				}, nil
   399  			},
   400  		}
   401  		srv.Start()
   402  		defer srv.Stop()
   403  
   404  		c := memory.Use(context.Background())
   405  		mgr := TaskManager{}
   406  		ctl := fakeController(srv.URL())
   407  
   408  		schedule := func(triggers []*internal.Trigger) *bbpb.ScheduleBuildRequest {
   409  			// Prepare the request the same way the engine does using RequestBuilder.
   410  			req := policy.RequestBuilder{}
   411  			req.FromTrigger(triggers[len(triggers)-1])
   412  			req.IncomingTriggers = triggers
   413  			ctl.Req = req.Request
   414  
   415  			// Launch with triggers,
   416  			So(mgr.LaunchTask(c, ctl), ShouldBeNil)
   417  			So(ctl.TaskState, ShouldResemble, task.State{
   418  				Status:   task.StatusRunning,
   419  				TaskData: []byte(`{"build_id":"9025781602559305888"}`),
   420  				ViewURL:  srv.URL() + "/build/9025781602559305888",
   421  			})
   422  
   423  			return <-scheduleRequest
   424  		}
   425  
   426  		Convey("Gitiles triggers", func() {
   427  			req := schedule([]*internal.Trigger{
   428  				{
   429  					Id: "1",
   430  					Payload: &internal.Trigger_Gitiles{
   431  						Gitiles: &api.GitilesTrigger{
   432  							Repo:     "https://r.googlesource.com/repo",
   433  							Ref:      "refs/heads/master",
   434  							Revision: "baadcafe",
   435  						},
   436  					},
   437  				},
   438  				{
   439  					Id: "2",
   440  					Payload: &internal.Trigger_Gitiles{
   441  						Gitiles: &api.GitilesTrigger{
   442  							Repo:       "https://r.googlesource.com/repo",
   443  							Ref:        "refs/heads/master",
   444  							Revision:   "deadbeef",
   445  							Tags:       []string{"extra:tag", "gitiles_ref:refs/heads/master"},
   446  							Properties: structFromJSON(`{"extra_prop": "val", "branch": "ignored"}`),
   447  						},
   448  					},
   449  				},
   450  			})
   451  
   452  			// Used the last trigger to get the commit.
   453  			So(req.GitilesCommit, ShouldResembleProto, &bbpb.GitilesCommit{
   454  				Host:    "r.googlesource.com",
   455  				Project: "repo",
   456  				Id:      "deadbeef",
   457  				Ref:     "refs/heads/master",
   458  			})
   459  
   460  			// Properties are sanitized.
   461  			So(structKeys(req.Properties), ShouldResemble, []string{
   462  				"$recipe_engine/scheduler",
   463  				"extra_prop",
   464  			})
   465  
   466  			// Tags are sanitized too.
   467  			So(req.Tags, ShouldResembleProto, []*bbpb.StringPair{
   468  				{Key: "scheduler_invocation_id", Value: "1"},
   469  				{Key: "scheduler_job_id", Value: "some-project/some-job"},
   470  				{Key: "user_agent", Value: "app"},
   471  				{Key: "a", Value: "from-task-def"},
   472  				{Key: "b", Value: "from-task-def"},
   473  				{Key: "extra", Value: "tag"},
   474  			})
   475  		})
   476  
   477  		Convey("Reconstructs gitiles commit from generic trigger", func() {
   478  			req := schedule([]*internal.Trigger{
   479  				{
   480  					Id: "1",
   481  					Payload: &internal.Trigger_Buildbucket{
   482  						Buildbucket: &api.BuildbucketTrigger{
   483  							Properties: structFromJSON(`{
   484  								"repository": "https://r.googlesource.com/repo",
   485  								"branch": "master",
   486  								"revision": "deadbeef",
   487  								"extra_prop": "val"
   488  							}`),
   489  							Tags: []string{
   490  								"buildset:commit/git/deadbeef",
   491  								"buildset:commit/gitiles/r.googlesource.com/repo/+/deadbeef",
   492  								"gitiles_ref:ignored",
   493  								"gitiles_ref:master",
   494  								"extra:tag",
   495  							},
   496  						},
   497  					},
   498  				},
   499  			})
   500  
   501  			// Reconstructed gitiles commit from properties.
   502  			So(req.GitilesCommit, ShouldResembleProto, &bbpb.GitilesCommit{
   503  				Host:    "r.googlesource.com",
   504  				Project: "repo",
   505  				Id:      "deadbeef",
   506  				Ref:     "refs/heads/master",
   507  			})
   508  
   509  			// Properties are sanitized.
   510  			So(structKeys(req.Properties), ShouldResemble, []string{
   511  				"$recipe_engine/scheduler",
   512  				"extra_prop",
   513  			})
   514  
   515  			// Tags are sanitized too.
   516  			So(req.Tags, ShouldResembleProto, []*bbpb.StringPair{
   517  				{Key: "scheduler_invocation_id", Value: "1"},
   518  				{Key: "scheduler_job_id", Value: "some-project/some-job"},
   519  				{Key: "user_agent", Value: "app"},
   520  				{Key: "a", Value: "from-task-def"},
   521  				{Key: "b", Value: "from-task-def"},
   522  				{Key: "extra", Value: "tag"},
   523  			})
   524  		})
   525  
   526  		Convey("Branch is optional when reconstructing", func() {
   527  			req := schedule([]*internal.Trigger{
   528  				{
   529  					Id: "1",
   530  					Payload: &internal.Trigger_Buildbucket{
   531  						Buildbucket: &api.BuildbucketTrigger{
   532  							Properties: structFromJSON(`{
   533  								"repository": "https://r.googlesource.com/repo",
   534  								"revision": "deadbeef"
   535  							}`),
   536  							Tags: []string{
   537  								"buildset:commit/gitiles/r.googlesource.com/repo/+/deadbeef",
   538  							},
   539  						},
   540  					},
   541  				},
   542  			})
   543  			So(req.GitilesCommit, ShouldResembleProto, &bbpb.GitilesCommit{
   544  				Host:    "r.googlesource.com",
   545  				Project: "repo",
   546  				Id:      "deadbeef",
   547  			})
   548  			So(countTags(req.Tags, "buildset"), ShouldEqual, 0)
   549  			So(countTags(req.Tags, "gitiles_ref"), ShouldEqual, 0)
   550  		})
   551  
   552  		Convey("Properties are ignored if buildset tag is missing", func() {
   553  			req := schedule([]*internal.Trigger{
   554  				{
   555  					Id: "1",
   556  					Payload: &internal.Trigger_Buildbucket{
   557  						Buildbucket: &api.BuildbucketTrigger{
   558  							Properties: structFromJSON(`{
   559  								"repository": "https://r.googlesource.com/repo",
   560  								"branch": "main",
   561  								"revision": "deadbeef"
   562  							}`),
   563  							Tags: []string{
   564  								"gitiles_ref:ignored",
   565  							},
   566  						},
   567  					},
   568  				},
   569  			})
   570  			So(req.GitilesCommit, ShouldBeNil)
   571  			So(structKeys(req.Properties), ShouldResemble, []string{
   572  				"$recipe_engine/scheduler",
   573  			})
   574  			So(countTags(req.Tags, "buildset"), ShouldEqual, 0)
   575  			So(countTags(req.Tags, "gitiles_ref"), ShouldEqual, 0)
   576  		})
   577  
   578  		Convey("Tags are authoritative over properties", func() {
   579  			req := schedule([]*internal.Trigger{
   580  				{
   581  					Id: "1",
   582  					Payload: &internal.Trigger_Buildbucket{
   583  						Buildbucket: &api.BuildbucketTrigger{
   584  							Properties: structFromJSON(`{
   585  								"repository": "https://prop.googlesource.com/repo-prop",
   586  								"branch": "main-prop",
   587  								"revision": "aaaa"
   588  							}`),
   589  							Tags: []string{
   590  								"buildset:commit/gitiles/tag.googlesource.com/repo-tag/+/bbbb",
   591  								"gitiles_ref:main-tag",
   592  							},
   593  						},
   594  					},
   595  				},
   596  			})
   597  			So(req.GitilesCommit, ShouldResembleProto, &bbpb.GitilesCommit{
   598  				Host:    "tag.googlesource.com",
   599  				Project: "repo-tag",
   600  				Id:      "bbbb",
   601  				Ref:     "refs/heads/main-tag",
   602  			})
   603  			So(structKeys(req.Properties), ShouldResemble, []string{
   604  				"$recipe_engine/scheduler",
   605  			})
   606  			So(countTags(req.Tags, "buildset"), ShouldEqual, 0)
   607  			So(countTags(req.Tags, "gitiles_ref"), ShouldEqual, 0)
   608  		})
   609  	})
   610  }
   611  
   612  func TestPassedTriggers(t *testing.T) {
   613  	t.Parallel()
   614  
   615  	Convey(fmt.Sprintf("Passed to buildbucket triggers are capped at %d", maxTriggersAsSchedulerProperty), t, func(ctx C) {
   616  		c := memory.Use(context.Background())
   617  		ctl := fakeController("doesn't matter")
   618  		triggers := make([]*internal.Trigger, 0, maxTriggersAsSchedulerProperty+10)
   619  		add := func(i int) {
   620  			triggers = append(triggers, &internal.Trigger{
   621  				Id: fmt.Sprintf("id=%d", i),
   622  				Payload: &internal.Trigger_Gitiles{
   623  					Gitiles: &api.GitilesTrigger{
   624  						Repo:     "https://r.googlesource.com/repo",
   625  						Ref:      "refs/heads/master",
   626  						Revision: fmt.Sprintf("sha1=%d", i),
   627  					},
   628  				},
   629  			})
   630  		}
   631  		for i := 0; i < maxTriggersAsSchedulerProperty; i++ {
   632  			add(i)
   633  		}
   634  
   635  		propertiesString := func() string {
   636  			ctl.Req = task.Request{IncomingTriggers: triggers}
   637  			v, err := schedulerProperty(c, ctl)
   638  			So(err, ShouldBeNil)
   639  			return v.String()
   640  		}
   641  
   642  		s := propertiesString()
   643  		So(s, ShouldContainSubstring, "sha1=0")
   644  		So(s, ShouldContainSubstring, fmt.Sprintf("sha1=%d", maxTriggersAsSchedulerProperty-1))
   645  
   646  		add(maxTriggersAsSchedulerProperty)
   647  		s = propertiesString()
   648  		So(s, ShouldContainSubstring, fmt.Sprintf("sha1=%d", maxTriggersAsSchedulerProperty))
   649  		So(s, ShouldNotContainSubstring, "sha1=0")
   650  	})
   651  }
   652  
   653  func TestExamineNotification(t *testing.T) {
   654  	t.Parallel()
   655  
   656  	Convey("Works", t, func() {
   657  		c := memory.Use(context.Background())
   658  		mgr := TaskManager{}
   659  
   660  		Convey("v1 builds", func() {
   661  			tok := mgr.ExamineNotification(c, &pubsub.PubsubMessage{
   662  				Attributes: map[string]string{"auth_token": "blah"},
   663  			})
   664  			So(tok, ShouldEqual, "blah")
   665  		})
   666  
   667  		Convey("v2 builds", func() {
   668  			Convey("old pubsub message", func() {
   669  				call := func(data string) string {
   670  					return mgr.ExamineNotification(c, &pubsub.PubsubMessage{
   671  						Data: data,
   672  					})
   673  				}
   674  				So(call(base64.StdEncoding.EncodeToString([]byte(`{"user_data": "blah"}`))), ShouldEqual, "blah")
   675  				So(call(base64.StdEncoding.EncodeToString([]byte(`not json`))), ShouldEqual, "")
   676  				So(call("not base64"), ShouldEqual, "")
   677  			})
   678  			Convey("new pubsub message", func() {
   679  				call := func(data string) string {
   680  					return mgr.ExamineNotification(c, &pubsub.PubsubMessage{
   681  						Data:       data,
   682  						Attributes: map[string]string{"version": "v2"},
   683  					})
   684  				}
   685  
   686  				ud := base64.StdEncoding.EncodeToString([]byte("blah"))
   687  				So(call(base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"userData": "%s"}`, ud)))), ShouldEqual, "blah")
   688  				So(call(base64.StdEncoding.EncodeToString([]byte(`not json`))), ShouldEqual, "")
   689  				So(call("not base64"), ShouldEqual, "")
   690  			})
   691  		})
   692  	})
   693  }
   694  
   695  func structFromJSON(json string) *structpb.Struct {
   696  	r := strings.NewReader(json)
   697  	s := &structpb.Struct{}
   698  	if err := (&jsonpb.Unmarshaler{}).Unmarshal(r, s); err != nil {
   699  		panic(err)
   700  	}
   701  	return s
   702  }
   703  
   704  func structKeys(s *structpb.Struct) []string {
   705  	keys := make([]string, 0, len(s.Fields))
   706  	for k := range s.Fields {
   707  		keys = append(keys, k)
   708  	}
   709  	sort.Strings(keys)
   710  	return keys
   711  }
   712  
   713  func countTags(tags []*bbpb.StringPair, key string) (count int) {
   714  	for _, tag := range tags {
   715  		if tag.Key == key {
   716  			count++
   717  		}
   718  	}
   719  	return
   720  }