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

     1  // Copyright 2024 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  	"fmt"
    20  	"strings"
    21  	"testing"
    22  	"time"
    23  
    24  	"cloud.google.com/go/spanner"
    25  	"google.golang.org/protobuf/types/known/timestamppb"
    26  
    27  	"go.chromium.org/luci/server/auth"
    28  	"go.chromium.org/luci/server/auth/authtest"
    29  	"go.chromium.org/luci/server/caching"
    30  	"go.chromium.org/luci/server/secrets"
    31  	"go.chromium.org/luci/server/secrets/testsecrets"
    32  	"go.chromium.org/luci/server/span"
    33  
    34  	"go.chromium.org/luci/tree_status/internal/status"
    35  	"go.chromium.org/luci/tree_status/internal/testutil"
    36  	pb "go.chromium.org/luci/tree_status/proto/v1"
    37  
    38  	. "github.com/smartystreets/goconvey/convey"
    39  	. "go.chromium.org/luci/common/testing/assertions"
    40  )
    41  
    42  func TestStatus(t *testing.T) {
    43  	Convey("With a Status server", t, func() {
    44  		ctx := testutil.IntegrationTestContext(t)
    45  		ctx = caching.WithEmptyProcessCache(ctx)
    46  
    47  		// For user identification.
    48  		ctx = authtest.MockAuthConfig(ctx)
    49  		authState := &authtest.FakeState{
    50  			Identity:       "user:someone@example.com",
    51  			IdentityGroups: []string{"luci-tree-status-access", "googlers"},
    52  		}
    53  		ctx = auth.WithState(ctx, authState)
    54  		ctx = secrets.Use(ctx, &testsecrets.Store{})
    55  
    56  		server := NewTreeStatusServer()
    57  		Convey("GetStatus", func() {
    58  			Convey("Anonymous rejected", func() {
    59  				ctx = fakeAuth().anonymous().setInContext(ctx)
    60  
    61  				request := &pb.GetStatusRequest{
    62  					Name: "trees/chromium/status/latest",
    63  				}
    64  				status, err := server.GetStatus(ctx, request)
    65  
    66  				So(err, ShouldBeRPCPermissionDenied, "log in")
    67  				So(status, ShouldBeNil)
    68  			})
    69  			Convey("No read access rejected", func() {
    70  				ctx = fakeAuth().setInContext(ctx)
    71  
    72  				request := &pb.GetStatusRequest{
    73  					Name: "trees/chromium/status/latest",
    74  				}
    75  				status, err := server.GetStatus(ctx, request)
    76  
    77  				So(err, ShouldBeRPCPermissionDenied, "not a member of luci-tree-status-access")
    78  				So(status, ShouldBeNil)
    79  			})
    80  			Convey("Read latest when no status updates returns fallback", func() {
    81  				ctx = fakeAuth().withReadAccess().withAuditAccess().setInContext(ctx)
    82  
    83  				request := &pb.GetStatusRequest{
    84  					Name: "trees/chromium/status/latest",
    85  				}
    86  				status, err := server.GetStatus(ctx, request)
    87  
    88  				So(err, ShouldBeNil)
    89  				So(status.Name, ShouldEqual, "trees/chromium/status/fallback")
    90  				So(status.GeneralState, ShouldEqual, pb.GeneralState_OPEN)
    91  				So(status.Message, ShouldEqual, "Tree is open (fallback due to no status updates in past 140 days)")
    92  			})
    93  			Convey("Read latest", func() {
    94  				ctx = fakeAuth().withReadAccess().withAuditAccess().setInContext(ctx)
    95  				NewStatusBuilder().WithMessage("earliest").CreateInDB(ctx)
    96  				latest := NewStatusBuilder().WithMessage("latest").CreateInDB(ctx)
    97  
    98  				request := &pb.GetStatusRequest{
    99  					Name: "trees/chromium/status/latest",
   100  				}
   101  				status, err := server.GetStatus(ctx, request)
   102  
   103  				So(err, ShouldBeNil)
   104  				So(status, ShouldResembleProto, &pb.Status{
   105  					Name:         fmt.Sprintf("trees/chromium/status/%s", latest.StatusID),
   106  					GeneralState: pb.GeneralState_OPEN,
   107  					Message:      "latest",
   108  					CreateUser:   "someone@example.com",
   109  					CreateTime:   timestamppb.New(latest.CreateTime),
   110  				})
   111  			})
   112  			Convey("Read latest with no audit access hides username", func() {
   113  				ctx = fakeAuth().withReadAccess().setInContext(ctx)
   114  				NewStatusBuilder().WithMessage("earliest").CreateInDB(ctx)
   115  				latest := NewStatusBuilder().WithMessage("latest").CreateInDB(ctx)
   116  
   117  				request := &pb.GetStatusRequest{
   118  					Name: "trees/chromium/status/latest",
   119  				}
   120  				status, err := server.GetStatus(ctx, request)
   121  
   122  				So(err, ShouldBeNil)
   123  				So(status, ShouldResembleProto, &pb.Status{
   124  					Name:         fmt.Sprintf("trees/chromium/status/%s", latest.StatusID),
   125  					GeneralState: pb.GeneralState_OPEN,
   126  					Message:      "latest",
   127  					CreateTime:   timestamppb.New(latest.CreateTime),
   128  				})
   129  			})
   130  			Convey("Read by name", func() {
   131  				ctx = fakeAuth().withReadAccess().withAuditAccess().setInContext(ctx)
   132  				earliest := NewStatusBuilder().WithMessage("earliest").CreateInDB(ctx)
   133  				NewStatusBuilder().WithMessage("latest").CreateInDB(ctx)
   134  
   135  				request := &pb.GetStatusRequest{
   136  					Name: fmt.Sprintf("trees/chromium/status/%s", earliest.StatusID),
   137  				}
   138  				status, err := server.GetStatus(ctx, request)
   139  
   140  				So(err, ShouldBeNil)
   141  				So(status, ShouldResembleProto, &pb.Status{
   142  					Name:         fmt.Sprintf("trees/chromium/status/%s", earliest.StatusID),
   143  					GeneralState: pb.GeneralState_OPEN,
   144  					Message:      "earliest",
   145  					CreateUser:   "someone@example.com",
   146  					CreateTime:   timestamppb.New(earliest.CreateTime),
   147  				})
   148  			})
   149  			Convey("Read by name with no audit access hides username", func() {
   150  				ctx = fakeAuth().withReadAccess().setInContext(ctx)
   151  				earliest := NewStatusBuilder().WithMessage("earliest").CreateInDB(ctx)
   152  				NewStatusBuilder().WithMessage("latest").CreateInDB(ctx)
   153  
   154  				request := &pb.GetStatusRequest{
   155  					Name: fmt.Sprintf("trees/chromium/status/%s", earliest.StatusID),
   156  				}
   157  				status, err := server.GetStatus(ctx, request)
   158  
   159  				So(err, ShouldBeNil)
   160  				So(status, ShouldResembleProto, &pb.Status{
   161  					Name:         fmt.Sprintf("trees/chromium/status/%s", earliest.StatusID),
   162  					GeneralState: pb.GeneralState_OPEN,
   163  					Message:      "earliest",
   164  					CreateTime:   timestamppb.New(earliest.CreateTime),
   165  				})
   166  			})
   167  			Convey("Read of invalid id", func() {
   168  				ctx = fakeAuth().withReadAccess().setInContext(ctx)
   169  
   170  				request := &pb.GetStatusRequest{
   171  					Name: "trees/chromium/status/INVALID",
   172  				}
   173  				_, err := server.GetStatus(ctx, request)
   174  
   175  				So(err, ShouldBeRPCInvalidArgument, "name: expected format")
   176  			})
   177  			Convey("Read of non existing valid id", func() {
   178  				ctx = fakeAuth().withReadAccess().setInContext(ctx)
   179  
   180  				request := &pb.GetStatusRequest{
   181  					Name: "trees/chromium/status/abcd1234abcd1234abcd1234abcd1234",
   182  				}
   183  				_, err := server.GetStatus(ctx, request)
   184  
   185  				So(err, ShouldBeRPCNotFound, "status value was not found")
   186  			})
   187  		})
   188  
   189  		Convey("Create", func() {
   190  			Convey("Anonymous rejected", func() {
   191  				ctx = fakeAuth().anonymous().setInContext(ctx)
   192  
   193  				request := &pb.CreateStatusRequest{
   194  					Parent: "trees/chromium/status",
   195  				}
   196  				status, err := server.CreateStatus(ctx, request)
   197  
   198  				So(err, ShouldBeRPCPermissionDenied, "log in")
   199  				So(status, ShouldBeNil)
   200  			})
   201  			Convey("No access rejected", func() {
   202  				ctx = fakeAuth().setInContext(ctx)
   203  
   204  				request := &pb.CreateStatusRequest{
   205  					Parent: "trees/chromium/status",
   206  				}
   207  				status, err := server.CreateStatus(ctx, request)
   208  
   209  				So(err, ShouldBeRPCPermissionDenied, "not a member of luci-tree-status-access")
   210  				So(status, ShouldBeNil)
   211  			})
   212  			Convey("No write access rejected", func() {
   213  				ctx = fakeAuth().withReadAccess().setInContext(ctx)
   214  
   215  				request := &pb.CreateStatusRequest{
   216  					Parent: "trees/chromium/status",
   217  				}
   218  				status, err := server.CreateStatus(ctx, request)
   219  
   220  				So(err, ShouldBeRPCPermissionDenied, "you do not have permission to update the tree status")
   221  				So(status, ShouldBeNil)
   222  			})
   223  			Convey("Successful Create", func() {
   224  				ctx = fakeAuth().withReadAccess().withWriteAccess().setInContext(ctx)
   225  
   226  				request := &pb.CreateStatusRequest{
   227  					Parent: "trees/chromium/status",
   228  					Status: &pb.Status{
   229  						GeneralState: pb.GeneralState_CLOSED,
   230  						Message:      "closed",
   231  					},
   232  				}
   233  				actual, err := server.CreateStatus(ctx, request)
   234  
   235  				So(err, ShouldBeNil)
   236  				So(actual, ShouldResembleProto, &pb.Status{
   237  					Name:         actual.Name,
   238  					GeneralState: pb.GeneralState_CLOSED,
   239  					Message:      "closed",
   240  					CreateUser:   "someone@example.com",
   241  					CreateTime:   actual.CreateTime,
   242  				})
   243  				So(actual.Name, ShouldContainSubstring, "trees/chromium/status/")
   244  				So(time.Since(actual.CreateTime.AsTime()), ShouldBeLessThan, time.Minute)
   245  
   246  				// Check it was actually written to the DB.
   247  				s, err := status.ReadLatest(span.Single(ctx), "chromium")
   248  				So(err, ShouldBeNil)
   249  				So(s, ShouldResemble, &status.Status{
   250  					TreeName:      "chromium",
   251  					StatusID:      strings.Split(actual.Name, "/")[3],
   252  					GeneralStatus: pb.GeneralState_CLOSED,
   253  					Message:       "closed",
   254  					CreateUser:    "someone@example.com",
   255  					CreateTime:    actual.CreateTime.AsTime(),
   256  				})
   257  			})
   258  			Convey("Invalid parent", func() {
   259  				ctx = fakeAuth().withReadAccess().withWriteAccess().setInContext(ctx)
   260  
   261  				request := &pb.CreateStatusRequest{
   262  					Parent: "chromium",
   263  					Status: &pb.Status{
   264  						GeneralState: pb.GeneralState_CLOSED,
   265  						Message:      "closed",
   266  					},
   267  				}
   268  				_, err := server.CreateStatus(ctx, request)
   269  
   270  				So(err, ShouldBeRPCInvalidArgument, "expected format: ^trees/")
   271  			})
   272  			Convey("Name ignored", func() {
   273  				ctx = fakeAuth().withReadAccess().withWriteAccess().setInContext(ctx)
   274  
   275  				request := &pb.CreateStatusRequest{
   276  					Parent: "trees/chromium/status",
   277  					Status: &pb.Status{
   278  						Name:         "incorrect_format_name",
   279  						GeneralState: pb.GeneralState_CLOSED,
   280  						Message:      "closed",
   281  					},
   282  				}
   283  				actual, err := server.CreateStatus(ctx, request)
   284  
   285  				So(err, ShouldBeNil)
   286  				So(actual.Name, ShouldContainSubstring, "trees/chromium/status/")
   287  			})
   288  			Convey("Empty general_state", func() {
   289  				ctx = fakeAuth().withReadAccess().withWriteAccess().setInContext(ctx)
   290  
   291  				request := &pb.CreateStatusRequest{
   292  					Parent: "trees/chromium/status",
   293  					Status: &pb.Status{
   294  						Message: "closed",
   295  					},
   296  				}
   297  				_, err := server.CreateStatus(ctx, request)
   298  
   299  				So(err, ShouldBeRPCInvalidArgument, "general_state: must be specified")
   300  			})
   301  			Convey("Empty message", func() {
   302  				ctx = fakeAuth().withReadAccess().withWriteAccess().setInContext(ctx)
   303  
   304  				request := &pb.CreateStatusRequest{
   305  					Parent: "trees/chromium/status",
   306  					Status: &pb.Status{
   307  						GeneralState: pb.GeneralState_CLOSED,
   308  					},
   309  				}
   310  				_, err := server.CreateStatus(ctx, request)
   311  
   312  				So(err, ShouldBeRPCInvalidArgument, "message: must be specified")
   313  			})
   314  			Convey("User ignored", func() {
   315  				ctx = fakeAuth().withReadAccess().withWriteAccess().setInContext(ctx)
   316  
   317  				request := &pb.CreateStatusRequest{
   318  					Parent: "trees/chromium/status",
   319  					Status: &pb.Status{
   320  						GeneralState: pb.GeneralState_CLOSED,
   321  						Message:      "closed",
   322  						CreateUser:   "someoneelse@somewhere.com",
   323  					},
   324  				}
   325  				actual, err := server.CreateStatus(ctx, request)
   326  
   327  				So(err, ShouldBeNil)
   328  				So(actual.CreateUser, ShouldEqual, "someone@example.com")
   329  			})
   330  			Convey("Time ignored", func() {
   331  				ctx = fakeAuth().withReadAccess().withWriteAccess().setInContext(ctx)
   332  
   333  				request := &pb.CreateStatusRequest{
   334  					Parent: "trees/chromium/status",
   335  					Status: &pb.Status{
   336  						GeneralState: pb.GeneralState_CLOSED,
   337  						Message:      "closed",
   338  						CreateTime:   timestamppb.New(time.Time{}),
   339  					},
   340  				}
   341  				actual, err := server.CreateStatus(ctx, request)
   342  
   343  				So(err, ShouldBeNil)
   344  				So(time.Since(actual.CreateTime.AsTime()), ShouldBeLessThan, time.Minute)
   345  			})
   346  
   347  		})
   348  
   349  		Convey("List", func() {
   350  			Convey("Anonymous rejected", func() {
   351  				ctx = fakeAuth().anonymous().setInContext(ctx)
   352  
   353  				request := &pb.ListStatusRequest{
   354  					Parent: "trees/chromium/status",
   355  				}
   356  				status, err := server.ListStatus(ctx, request)
   357  
   358  				So(err, ShouldBeRPCPermissionDenied, "log in")
   359  				So(status, ShouldBeNil)
   360  			})
   361  			Convey("No read access rejected", func() {
   362  				ctx = fakeAuth().setInContext(ctx)
   363  
   364  				request := &pb.ListStatusRequest{
   365  					Parent: "trees/chromium/status",
   366  				}
   367  				status, err := server.ListStatus(ctx, request)
   368  
   369  				So(err, ShouldBeRPCPermissionDenied, "not a member of luci-tree-status-access")
   370  				So(status, ShouldBeNil)
   371  			})
   372  			Convey("List with no values", func() {
   373  				ctx = fakeAuth().withReadAccess().withAuditAccess().setInContext(ctx)
   374  
   375  				request := &pb.ListStatusRequest{
   376  					Parent: "trees/chromium/status",
   377  				}
   378  				response, err := server.ListStatus(ctx, request)
   379  
   380  				So(err, ShouldBeNil)
   381  				So(response.NextPageToken, ShouldBeEmpty)
   382  				So(response.Status, ShouldBeEmpty)
   383  			})
   384  			Convey("List with one page", func() {
   385  				ctx = fakeAuth().withReadAccess().withAuditAccess().setInContext(ctx)
   386  				earliest := NewStatusBuilder().WithMessage("earliest").CreateInDB(ctx)
   387  				latest := NewStatusBuilder().WithMessage("latest").CreateInDB(ctx)
   388  
   389  				request := &pb.ListStatusRequest{
   390  					Parent:   "trees/chromium/status",
   391  					PageSize: 2,
   392  				}
   393  				response, err := server.ListStatus(ctx, request)
   394  
   395  				So(err, ShouldBeNil)
   396  				So(response.NextPageToken, ShouldBeEmpty)
   397  				So(response, ShouldResembleProto, &pb.ListStatusResponse{
   398  					Status: []*pb.Status{
   399  						toStatusProto(latest, true),
   400  						toStatusProto(earliest, true),
   401  					},
   402  				})
   403  			})
   404  			Convey("List with two pages", func() {
   405  				ctx = fakeAuth().withReadAccess().withAuditAccess().setInContext(ctx)
   406  				earliest := NewStatusBuilder().WithMessage("earliest").CreateInDB(ctx)
   407  				latest := NewStatusBuilder().WithMessage("latest").CreateInDB(ctx)
   408  
   409  				request := &pb.ListStatusRequest{
   410  					Parent:   "trees/chromium/status",
   411  					PageSize: 1,
   412  				}
   413  				response1, err := server.ListStatus(ctx, request)
   414  				So(err, ShouldBeNil)
   415  				request.PageToken = response1.NextPageToken
   416  				response2, err := server.ListStatus(ctx, request)
   417  				So(err, ShouldBeNil)
   418  
   419  				So(response1.NextPageToken, ShouldNotBeEmpty)
   420  				So(response2.NextPageToken, ShouldBeEmpty)
   421  				So(response1.Status[0], ShouldResembleProto, toStatusProto(latest, true))
   422  				So(response2.Status[0], ShouldResembleProto, toStatusProto(earliest, true))
   423  			})
   424  		})
   425  		Convey("List with no audit access hides usernames", func() {
   426  			ctx = fakeAuth().withReadAccess().setInContext(ctx)
   427  			expected := toStatusProto(NewStatusBuilder().CreateInDB(ctx), true)
   428  			expected.CreateUser = ""
   429  
   430  			request := &pb.ListStatusRequest{
   431  				Parent: "trees/chromium/status",
   432  			}
   433  			response, err := server.ListStatus(ctx, request)
   434  
   435  			So(err, ShouldBeNil)
   436  			So(response, ShouldResembleProto, &pb.ListStatusResponse{
   437  				Status: []*pb.Status{expected},
   438  			})
   439  		})
   440  	})
   441  
   442  }
   443  
   444  type fakeAuthBuilder struct {
   445  	state *authtest.FakeState
   446  }
   447  
   448  func fakeAuth() *fakeAuthBuilder {
   449  	return &fakeAuthBuilder{
   450  		state: &authtest.FakeState{
   451  			Identity:       "user:someone@example.com",
   452  			IdentityGroups: []string{},
   453  		},
   454  	}
   455  }
   456  func (a *fakeAuthBuilder) anonymous() *fakeAuthBuilder {
   457  	a.state.Identity = "anonymous:anonymous"
   458  	return a
   459  }
   460  func (a *fakeAuthBuilder) withReadAccess() *fakeAuthBuilder {
   461  	a.state.IdentityGroups = append(a.state.IdentityGroups, treeStatusAccessGroup)
   462  	return a
   463  }
   464  func (a *fakeAuthBuilder) withAuditAccess() *fakeAuthBuilder {
   465  	a.state.IdentityGroups = append(a.state.IdentityGroups, treeStatusAuditAccessGroup)
   466  	return a
   467  }
   468  func (a *fakeAuthBuilder) withWriteAccess() *fakeAuthBuilder {
   469  	a.state.IdentityGroups = append(a.state.IdentityGroups, treeStatusWriteAccessGroup)
   470  	return a
   471  }
   472  func (a *fakeAuthBuilder) setInContext(ctx context.Context) context.Context {
   473  	if a.state.Identity == "anonymous:anonymous" && len(a.state.IdentityGroups) > 0 {
   474  		panic("You cannot call any of the with methods on fakeAuthBuilder if you call the anonymous method")
   475  	}
   476  	return auth.WithState(ctx, a.state)
   477  }
   478  
   479  type StatusBuilder struct {
   480  	status status.Status
   481  }
   482  
   483  func NewStatusBuilder() *StatusBuilder {
   484  	id, err := status.GenerateID()
   485  	So(err, ShouldBeNil)
   486  	return &StatusBuilder{status: status.Status{
   487  		TreeName:      "chromium",
   488  		StatusID:      id,
   489  		GeneralStatus: pb.GeneralState_OPEN,
   490  		Message:       "Tree is open!",
   491  		CreateUser:    "someone@example.com",
   492  		CreateTime:    spanner.CommitTimestamp,
   493  	}}
   494  }
   495  
   496  func (b *StatusBuilder) WithMessage(message string) *StatusBuilder {
   497  	b.status.Message = message
   498  	return b
   499  }
   500  
   501  func (b *StatusBuilder) Build() *status.Status {
   502  	s := b.status
   503  	return &s
   504  }
   505  
   506  func (b *StatusBuilder) CreateInDB(ctx context.Context) *status.Status {
   507  	s := b.Build()
   508  	row := map[string]any{
   509  		"TreeName":      s.TreeName,
   510  		"StatusId":      s.StatusID,
   511  		"GeneralStatus": int64(s.GeneralStatus),
   512  		"Message":       s.Message,
   513  		"CreateUser":    s.CreateUser,
   514  		"CreateTime":    s.CreateTime,
   515  	}
   516  	m := spanner.InsertOrUpdateMap("Status", row)
   517  	ts, err := span.Apply(ctx, []*spanner.Mutation{m})
   518  	So(err, ShouldBeNil)
   519  	if s.CreateTime == spanner.CommitTimestamp {
   520  		s.CreateTime = ts.UTC()
   521  	}
   522  	return s
   523  }