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 }