@@ -14,6 +14,9 @@ import (
1414 gstatus "google.golang.org/grpc/status"
1515 "google.golang.org/protobuf/types/known/wrapperspb"
1616
17+ acmock "github.com/bucketeer-io/bucketeer/pkg/account/client/mock"
18+ accountdomain "github.com/bucketeer-io/bucketeer/pkg/account/domain"
19+ v2as "github.com/bucketeer-io/bucketeer/pkg/account/storage/v2"
1720 accstoragemock "github.com/bucketeer-io/bucketeer/pkg/account/storage/v2/mock"
1821 "github.com/bucketeer-io/bucketeer/pkg/environment/domain"
1922 v2es "github.com/bucketeer-io/bucketeer/pkg/environment/storage/v2"
@@ -22,6 +25,7 @@ import (
2225 publishermock "github.com/bucketeer-io/bucketeer/pkg/pubsub/publisher/mock"
2326 "github.com/bucketeer-io/bucketeer/pkg/storage/v2/mysql"
2427 mysqlmock "github.com/bucketeer-io/bucketeer/pkg/storage/v2/mysql/mock"
28+ accountproto "github.com/bucketeer-io/bucketeer/proto/account"
2529 proto "github.com/bucketeer-io/bucketeer/proto/environment"
2630)
2731
@@ -1264,3 +1268,213 @@ func TestEnvironmentService_CreateDemoOrganization(t *testing.T) {
12641268 })
12651269 }
12661270}
1271+
1272+ func TestValidateOwnershipTransfer (t * testing.T ) {
1273+ t .Parallel ()
1274+ mockController := gomock .NewController (t )
1275+ defer mockController .Finish ()
1276+
1277+ // Create context with system admin token for most tests
1278+ ctxAdmin := createContextWithToken (t )
1279+ ctxAdmin = metadata .NewIncomingContext (ctxAdmin , metadata.MD {
1280+ "accept-language" : []string {"ja" },
1281+ })
1282+
1283+ // Create context with non-admin token for ownership validation tests
1284+ ctxOwner := createContextWithTokenRoleUnassigned (t )
1285+ ctxOwner = metadata .NewIncomingContext (ctxOwner , metadata.MD {
1286+ "accept-language" : []string {"ja" },
1287+ })
1288+
1289+ localizer := locale .NewLocalizer (ctxAdmin )
1290+ createError := func (status * gstatus.Status , msg string ) error {
1291+ st , err := status .WithDetails (& errdetails.LocalizedMessage {
1292+ Locale : localizer .GetLocale (),
1293+ Message : msg ,
1294+ })
1295+ require .NoError (t , err )
1296+ return st .Err ()
1297+ }
1298+
1299+ patterns := []struct {
1300+ desc string
1301+ ctx context.Context
1302+ setup func (* EnvironmentService )
1303+ req * proto.UpdateOrganizationRequest
1304+ expectedErr error
1305+ }{
1306+ {
1307+ desc : "success: no ownership transfer (name change only)" ,
1308+ ctx : ctxAdmin ,
1309+ setup : func (s * EnvironmentService ) {
1310+ s .mysqlClient .(* mysqlmock.MockClient ).EXPECT ().RunInTransactionV2 (
1311+ gomock .Any (), gomock .Any (),
1312+ ).Return (nil )
1313+ s .publisher .(* publishermock.MockPublisher ).EXPECT ().Publish (gomock .Any (), gomock .Any ()).Return (nil )
1314+ },
1315+ req : & proto.UpdateOrganizationRequest {
1316+ Id : "org-1" ,
1317+ Name : wrapperspb .String ("New Organization Name" ),
1318+ },
1319+ expectedErr : nil ,
1320+ },
1321+ {
1322+ desc : "err: no-op ownership transfer (same owner email)" ,
1323+ ctx : ctxAdmin ,
1324+ setup : func (s * EnvironmentService ) {
1325+ s .orgStorage .(* storagemock.MockOrganizationStorage ).EXPECT ().GetOrganization (
1326+ gomock .Any (), "org-1" ,
1327+ ).Return (& domain.Organization {
1328+ Organization : & proto.Organization {
1329+ Id : "org-1" ,
1330+ OwnerEmail : "current-owner@example.com" ,
1331+ },
1332+ }, nil )
1333+ },
1334+ req : & proto.UpdateOrganizationRequest {
1335+ Id : "org-1" ,
1336+ OwnerEmail : wrapperspb .String ("current-owner@example.com" ),
1337+ },
1338+ expectedErr : createError (statusNoCommand , localizer .MustLocalizeWithTemplate (
1339+ locale .InvalidArgumentError ,
1340+ "new owner email is the same as the current owner" ,
1341+ )),
1342+ },
1343+ {
1344+ desc : "err: non-owner trying to transfer ownership" ,
1345+ ctx : ctxOwner ,
1346+ setup : func (s * EnvironmentService ) {
1347+ s .accountClient .(* acmock.MockClient ).EXPECT ().GetAccountV2 (
1348+ gomock .Any (), gomock .Any (),
1349+ ).Return (& accountproto.GetAccountV2Response {
1350+ Account : & accountproto.AccountV2 {
1351+ Email : "email" ,
1352+ OrganizationRole : accountproto .AccountV2_Role_Organization_OWNER ,
1353+ },
1354+ }, nil )
1355+ s .orgStorage .(* storagemock.MockOrganizationStorage ).EXPECT ().GetOrganization (
1356+ gomock .Any (), "org-1" ,
1357+ ).Return (& domain.Organization {
1358+ Organization : & proto.Organization {
1359+ Id : "org-1" ,
1360+ OwnerEmail : "current-owner@example.com" , // Different from token email
1361+ },
1362+ }, nil )
1363+ },
1364+ req : & proto.UpdateOrganizationRequest {
1365+ Id : "org-1" ,
1366+ OwnerEmail : wrapperspb .String ("new-owner@example.com" ),
1367+ },
1368+ expectedErr : createError (statusPermissionDenied , localizer .MustLocalize (locale .PermissionDenied )),
1369+ },
1370+ {
1371+ desc : "err: new owner account not found" ,
1372+ ctx : ctxAdmin ,
1373+ setup : func (s * EnvironmentService ) {
1374+ s .orgStorage .(* storagemock.MockOrganizationStorage ).EXPECT ().GetOrganization (
1375+ gomock .Any (), "org-1" ,
1376+ ).Return (& domain.Organization {
1377+ Organization : & proto.Organization {
1378+ Id : "org-1" ,
1379+ OwnerEmail : "current-owner@example.com" ,
1380+ },
1381+ }, nil )
1382+ s .accountStorage .(* accstoragemock.MockAccountStorage ).EXPECT ().GetAccountV2 (
1383+ gomock .Any (), "new-owner@example.com" , "org-1" ,
1384+ ).Return (nil , v2as .ErrAccountNotFound )
1385+ },
1386+ req : & proto.UpdateOrganizationRequest {
1387+ Id : "org-1" ,
1388+ OwnerEmail : wrapperspb .String ("new-owner@example.com" ),
1389+ },
1390+ expectedErr : createError (statusNotFound , localizer .MustLocalizeWithTemplate (locale .NotFoundError , "new owner account not found in organization" )),
1391+ },
1392+ {
1393+ desc : "err: new owner account is disabled" ,
1394+ ctx : ctxAdmin ,
1395+ setup : func (s * EnvironmentService ) {
1396+ s .orgStorage .(* storagemock.MockOrganizationStorage ).EXPECT ().GetOrganization (
1397+ gomock .Any (), "org-1" ,
1398+ ).Return (& domain.Organization {
1399+ Organization : & proto.Organization {
1400+ Id : "org-1" ,
1401+ OwnerEmail : "current-owner@example.com" ,
1402+ },
1403+ }, nil )
1404+ s .accountStorage .(* accstoragemock.MockAccountStorage ).EXPECT ().GetAccountV2 (
1405+ gomock .Any (), "new-owner@example.com" , "org-1" ,
1406+ ).Return (& accountdomain.AccountV2 {
1407+ AccountV2 : & accountproto.AccountV2 {
1408+ Email : "new-owner@example.com" ,
1409+ Disabled : true ,
1410+ },
1411+ }, nil )
1412+ },
1413+ req : & proto.UpdateOrganizationRequest {
1414+ Id : "org-1" ,
1415+ OwnerEmail : wrapperspb .String ("new-owner@example.com" ),
1416+ },
1417+ expectedErr : createError (statusPermissionDenied , localizer .MustLocalizeWithTemplate (locale .InvalidArgumentError , "new owner account is disabled" )),
1418+ },
1419+ {
1420+ desc : "success: valid ownership transfer passes validation" ,
1421+ ctx : ctxAdmin ,
1422+ setup : func (s * EnvironmentService ) {
1423+ // Mock validation phase - validateOwnershipTransfer
1424+ s .orgStorage .(* storagemock.MockOrganizationStorage ).EXPECT ().GetOrganization (
1425+ gomock .Any (), "org-1" ,
1426+ ).Return (& domain.Organization {
1427+ Organization : & proto.Organization {
1428+ Id : "org-1" ,
1429+ OwnerEmail : "current-owner@example.com" ,
1430+ },
1431+ }, nil )
1432+ s .accountStorage .(* accstoragemock.MockAccountStorage ).EXPECT ().GetAccountV2 (
1433+ gomock .Any (), "new-owner@example.com" , "org-1" ,
1434+ ).Return (& accountdomain.AccountV2 {
1435+ AccountV2 : & accountproto.AccountV2 {
1436+ Email : "new-owner@example.com" ,
1437+ Disabled : false ,
1438+ },
1439+ }, nil )
1440+
1441+ // Mock transaction execution (simplified)
1442+ s .mysqlClient .(* mysqlmock.MockClient ).EXPECT ().RunInTransactionV2 (
1443+ gomock .Any (), gomock .Any (),
1444+ ).Return (nil )
1445+ s .publisher .(* publishermock.MockPublisher ).EXPECT ().Publish (gomock .Any (), gomock .Any ()).Return (nil )
1446+
1447+ // Mock updateOwnerRole calls (these happen after transaction)
1448+ s .accountStorage .(* accstoragemock.MockAccountStorage ).EXPECT ().GetAccountV2 (
1449+ gomock .Any (), "current-owner@example.com" , "org-1" ,
1450+ ).Return (& accountdomain.AccountV2 {
1451+ AccountV2 : & accountproto.AccountV2 {Email : "current-owner@example.com" },
1452+ }, nil ).AnyTimes ()
1453+ s .accountStorage .(* accstoragemock.MockAccountStorage ).EXPECT ().UpdateAccountV2 (
1454+ gomock .Any (), gomock .Any (),
1455+ ).Return (nil ).AnyTimes ()
1456+ s .accountStorage .(* accstoragemock.MockAccountStorage ).EXPECT ().GetAccountV2 (
1457+ gomock .Any (), "new-owner@example.com" , "org-1" ,
1458+ ).Return (& accountdomain.AccountV2 {
1459+ AccountV2 : & accountproto.AccountV2 {Email : "new-owner@example.com" },
1460+ }, nil ).AnyTimes ()
1461+ },
1462+ req : & proto.UpdateOrganizationRequest {
1463+ Id : "org-1" ,
1464+ OwnerEmail : wrapperspb .String ("new-owner@example.com" ),
1465+ },
1466+ expectedErr : nil ,
1467+ },
1468+ }
1469+
1470+ for _ , p := range patterns {
1471+ t .Run (p .desc , func (t * testing.T ) {
1472+ service := newEnvironmentService (t , mockController , nil )
1473+ if p .setup != nil {
1474+ p .setup (service )
1475+ }
1476+ _ , err := service .UpdateOrganization (p .ctx , p .req )
1477+ assert .Equal (t , p .expectedErr , err )
1478+ })
1479+ }
1480+ }
0 commit comments