go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/auth_service/impl/servers/groups/server_test.go (about)

     1  // Copyright 2021 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 groups
    16  
    17  import (
    18  	"context"
    19  	"testing"
    20  	"time"
    21  
    22  	"google.golang.org/grpc/codes"
    23  	"google.golang.org/protobuf/types/known/emptypb"
    24  	fieldmaskpb "google.golang.org/protobuf/types/known/fieldmaskpb"
    25  	"google.golang.org/protobuf/types/known/timestamppb"
    26  
    27  	"go.chromium.org/luci/gae/filter/txndefer"
    28  	"go.chromium.org/luci/gae/impl/memory"
    29  	"go.chromium.org/luci/gae/service/datastore"
    30  	"go.chromium.org/luci/server/auth"
    31  	"go.chromium.org/luci/server/auth/authtest"
    32  	"go.chromium.org/luci/server/tq"
    33  
    34  	"go.chromium.org/luci/auth_service/api/rpcpb"
    35  	"go.chromium.org/luci/auth_service/impl/info"
    36  	"go.chromium.org/luci/auth_service/impl/model"
    37  
    38  	. "github.com/smartystreets/goconvey/convey"
    39  	. "go.chromium.org/luci/common/testing/assertions"
    40  )
    41  
    42  func TestGroupsServer(t *testing.T) {
    43  	t.Parallel()
    44  	srv := Server{}
    45  	createdTime := time.Date(2021, time.August, 16, 15, 20, 0, 0, time.UTC)
    46  	modifiedTime := time.Date(2022, time.July, 4, 15, 45, 0, 0, time.UTC)
    47  	versionedEntity := model.AuthVersionedEntityMixin{
    48  		ModifiedTS: modifiedTime,
    49  	}
    50  	// Etag derived from the above modified time.
    51  	etag := `W/"MjAyMi0wNy0wNFQxNTo0NTowMFo="`
    52  
    53  	Convey("ListGroups RPC call", t, func() {
    54  		ctx := auth.WithState(memory.Use(context.Background()), &authtest.FakeState{
    55  			Identity:       "user:someone@example.com",
    56  			IdentityGroups: []string{"testers"},
    57  		})
    58  
    59  		// Groups built from model.AuthGroup definition.
    60  		So(datastore.Put(ctx,
    61  			&model.AuthGroup{
    62  				ID:     "z-test-group",
    63  				Parent: model.RootKey(ctx),
    64  				Members: []string{
    65  					"user:test-user-1",
    66  					"user:test-user-2",
    67  				},
    68  				Globs: []string{
    69  					"test-user-1@example.com",
    70  					"test-user-2@example.com",
    71  				},
    72  				Nested: []string{
    73  					"group/tester",
    74  				},
    75  				Description:              "This is a test group.",
    76  				Owners:                   "testers",
    77  				CreatedTS:                createdTime,
    78  				CreatedBy:                "user:test-user-1@example.com",
    79  				AuthVersionedEntityMixin: versionedEntity,
    80  			},
    81  			&model.AuthGroup{
    82  				ID:     "test-group-2",
    83  				Parent: model.RootKey(ctx),
    84  				Members: []string{
    85  					"user:test-user-2",
    86  				},
    87  				Globs: []string{
    88  					"test-user-2@example.com",
    89  				},
    90  				Nested: []string{
    91  					"group/test-group",
    92  				},
    93  				Description:              "This is another test group.",
    94  				Owners:                   "test-group",
    95  				CreatedTS:                createdTime,
    96  				CreatedBy:                "user:test-user-2@example.com",
    97  				AuthVersionedEntityMixin: versionedEntity,
    98  			},
    99  			&model.AuthGroup{
   100  				ID:      "test-group-3",
   101  				Parent:  model.RootKey(ctx),
   102  				Members: []string{},
   103  				Globs:   []string{},
   104  				Nested: []string{
   105  					"group/tester",
   106  				},
   107  				Description:              "This is yet another test group.",
   108  				Owners:                   "testers",
   109  				CreatedTS:                createdTime,
   110  				CreatedBy:                "user:test-user-1@example.com",
   111  				AuthVersionedEntityMixin: versionedEntity,
   112  			}), ShouldBeNil)
   113  
   114  		// What expected response should be, built with pb.
   115  		expectedResp := &rpcpb.ListGroupsResponse{
   116  			Groups: []*rpcpb.AuthGroup{
   117  				{
   118  					Name:            "test-group-2",
   119  					Description:     "This is another test group.",
   120  					Owners:          "test-group",
   121  					CreatedTs:       timestamppb.New(createdTime),
   122  					CreatedBy:       "user:test-user-2@example.com",
   123  					CallerCanModify: false,
   124  					Etag:            etag,
   125  				},
   126  				{
   127  					Name:            "test-group-3",
   128  					Description:     "This is yet another test group.",
   129  					Owners:          "testers",
   130  					CreatedTs:       timestamppb.New(createdTime),
   131  					CreatedBy:       "user:test-user-1@example.com",
   132  					CallerCanModify: true,
   133  					Etag:            etag,
   134  				},
   135  				{
   136  					Name:            "z-test-group",
   137  					Description:     "This is a test group.",
   138  					Owners:          "testers",
   139  					CreatedTs:       timestamppb.New(createdTime),
   140  					CreatedBy:       "user:test-user-1@example.com",
   141  					CallerCanModify: true,
   142  					Etag:            etag,
   143  				},
   144  			},
   145  		}
   146  
   147  		resp, err := srv.ListGroups(ctx, &emptypb.Empty{})
   148  		So(err, ShouldBeNil)
   149  		So(resp.Groups, ShouldResembleProto, expectedResp.Groups)
   150  	})
   151  
   152  	Convey("GetGroup RPC call", t, func() {
   153  		ctx := auth.WithState(memory.Use(context.Background()), &authtest.FakeState{
   154  			Identity:       "user:someone@example.com",
   155  			IdentityGroups: []string{"testers"},
   156  		})
   157  
   158  		request := &rpcpb.GetGroupRequest{
   159  			Name: "test-group",
   160  		}
   161  
   162  		_, err := srv.GetGroup(ctx, request)
   163  		So(err, ShouldHaveGRPCStatus, codes.NotFound)
   164  
   165  		// Groups built from model.AuthGroup definition.
   166  		So(datastore.Put(ctx,
   167  			&model.AuthGroup{
   168  				ID:     "test-group",
   169  				Parent: model.RootKey(ctx),
   170  				Members: []string{
   171  					"user:test-user-1",
   172  					"user:test-user-2",
   173  				},
   174  				Globs: []string{
   175  					"test-user-1@example.com",
   176  					"test-user-2@example.com",
   177  				},
   178  				Nested: []string{
   179  					"group/tester",
   180  				},
   181  				Description:              "This is a test group.",
   182  				Owners:                   "testers",
   183  				CreatedTS:                createdTime,
   184  				CreatedBy:                "user:test-user-1@example.com",
   185  				AuthVersionedEntityMixin: versionedEntity,
   186  			}), ShouldBeNil)
   187  
   188  		expectedResponse := &rpcpb.AuthGroup{
   189  			Name: "test-group",
   190  			Members: []string{
   191  				"user:test-user-1",
   192  				"user:test-user-2",
   193  			},
   194  			Globs: []string{
   195  				"test-user-1@example.com",
   196  				"test-user-2@example.com",
   197  			},
   198  			Nested: []string{
   199  				"group/tester",
   200  			},
   201  			Description:     "This is a test group.",
   202  			Owners:          "testers",
   203  			CreatedTs:       timestamppb.New(createdTime),
   204  			CreatedBy:       "user:test-user-1@example.com",
   205  			CallerCanModify: true,
   206  			Etag:            etag,
   207  		}
   208  
   209  		actualGroupResponse, err := srv.GetGroup(ctx, request)
   210  		So(err, ShouldBeNil)
   211  		So(actualGroupResponse, ShouldResembleProto, expectedResponse)
   212  
   213  	})
   214  
   215  	Convey("CreateGroup RPC call", t, func() {
   216  		ctx := auth.WithState(memory.Use(context.Background()), &authtest.FakeState{
   217  			Identity: "user:someone@example.com",
   218  		})
   219  		ctx = info.SetImageVersion(ctx, "test-version")
   220  		ctx, _ = tq.TestingContext(txndefer.FilterRDS(ctx), nil)
   221  
   222  		Convey("Invalid name", func() {
   223  			request := &rpcpb.CreateGroupRequest{
   224  				Group: &rpcpb.AuthGroup{
   225  					Name:        "#^&",
   226  					Description: "This is a group with an invalid name",
   227  				},
   228  			}
   229  			_, err := srv.CreateGroup(ctx, request)
   230  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   231  		})
   232  
   233  		Convey("Invalid name (looks like external group)", func() {
   234  			request := &rpcpb.CreateGroupRequest{
   235  				Group: &rpcpb.AuthGroup{
   236  					Name:        "mdb/foo",
   237  					Description: "This is a group with an invalid name",
   238  				},
   239  			}
   240  			_, err := srv.CreateGroup(ctx, request)
   241  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   242  		})
   243  
   244  		Convey("Invalid members", func() {
   245  			request := &rpcpb.CreateGroupRequest{
   246  				Group: &rpcpb.AuthGroup{
   247  					Name:        "test-group",
   248  					Description: "This is a group with invalid members.",
   249  					Owners:      "test-group",
   250  					Members:     []string{"no-prefix@identity.com"},
   251  				},
   252  			}
   253  			_, err := srv.CreateGroup(ctx, request)
   254  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   255  		})
   256  
   257  		Convey("Invalid globs", func() {
   258  			request := &rpcpb.CreateGroupRequest{
   259  				Group: &rpcpb.AuthGroup{
   260  					Name:        "test-group",
   261  					Description: "This is a group with invalid members.",
   262  					Owners:      "test-group",
   263  					Globs:       []string{"*@no-prefix.com"},
   264  				},
   265  			}
   266  			_, err := srv.CreateGroup(ctx, request)
   267  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   268  		})
   269  
   270  		Convey("Group already exists", func() {
   271  			So(datastore.Put(ctx,
   272  				&model.AuthGroup{
   273  					ID:          "test-group",
   274  					Parent:      model.RootKey(ctx),
   275  					Description: "This is a test group.",
   276  					Owners:      "testers",
   277  					CreatedTS:   createdTime,
   278  					CreatedBy:   "user:test-user-1@example.com",
   279  				}), ShouldBeNil)
   280  
   281  			request := &rpcpb.CreateGroupRequest{
   282  				Group: &rpcpb.AuthGroup{
   283  					Name:        "test-group",
   284  					Description: "This is a group that already exists",
   285  				},
   286  			}
   287  			_, err := srv.CreateGroup(ctx, request)
   288  			So(err, ShouldHaveGRPCStatus, codes.AlreadyExists)
   289  		})
   290  
   291  		Convey("Group refers to another group that doesn't exist", func() {
   292  			request := &rpcpb.CreateGroupRequest{
   293  				Group: &rpcpb.AuthGroup{
   294  					Name:        "test-group",
   295  					Description: "This is a test group.",
   296  					Owners:      "invalid-owner",
   297  					Nested:      []string{"bad1, bad2"},
   298  				},
   299  			}
   300  			_, err := srv.CreateGroup(ctx, request)
   301  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   302  			So(err, ShouldErrLike, "some referenced groups don't exist: bad1, bad2, invalid-owner")
   303  		})
   304  
   305  		Convey("Successful creation", func() {
   306  			request := &rpcpb.CreateGroupRequest{
   307  				Group: &rpcpb.AuthGroup{
   308  					Name:        "test-group",
   309  					Description: "This is a test group.",
   310  					Owners:      "test-group",
   311  				},
   312  			}
   313  
   314  			resp, err := srv.CreateGroup(ctx, request)
   315  			So(err, ShouldBeNil)
   316  			So(resp.Name, ShouldEqual, "test-group")
   317  			So(resp.Description, ShouldEqual, "This is a test group.")
   318  			So(resp.Owners, ShouldEqual, "test-group")
   319  			So(resp.CreatedBy, ShouldEqual, "user:someone@example.com")
   320  			So(resp.CreatedTs.Seconds, ShouldNotBeZeroValue)
   321  		})
   322  	})
   323  
   324  	Convey("UpdateGroup RPC call", t, func() {
   325  		ctx := auth.WithState(memory.Use(context.Background()), &authtest.FakeState{
   326  			Identity:       "user:someone@example.com",
   327  			IdentityGroups: []string{"owners"},
   328  		})
   329  		ctx = info.SetImageVersion(ctx, "test-version")
   330  		ctx, _ = tq.TestingContext(txndefer.FilterRDS(ctx), nil)
   331  
   332  		So(datastore.Put(ctx,
   333  			&model.AuthGroup{
   334  				ID:     "test-group",
   335  				Parent: model.RootKey(ctx),
   336  				Members: []string{
   337  					"user:test-user-1",
   338  					"user:test-user-2",
   339  				},
   340  				Globs: []string{
   341  					"test-user-1@example.com",
   342  					"test-user-2@example.com",
   343  				},
   344  				Nested: []string{
   345  					"group/tester",
   346  				},
   347  				Description:              "This is a test group.",
   348  				Owners:                   "owners",
   349  				CreatedTS:                createdTime,
   350  				CreatedBy:                "user:test-user-1@example.com",
   351  				AuthVersionedEntityMixin: versionedEntity,
   352  			}), ShouldBeNil)
   353  
   354  		Convey("Cannot update external group", func() {
   355  			request := &rpcpb.UpdateGroupRequest{
   356  				Group: &rpcpb.AuthGroup{
   357  					Name:        "mdb/foo",
   358  					Description: "update",
   359  				},
   360  			}
   361  
   362  			_, err := srv.UpdateGroup(ctx, request)
   363  			So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
   364  			So(err, ShouldErrLike, "cannot update external group")
   365  		})
   366  
   367  		Convey("Group not found", func() {
   368  			request := &rpcpb.UpdateGroupRequest{
   369  				Group: &rpcpb.AuthGroup{
   370  					Name:        "non-existent-group",
   371  					Description: "update",
   372  				},
   373  			}
   374  
   375  			_, err := srv.UpdateGroup(ctx, request)
   376  			So(err, ShouldHaveGRPCStatus, codes.NotFound)
   377  		})
   378  
   379  		Convey("Invalid field mask", func() {
   380  			request := &rpcpb.UpdateGroupRequest{
   381  				Group: &rpcpb.AuthGroup{
   382  					Name:        "test-group",
   383  					Description: "update",
   384  				},
   385  				UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"bad"}},
   386  			}
   387  
   388  			_, err := srv.UpdateGroup(ctx, request)
   389  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   390  		})
   391  
   392  		Convey("Set owners to group that doesn't exist", func() {
   393  			request := &rpcpb.UpdateGroupRequest{
   394  				Group: &rpcpb.AuthGroup{
   395  					Name:   "test-group",
   396  					Owners: "non-existent",
   397  				},
   398  				UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"owners"}},
   399  			}
   400  
   401  			_, err := srv.UpdateGroup(ctx, request)
   402  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   403  		})
   404  
   405  		Convey("Set invalid member identity", func() {
   406  			request := &rpcpb.UpdateGroupRequest{
   407  				Group: &rpcpb.AuthGroup{
   408  					Name:    "test-group",
   409  					Members: []string{"bad"},
   410  				},
   411  				UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"members"}},
   412  			}
   413  
   414  			_, err := srv.UpdateGroup(ctx, request)
   415  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   416  		})
   417  
   418  		Convey("Cyclic dependency", func() {
   419  			request := &rpcpb.UpdateGroupRequest{
   420  				Group: &rpcpb.AuthGroup{
   421  					Name:   "test-group",
   422  					Nested: []string{"test-group"},
   423  				},
   424  				UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"nested"}},
   425  			}
   426  
   427  			_, err := srv.UpdateGroup(ctx, request)
   428  			So(err, ShouldHaveGRPCStatus, codes.FailedPrecondition)
   429  		})
   430  
   431  		Convey("Permissions", func() {
   432  			request := &rpcpb.UpdateGroupRequest{
   433  				Group: &rpcpb.AuthGroup{
   434  					Name:        "test-group",
   435  					Description: "update",
   436  				},
   437  				UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}},
   438  			}
   439  			Convey("Anonymous is denied", func() {
   440  				ctx := auth.WithState(ctx, &authtest.FakeState{})
   441  				_, err := srv.UpdateGroup(ctx, request)
   442  				So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
   443  			})
   444  
   445  			Convey("Normal user is denied", func() {
   446  				ctx := auth.WithState(ctx, &authtest.FakeState{
   447  					Identity: "user:someone@example.com",
   448  				})
   449  				_, err := srv.UpdateGroup(ctx, request)
   450  				So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
   451  			})
   452  
   453  			Convey("Group owner succeeds", func() {
   454  				ctx := auth.WithState(ctx, &authtest.FakeState{
   455  					Identity:       "user:someone@example.com",
   456  					IdentityGroups: []string{"owners"},
   457  				})
   458  				_, err := srv.UpdateGroup(ctx, request)
   459  				So(err, ShouldBeNil)
   460  			})
   461  
   462  			Convey("Admin succeeds", func() {
   463  				ctx := auth.WithState(ctx, &authtest.FakeState{
   464  					Identity:       "user:someone@example.com",
   465  					IdentityGroups: []string{model.AdminGroup},
   466  				})
   467  				_, err := srv.UpdateGroup(ctx, request)
   468  				So(err, ShouldBeNil)
   469  			})
   470  		})
   471  
   472  		Convey("Etags", func() {
   473  			request := &rpcpb.UpdateGroupRequest{
   474  				Group: &rpcpb.AuthGroup{
   475  					Name:        "test-group",
   476  					Description: "update",
   477  				},
   478  				UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}},
   479  			}
   480  
   481  			Convey("Incorrect etag is aborted", func() {
   482  				request.Group.Etag = "blah"
   483  				_, err := srv.UpdateGroup(ctx, request)
   484  				So(err, ShouldHaveGRPCStatus, codes.Aborted)
   485  			})
   486  
   487  			Convey("Empty etag succeeds", func() {
   488  				request.Group.Etag = ""
   489  				_, err := srv.UpdateGroup(ctx, request)
   490  				So(err, ShouldBeNil)
   491  			})
   492  
   493  			Convey("Correct etag succeeds if present", func() {
   494  				request.Group.Etag = etag
   495  				_, err := srv.UpdateGroup(ctx, request)
   496  				So(err, ShouldBeNil)
   497  			})
   498  		})
   499  	})
   500  
   501  	Convey("DeleteGroup RPC call", t, func() {
   502  		ctx := memory.Use(context.Background())
   503  		ctx = info.SetImageVersion(ctx, "test-version")
   504  		ctx, _ = tq.TestingContext(txndefer.FilterRDS(ctx), nil)
   505  
   506  		So(datastore.Put(ctx,
   507  			&model.AuthGroup{
   508  				ID:     "test-group",
   509  				Parent: model.RootKey(ctx),
   510  				Members: []string{
   511  					"user:test-user-1",
   512  					"user:test-user-2",
   513  				},
   514  				Globs: []string{
   515  					"test-user-1@example.com",
   516  					"test-user-2@example.com",
   517  				},
   518  				Nested: []string{
   519  					"group/tester",
   520  				},
   521  				Description:              "This is a test group.",
   522  				Owners:                   "owners",
   523  				CreatedTS:                createdTime,
   524  				CreatedBy:                "user:test-user-1@example.com",
   525  				AuthVersionedEntityMixin: versionedEntity,
   526  			}), ShouldBeNil)
   527  
   528  		Convey("Cannot delete external group", func() {
   529  			request := &rpcpb.DeleteGroupRequest{
   530  				Name: "mdb/foo",
   531  			}
   532  
   533  			_, err := srv.DeleteGroup(ctx, request)
   534  			So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
   535  			So(err, ShouldErrLike, "cannot delete external group")
   536  		})
   537  
   538  		Convey("Group not found", func() {
   539  			request := &rpcpb.DeleteGroupRequest{
   540  				Name: "non-existent-group",
   541  			}
   542  
   543  			_, err := srv.DeleteGroup(ctx, request)
   544  			So(err, ShouldHaveGRPCStatus, codes.NotFound)
   545  		})
   546  
   547  		Convey("Group referenced elsewhere", func() {
   548  			ctx := auth.WithState(ctx, &authtest.FakeState{
   549  				Identity:       "user:someone@example.com",
   550  				IdentityGroups: []string{"owners"},
   551  			})
   552  			So(datastore.Put(ctx,
   553  				&model.AuthGroup{
   554  					ID:     "nesting-group",
   555  					Parent: model.RootKey(ctx),
   556  					Nested: []string{
   557  						"test-group",
   558  					},
   559  				}), ShouldBeNil)
   560  			request := &rpcpb.DeleteGroupRequest{
   561  				Name: "test-group",
   562  			}
   563  
   564  			_, err := srv.DeleteGroup(ctx, request)
   565  			So(err, ShouldHaveGRPCStatus, codes.FailedPrecondition)
   566  		})
   567  
   568  		Convey("Permissions", func() {
   569  
   570  			Convey("Anonymous is denied", func() {
   571  				ctx := auth.WithState(ctx, &authtest.FakeState{})
   572  				_, err := srv.DeleteGroup(ctx, &rpcpb.DeleteGroupRequest{
   573  					Name: "test-group",
   574  				})
   575  				So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
   576  			})
   577  
   578  			Convey("Normal user is denied", func() {
   579  				ctx := auth.WithState(ctx, &authtest.FakeState{
   580  					Identity: "user:someone@example.com",
   581  				})
   582  				_, err := srv.DeleteGroup(ctx, &rpcpb.DeleteGroupRequest{
   583  					Name: "test-group",
   584  				})
   585  				So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
   586  			})
   587  
   588  			Convey("Group owner succeeds", func() {
   589  				ctx := auth.WithState(ctx, &authtest.FakeState{
   590  					Identity:       "user:someone@example.com",
   591  					IdentityGroups: []string{"owners"},
   592  				})
   593  				_, err := srv.DeleteGroup(ctx, &rpcpb.DeleteGroupRequest{
   594  					Name: "test-group",
   595  				})
   596  				So(err, ShouldBeNil)
   597  			})
   598  
   599  			Convey("Admin succeeds", func() {
   600  				ctx := auth.WithState(ctx, &authtest.FakeState{
   601  					Identity:       "user:someone@example.com",
   602  					IdentityGroups: []string{model.AdminGroup},
   603  				})
   604  				_, err := srv.DeleteGroup(ctx, &rpcpb.DeleteGroupRequest{
   605  					Name: "test-group",
   606  				})
   607  				So(err, ShouldBeNil)
   608  			})
   609  		})
   610  
   611  		Convey("Etags", func() {
   612  			ctx := auth.WithState(ctx, &authtest.FakeState{
   613  				Identity:       "user:someone@example.com",
   614  				IdentityGroups: []string{"owners"},
   615  			})
   616  
   617  			Convey("Incorrect etag is aborted", func() {
   618  				_, err := srv.DeleteGroup(ctx, &rpcpb.DeleteGroupRequest{
   619  					Name: "test-group",
   620  					Etag: "blah",
   621  				})
   622  				So(err, ShouldHaveGRPCStatus, codes.Aborted)
   623  			})
   624  
   625  			Convey("Empty etag succeeds", func() {
   626  				_, err := srv.DeleteGroup(ctx, &rpcpb.DeleteGroupRequest{
   627  					Name: "test-group",
   628  				})
   629  				So(err, ShouldBeNil)
   630  			})
   631  
   632  			Convey("Correct etag succeeds if present", func() {
   633  				_, err := srv.DeleteGroup(ctx, &rpcpb.DeleteGroupRequest{
   634  					Name: "test-group",
   635  					Etag: etag,
   636  				})
   637  				So(err, ShouldBeNil)
   638  			})
   639  		})
   640  	})
   641  
   642  	Convey("GetSubgraph RPC call", t, func() {
   643  		const (
   644  			// Identities, groups, globs
   645  			owningGroup = "owning-group"
   646  			nestedGroup = "nested-group"
   647  			soloGroup   = "solo-group"
   648  			testUser0   = "user:m0@example.com"
   649  			testUser1   = "user:m1@example.com"
   650  			testUser2   = "user:t2@example.com"
   651  			testGlob0   = "user:m*@example.com"
   652  			testGlob1   = "user:t2*"
   653  		)
   654  
   655  		ctx := memory.Use(context.Background())
   656  		So(datastore.Put(ctx,
   657  			&model.AuthGroup{
   658  				ID:     owningGroup,
   659  				Parent: model.RootKey(ctx),
   660  				Members: []string{
   661  					testUser0,
   662  					testUser1,
   663  				},
   664  				Nested: []string{
   665  					nestedGroup,
   666  				},
   667  			},
   668  			&model.AuthGroup{
   669  				ID:     soloGroup,
   670  				Parent: model.RootKey(ctx),
   671  				Globs: []string{
   672  					testGlob1,
   673  				},
   674  			},
   675  			&model.AuthGroup{
   676  				ID:     nestedGroup,
   677  				Parent: model.RootKey(ctx),
   678  				Members: []string{
   679  					testUser2,
   680  				},
   681  				Globs: []string{
   682  					testGlob0,
   683  					testGlob1,
   684  				},
   685  				Owners: owningGroup,
   686  			}), ShouldBeNil)
   687  
   688  		Convey("Identity principal", func() {
   689  			request := &rpcpb.GetSubgraphRequest{
   690  				Principal: &rpcpb.Principal{
   691  					Kind: rpcpb.PrincipalKind_IDENTITY,
   692  					Name: testUser0,
   693  				},
   694  			}
   695  
   696  			actualSubgraph, err := srv.GetSubgraph(ctx, request)
   697  			So(err, ShouldBeNil)
   698  
   699  			expectedSubgraph := &rpcpb.Subgraph{
   700  				Nodes: []*rpcpb.Node{
   701  					{
   702  						Principal: &rpcpb.Principal{
   703  							Kind: rpcpb.PrincipalKind_IDENTITY,
   704  							Name: testUser0,
   705  						},
   706  						IncludedBy: []int32{1, 3},
   707  					},
   708  					{
   709  						Principal: &rpcpb.Principal{
   710  							Kind: rpcpb.PrincipalKind_GLOB,
   711  							Name: testGlob0,
   712  						},
   713  						IncludedBy: []int32{2},
   714  					},
   715  					{
   716  						Principal: &rpcpb.Principal{
   717  							Kind: rpcpb.PrincipalKind_GROUP,
   718  							Name: nestedGroup,
   719  						},
   720  						IncludedBy: []int32{3},
   721  					},
   722  					{
   723  						Principal: &rpcpb.Principal{
   724  							Kind: rpcpb.PrincipalKind_GROUP,
   725  							Name: owningGroup,
   726  						},
   727  					},
   728  				},
   729  			}
   730  
   731  			So(actualSubgraph, ShouldResemble, expectedSubgraph)
   732  		})
   733  
   734  		Convey("Group principal", func() {
   735  			request := rpcpb.GetSubgraphRequest{
   736  				Principal: &rpcpb.Principal{
   737  					Kind: rpcpb.PrincipalKind_GROUP,
   738  					Name: nestedGroup,
   739  				},
   740  			}
   741  
   742  			actualSubgraph, err := srv.GetSubgraph(ctx, &request)
   743  			So(err, ShouldBeNil)
   744  
   745  			expectedSubgraph := &rpcpb.Subgraph{
   746  				Nodes: []*rpcpb.Node{
   747  					{
   748  						Principal:  request.Principal,
   749  						IncludedBy: []int32{1},
   750  					},
   751  					{
   752  						Principal: &rpcpb.Principal{
   753  							Kind: rpcpb.PrincipalKind_GROUP,
   754  							Name: owningGroup,
   755  						},
   756  					},
   757  				},
   758  			}
   759  
   760  			So(actualSubgraph, ShouldResemble, expectedSubgraph)
   761  
   762  		})
   763  
   764  		Convey("Glob principal", func() {
   765  			request := rpcpb.GetSubgraphRequest{
   766  				Principal: &rpcpb.Principal{
   767  					Kind: rpcpb.PrincipalKind_GLOB,
   768  					Name: testGlob1,
   769  				},
   770  			}
   771  
   772  			actualSubgraph, err := srv.GetSubgraph(ctx, &request)
   773  			So(err, ShouldBeNil)
   774  
   775  			expectedSubgraph := &rpcpb.Subgraph{
   776  				Nodes: []*rpcpb.Node{
   777  					{
   778  						Principal:  request.Principal,
   779  						IncludedBy: []int32{1, 3},
   780  					},
   781  					{
   782  						Principal: &rpcpb.Principal{
   783  							Kind: rpcpb.PrincipalKind_GROUP,
   784  							Name: nestedGroup,
   785  						},
   786  						IncludedBy: []int32{2},
   787  					},
   788  					{
   789  						Principal: &rpcpb.Principal{
   790  							Kind: rpcpb.PrincipalKind_GROUP,
   791  							Name: owningGroup,
   792  						},
   793  					},
   794  					{
   795  						Principal: &rpcpb.Principal{
   796  							Kind: rpcpb.PrincipalKind_GROUP,
   797  							Name: soloGroup,
   798  						},
   799  					},
   800  				},
   801  			}
   802  
   803  			So(actualSubgraph, ShouldResemble, expectedSubgraph)
   804  		})
   805  
   806  		Convey("Unspecified Principal kind", func() {
   807  			request := rpcpb.GetSubgraphRequest{
   808  				Principal: &rpcpb.Principal{
   809  					Kind: rpcpb.PrincipalKind_PRINCIPAL_KIND_UNSPECIFIED,
   810  					Name: "aeua//",
   811  				},
   812  			}
   813  
   814  			_, err := srv.GetSubgraph(ctx, &request)
   815  			So(err.Error(), ShouldContainSubstring, "invalid principal kind")
   816  
   817  		})
   818  
   819  		Convey("Group principal not in groups graph", func() {
   820  			request := rpcpb.GetSubgraphRequest{
   821  				Principal: &rpcpb.Principal{
   822  					Kind: rpcpb.PrincipalKind_GROUP,
   823  					Name: "i-dont-exist",
   824  				},
   825  			}
   826  
   827  			_, err := srv.GetSubgraph(ctx, &request)
   828  			So(err.Error(), ShouldContainSubstring, "no such group")
   829  		})
   830  	})
   831  }