go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/resultdb/internal/services/recorder/create_invocation_test.go (about)

     1  // Copyright 2019 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 recorder
    16  
    17  import (
    18  	"context"
    19  	"strings"
    20  	"testing"
    21  	"time"
    22  
    23  	"cloud.google.com/go/spanner"
    24  	"github.com/golang/protobuf/proto"
    25  	"google.golang.org/grpc"
    26  	"google.golang.org/grpc/codes"
    27  	"google.golang.org/grpc/metadata"
    28  	"google.golang.org/protobuf/reflect/protoreflect"
    29  	"google.golang.org/protobuf/types/known/structpb"
    30  
    31  	"go.chromium.org/luci/common/clock"
    32  	"go.chromium.org/luci/common/clock/testclock"
    33  	"go.chromium.org/luci/common/testing/prpctest"
    34  	"go.chromium.org/luci/grpc/appstatus"
    35  	"go.chromium.org/luci/server/auth"
    36  	"go.chromium.org/luci/server/auth/authtest"
    37  	"go.chromium.org/luci/server/span"
    38  	"go.chromium.org/luci/server/tq"
    39  
    40  	"go.chromium.org/luci/resultdb/internal/invocations"
    41  	"go.chromium.org/luci/resultdb/internal/tasks/taskspb"
    42  	"go.chromium.org/luci/resultdb/internal/testutil"
    43  	"go.chromium.org/luci/resultdb/internal/testutil/insert"
    44  	"go.chromium.org/luci/resultdb/pbutil"
    45  	pb "go.chromium.org/luci/resultdb/proto/v1"
    46  
    47  	. "github.com/smartystreets/goconvey/convey"
    48  	. "go.chromium.org/luci/common/testing/assertions"
    49  )
    50  
    51  func TestValidateInvocationDeadline(t *testing.T) {
    52  	Convey(`ValidateInvocationDeadline`, t, func() {
    53  		now := testclock.TestRecentTimeUTC
    54  
    55  		Convey(`deadline in the past`, func() {
    56  			deadline := pbutil.MustTimestampProto(now.Add(-time.Hour))
    57  			err := validateInvocationDeadline(deadline, now)
    58  			So(err, ShouldErrLike, `must be at least 10 seconds in the future`)
    59  		})
    60  
    61  		Convey(`deadline 5s in the future`, func() {
    62  			deadline := pbutil.MustTimestampProto(now.Add(5 * time.Second))
    63  			err := validateInvocationDeadline(deadline, now)
    64  			So(err, ShouldErrLike, `must be at least 10 seconds in the future`)
    65  		})
    66  
    67  		Convey(`deadline in the future`, func() {
    68  			deadline := pbutil.MustTimestampProto(now.Add(1e3 * time.Hour))
    69  			err := validateInvocationDeadline(deadline, now)
    70  			So(err, ShouldErrLike, `must be before 120h in the future`)
    71  		})
    72  	})
    73  }
    74  
    75  func TestVerifyCreateInvocationPermissions(t *testing.T) {
    76  	t.Parallel()
    77  	Convey(`TestVerifyCreateInvocationPermissions`, t, func() {
    78  		ctx := auth.WithState(context.Background(), &authtest.FakeState{
    79  			Identity: "user:someone@example.com",
    80  			IdentityPermissions: []authtest.RealmPermission{
    81  				{Realm: "chromium:ci", Permission: permCreateInvocation},
    82  			},
    83  		})
    84  		Convey(`reserved prefix`, func() {
    85  			err := verifyCreateInvocationPermissions(ctx, &pb.CreateInvocationRequest{
    86  				InvocationId: "build:8765432100",
    87  				Invocation: &pb.Invocation{
    88  					Realm: "chromium:ci",
    89  				},
    90  			})
    91  			So(err, ShouldErrLike, `only invocations created by trusted systems may have id not starting with "u-"`)
    92  		})
    93  
    94  		Convey(`reserved prefix, allowed`, func() {
    95  			ctx = auth.WithState(context.Background(), &authtest.FakeState{
    96  				Identity: "user:someone@example.com",
    97  				IdentityPermissions: []authtest.RealmPermission{
    98  					{Realm: "chromium:ci", Permission: permCreateInvocation},
    99  					{Realm: "chromium:ci", Permission: permCreateWithReservedID},
   100  				},
   101  			})
   102  			err := verifyCreateInvocationPermissions(ctx, &pb.CreateInvocationRequest{
   103  				InvocationId: "build:8765432100",
   104  				Invocation: &pb.Invocation{
   105  					Realm: "chromium:ci",
   106  				},
   107  			})
   108  			So(err, ShouldBeNil)
   109  		})
   110  		Convey(`producer_resource disallowed`, func() {
   111  			ctx = auth.WithState(context.Background(), &authtest.FakeState{
   112  				Identity: "user:someone@example.com",
   113  				IdentityPermissions: []authtest.RealmPermission{
   114  					{Realm: "chromium:ci", Permission: permCreateInvocation},
   115  				},
   116  			})
   117  			err := verifyCreateInvocationPermissions(ctx, &pb.CreateInvocationRequest{
   118  				InvocationId: "u-0",
   119  				Invocation: &pb.Invocation{
   120  					Realm:            "chromium:ci",
   121  					ProducerResource: "//builds.example.com/builds/1",
   122  				},
   123  			})
   124  			So(err, ShouldErrLike, `only invocations created by trusted system may have a populated producer_resource field`)
   125  		})
   126  
   127  		Convey(`producer_resource allowed`, func() {
   128  			ctx = auth.WithState(context.Background(), &authtest.FakeState{
   129  				Identity: "user:someone@example.com",
   130  				IdentityPermissions: []authtest.RealmPermission{
   131  					{Realm: "chromium:ci", Permission: permCreateInvocation},
   132  					{Realm: "chromium:ci", Permission: permSetProducerResource},
   133  				},
   134  			})
   135  			err := verifyCreateInvocationPermissions(ctx, &pb.CreateInvocationRequest{
   136  				InvocationId: "u-0",
   137  				Invocation: &pb.Invocation{
   138  					Realm:            "chromium:ci",
   139  					ProducerResource: "//builds.example.com/builds/1",
   140  				},
   141  			})
   142  			So(err, ShouldBeNil)
   143  		})
   144  		Convey(`bigquery_exports allowed`, func() {
   145  			ctx = auth.WithState(context.Background(), &authtest.FakeState{
   146  				Identity: "user:someone@example.com",
   147  				IdentityPermissions: []authtest.RealmPermission{
   148  					{Realm: "chromium:ci", Permission: permCreateInvocation},
   149  					{Realm: "chromium:ci", Permission: permExportToBigQuery},
   150  				},
   151  			})
   152  			err := verifyCreateInvocationPermissions(ctx, &pb.CreateInvocationRequest{
   153  				InvocationId: "u-abc",
   154  				Invocation: &pb.Invocation{
   155  					Realm: "chromium:ci",
   156  					BigqueryExports: []*pb.BigQueryExport{
   157  						{
   158  							Project: "project",
   159  							Dataset: "dataset",
   160  							Table:   "table",
   161  							ResultType: &pb.BigQueryExport_TestResults_{
   162  								TestResults: &pb.BigQueryExport_TestResults{},
   163  							},
   164  						},
   165  					},
   166  				},
   167  			})
   168  			So(err, ShouldBeNil)
   169  		})
   170  		Convey(`bigquery_exports disallowed`, func() {
   171  			ctx = auth.WithState(context.Background(), &authtest.FakeState{
   172  				Identity: "user:someone@example.com",
   173  				IdentityPermissions: []authtest.RealmPermission{
   174  					{Realm: "chromium:ci", Permission: permCreateInvocation},
   175  				},
   176  			})
   177  			err := verifyCreateInvocationPermissions(ctx, &pb.CreateInvocationRequest{
   178  				InvocationId: "u-abc",
   179  				Invocation: &pb.Invocation{
   180  					Realm: "chromium:ci",
   181  					BigqueryExports: []*pb.BigQueryExport{
   182  						{
   183  							Project: "project",
   184  							Dataset: "dataset",
   185  							Table:   "table",
   186  							ResultType: &pb.BigQueryExport_TestResults_{
   187  								TestResults: &pb.BigQueryExport_TestResults{},
   188  							},
   189  						},
   190  					},
   191  				},
   192  			})
   193  			So(err, ShouldErrLike, `does not have permission to set bigquery exports`)
   194  		})
   195  		Convey(`baseline allowed`, func() {
   196  			ctx = auth.WithState(context.Background(), &authtest.FakeState{
   197  				Identity: "user:someone@example.com",
   198  				IdentityPermissions: []authtest.RealmPermission{
   199  					{Realm: "chromium:try", Permission: permCreateInvocation},
   200  					{Realm: "chromium:try", Permission: permPutBaseline},
   201  				},
   202  			})
   203  			err := verifyCreateInvocationPermissions(ctx, &pb.CreateInvocationRequest{
   204  				InvocationId: "u-abc",
   205  				Invocation: &pb.Invocation{
   206  					Realm:      "chromium:try",
   207  					BaselineId: "try:linux-rel",
   208  				},
   209  			})
   210  			So(err, ShouldBeNil)
   211  		})
   212  		Convey(`baseline disallowed`, func() {
   213  			ctx = auth.WithState(context.Background(), &authtest.FakeState{
   214  				Identity: "user:someone@example.com",
   215  				IdentityPermissions: []authtest.RealmPermission{
   216  					{Realm: "chromium:try", Permission: permCreateInvocation},
   217  				},
   218  			})
   219  			err := verifyCreateInvocationPermissions(ctx, &pb.CreateInvocationRequest{
   220  				InvocationId: "u-abc",
   221  				Invocation: &pb.Invocation{
   222  					Realm:      "chromium:try",
   223  					BaselineId: "try:linux-rel",
   224  				},
   225  			})
   226  			So(err, ShouldErrLike, `does not have permission to set baseline ids`)
   227  		})
   228  		Convey(`creation disallowed`, func() {
   229  			ctx = auth.WithState(context.Background(), &authtest.FakeState{
   230  				Identity:            "user:someone@example.com",
   231  				IdentityPermissions: []authtest.RealmPermission{},
   232  			})
   233  			err := verifyCreateInvocationPermissions(ctx, &pb.CreateInvocationRequest{
   234  				InvocationId: "build:8765432100",
   235  				Invocation: &pb.Invocation{
   236  					Realm: "chromium:ci",
   237  				},
   238  			})
   239  			So(err, ShouldErrLike, `does not have permission to create invocations`)
   240  		})
   241  		Convey(`invalid realm`, func() {
   242  			ctx = auth.WithState(context.Background(), &authtest.FakeState{
   243  				Identity:            "user:someone@example.com",
   244  				IdentityPermissions: []authtest.RealmPermission{},
   245  			})
   246  			err := verifyCreateInvocationPermissions(ctx, &pb.CreateInvocationRequest{
   247  				InvocationId: "build:8765432100",
   248  				Invocation: &pb.Invocation{
   249  					Realm: "invalid:",
   250  				},
   251  			})
   252  			So(err, ShouldHaveAppStatus, codes.InvalidArgument, `invocation: realm: bad global realm name`)
   253  		})
   254  	})
   255  
   256  }
   257  func TestValidateCreateInvocationRequest(t *testing.T) {
   258  	t.Parallel()
   259  	now := testclock.TestRecentTimeUTC
   260  	Convey(`TestValidateCreateInvocationRequest`, t, func() {
   261  		addedInvs := make(invocations.IDSet)
   262  		deadline := pbutil.MustTimestampProto(now.Add(time.Hour))
   263  		request := &pb.CreateInvocationRequest{
   264  			InvocationId: "u-abc",
   265  			Invocation: &pb.Invocation{
   266  				Deadline:            deadline,
   267  				Tags:                pbutil.StringPairs("a", "b", "a", "c", "d", "e"),
   268  				Realm:               "chromium:ci",
   269  				IncludedInvocations: []string{"invocations/u-abc-2"},
   270  				State:               pb.Invocation_FINALIZING,
   271  			},
   272  		}
   273  
   274  		Convey(`valid`, func() {
   275  			err := validateCreateInvocationRequest(request, now, addedInvs)
   276  			So(err, ShouldBeNil)
   277  		})
   278  
   279  		Convey(`empty`, func() {
   280  			err := validateCreateInvocationRequest(&pb.CreateInvocationRequest{}, now, addedInvs)
   281  			So(err, ShouldErrLike, `invocation_id: unspecified`)
   282  		})
   283  
   284  		Convey(`invalid id`, func() {
   285  			request.InvocationId = "1"
   286  			err := validateCreateInvocationRequest(request, now, addedInvs)
   287  			So(err, ShouldErrLike, `invocation_id: does not match`)
   288  		})
   289  
   290  		Convey(`invalid request id`, func() {
   291  			request.RequestId = "😃"
   292  			err := validateCreateInvocationRequest(request, now, addedInvs)
   293  			So(err, ShouldErrLike, "request_id: does not match")
   294  		})
   295  
   296  		Convey(`invalid tags`, func() {
   297  			request.Invocation.Tags = pbutil.StringPairs("1", "a")
   298  			err := validateCreateInvocationRequest(request, now, addedInvs)
   299  			So(err, ShouldErrLike, `invocation: tags: "1":"a": key: does not match`)
   300  		})
   301  
   302  		Convey(`invalid deadline`, func() {
   303  			request.Invocation.Deadline = pbutil.MustTimestampProto(now.Add(-time.Hour))
   304  			err := validateCreateInvocationRequest(request, now, addedInvs)
   305  			So(err, ShouldErrLike, `invocation: deadline: must be at least 10 seconds in the future`)
   306  		})
   307  
   308  		Convey(`invalid realm`, func() {
   309  			request.Invocation.Realm = "B@d/f::rm@t"
   310  			err := validateCreateInvocationRequest(request, now, addedInvs)
   311  			So(err, ShouldErrLike, `invocation: realm: bad global realm name`)
   312  		})
   313  
   314  		Convey(`invalid state`, func() {
   315  			request.Invocation.State = pb.Invocation_FINALIZED
   316  			err := validateCreateInvocationRequest(request, now, addedInvs)
   317  			So(err, ShouldErrLike, `invocation: state: cannot be created in the state FINALIZED`)
   318  		})
   319  
   320  		Convey(`invalid included invocation`, func() {
   321  			request.Invocation.IncludedInvocations = []string{"not an invocation name"}
   322  			err := validateCreateInvocationRequest(request, now, addedInvs)
   323  			So(err, ShouldErrLike, `included_invocations[0]: invalid included invocation name`)
   324  		})
   325  
   326  		Convey(`invalid bigqueryExports`, func() {
   327  			request.Invocation.BigqueryExports = []*pb.BigQueryExport{
   328  				{
   329  					Project: "project",
   330  				},
   331  			}
   332  			err := validateCreateInvocationRequest(request, now, addedInvs)
   333  			So(err, ShouldErrLike, `bigquery_export[0]: dataset: unspecified`)
   334  		})
   335  
   336  		Convey(`invalid source spec`, func() {
   337  			request.Invocation.SourceSpec = &pb.SourceSpec{
   338  				Sources: &pb.Sources{
   339  					GitilesCommit: &pb.GitilesCommit{},
   340  				},
   341  			}
   342  			err := validateCreateInvocationRequest(request, now, addedInvs)
   343  			So(err, ShouldErrLike, `source_spec: sources: gitiles_commit: host: unspecified`)
   344  		})
   345  
   346  		Convey(`invalid baseline`, func() {
   347  			request.Invocation.BaselineId = "try/linux-rel"
   348  			err := validateCreateInvocationRequest(request, now, addedInvs)
   349  			So(err, ShouldErrLike, `invocation: baseline_id: does not match`)
   350  		})
   351  
   352  		Convey(`invalid properties`, func() {
   353  			request.Invocation.Properties = &structpb.Struct{
   354  				Fields: map[string]*structpb.Value{
   355  					"a": structpb.NewStringValue(strings.Repeat("a", pbutil.MaxSizeProperties)),
   356  				},
   357  			}
   358  			err := validateCreateInvocationRequest(request, now, addedInvs)
   359  			So(err, ShouldErrLike, `properties: exceeds the maximum size of`, `bytes`)
   360  		})
   361  	})
   362  }
   363  
   364  func TestCreateInvocation(t *testing.T) {
   365  	Convey(`TestCreateInvocation`, t, func() {
   366  		ctx := testutil.SpannerTestContext(t)
   367  		ctx, sched := tq.TestingContext(ctx, nil)
   368  		ctx = auth.WithState(ctx, &authtest.FakeState{
   369  			Identity: "user:someone@example.com",
   370  			IdentityPermissions: []authtest.RealmPermission{
   371  				{Realm: "testproject:testrealm", Permission: permCreateInvocation},
   372  				{Realm: "testproject:testrealm", Permission: permCreateWithReservedID},
   373  				{Realm: "testproject:testrealm", Permission: permExportToBigQuery},
   374  				{Realm: "testproject:testrealm", Permission: permSetProducerResource},
   375  				{Realm: "testproject:testrealm", Permission: permIncludeInvocation},
   376  				{Realm: "testproject:createonly", Permission: permCreateInvocation},
   377  				{Realm: "testproject:testrealm", Permission: permPutBaseline},
   378  			},
   379  		})
   380  
   381  		start := clock.Now(ctx).UTC()
   382  
   383  		// Setup a full HTTP server in order to retrieve response headers.
   384  		server := &prpctest.Server{}
   385  		server.UnaryServerInterceptor = func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
   386  			res, err := handler(ctx, req)
   387  			err = appstatus.GRPCifyAndLog(ctx, err)
   388  			return res, err
   389  		}
   390  		pb.RegisterRecorderServer(server, newTestRecorderServer())
   391  		server.Start(ctx)
   392  		defer server.Close()
   393  		client, err := server.NewClient()
   394  		So(err, ShouldBeNil)
   395  		recorder := pb.NewRecorderPRPCClient(client)
   396  
   397  		Convey(`empty request`, func() {
   398  			_, err := recorder.CreateInvocation(ctx, &pb.CreateInvocationRequest{})
   399  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, `invocation: unspecified`)
   400  		})
   401  		Convey(`invalid realm`, func() {
   402  			req := &pb.CreateInvocationRequest{
   403  				InvocationId: "u-inv",
   404  				Invocation: &pb.Invocation{
   405  					Realm: "testproject:",
   406  				},
   407  				RequestId: "request id",
   408  			}
   409  			_, err := recorder.CreateInvocation(ctx, req)
   410  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, `invocation: realm`)
   411  		})
   412  		Convey(`missing invocation id`, func() {
   413  			_, err := recorder.CreateInvocation(ctx, &pb.CreateInvocationRequest{
   414  				Invocation: &pb.Invocation{
   415  					Realm: "testproject:testrealm",
   416  				},
   417  			})
   418  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument, `invocation_id: unspecified`)
   419  		})
   420  
   421  		req := &pb.CreateInvocationRequest{
   422  			InvocationId: "u-inv",
   423  			Invocation: &pb.Invocation{
   424  				Realm: "testproject:testrealm",
   425  			},
   426  		}
   427  
   428  		Convey(`already exists`, func() {
   429  			_, err := span.Apply(ctx, []*spanner.Mutation{
   430  				insert.Invocation("u-inv", 1, nil),
   431  			})
   432  			So(err, ShouldBeNil)
   433  
   434  			_, err = recorder.CreateInvocation(ctx, req)
   435  			So(err, ShouldHaveGRPCStatus, codes.AlreadyExists)
   436  		})
   437  
   438  		Convey(`unsorted tags`, func() {
   439  			req.Invocation.Tags = pbutil.StringPairs("b", "2", "a", "1")
   440  			inv, err := recorder.CreateInvocation(ctx, req)
   441  			So(err, ShouldBeNil)
   442  			So(inv.Tags, ShouldResemble, pbutil.StringPairs("a", "1", "b", "2"))
   443  		})
   444  
   445  		Convey(`no invocation in request`, func() {
   446  			_, err := recorder.CreateInvocation(ctx, &pb.CreateInvocationRequest{InvocationId: "u-inv"})
   447  			So(err, ShouldErrLike, "invocation: unspecified")
   448  		})
   449  
   450  		Convey(`idempotent`, func() {
   451  			req := &pb.CreateInvocationRequest{
   452  				InvocationId: "u-inv",
   453  				Invocation: &pb.Invocation{
   454  					Realm: "testproject:testrealm",
   455  				},
   456  				RequestId: "request id",
   457  			}
   458  			res, err := recorder.CreateInvocation(ctx, req)
   459  			So(err, ShouldBeNil)
   460  
   461  			res2, err := recorder.CreateInvocation(ctx, req)
   462  			So(err, ShouldBeNil)
   463  			So(res2, ShouldResembleProto, res)
   464  		})
   465  		Convey(`included invocation`, func() {
   466  			req = &pb.CreateInvocationRequest{
   467  				InvocationId: "u-inv",
   468  				Invocation: &pb.Invocation{
   469  					Realm:               "testproject:testrealm",
   470  					IncludedInvocations: []string{"invocations/u-inv-child"},
   471  				},
   472  			}
   473  			Convey(`non-existing invocation`, func() {
   474  				_, err := recorder.CreateInvocation(ctx, req)
   475  				So(err, ShouldErrLike, "invocations/u-inv-child not found")
   476  			})
   477  			Convey(`non-permitted invocation`, func() {
   478  				incReq := &pb.CreateInvocationRequest{
   479  					InvocationId: "u-inv-child",
   480  					Invocation: &pb.Invocation{
   481  						Realm: "testproject:createonly",
   482  					},
   483  				}
   484  				_, err := recorder.CreateInvocation(ctx, incReq)
   485  				So(err, ShouldBeNil)
   486  
   487  				_, err = recorder.CreateInvocation(ctx, req)
   488  				So(err, ShouldErrLike, "caller does not have permission resultdb.invocations.include")
   489  			})
   490  			Convey(`valid`, func() {
   491  				_, err := recorder.CreateInvocation(ctx, &pb.CreateInvocationRequest{
   492  					InvocationId: "u-inv-child",
   493  					Invocation: &pb.Invocation{
   494  						Realm: "testproject:testrealm",
   495  					},
   496  				})
   497  				So(err, ShouldBeNil)
   498  
   499  				_, err = recorder.CreateInvocation(ctx, req)
   500  				So(err, ShouldBeNil)
   501  
   502  				incIDs, err := invocations.ReadIncluded(span.Single(ctx), invocations.ID("u-inv"))
   503  				So(err, ShouldBeNil)
   504  				So(incIDs.Has(invocations.ID("u-inv-child")), ShouldBeTrue)
   505  			})
   506  		})
   507  
   508  		Convey(`end to end`, func() {
   509  			deadline := pbutil.MustTimestampProto(start.Add(time.Hour))
   510  			headers := &metadata.MD{}
   511  
   512  			// Included invocation
   513  			req := &pb.CreateInvocationRequest{
   514  				InvocationId: "u-inv-child",
   515  				Invocation: &pb.Invocation{
   516  					Realm: "testproject:testrealm",
   517  				},
   518  			}
   519  			_, err := recorder.CreateInvocation(ctx, req, grpc.Header(headers))
   520  			So(err, ShouldBeNil)
   521  
   522  			// Including invocation.
   523  			bqExport := &pb.BigQueryExport{
   524  				Project: "project",
   525  				Dataset: "dataset",
   526  				Table:   "table",
   527  				ResultType: &pb.BigQueryExport_TestResults_{
   528  					TestResults: &pb.BigQueryExport_TestResults{},
   529  				},
   530  			}
   531  
   532  			req = &pb.CreateInvocationRequest{
   533  				InvocationId: "u-inv",
   534  				Invocation: &pb.Invocation{
   535  					Deadline: deadline,
   536  					Tags:     pbutil.StringPairs("a", "1", "b", "2"),
   537  					BigqueryExports: []*pb.BigQueryExport{
   538  						bqExport,
   539  					},
   540  					ProducerResource:    "//builds.example.com/builds/1",
   541  					Realm:               "testproject:testrealm",
   542  					IncludedInvocations: []string{"invocations/u-inv-child"},
   543  					State:               pb.Invocation_FINALIZING,
   544  					Properties:          testutil.TestProperties(),
   545  					SourceSpec: &pb.SourceSpec{
   546  						Sources: testutil.TestSources(),
   547  					},
   548  					BaselineId: "testrealm:test-builder",
   549  				},
   550  			}
   551  			inv, err := recorder.CreateInvocation(ctx, req, grpc.Header(headers))
   552  			So(err, ShouldBeNil)
   553  			So(sched.Tasks().Payloads(), ShouldResembleProto, []protoreflect.ProtoMessage{
   554  				&taskspb.TryFinalizeInvocation{InvocationId: "u-inv"},
   555  			})
   556  
   557  			expected := proto.Clone(req.Invocation).(*pb.Invocation)
   558  			proto.Merge(expected, &pb.Invocation{
   559  				Name:      "invocations/u-inv",
   560  				CreatedBy: "user:someone@example.com",
   561  
   562  				// we use Spanner commit time, so skip the check
   563  				CreateTime: inv.CreateTime,
   564  			})
   565  			So(inv, ShouldResembleProto, expected)
   566  
   567  			So(headers.Get(pb.UpdateTokenMetadataKey), ShouldHaveLength, 1)
   568  
   569  			ctx, cancel := span.ReadOnlyTransaction(ctx)
   570  			defer cancel()
   571  
   572  			inv, err = invocations.Read(ctx, "u-inv")
   573  			So(err, ShouldBeNil)
   574  			So(inv, ShouldResembleProto, expected)
   575  
   576  			// Check fields not present in the proto.
   577  			var invExpirationTime, expectedResultsExpirationTime time.Time
   578  			err = invocations.ReadColumns(ctx, "u-inv", map[string]any{
   579  				"InvocationExpirationTime":          &invExpirationTime,
   580  				"ExpectedTestResultsExpirationTime": &expectedResultsExpirationTime,
   581  			})
   582  			So(err, ShouldBeNil)
   583  			So(expectedResultsExpirationTime, ShouldHappenWithin, time.Second, start.Add(expectedResultExpiration))
   584  			So(invExpirationTime, ShouldHappenWithin, time.Second, start.Add(invocationExpirationDuration))
   585  		})
   586  	})
   587  }