go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/quota/manager_test.go (about)

     1  // Copyright 2023 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 quota
    16  
    17  import (
    18  	"crypto/md5"
    19  	"encoding/hex"
    20  	"fmt"
    21  	"testing"
    22  	"time"
    23  
    24  	"github.com/alicebob/miniredis/v2"
    25  	"github.com/gomodule/redigo/redis"
    26  
    27  	"go.chromium.org/luci/auth/identity"
    28  	"go.chromium.org/luci/common/clock"
    29  	"go.chromium.org/luci/server/auth"
    30  	"go.chromium.org/luci/server/auth/authtest"
    31  	"go.chromium.org/luci/server/quota"
    32  	"go.chromium.org/luci/server/quota/quotapb"
    33  	_ "go.chromium.org/luci/server/quota/quotatestmonkeypatch"
    34  	"go.chromium.org/luci/server/redisconn"
    35  
    36  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    37  	"go.chromium.org/luci/cv/internal/common"
    38  	"go.chromium.org/luci/cv/internal/configs/prjcfg"
    39  	"go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest"
    40  	"go.chromium.org/luci/cv/internal/cvtesting"
    41  	"go.chromium.org/luci/cv/internal/metrics"
    42  	"go.chromium.org/luci/cv/internal/run"
    43  
    44  	. "github.com/smartystreets/goconvey/convey"
    45  	. "go.chromium.org/luci/common/testing/assertions"
    46  )
    47  
    48  func TestManager(t *testing.T) {
    49  	t.Parallel()
    50  
    51  	Convey("Manager", t, func() {
    52  		ct := cvtesting.Test{}
    53  		ctx, cancel := ct.SetUp(t)
    54  		defer cancel()
    55  
    56  		s, err := miniredis.Run()
    57  		So(err, ShouldBeNil)
    58  		defer s.Close()
    59  		s.SetTime(clock.Now(ctx))
    60  
    61  		ctx = redisconn.UsePool(ctx, &redis.Pool{
    62  			Dial: func() (redis.Conn, error) {
    63  				return redis.Dial("tcp", s.Addr())
    64  			},
    65  		})
    66  
    67  		makeIdentity := func(email string) identity.Identity {
    68  			id, err := identity.MakeIdentity(fmt.Sprintf("%s:%s", identity.User, email))
    69  			So(err, ShouldBeNil)
    70  			return id
    71  		}
    72  
    73  		const tEmail = "t@example.org"
    74  		ct.AddMember(tEmail, "googlers")
    75  		ct.AddMember(tEmail, "partners")
    76  
    77  		const lProject = "chromium"
    78  		cg := &cfgpb.ConfigGroup{Name: "infra"}
    79  		cfg := &cfgpb.Config{ConfigGroups: []*cfgpb.ConfigGroup{cg}}
    80  		prjcfgtest.Create(ctx, lProject, cfg)
    81  
    82  		genUserLimit := func(name string, limit int64, principals []string) *cfgpb.UserLimit {
    83  			userLimit := &cfgpb.UserLimit{
    84  				Name:       name,
    85  				Principals: principals,
    86  				Run: &cfgpb.UserLimit_Run{
    87  					MaxActive: &cfgpb.UserLimit_Limit{},
    88  				},
    89  			}
    90  
    91  			if limit == 0 {
    92  				userLimit.Run.MaxActive.Limit = &cfgpb.UserLimit_Limit_Unlimited{
    93  					Unlimited: true,
    94  				}
    95  			} else {
    96  				userLimit.Run.MaxActive.Limit = &cfgpb.UserLimit_Limit_Value{
    97  					Value: limit,
    98  				}
    99  			}
   100  
   101  			return userLimit
   102  		}
   103  
   104  		qm := NewManager()
   105  
   106  		Convey("WritePolicy() with config groups but no run limit", func() {
   107  			pid, err := qm.WritePolicy(ctx, lProject)
   108  			So(err, ShouldBeNil)
   109  			So(pid, ShouldBeNil)
   110  		})
   111  
   112  		Convey("WritePolicy() with run limit values", func() {
   113  			cg.UserLimits = append(cg.UserLimits,
   114  				genUserLimit("chromies-limit", 10, []string{"group:chromies", "group:chrome-infra"}),
   115  				genUserLimit("googlers-limit", 5, []string{"group:googlers"}),
   116  				genUserLimit("partners-limit", 10, []string{"group:partners"}),
   117  			)
   118  			prjcfgtest.Update(ctx, lProject, cfg)
   119  			pid, err := qm.WritePolicy(ctx, lProject)
   120  
   121  			So(err, ShouldBeNil)
   122  			So(pid, ShouldResembleProto, &quotapb.PolicyConfigID{
   123  				AppId:   "cv",
   124  				Realm:   "chromium",
   125  				Version: pid.Version,
   126  			})
   127  		})
   128  
   129  		Convey("WritePolicy() with run limit defaults", func() {
   130  			cg.UserLimits = append(cg.UserLimits,
   131  				genUserLimit("chromies-limit", 10, []string{"group:chromies", "group:chrome-infra"}),
   132  			)
   133  			cg.UserLimitDefault = genUserLimit("chromies-limit", 5, []string{"group:chromies", "group:chrome-infra"})
   134  			prjcfgtest.Update(ctx, lProject, cfg)
   135  			pid, err := qm.WritePolicy(ctx, lProject)
   136  
   137  			So(err, ShouldBeNil)
   138  			So(pid, ShouldResembleProto, &quotapb.PolicyConfigID{
   139  				AppId:   "cv",
   140  				Realm:   "chromium",
   141  				Version: pid.Version,
   142  			})
   143  		})
   144  
   145  		Convey("WritePolicy() with run limit set to unlimited", func() {
   146  			cg.UserLimits = append(cg.UserLimits,
   147  				genUserLimit("chromies-limit", 10, []string{"group:chromies", "group:chrome-infra"}),
   148  				genUserLimit("googlers-limit", 0, []string{"group:googlers"}),
   149  			)
   150  			prjcfgtest.Update(ctx, lProject, cfg)
   151  			pid, err := qm.WritePolicy(ctx, lProject)
   152  
   153  			So(err, ShouldBeNil)
   154  			So(pid, ShouldResembleProto, &quotapb.PolicyConfigID{
   155  				AppId:   "cv",
   156  				Realm:   "chromium",
   157  				Version: pid.Version,
   158  			})
   159  		})
   160  
   161  		Convey("findRunLimit() returns first valid user_limit", func() {
   162  			googlerLimit := genUserLimit("googlers-limit", 5, []string{"group:googlers"})
   163  			cg.UserLimits = append(cg.UserLimits,
   164  				genUserLimit("chromies-limit", 10, []string{"group:chromies", "group:chrome-infra"}),
   165  				googlerLimit,
   166  				genUserLimit("partners-limit", 10, []string{"group:partners"}),
   167  			)
   168  			prjcfgtest.Update(ctx, lProject, cfg)
   169  
   170  			r := &run.Run{
   171  				ID:            common.MakeRunID(lProject, time.Now(), 1, []byte{}),
   172  				ConfigGroupID: prjcfg.MakeConfigGroupID(prjcfg.ComputeHash(cfg), "infra"),
   173  				BilledTo:      makeIdentity(tEmail),
   174  			}
   175  
   176  			res, err := findRunLimit(ctx, r)
   177  			So(err, ShouldBeNil)
   178  			So(res, ShouldResembleProto, googlerLimit)
   179  		})
   180  
   181  		Convey("findRunLimit() works with user entry in principals", func() {
   182  			exampleLimit := genUserLimit("example-limit", 10, []string{"group:chromies", "user:t@example.org"})
   183  			cg.UserLimits = append(cg.UserLimits,
   184  				genUserLimit("chromies-limit", 10, []string{"group:chromies", "group:chrome-infra"}),
   185  				exampleLimit,
   186  				genUserLimit("googlers-limit", 5, []string{"group:googlers"}),
   187  				genUserLimit("partners-limit", 10, []string{"group:partners"}),
   188  			)
   189  			prjcfgtest.Update(ctx, lProject, cfg)
   190  
   191  			r := &run.Run{
   192  				ID:            common.MakeRunID(lProject, time.Now(), 1, []byte{}),
   193  				ConfigGroupID: prjcfg.MakeConfigGroupID(prjcfg.ComputeHash(cfg), "infra"),
   194  				BilledTo:      makeIdentity(tEmail),
   195  			}
   196  			res, err := findRunLimit(ctx, r)
   197  			So(err, ShouldBeNil)
   198  			So(res, ShouldResembleProto, exampleLimit)
   199  		})
   200  
   201  		Convey("findRunLimit() returns default user_limit if no valid user_limit is found", func() {
   202  			cg.UserLimits = append(cg.UserLimits,
   203  				genUserLimit("chromies-limit", 10, []string{"group:chromies", "group:chrome-infra"}),
   204  				genUserLimit("googlers-limit", 5, []string{"group:invalid"}),
   205  				genUserLimit("partners-limit", 10, []string{"group:invalid"}),
   206  			)
   207  			cg.UserLimitDefault = genUserLimit("", 5, nil)
   208  			prjcfgtest.Update(ctx, lProject, cfg)
   209  
   210  			r := &run.Run{
   211  				ID:            common.MakeRunID(lProject, time.Now(), 1, []byte{}),
   212  				ConfigGroupID: prjcfg.MakeConfigGroupID(prjcfg.ComputeHash(cfg), "infra"),
   213  				BilledTo:      makeIdentity(tEmail),
   214  			}
   215  
   216  			res, err := findRunLimit(ctx, r)
   217  			So(err, ShouldBeNil)
   218  			So(res, ShouldResembleProto, genUserLimit("default", 5, nil)) // default name is overriden.
   219  		})
   220  
   221  		Convey("findRunLimit() returns nil when no valid policy is found", func() {
   222  			cg.UserLimits = append(cg.UserLimits,
   223  				genUserLimit("chromies-limit", 10, []string{"group:chromies", "group:chrome-infra"}),
   224  				genUserLimit("googlers-limit", 5, []string{"group:invalid"}),
   225  				genUserLimit("partners-limit", 10, []string{"group:invalid"}),
   226  			)
   227  			prjcfgtest.Update(ctx, lProject, cfg)
   228  
   229  			r := &run.Run{
   230  				ID:            common.MakeRunID(lProject, time.Now(), 1, []byte{}),
   231  				ConfigGroupID: prjcfg.MakeConfigGroupID(prjcfg.ComputeHash(cfg), "infra"),
   232  				BilledTo:      makeIdentity(tEmail),
   233  			}
   234  
   235  			res, err := findRunLimit(ctx, r)
   236  			So(err, ShouldBeNil)
   237  			So(res, ShouldBeNil)
   238  		})
   239  
   240  		Convey("DebitRunQuota() debits quota for a given run state", func() {
   241  			googlerLimit := genUserLimit("googlers-limit", 5, []string{"group:googlers"})
   242  			cg.UserLimits = append(cg.UserLimits,
   243  				genUserLimit("chromies-limit", 10, []string{"group:chromies", "group:chrome-infra"}),
   244  				googlerLimit,
   245  				genUserLimit("partners-limit", 10, []string{"group:partners"}),
   246  			)
   247  			prjcfgtest.Update(ctx, lProject, cfg)
   248  
   249  			r := &run.Run{
   250  				ID:            common.MakeRunID(lProject, time.Now(), 1, []byte{}),
   251  				ConfigGroupID: prjcfg.MakeConfigGroupID(prjcfg.ComputeHash(cfg), "infra"),
   252  				BilledTo:      makeIdentity(tEmail),
   253  			}
   254  
   255  			res, userLimit, err := qm.DebitRunQuota(ctx, r)
   256  			So(err, ShouldBeNil)
   257  			So(userLimit, ShouldResembleProto, googlerLimit)
   258  			So(res, ShouldResembleProto, &quotapb.OpResult{
   259  				NewBalance:    4,
   260  				AccountStatus: quotapb.OpResult_CREATED,
   261  			})
   262  			So(ct.TSMonSentValue(
   263  				ctx,
   264  				metrics.Internal.QuotaOp,
   265  				lProject,
   266  				"infra",
   267  				"googlers-limit",
   268  				"runs",
   269  				"debit",
   270  				"SUCCESS",
   271  			), ShouldEqual, 1)
   272  		})
   273  
   274  		Convey("CreditRunQuota() credits quota for a given run state", func() {
   275  			googlerLimit := genUserLimit("googlers-limit", 5, []string{"group:googlers"})
   276  			cg.UserLimits = append(cg.UserLimits,
   277  				genUserLimit("chromies-limit", 10, []string{"group:chromies", "group:chrome-infra"}),
   278  				googlerLimit,
   279  				genUserLimit("partners-limit", 10, []string{"group:partners"}),
   280  			)
   281  			prjcfgtest.Update(ctx, lProject, cfg)
   282  
   283  			r := &run.Run{
   284  				ID:            common.MakeRunID(lProject, time.Now(), 1, []byte{}),
   285  				ConfigGroupID: prjcfg.MakeConfigGroupID(prjcfg.ComputeHash(cfg), "infra"),
   286  				BilledTo:      makeIdentity(tEmail),
   287  			}
   288  
   289  			res, userLimit, err := qm.CreditRunQuota(ctx, r)
   290  			So(err, ShouldBeNil)
   291  			So(userLimit, ShouldResembleProto, googlerLimit)
   292  			So(res, ShouldResembleProto, &quotapb.OpResult{
   293  				NewBalance:    5,
   294  				AccountStatus: quotapb.OpResult_CREATED,
   295  			})
   296  			So(ct.TSMonSentValue(
   297  				ctx,
   298  				metrics.Internal.QuotaOp,
   299  				lProject,
   300  				"infra",
   301  				"googlers-limit",
   302  				"runs",
   303  				"credit",
   304  				"SUCCESS",
   305  			), ShouldEqual, 1)
   306  		})
   307  
   308  		Convey("runQuotaOp() updates the same account on multiple ops", func() {
   309  			googlerLimit := genUserLimit("googlers-limit", 5, []string{"group:googlers"})
   310  			cg.UserLimits = append(cg.UserLimits,
   311  				genUserLimit("chromies-limit", 10, []string{"group:chromies", "group:chrome-infra"}),
   312  				googlerLimit,
   313  				genUserLimit("partners-limit", 10, []string{"group:partners"}),
   314  			)
   315  			prjcfgtest.Update(ctx, lProject, cfg)
   316  
   317  			r := &run.Run{
   318  				ID:            common.MakeRunID(lProject, time.Now(), 1, []byte{}),
   319  				ConfigGroupID: prjcfg.MakeConfigGroupID(prjcfg.ComputeHash(cfg), "infra"),
   320  				BilledTo:      makeIdentity(tEmail),
   321  			}
   322  
   323  			res, userLimit, err := qm.runQuotaOp(ctx, r, "foo1", -1)
   324  			So(err, ShouldBeNil)
   325  			So(userLimit, ShouldResembleProto, googlerLimit)
   326  			So(res, ShouldResembleProto, &quotapb.OpResult{
   327  				NewBalance:    4,
   328  				AccountStatus: quotapb.OpResult_CREATED,
   329  			})
   330  			So(ct.TSMonSentValue(
   331  				ctx,
   332  				metrics.Internal.QuotaOp,
   333  				lProject,
   334  				"infra",
   335  				"googlers-limit",
   336  				"runs",
   337  				"foo1",
   338  				"SUCCESS",
   339  			), ShouldEqual, 1)
   340  
   341  			res, userLimit, err = qm.runQuotaOp(ctx, r, "foo2", -2)
   342  			So(err, ShouldBeNil)
   343  			So(userLimit, ShouldResembleProto, googlerLimit)
   344  			So(res, ShouldResembleProto, &quotapb.OpResult{
   345  				NewBalance:              2,
   346  				PreviousBalance:         4,
   347  				PreviousBalanceAdjusted: 4,
   348  				AccountStatus:           quotapb.OpResult_ALREADY_EXISTS,
   349  			})
   350  			So(ct.TSMonSentValue(
   351  				ctx,
   352  				metrics.Internal.QuotaOp,
   353  				lProject,
   354  				"infra",
   355  				"googlers-limit",
   356  				"runs",
   357  				"foo2",
   358  				"SUCCESS",
   359  			), ShouldEqual, 1)
   360  		})
   361  
   362  		Convey("runQuotaOp() respects unlimited policy", func() {
   363  			googlerLimit := genUserLimit("googlers-limit", 0, []string{"group:googlers"})
   364  			cg.UserLimits = append(cg.UserLimits, googlerLimit)
   365  			prjcfgtest.Update(ctx, lProject, cfg)
   366  
   367  			r := &run.Run{
   368  				ID:            common.MakeRunID(lProject, time.Now(), 1, []byte{}),
   369  				ConfigGroupID: prjcfg.MakeConfigGroupID(prjcfg.ComputeHash(cfg), "infra"),
   370  				BilledTo:      makeIdentity(tEmail),
   371  			}
   372  
   373  			res, userLimit, err := qm.runQuotaOp(ctx, r, "", -1)
   374  			So(err, ShouldBeNil)
   375  			So(userLimit, ShouldResembleProto, googlerLimit)
   376  			So(res, ShouldResembleProto, &quotapb.OpResult{
   377  				NewBalance:    -1,
   378  				AccountStatus: quotapb.OpResult_CREATED,
   379  			})
   380  		})
   381  
   382  		Convey("runQuotaOp() bound checks", func() {
   383  			googlerLimit := genUserLimit("googlers-limit", 1, []string{"group:googlers"})
   384  			cg.UserLimits = append(cg.UserLimits, googlerLimit)
   385  			prjcfgtest.Update(ctx, lProject, cfg)
   386  
   387  			r := &run.Run{
   388  				ID:            common.MakeRunID(lProject, time.Now(), 1, []byte{}),
   389  				ConfigGroupID: prjcfg.MakeConfigGroupID(prjcfg.ComputeHash(cfg), "infra"),
   390  				BilledTo:      makeIdentity(tEmail),
   391  			}
   392  
   393  			Convey("quota underflow", func() {
   394  				res, userLimit, err := qm.runQuotaOp(ctx, r, "debit", -2)
   395  				So(err, ShouldEqual, quota.ErrQuotaApply)
   396  				So(userLimit, ShouldResembleProto, googlerLimit)
   397  				So(res, ShouldResembleProto, &quotapb.OpResult{
   398  					AccountStatus: quotapb.OpResult_CREATED,
   399  					Status:        quotapb.OpResult_ERR_UNDERFLOW,
   400  				})
   401  				So(ct.TSMonSentValue(
   402  					ctx,
   403  					metrics.Internal.QuotaOp,
   404  					lProject,
   405  					"infra",
   406  					"googlers-limit",
   407  					"runs",
   408  					"debit",
   409  					"ERR_UNDERFLOW",
   410  				), ShouldEqual, 1)
   411  			})
   412  
   413  			Convey("quota overflow", func() {
   414  				// overflow doesn't err but gets capped.
   415  				res, userLimit, err := qm.runQuotaOp(ctx, r, "credit", 10)
   416  				So(err, ShouldBeNil)
   417  				So(userLimit, ShouldResembleProto, googlerLimit)
   418  				So(res, ShouldResembleProto, &quotapb.OpResult{
   419  					AccountStatus: quotapb.OpResult_CREATED,
   420  					NewBalance:    1,
   421  				})
   422  				So(ct.TSMonSentValue(
   423  					ctx,
   424  					metrics.Internal.QuotaOp,
   425  					lProject,
   426  					"infra",
   427  					"googlers-limit",
   428  					"runs",
   429  					"credit",
   430  					"SUCCESS",
   431  				), ShouldEqual, 1)
   432  			})
   433  		})
   434  
   435  		Convey("runQuotaOp() on policy change", func() {
   436  			chromiesLimit := genUserLimit("chromies-limit", 10, []string{"group:chromies", "group:chrome-infra"})
   437  			googlerLimit := genUserLimit("googlers-limit", 5, []string{"group:googlers"})
   438  			partnerLimit := genUserLimit("partners-limit", 2, []string{"group:partners"})
   439  			cg.UserLimits = append(cg.UserLimits, chromiesLimit, googlerLimit, partnerLimit)
   440  			prjcfgtest.Update(ctx, lProject, cfg)
   441  
   442  			r := &run.Run{
   443  				ID:            common.MakeRunID(lProject, time.Now(), 1, []byte{}),
   444  				ConfigGroupID: prjcfg.MakeConfigGroupID(prjcfg.ComputeHash(cfg), "infra"),
   445  				BilledTo:      makeIdentity(tEmail),
   446  			}
   447  
   448  			res, userLimit, err := qm.runQuotaOp(ctx, r, "", -1)
   449  			So(err, ShouldBeNil)
   450  			So(userLimit, ShouldResembleProto, googlerLimit)
   451  			So(res, ShouldResembleProto, &quotapb.OpResult{
   452  				NewBalance:    4,
   453  				AccountStatus: quotapb.OpResult_CREATED,
   454  			})
   455  
   456  			Convey("decrease in quota allowance results in underflow", func() {
   457  				// Update policy
   458  				ctx = auth.WithState(ctx, &authtest.FakeState{
   459  					Identity:       makeIdentity(tEmail),
   460  					IdentityGroups: []string{"partners"},
   461  				})
   462  
   463  				// This is not a real scenario within CV but just checks
   464  				// extreme examples.
   465  				res, userLimit, err = qm.runQuotaOp(ctx, r, "", -2)
   466  				So(err, ShouldEqual, quota.ErrQuotaApply)
   467  				So(userLimit, ShouldResembleProto, partnerLimit)
   468  				So(res, ShouldResembleProto, &quotapb.OpResult{
   469  					PreviousBalance:         4,
   470  					PreviousBalanceAdjusted: 1,
   471  					Status:                  quotapb.OpResult_ERR_UNDERFLOW,
   472  				})
   473  			})
   474  
   475  			Convey("decrease in quota allowance within bounds", func() {
   476  				// Update policy
   477  				ctx = auth.WithState(ctx, &authtest.FakeState{
   478  					Identity:       makeIdentity(tEmail),
   479  					IdentityGroups: []string{"partners"},
   480  				})
   481  
   482  				res, userLimit, err = qm.runQuotaOp(ctx, r, "", -1)
   483  				So(err, ShouldBeNil)
   484  				So(userLimit, ShouldResembleProto, partnerLimit)
   485  				So(res, ShouldResembleProto, &quotapb.OpResult{
   486  					NewBalance:              0,
   487  					PreviousBalance:         4,
   488  					PreviousBalanceAdjusted: 1,
   489  					AccountStatus:           quotapb.OpResult_ALREADY_EXISTS,
   490  				})
   491  			})
   492  
   493  			Convey("increase in quota allowance", func() {
   494  				// Update policy
   495  				ctx = auth.WithState(ctx, &authtest.FakeState{
   496  					Identity:       makeIdentity(tEmail),
   497  					IdentityGroups: []string{"chromies"},
   498  				})
   499  
   500  				res, userLimit, err = qm.runQuotaOp(ctx, r, "", -1)
   501  				So(err, ShouldBeNil)
   502  				So(userLimit, ShouldResembleProto, chromiesLimit)
   503  				So(res, ShouldResembleProto, &quotapb.OpResult{
   504  					NewBalance:              8,
   505  					PreviousBalance:         4,
   506  					PreviousBalanceAdjusted: 9,
   507  					AccountStatus:           quotapb.OpResult_ALREADY_EXISTS,
   508  				})
   509  			})
   510  
   511  			Convey("increase in quota allowance in the overflow case is bounded by the new limit", func() {
   512  				// Update policy
   513  				ctx = auth.WithState(ctx, &authtest.FakeState{
   514  					Identity:       makeIdentity(tEmail),
   515  					IdentityGroups: []string{"chromies"},
   516  				})
   517  
   518  				// This is not a real scenario within CV but just checks
   519  				// extreme examples.
   520  				res, userLimit, err = qm.runQuotaOp(ctx, r, "", 2)
   521  				So(err, ShouldBeNil)
   522  				So(userLimit, ShouldResembleProto, chromiesLimit)
   523  				So(res, ShouldResembleProto, &quotapb.OpResult{
   524  					NewBalance:              10,
   525  					PreviousBalance:         4,
   526  					PreviousBalanceAdjusted: 9,
   527  					AccountStatus:           quotapb.OpResult_ALREADY_EXISTS,
   528  				})
   529  			})
   530  		})
   531  
   532  		Convey("runQuotaOp() is idempotent", func() {
   533  			googlerLimit := genUserLimit("googlers-limit", 5, []string{"group:googlers"})
   534  			cg.UserLimits = append(cg.UserLimits,
   535  				genUserLimit("chromies-limit", 10, []string{"group:chromies", "group:chrome-infra"}),
   536  				googlerLimit,
   537  				genUserLimit("partners-limit", 10, []string{"group:partners"}),
   538  			)
   539  			prjcfgtest.Update(ctx, lProject, cfg)
   540  
   541  			r := &run.Run{
   542  				ID:            common.MakeRunID(lProject, time.Now(), 1, []byte{}),
   543  				ConfigGroupID: prjcfg.MakeConfigGroupID(prjcfg.ComputeHash(cfg), "infra"),
   544  				BilledTo:      makeIdentity(tEmail),
   545  			}
   546  
   547  			res, userLimit, err := qm.runQuotaOp(ctx, r, "foo", -1)
   548  			So(err, ShouldBeNil)
   549  			So(userLimit, ShouldResembleProto, googlerLimit)
   550  			So(res, ShouldResembleProto, &quotapb.OpResult{
   551  				NewBalance:    4,
   552  				AccountStatus: quotapb.OpResult_CREATED,
   553  			})
   554  
   555  			res, userLimit, err = qm.runQuotaOp(ctx, r, "foo", -1)
   556  			So(err, ShouldBeNil)
   557  			So(userLimit, ShouldResembleProto, googlerLimit)
   558  			So(res, ShouldResembleProto, &quotapb.OpResult{
   559  				NewBalance:    4,
   560  				AccountStatus: quotapb.OpResult_CREATED,
   561  			})
   562  
   563  			res, userLimit, err = qm.runQuotaOp(ctx, r, "foo2", -2)
   564  			So(err, ShouldBeNil)
   565  			So(userLimit, ShouldResembleProto, googlerLimit)
   566  			So(res, ShouldResembleProto, &quotapb.OpResult{
   567  				NewBalance:              2,
   568  				PreviousBalance:         4,
   569  				PreviousBalanceAdjusted: 4,
   570  				AccountStatus:           quotapb.OpResult_ALREADY_EXISTS,
   571  			})
   572  
   573  			res, userLimit, err = qm.runQuotaOp(ctx, r, "foo2", -2)
   574  			So(err, ShouldBeNil)
   575  			So(userLimit, ShouldResembleProto, googlerLimit)
   576  			So(res, ShouldResembleProto, &quotapb.OpResult{
   577  				NewBalance:              2,
   578  				PreviousBalance:         4,
   579  				PreviousBalanceAdjusted: 4,
   580  				AccountStatus:           quotapb.OpResult_ALREADY_EXISTS,
   581  			})
   582  		})
   583  
   584  		Convey("RunQuotaAccountID() hashes emailID", func() {
   585  			r := &run.Run{
   586  				ID:            common.MakeRunID(lProject, time.Now(), 1, []byte{}),
   587  				ConfigGroupID: prjcfg.MakeConfigGroupID(prjcfg.ComputeHash(cfg), "infra"),
   588  				BilledTo:      makeIdentity(tEmail),
   589  			}
   590  
   591  			emailHash := md5.Sum([]byte(tEmail))
   592  
   593  			So(qm.RunQuotaAccountID(r), ShouldResembleProto, &quotapb.AccountID{
   594  				AppId:        "cv",
   595  				Realm:        "chromium",
   596  				Namespace:    "infra",
   597  				Name:         hex.EncodeToString(emailHash[:]),
   598  				ResourceType: "runs",
   599  			})
   600  		})
   601  	})
   602  }