go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/luci_notify/notify/pubsub_test.go (about)

     1  // Copyright 2017 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 notify
    16  
    17  import (
    18  	"bytes"
    19  	"compress/zlib"
    20  	"context"
    21  	"encoding/base64"
    22  	"encoding/json"
    23  	"fmt"
    24  	"io"
    25  	"net/http"
    26  	"sort"
    27  	"strconv"
    28  	"testing"
    29  	"time"
    30  
    31  	"google.golang.org/protobuf/encoding/protojson"
    32  	"google.golang.org/protobuf/proto"
    33  	"google.golang.org/protobuf/types/known/structpb"
    34  	"google.golang.org/protobuf/types/known/timestamppb"
    35  
    36  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    37  	"go.chromium.org/luci/common/clock"
    38  	"go.chromium.org/luci/common/clock/testclock"
    39  	"go.chromium.org/luci/common/errors"
    40  	"go.chromium.org/luci/common/logging/memlogger"
    41  	gitpb "go.chromium.org/luci/common/proto/git"
    42  	"go.chromium.org/luci/gae/impl/memory"
    43  	"go.chromium.org/luci/gae/service/datastore"
    44  	"go.chromium.org/luci/server/caching"
    45  	"go.chromium.org/luci/server/tq"
    46  
    47  	apicfg "go.chromium.org/luci/luci_notify/api/config"
    48  	"go.chromium.org/luci/luci_notify/common"
    49  	"go.chromium.org/luci/luci_notify/config"
    50  	"go.chromium.org/luci/luci_notify/internal"
    51  	"go.chromium.org/luci/luci_notify/testutil"
    52  
    53  	. "github.com/smartystreets/goconvey/convey"
    54  	. "go.chromium.org/luci/common/testing/assertions"
    55  )
    56  
    57  func dummyBuildWithEmails(builder string, status buildbucketpb.Status, creationTime time.Time, revision string, notifyEmails ...EmailNotify) *Build {
    58  	ret := &Build{
    59  		Build: buildbucketpb.Build{
    60  			Builder: &buildbucketpb.BuilderID{
    61  				Project: "chromium",
    62  				Bucket:  "ci",
    63  				Builder: builder,
    64  			},
    65  			Status: status,
    66  			Input: &buildbucketpb.Build_Input{
    67  				GitilesCommit: &buildbucketpb.GitilesCommit{
    68  					Host:    defaultGitilesHost,
    69  					Project: defaultGitilesProject,
    70  					Id:      revision,
    71  				},
    72  			},
    73  		},
    74  		EmailNotify: notifyEmails,
    75  	}
    76  	ret.Build.CreateTime = timestamppb.New(creationTime)
    77  	return ret
    78  }
    79  
    80  func dummyBuildWithFailingSteps(status buildbucketpb.Status, failingSteps []string) *Build {
    81  	build := &Build{
    82  		Build: buildbucketpb.Build{
    83  			Builder: &buildbucketpb.BuilderID{
    84  				Project: "chromium",
    85  				Bucket:  "ci",
    86  				Builder: "test-builder-tree-closer",
    87  			},
    88  			Status: status,
    89  			Input: &buildbucketpb.Build_Input{
    90  				GitilesCommit: &buildbucketpb.GitilesCommit{
    91  					Host:    defaultGitilesHost,
    92  					Project: defaultGitilesProject,
    93  					Id:      "deadbeef",
    94  				},
    95  			},
    96  			EndTime: timestamppb.Now(),
    97  		},
    98  	}
    99  
   100  	for _, stepName := range failingSteps {
   101  		build.Build.Steps = append(build.Build.Steps, &buildbucketpb.Step{
   102  			Name:   stepName,
   103  			Status: buildbucketpb.Status_FAILURE,
   104  		})
   105  	}
   106  
   107  	return build
   108  }
   109  
   110  func TestExtractEmailNotifyValues(t *testing.T) {
   111  	Convey(`Test Environment for extractEmailNotifyValues`, t, func() {
   112  		extract := func(buildJSONPB string) ([]EmailNotify, error) {
   113  			build := &buildbucketpb.Build{}
   114  			err := protojson.Unmarshal([]byte(buildJSONPB), build)
   115  			So(err, ShouldBeNil)
   116  			return extractEmailNotifyValues(build, "")
   117  		}
   118  
   119  		Convey(`empty`, func() {
   120  			results, err := extract(`{}`)
   121  			So(err, ShouldBeNil)
   122  			So(results, ShouldHaveLength, 0)
   123  		})
   124  
   125  		Convey(`populated without email_notify`, func() {
   126  			results, err := extract(`{
   127  				"input": {
   128  					"properties": {
   129  						"foo": 1
   130  					}
   131  				}
   132  			}`)
   133  			So(err, ShouldBeNil)
   134  			So(results, ShouldHaveLength, 0)
   135  		})
   136  
   137  		Convey(`single email_notify value in input`, func() {
   138  			results, err := extract(`{
   139  				"input": {
   140  					"properties": {
   141  						"email_notify": [{"email": "test@email"}]
   142  					}
   143  				}
   144  			}`)
   145  			So(err, ShouldBeNil)
   146  			So(results, ShouldResemble, []EmailNotify{
   147  				{
   148  					Email:    "test@email",
   149  					Template: "",
   150  				},
   151  			})
   152  		})
   153  
   154  		Convey(`single email_notify value_with_template`, func() {
   155  			results, err := extract(`{
   156  				"input": {
   157  					"properties": {
   158  						"email_notify": [{
   159  							"email": "test@email",
   160  							"template": "test-template"
   161  						}]
   162  					}
   163  				}
   164  			}`)
   165  			So(err, ShouldBeNil)
   166  			So(results, ShouldResemble, []EmailNotify{
   167  				{
   168  					Email:    "test@email",
   169  					Template: "test-template",
   170  				},
   171  			})
   172  		})
   173  
   174  		Convey(`multiple email_notify values`, func() {
   175  			results, err := extract(`{
   176  				"input": {
   177  					"properties": {
   178  						"email_notify": [
   179  							{"email": "test@email"},
   180  							{"email": "test2@email"}
   181  						]
   182  					}
   183  				}
   184  			}`)
   185  			So(err, ShouldBeNil)
   186  			So(results, ShouldResemble, []EmailNotify{
   187  				{
   188  					Email:    "test@email",
   189  					Template: "",
   190  				},
   191  				{
   192  					Email:    "test2@email",
   193  					Template: "",
   194  				},
   195  			})
   196  		})
   197  
   198  		Convey(`output takes precedence`, func() {
   199  			results, err := extract(`{
   200  				"input": {
   201  					"properties": {
   202  						"email_notify": [
   203  							{"email": "test@email"}
   204  						]
   205  					}
   206  				},
   207  				"output": {
   208  					"properties": {
   209  						"email_notify": [
   210  							{"email": "test2@email"}
   211  						]
   212  					}
   213  				}
   214  			}`)
   215  			So(err, ShouldBeNil)
   216  			So(results, ShouldResemble, []EmailNotify{
   217  				{
   218  					Email:    "test2@email",
   219  					Template: "",
   220  				},
   221  			})
   222  		})
   223  	})
   224  }
   225  
   226  func init() {
   227  	InitDispatcher(&tq.Default)
   228  }
   229  
   230  func TestHandleBuild(t *testing.T) {
   231  	t.Parallel()
   232  
   233  	Convey(`Test Environment for handleBuild`, t, func() {
   234  		cfgName := "basic"
   235  		cfg, err := testutil.LoadProjectConfig(cfgName)
   236  		So(err, ShouldBeNil)
   237  
   238  		c := memory.Use(context.Background())
   239  		c = common.SetAppIDForTest(c, "luci-notify-test")
   240  		c = caching.WithEmptyProcessCache(c)
   241  		c = clock.Set(c, testclock.New(time.Now()))
   242  		c = memlogger.Use(c)
   243  		c, sched := tq.TestingContext(c, nil)
   244  
   245  		// Add entities to datastore and update indexes.
   246  		project := &config.Project{Name: "chromium"}
   247  		builders := makeBuilders(c, "chromium", cfg)
   248  		template := &config.EmailTemplate{
   249  			ProjectKey:          datastore.KeyForObj(c, project),
   250  			Name:                "template",
   251  			SubjectTextTemplate: "Builder {{.Build.Builder.Builder}} failed on steps {{stepNames .MatchingFailedSteps}}",
   252  		}
   253  		So(datastore.Put(c, project, builders, template), ShouldBeNil)
   254  		datastore.GetTestable(c).CatchupIndexes()
   255  
   256  		oldTime := time.Date(2015, 2, 3, 12, 54, 3, 0, time.UTC)
   257  		newTime := time.Date(2015, 2, 3, 12, 58, 7, 0, time.UTC)
   258  		newTime2 := time.Date(2015, 2, 3, 12, 59, 8, 0, time.UTC)
   259  
   260  		assertTasks := func(build *Build, checkoutFunc CheckoutFunc, expectedRecipients ...EmailNotify) {
   261  			history := mockHistoryFunc(map[string][]*gitpb.Commit{
   262  				"chromium/src":      testCommits,
   263  				"third_party/hello": revTestCommits,
   264  			})
   265  
   266  			// Test handleBuild.
   267  			err := handleBuild(c, build, checkoutFunc, history)
   268  			So(err, ShouldBeNil)
   269  
   270  			// Verify tasks were scheduled.
   271  			var actualEmails []string
   272  			for _, t := range sched.Tasks() {
   273  				et := t.Payload.(*internal.EmailTask)
   274  				actualEmails = append(actualEmails, et.Recipients...)
   275  			}
   276  			var expectedEmails []string
   277  			for _, r := range expectedRecipients {
   278  				expectedEmails = append(expectedEmails, r.Email)
   279  			}
   280  			sort.Strings(actualEmails)
   281  			sort.Strings(expectedEmails)
   282  			So(actualEmails, ShouldResemble, expectedEmails)
   283  		}
   284  
   285  		verifyBuilder := func(build *Build, revision string, checkout Checkout) {
   286  			datastore.GetTestable(c).CatchupIndexes()
   287  			id := getBuilderID(&build.Build)
   288  			builder := config.Builder{
   289  				ProjectKey: datastore.KeyForObj(c, project),
   290  				ID:         id,
   291  			}
   292  			So(datastore.Get(c, &builder), ShouldBeNil)
   293  			So(builder.Revision, ShouldResemble, revision)
   294  			So(builder.Status, ShouldEqual, build.Status)
   295  			expectCommits := checkout.ToGitilesCommits()
   296  			So(builder.GitilesCommits, ShouldResembleProto, expectCommits)
   297  		}
   298  
   299  		propEmail := EmailNotify{
   300  			Email: "property@google.com",
   301  		}
   302  		successEmail := EmailNotify{
   303  			Email: "test-example-success@google.com",
   304  		}
   305  		failEmail := EmailNotify{
   306  			Email: "test-example-failure@google.com",
   307  		}
   308  		infraFailEmail := EmailNotify{
   309  			Email: "test-example-infra-failure@google.com",
   310  		}
   311  		failAndInfraFailEmail := EmailNotify{
   312  			Email: "test-example-failure-and-infra-failure@google.com",
   313  		}
   314  		changeEmail := EmailNotify{
   315  			Email: "test-example-change@google.com",
   316  		}
   317  		commit1Email := EmailNotify{
   318  			Email: commitEmail1,
   319  		}
   320  		commit2Email := EmailNotify{
   321  			Email: commitEmail2,
   322  		}
   323  
   324  		grepLog := func(substring string) {
   325  			buf := new(bytes.Buffer)
   326  			_, err := memlogger.Dump(c, buf)
   327  			So(err, ShouldBeNil)
   328  			So(buf.String(), ShouldContainSubstring, substring)
   329  		}
   330  
   331  		Convey(`no config`, func() {
   332  			build := dummyBuildWithEmails("not-a-builder", buildbucketpb.Status_FAILURE, oldTime, rev1)
   333  			assertTasks(build, mockCheckoutFunc(nil))
   334  			grepLog("No builder")
   335  		})
   336  
   337  		Convey(`no config w/property`, func() {
   338  			build := dummyBuildWithEmails("not-a-builder", buildbucketpb.Status_FAILURE, oldTime, rev1, propEmail)
   339  			assertTasks(build, mockCheckoutFunc(nil), propEmail)
   340  		})
   341  
   342  		Convey(`no repository in-order`, func() {
   343  			build := dummyBuildWithEmails("test-builder-no-repo", buildbucketpb.Status_FAILURE, oldTime, rev1)
   344  			assertTasks(build, mockCheckoutFunc(nil), failEmail)
   345  		})
   346  
   347  		Convey(`no repository out-of-order`, func() {
   348  			build := dummyBuildWithEmails("test-builder-no-repo", buildbucketpb.Status_FAILURE, newTime, rev1)
   349  			assertTasks(build, mockCheckoutFunc(nil), failEmail)
   350  
   351  			newBuild := dummyBuildWithEmails("test-builder-no-repo", buildbucketpb.Status_SUCCESS, oldTime, rev2)
   352  			assertTasks(newBuild, mockCheckoutFunc(nil), failEmail, successEmail)
   353  			grepLog("old time")
   354  		})
   355  
   356  		Convey(`no revision`, func() {
   357  			build := &Build{
   358  				Build: buildbucketpb.Build{
   359  					Builder: &buildbucketpb.BuilderID{
   360  						Project: "chromium",
   361  						Bucket:  "ci",
   362  						Builder: "test-builder-1",
   363  					},
   364  					Status: buildbucketpb.Status_SUCCESS,
   365  				},
   366  			}
   367  			assertTasks(build, mockCheckoutFunc(nil), successEmail)
   368  			grepLog("revision")
   369  		})
   370  
   371  		Convey(`init builder`, func() {
   372  			build := dummyBuildWithEmails("test-builder-1", buildbucketpb.Status_FAILURE, oldTime, rev1)
   373  			assertTasks(build, mockCheckoutFunc(nil), failEmail)
   374  			verifyBuilder(build, rev1, nil)
   375  		})
   376  
   377  		Convey(`init builder w/property`, func() {
   378  			build := dummyBuildWithEmails("test-builder-1", buildbucketpb.Status_FAILURE, oldTime, rev1, propEmail)
   379  			assertTasks(build, mockCheckoutFunc(nil), failEmail, propEmail)
   380  			verifyBuilder(build, rev1, nil)
   381  		})
   382  
   383  		Convey(`source manifest return error`, func() {
   384  			build := dummyBuildWithEmails("test-builder-1", buildbucketpb.Status_FAILURE, oldTime, rev1, propEmail)
   385  			assertTasks(build, mockCheckoutReturnsErrorFunc(), failEmail, propEmail)
   386  			verifyBuilder(build, rev1, nil)
   387  			grepLog("Got error when getting source manifest for build")
   388  		})
   389  
   390  		Convey(`repository mismatch`, func() {
   391  			build := dummyBuildWithEmails("test-builder-1", buildbucketpb.Status_FAILURE, oldTime, rev1, propEmail)
   392  			assertTasks(build, mockCheckoutFunc(nil), failEmail, propEmail)
   393  			verifyBuilder(build, rev1, nil)
   394  
   395  			newBuild := &Build{
   396  				Build: buildbucketpb.Build{
   397  					Builder: &buildbucketpb.BuilderID{
   398  						Project: "chromium",
   399  						Bucket:  "ci",
   400  						Builder: "test-builder-1",
   401  					},
   402  					Status: buildbucketpb.Status_SUCCESS,
   403  					Input: &buildbucketpb.Build_Input{
   404  						GitilesCommit: &buildbucketpb.GitilesCommit{
   405  							Host:    defaultGitilesHost,
   406  							Project: "example/src",
   407  							Id:      rev2,
   408  						},
   409  					},
   410  				},
   411  			}
   412  			assertTasks(newBuild, mockCheckoutFunc(nil), failEmail, propEmail, successEmail)
   413  			grepLog("triggered by commit")
   414  		})
   415  
   416  		Convey(`out-of-order revision`, func() {
   417  			build := dummyBuildWithEmails("test-builder-2", buildbucketpb.Status_SUCCESS, oldTime, rev2)
   418  			assertTasks(build, mockCheckoutFunc(nil), successEmail)
   419  			verifyBuilder(build, rev2, nil)
   420  
   421  			oldRevBuild := dummyBuildWithEmails("test-builder-2", buildbucketpb.Status_FAILURE, newTime, rev1)
   422  			assertTasks(oldRevBuild, mockCheckoutFunc(nil), successEmail, failEmail)
   423  			grepLog("old commit")
   424  		})
   425  
   426  		Convey(`revision update`, func() {
   427  			build := dummyBuildWithEmails("test-builder-3", buildbucketpb.Status_SUCCESS, oldTime, rev1)
   428  			assertTasks(build, mockCheckoutFunc(nil), successEmail)
   429  			verifyBuilder(build, rev1, nil)
   430  
   431  			newBuild := dummyBuildWithEmails("test-builder-3", buildbucketpb.Status_FAILURE, newTime, rev2)
   432  			newBuild.Id++
   433  			assertTasks(newBuild, mockCheckoutFunc(nil), successEmail, failEmail, changeEmail)
   434  			verifyBuilder(newBuild, rev2, nil)
   435  		})
   436  
   437  		Convey(`revision update w/property`, func() {
   438  			build := dummyBuildWithEmails("test-builder-3", buildbucketpb.Status_SUCCESS, oldTime, rev1, propEmail)
   439  			assertTasks(build, mockCheckoutFunc(nil), successEmail, propEmail)
   440  			verifyBuilder(build, rev1, nil)
   441  
   442  			newBuild := dummyBuildWithEmails("test-builder-3", buildbucketpb.Status_FAILURE, newTime, rev2, propEmail)
   443  			newBuild.Id++
   444  			assertTasks(newBuild, mockCheckoutFunc(nil), successEmail, propEmail, failEmail, changeEmail, propEmail)
   445  			verifyBuilder(newBuild, rev2, nil)
   446  		})
   447  
   448  		Convey(`out-of-order creation time`, func() {
   449  			build := dummyBuildWithEmails("test-builder-4", buildbucketpb.Status_SUCCESS, newTime, rev1)
   450  			build.Id = 2
   451  			assertTasks(build, mockCheckoutFunc(nil), successEmail)
   452  			verifyBuilder(build, rev1, nil)
   453  
   454  			oldBuild := dummyBuildWithEmails("test-builder-4", buildbucketpb.Status_FAILURE, oldTime, rev1)
   455  			oldBuild.Id = 1
   456  			assertTasks(oldBuild, mockCheckoutFunc(nil), successEmail, failEmail)
   457  			grepLog("old time")
   458  		})
   459  
   460  		checkoutOld := Checkout{
   461  			"https://chromium.googlesource.com/chromium/src":      rev1,
   462  			"https://chromium.googlesource.com/third_party/hello": rev1,
   463  		}
   464  		checkoutNew := Checkout{
   465  			"https://chromium.googlesource.com/chromium/src":      rev2,
   466  			"https://chromium.googlesource.com/third_party/hello": rev2,
   467  		}
   468  
   469  		testBlamelistConfig := func(builderID string, emails ...EmailNotify) {
   470  			build := dummyBuildWithEmails(builderID, buildbucketpb.Status_SUCCESS, oldTime, rev1)
   471  			assertTasks(build, mockCheckoutFunc(checkoutOld))
   472  			verifyBuilder(build, rev1, checkoutOld)
   473  
   474  			newBuild := dummyBuildWithEmails(builderID, buildbucketpb.Status_FAILURE, newTime, rev2)
   475  			newBuild.Id++
   476  			assertTasks(newBuild, mockCheckoutFunc(checkoutNew), emails...)
   477  			verifyBuilder(newBuild, rev2, checkoutNew)
   478  		}
   479  
   480  		Convey(`blamelist no allowlist`, func() {
   481  			testBlamelistConfig("test-builder-blamelist-1", changeEmail, commit2Email)
   482  		})
   483  
   484  		Convey(`blamelist with allowlist`, func() {
   485  			testBlamelistConfig("test-builder-blamelist-2", changeEmail, commit1Email)
   486  		})
   487  
   488  		Convey(`blamelist against last non-empty checkout`, func() {
   489  			build := dummyBuildWithEmails("test-builder-blamelist-2", buildbucketpb.Status_SUCCESS, oldTime, rev1)
   490  			assertTasks(build, mockCheckoutFunc(checkoutOld))
   491  			verifyBuilder(build, rev1, checkoutOld)
   492  
   493  			newBuild := dummyBuildWithEmails("test-builder-blamelist-2", buildbucketpb.Status_FAILURE, newTime, rev2)
   494  			newBuild.Id++
   495  			assertTasks(newBuild, mockCheckoutFunc(nil), changeEmail)
   496  			verifyBuilder(newBuild, rev2, checkoutOld)
   497  
   498  			newestTime := time.Date(2017, 2, 3, 12, 59, 9, 0, time.UTC)
   499  			newestBuild := dummyBuildWithEmails("test-builder-blamelist-2", buildbucketpb.Status_SUCCESS, newestTime, rev2)
   500  			newestBuild.Id++
   501  			assertTasks(newestBuild, mockCheckoutFunc(checkoutNew), changeEmail, commit1Email)
   502  			verifyBuilder(newestBuild, rev2, checkoutNew)
   503  		})
   504  
   505  		Convey(`blamelist mixed`, func() {
   506  			testBlamelistConfig("test-builder-blamelist-3", commit1Email, commit2Email)
   507  		})
   508  
   509  		Convey(`blamelist duplicate`, func() {
   510  			testBlamelistConfig("test-builder-blamelist-4", commit2Email, commit2Email, commit2Email)
   511  		})
   512  
   513  		Convey(`failure type infra`, func() {
   514  			infra_failure_build := dummyBuildWithEmails("test-builder-infra-1", buildbucketpb.Status_SUCCESS, oldTime, rev2)
   515  			assertTasks(infra_failure_build, mockCheckoutFunc(nil))
   516  
   517  			infra_failure_build = dummyBuildWithEmails("test-builder-infra-1", buildbucketpb.Status_FAILURE, newTime, rev2)
   518  			assertTasks(infra_failure_build, mockCheckoutFunc(nil))
   519  
   520  			infra_failure_build = dummyBuildWithEmails("test-builder-infra-1", buildbucketpb.Status_INFRA_FAILURE, newTime2, rev2)
   521  			assertTasks(infra_failure_build, mockCheckoutFunc(nil), infraFailEmail)
   522  		})
   523  
   524  		Convey(`failure type mixed`, func() {
   525  			failure_and_infra_failure_build := dummyBuildWithEmails("test-builder-failure-and-infra-failures-1", buildbucketpb.Status_SUCCESS, oldTime, rev2)
   526  			assertTasks(failure_and_infra_failure_build, mockCheckoutFunc(nil))
   527  
   528  			failure_and_infra_failure_build = dummyBuildWithEmails("test-builder-failure-and-infra-failures-1", buildbucketpb.Status_FAILURE, newTime, rev2)
   529  			assertTasks(failure_and_infra_failure_build, mockCheckoutFunc(nil), failAndInfraFailEmail)
   530  
   531  			failure_and_infra_failure_build = dummyBuildWithEmails("test-builder-failure-and-infra-failures-1", buildbucketpb.Status_INFRA_FAILURE, newTime2, rev2)
   532  			assertTasks(failure_and_infra_failure_build, mockCheckoutFunc(nil), failAndInfraFailEmail)
   533  		})
   534  
   535  		// Some arbitrary time guaranteed to be less than time.Now() when called from handleBuild.
   536  		µs, _ := time.ParseDuration("1µs")
   537  		initialTimestamp := time.Now().AddDate(-1, 0, 0).UTC().Round(µs)
   538  
   539  		runHandleBuild := func(buildStatus buildbucketpb.Status, initialStatus config.TreeCloserStatus, failingSteps []string) *config.TreeCloser {
   540  			// Insert the tree closer to test into datastore.
   541  			builderKey := datastore.KeyForObj(c, &config.Builder{
   542  				ProjectKey: datastore.KeyForObj(c, &config.Project{Name: "chromium"}),
   543  				ID:         "ci/test-builder-tree-closer",
   544  			})
   545  
   546  			tc := &config.TreeCloser{
   547  				BuilderKey:     builderKey,
   548  				TreeStatusHost: "chromium-status.appspot.com",
   549  				TreeCloser: apicfg.TreeCloser{
   550  					FailedStepRegexp:        "include",
   551  					FailedStepRegexpExclude: "exclude",
   552  					Template:                "template",
   553  				},
   554  				Status:    initialStatus,
   555  				Timestamp: initialTimestamp,
   556  			}
   557  			So(datastore.Put(c, tc), ShouldBeNil)
   558  
   559  			// Handle a new build.
   560  			build := dummyBuildWithFailingSteps(buildStatus, failingSteps)
   561  			history := mockHistoryFunc(map[string][]*gitpb.Commit{})
   562  			So(handleBuild(c, build, mockCheckoutFunc(nil), history), ShouldBeNil)
   563  
   564  			// Fetch the new tree closer.
   565  			So(datastore.Get(c, tc), ShouldBeNil)
   566  			return tc
   567  		}
   568  
   569  		testStatus := func(buildStatus buildbucketpb.Status, initialStatus, expectedNewStatus config.TreeCloserStatus, expectingUpdatedTimestamp bool, failingSteps []string) {
   570  			tc := runHandleBuild(buildStatus, initialStatus, failingSteps)
   571  
   572  			// Assert the resulting state of the tree closer.
   573  			So(tc.Status, ShouldEqual, expectedNewStatus)
   574  			So(tc.Timestamp.After(initialTimestamp), ShouldEqual, expectingUpdatedTimestamp)
   575  		}
   576  
   577  		// We want to exhaustively test all combinations of the following:
   578  		//   * Did the build succeed?
   579  		//   * If not, do the filters (if any) match?
   580  		//   * Is the resulting status the same as the old status?
   581  		// All possibilities are explored in the tests below.
   582  
   583  		Convey(`Build passed, Closed -> Open`, func() {
   584  			testStatus(buildbucketpb.Status_SUCCESS, config.Closed, config.Open, true, []string{})
   585  		})
   586  
   587  		Convey(`Build passed, Open -> Open`, func() {
   588  			testStatus(buildbucketpb.Status_SUCCESS, config.Open, config.Open, true, []string{})
   589  		})
   590  
   591  		Convey(`Build failed, filters don't match, Closed -> Open`, func() {
   592  			testStatus(buildbucketpb.Status_FAILURE, config.Closed, config.Open, true, []string{"exclude"})
   593  		})
   594  
   595  		Convey(`Build failed, filters don't match, Open -> Open`, func() {
   596  			testStatus(buildbucketpb.Status_FAILURE, config.Open, config.Open, true, []string{"exclude"})
   597  		})
   598  
   599  		Convey(`Build failed, filters match, Open -> Closed`, func() {
   600  			testStatus(buildbucketpb.Status_FAILURE, config.Open, config.Closed, true, []string{"include"})
   601  		})
   602  
   603  		Convey(`Build failed, filters match, Closed -> Closed`, func() {
   604  			testStatus(buildbucketpb.Status_FAILURE, config.Closed, config.Closed, true, []string{"include"})
   605  		})
   606  
   607  		// In addition, we want to test that statuses other than SUCCESS and FAILURE don't
   608  		// cause any updates, regardless of the initial state.
   609  
   610  		Convey(`Infra failure, stays Open`, func() {
   611  			testStatus(buildbucketpb.Status_INFRA_FAILURE, config.Open, config.Open, false, []string{"include"})
   612  		})
   613  
   614  		Convey(`Infra failure, stays Closed`, func() {
   615  			testStatus(buildbucketpb.Status_INFRA_FAILURE, config.Closed, config.Closed, false, []string{"include"})
   616  		})
   617  
   618  		// Test that the correct status message is generated.
   619  		Convey(`Status message`, func() {
   620  			tc := runHandleBuild(buildbucketpb.Status_FAILURE, config.Open, []string{"include"})
   621  
   622  			So(tc.Message, ShouldEqual, `Builder test-builder-tree-closer failed on steps "include"`)
   623  		})
   624  
   625  		Convey(`All failed steps listed if no filter`, func() {
   626  			// Insert the tree closer to test into datastore.
   627  			builderKey := datastore.KeyForObj(c, &config.Builder{
   628  				ProjectKey: datastore.KeyForObj(c, &config.Project{Name: "chromium"}),
   629  				ID:         "ci/test-builder-tree-closer",
   630  			})
   631  
   632  			tc := &config.TreeCloser{
   633  				BuilderKey:     builderKey,
   634  				TreeStatusHost: "chromium-status.appspot.com",
   635  				TreeCloser:     apicfg.TreeCloser{Template: "template"},
   636  				Status:         config.Open,
   637  				Timestamp:      initialTimestamp,
   638  			}
   639  			So(datastore.Put(c, tc), ShouldBeNil)
   640  
   641  			// Handle a new build.
   642  			build := dummyBuildWithFailingSteps(buildbucketpb.Status_FAILURE, []string{"step1", "step2"})
   643  			history := mockHistoryFunc(map[string][]*gitpb.Commit{})
   644  			So(handleBuild(c, build, mockCheckoutFunc(nil), history), ShouldBeNil)
   645  
   646  			// Fetch the new tree closer.
   647  			So(datastore.Get(c, tc), ShouldBeNil)
   648  
   649  			So(tc.Message, ShouldEqual, `Builder test-builder-tree-closer failed on steps "step1", "step2"`)
   650  		})
   651  	})
   652  }
   653  
   654  func makeBuilders(c context.Context, projectID string, cfg *apicfg.ProjectConfig) []*config.Builder {
   655  	var builders []*config.Builder
   656  	parentKey := datastore.MakeKey(c, "Project", projectID)
   657  	for _, cfgNotifier := range cfg.Notifiers {
   658  		for _, cfgBuilder := range cfgNotifier.Builders {
   659  			builders = append(builders, &config.Builder{
   660  				ProjectKey: parentKey,
   661  				ID:         fmt.Sprintf("%s/%s", cfgBuilder.Bucket, cfgBuilder.Name),
   662  				Repository: cfgBuilder.Repository,
   663  				Notifications: apicfg.Notifications{
   664  					Notifications: cfgNotifier.Notifications,
   665  				},
   666  			})
   667  		}
   668  	}
   669  	return builders
   670  }
   671  
   672  func mockCheckoutFunc(c Checkout) CheckoutFunc {
   673  	return func(_ context.Context, _ *Build) (Checkout, error) {
   674  		return c, nil
   675  	}
   676  }
   677  
   678  func mockCheckoutReturnsErrorFunc() CheckoutFunc {
   679  	return func(_ context.Context, _ *Build) (Checkout, error) {
   680  		return nil, errors.New("Some error")
   681  	}
   682  }
   683  
   684  func TestExtractBuild(t *testing.T) {
   685  	t.Parallel()
   686  
   687  	Convey("builds_v2 pubsub message", t, func() {
   688  		Convey("success", func() {
   689  			ctx := memory.Use(context.Background())
   690  			props := &structpb.Struct{
   691  				Fields: map[string]*structpb.Value{
   692  					"email_notify": structpb.NewListValue(&structpb.ListValue{Values: []*structpb.Value{
   693  						structpb.NewStructValue(&structpb.Struct{
   694  							Fields: map[string]*structpb.Value{
   695  								"email": {
   696  									Kind: &structpb.Value_StringValue{
   697  										StringValue: "abc@gmail.com",
   698  									},
   699  								},
   700  							},
   701  						}),
   702  					}}),
   703  				},
   704  			}
   705  			originalBuild := &buildbucketpb.Build{
   706  				Id: 123,
   707  				Builder: &buildbucketpb.BuilderID{
   708  					Project: "project",
   709  					Bucket:  "bucket",
   710  					Builder: "builder",
   711  				},
   712  				Status: buildbucketpb.Status_SUCCESS,
   713  				Infra: &buildbucketpb.BuildInfra{
   714  					Buildbucket: &buildbucketpb.BuildInfra_Buildbucket{
   715  						Hostname: "buildbuckt.com",
   716  					},
   717  				},
   718  				Input: &buildbucketpb.Build_Input{},
   719  				Output: &buildbucketpb.Build_Output{
   720  					Properties: props,
   721  				},
   722  				Steps: []*buildbucketpb.Step{{Name: "step1"}},
   723  			}
   724  			pubsubMsg, err := makeBuildsV2PubsubMsg(originalBuild)
   725  			So(err, ShouldBeNil)
   726  			b, err := extractBuild(ctx, &http.Request{Body: pubsubMsg})
   727  			So(err, ShouldBeNil)
   728  			So(b.Id, ShouldEqual, originalBuild.Id)
   729  			So(b.Builder, ShouldResembleProto, originalBuild.Builder)
   730  			So(b.Status, ShouldEqual, buildbucketpb.Status_SUCCESS)
   731  			So(b.Infra, ShouldResembleProto, originalBuild.Infra)
   732  			So(b.Input, ShouldResembleProto, originalBuild.Input)
   733  			So(b.Output, ShouldResembleProto, originalBuild.Output)
   734  			So(b.Steps, ShouldResembleProto, originalBuild.Steps)
   735  			So(b.BuildbucketHostname, ShouldEqual, originalBuild.Infra.Buildbucket.Hostname)
   736  			So(b.EmailNotify, ShouldResemble, []EmailNotify{{Email: "abc@gmail.com"}})
   737  		})
   738  
   739  		Convey("success with no email_notify field", func() {
   740  			ctx := memory.Use(context.Background())
   741  			props := &structpb.Struct{
   742  				Fields: map[string]*structpb.Value{
   743  					"other": structpb.NewListValue(&structpb.ListValue{Values: []*structpb.Value{
   744  						structpb.NewStructValue(&structpb.Struct{
   745  							Fields: map[string]*structpb.Value{
   746  								"other": {
   747  									Kind: &structpb.Value_StringValue{
   748  										StringValue: "other",
   749  									},
   750  								},
   751  							},
   752  						}),
   753  					}}),
   754  				},
   755  			}
   756  			originalBuild := &buildbucketpb.Build{
   757  				Id: 123,
   758  				Builder: &buildbucketpb.BuilderID{
   759  					Project: "project",
   760  					Bucket:  "bucket",
   761  					Builder: "builder",
   762  				},
   763  				Status: buildbucketpb.Status_CANCELED,
   764  				Infra: &buildbucketpb.BuildInfra{
   765  					Buildbucket: &buildbucketpb.BuildInfra_Buildbucket{
   766  						Hostname: "buildbuckt.com",
   767  					},
   768  				},
   769  				Input: &buildbucketpb.Build_Input{},
   770  				Output: &buildbucketpb.Build_Output{
   771  					Properties: props,
   772  				},
   773  				Steps: []*buildbucketpb.Step{{Name: "step1"}},
   774  			}
   775  			pubsubMsg, err := makeBuildsV2PubsubMsg(originalBuild)
   776  			So(err, ShouldBeNil)
   777  			b, err := extractBuild(ctx, &http.Request{Body: pubsubMsg})
   778  			So(err, ShouldBeNil)
   779  			So(b.Id, ShouldEqual, originalBuild.Id)
   780  			So(b.Builder, ShouldResembleProto, originalBuild.Builder)
   781  			So(b.Status, ShouldEqual, buildbucketpb.Status_CANCELED)
   782  			So(b.Infra, ShouldResembleProto, originalBuild.Infra)
   783  			So(b.Input, ShouldResembleProto, originalBuild.Input)
   784  			So(b.Output, ShouldResembleProto, originalBuild.Output)
   785  			So(b.Steps, ShouldResembleProto, originalBuild.Steps)
   786  			So(b.BuildbucketHostname, ShouldEqual, originalBuild.Infra.Buildbucket.Hostname)
   787  			So(b.EmailNotify, ShouldBeNil)
   788  		})
   789  
   790  		Convey("incompleted build", func() {
   791  			ctx := memory.Use(context.Background())
   792  			originalBuild := &buildbucketpb.Build{
   793  				Id: 123,
   794  				Builder: &buildbucketpb.BuilderID{
   795  					Project: "project",
   796  					Bucket:  "bucket",
   797  					Builder: "builder",
   798  				},
   799  				Status: buildbucketpb.Status_SCHEDULED,
   800  				Infra: &buildbucketpb.BuildInfra{
   801  					Buildbucket: &buildbucketpb.BuildInfra_Buildbucket{
   802  						Hostname: "buildbuckt.com",
   803  					},
   804  				},
   805  				Input:  &buildbucketpb.Build_Input{},
   806  				Output: &buildbucketpb.Build_Output{},
   807  				Steps:  []*buildbucketpb.Step{{Name: "step1"}},
   808  			}
   809  			pubsubMsg, err := makeBuildsV2PubsubMsg(originalBuild)
   810  			So(err, ShouldBeNil)
   811  			b, err := extractBuild(ctx, &http.Request{Body: pubsubMsg})
   812  			So(err, ShouldBeNil)
   813  			So(b, ShouldBeNil)
   814  		})
   815  
   816  	})
   817  }
   818  
   819  func makeBuildsV2PubsubMsg(b *buildbucketpb.Build) (io.ReadCloser, error) {
   820  	copyB := proto.Clone(b).(*buildbucketpb.Build)
   821  	large := &buildbucketpb.Build{
   822  		Input: &buildbucketpb.Build_Input{
   823  			Properties: copyB.GetInput().GetProperties(),
   824  		},
   825  		Output: &buildbucketpb.Build_Output{
   826  			Properties: copyB.GetOutput().GetProperties(),
   827  		},
   828  		Steps: copyB.GetSteps(),
   829  	}
   830  	if copyB.Input != nil {
   831  		copyB.Input.Properties = nil
   832  	}
   833  	if copyB.Output != nil {
   834  		copyB.Output.Properties = nil
   835  	}
   836  	copyB.Steps = nil
   837  	compress := func(data []byte) ([]byte, error) {
   838  		buf := &bytes.Buffer{}
   839  		zw := zlib.NewWriter(buf)
   840  		if _, err := zw.Write(data); err != nil {
   841  			return nil, errors.Annotate(err, "failed to compress").Err()
   842  		}
   843  		if err := zw.Close(); err != nil {
   844  			return nil, errors.Annotate(err, "error closing zlib writer").Err()
   845  		}
   846  		return buf.Bytes(), nil
   847  	}
   848  	largeBytes, err := proto.Marshal(large)
   849  	if err != nil {
   850  		return nil, errors.Annotate(err, "failed to marshal large").Err()
   851  	}
   852  	compressedLarge, err := compress(largeBytes)
   853  	if err != nil {
   854  		return nil, err
   855  	}
   856  	data, _ := protojson.Marshal(&buildbucketpb.BuildsV2PubSub{
   857  		Build:            copyB,
   858  		BuildLargeFields: compressedLarge,
   859  	})
   860  	isCompleted := copyB.Status&buildbucketpb.Status_ENDED_MASK == buildbucketpb.Status_ENDED_MASK
   861  	attrs := map[string]any{
   862  		"project":      copyB.Builder.GetProject(),
   863  		"bucket":       copyB.Builder.GetBucket(),
   864  		"builder":      copyB.Builder.GetBuilder(),
   865  		"is_completed": strconv.FormatBool(isCompleted),
   866  		"version":      "v2",
   867  	}
   868  	msg := struct {
   869  		Message struct {
   870  			Data       string
   871  			Attributes map[string]any
   872  		}
   873  	}{struct {
   874  		Data       string
   875  		Attributes map[string]any
   876  	}{Data: base64.StdEncoding.EncodeToString(data), Attributes: attrs}}
   877  	jmsg, _ := json.Marshal(msg)
   878  	return io.NopCloser(bytes.NewReader(jmsg)), nil
   879  }