// Copyright IBM Corp. 2024, 2025 // SPDX-License-Identifier: MPL-2.0 package credential import ( "context" "errors" "testing" "time" "cloud.google.com/go/iam/admin/apiv1/adminpb" "cloud.google.com/go/iam/apiv1/iampb" "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" "github.com/stretchr/testify/require" googleOption "google.golang.org/api/option" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" ) func TestRotateServiceAccountKey(t *testing.T) { testIAMAdminServer := &testIAMAdminServer{} testResourceServer := &testResourceServer{ testIamPermissionsResponse: &iampb.TestIamPermissionsResponse{ Permissions: []string{ ComputeInstancesListPermission, IAMServiceAccountKeysCreatePermission, IAMServiceAccountKeysDeletePermission, }, }, } testValidateTimeout := 1 * time.Second tests := []struct { name string config *Config setup func() expectedError error }{ { name: "PrivateKey is empty", config: &Config{ PrivateKey: "", }, expectedError: status.Error(codes.InvalidArgument, "cannot rotate credentials when private key is not set"), }, { name: "PrivateKeyId is empty", config: &Config{ PrivateKey: "some-private-key", PrivateKeyId: "", }, expectedError: status.Error(codes.InvalidArgument, "cannot rotate credentials when private key ID is not set"), }, { name: "ClientEmail is empty", config: &Config{ PrivateKey: "some-private-key", PrivateKeyId: "some-private-key-id", ClientEmail: "", }, expectedError: status.Error(codes.InvalidArgument, "cannot rotate credentials when client email is not set"), }, { name: "CreateServiceAccountKey returns error", config: &Config{ PrivateKey: "some-private-key", PrivateKeyId: "some-private-key-id", ClientEmail: "client@example.com", }, setup: func() { testIAMAdminServer.testCreateServiceAccountKeyError = errors.New("create key error") testIAMAdminServer.testDeleteServiceAccountKeyError = nil testIAMAdminServer.testDeleteServiceAccountKeyErrors = nil testResourceServer.testIamPermissionsError = nil }, expectedError: errors.New("create key error"), }, { name: "ValidateServiceAccountKey fails and rollback succeeds", config: &Config{ PrivateKey: "some-private-key", PrivateKeyId: "some-private-key-id", ClientEmail: "client@example.com", validateServiceAccountKeyTimeout: testValidateTimeout, }, setup: func() { testIAMAdminServer.testCreateServiceAccountKeyError = nil testIAMAdminServer.testDeleteServiceAccountKeyError = nil testIAMAdminServer.testDeleteServiceAccountKeyErrors = []error{nil} testResourceServer.testIamPermissionsError = errors.New("iam permissions error") }, expectedError: errors.New("successfully rolled back newly created service account key"), }, { name: "ValidateServiceAccountKey fails and rollback fails", config: &Config{ PrivateKey: "some-private-key", PrivateKeyId: "some-private-key-id", ClientEmail: "client@example.com", validateServiceAccountKeyTimeout: testValidateTimeout, }, setup: func() { testIAMAdminServer.testCreateServiceAccountKeyError = nil testIAMAdminServer.testDeleteServiceAccountKeyError = nil testIAMAdminServer.testDeleteServiceAccountKeyErrors = []error{errors.New("rollback key error")} testResourceServer.testIamPermissionsError = errors.New("iam permissions error") }, expectedError: errors.New("error rolling back newly created service account key"), }, { name: "DeleteServiceAccountKey old key fails and rollback fails", config: &Config{ PrivateKey: "some-private-key", PrivateKeyId: "some-private-key-id", ClientEmail: "client@example.com", validateServiceAccountKeyTimeout: testValidateTimeout, }, setup: func() { testIAMAdminServer.testCreateServiceAccountKeyError = nil testIAMAdminServer.testDeleteServiceAccountKeyError = nil testIAMAdminServer.testDeleteServiceAccountKeyErrors = []error{ errors.New("delete key error"), errors.New("rollback key error"), } testResourceServer.testIamPermissionsError = nil }, expectedError: errors.New("error rolling back newly created service account key"), }, { name: "DeleteServiceAccountKey old key fails and rollback succeeds", config: &Config{ PrivateKey: "some-private-key", PrivateKeyId: "some-private-key-id", ClientEmail: "client@example.com", validateServiceAccountKeyTimeout: testValidateTimeout, }, setup: func() { testIAMAdminServer.testCreateServiceAccountKeyError = nil testIAMAdminServer.testDeleteServiceAccountKeyError = nil testIAMAdminServer.testDeleteServiceAccountKeyErrors = []error{ errors.New("delete key error"), nil, } testResourceServer.testIamPermissionsError = nil }, expectedError: errors.New("successfully rolled back newly created service account key"), }, { name: "Successful rotation", config: &Config{ PrivateKey: "some-private-key", PrivateKeyId: "some-private-key-id", ClientEmail: "client@example.com", validateServiceAccountKeyTimeout: testValidateTimeout, }, setup: func() { testIAMAdminServer.testCreateServiceAccountKeyError = nil testIAMAdminServer.testDeleteServiceAccountKeyError = nil testIAMAdminServer.testDeleteServiceAccountKeyErrors = nil testResourceServer.testIamPermissionsError = nil }, expectedError: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gsrv := NewGRPCServer() adminpb.RegisterIAMServer(gsrv.Server, testIAMAdminServer) resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer) addr, err := gsrv.Start() require.NoError(t, err) ctx := context.Background() if tt.setup != nil { tt.setup() } testOptions := []googleOption.ClientOption{ googleOption.WithEndpoint(addr), googleOption.WithoutAuthentication(), googleOption.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), googleOption.WithTokenSource(nil), } initialPrivateKey := tt.config.PrivateKey initialPrivateKeyId := tt.config.PrivateKeyId initialClientEmail := tt.config.ClientEmail permissions := []string{ ComputeInstancesListPermission, IAMServiceAccountKeysCreatePermission, IAMServiceAccountKeysDeletePermission, } validateCredsCallbackCalled := false validateCredsCallBack := func(c *Config, opts ...googleOption.ClientOption) error { validateCredsCallbackCalled = true return nil } err = tt.config.RotateServiceAccountKey(ctx, permissions, validateCredsCallBack, testOptions...) if tt.expectedError != nil { require.ErrorContains(t, err, tt.expectedError.Error()) require.Equal(t, initialClientEmail, tt.config.ClientEmail) require.Equal(t, initialPrivateKey, tt.config.PrivateKey) require.Equal(t, initialPrivateKeyId, tt.config.PrivateKeyId) return } require.Equal(t, initialClientEmail, tt.config.ClientEmail) require.NotEmpty(t, tt.config.PrivateKey) require.NotEmpty(t, tt.config.PrivateKeyId) require.NotEqual(t, initialPrivateKey, tt.config.PrivateKey) require.NotEqual(t, initialPrivateKeyId, tt.config.PrivateKeyId) require.True(t, validateCredsCallbackCalled) }) } } func TestValidateIamPermissions(t *testing.T) { ctx := context.Background() testResourceServer := &testResourceServer{} tests := []struct { name string config *Config testIamResponse *iampb.TestIamPermissionsResponse testIamPermissionsError error permissions []string wantErr bool expectedErr string expectedPermissions []string }{ { name: "Successful IAM Permissions Test", config: &Config{ ProjectId: "test-project-id", PrivateKey: "test-private-key", ClientEmail: "test@test.com", }, testIamResponse: &iampb.TestIamPermissionsResponse{ Permissions: []string{"permission1", "permission2"}, }, permissions: []string{"permission1", "permission2"}, expectedPermissions: []string{"permission1", "permission2"}, }, { name: "Missing Permissions", config: &Config{ ProjectId: "test-project-id", PrivateKey: "test-private-key", }, wantErr: true, expectedErr: "permissions are required", }, { name: "Failed to Test IAM Permissions", config: &Config{ ProjectId: "test-project-id", PrivateKey: "test-private-key", ClientEmail: "test@test.com", }, testIamPermissionsError: errors.New("failed to test IAM permissions"), permissions: []string{"permission1", "permission2"}, wantErr: true, expectedErr: "failed to test IAM permissions", }, { name: "No Permissions Granted", config: &Config{ ProjectId: "test-project-id", PrivateKey: "test-private-key", ClientEmail: "test@test.com", }, testIamResponse: &iampb.TestIamPermissionsResponse{ Permissions: []string{}, }, testIamPermissionsError: nil, permissions: []string{"permission1", "permission2"}, wantErr: true, expectedErr: "no permissions granted", }, { name: "Missing Permissions", config: &Config{ ProjectId: "test-project-id", PrivateKey: "test-private-key", ClientEmail: "test@test.com", }, testIamResponse: &iampb.TestIamPermissionsResponse{ Permissions: []string{"permission1"}, }, testIamPermissionsError: nil, permissions: []string{"permission1", "permission2"}, wantErr: true, expectedErr: "missing permissions: [permission2]", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { testResourceServer.testIamPermissionsResponse = tt.testIamResponse testResourceServer.testIamPermissionsError = tt.testIamPermissionsError gsrv := NewGRPCServer() resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer) addr, err := gsrv.Start() require.NoError(t, err) testOptions := []googleOption.ClientOption{ googleOption.WithEndpoint(addr), googleOption.WithoutAuthentication(), googleOption.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), googleOption.WithTokenSource(nil), } permissions, err := tt.config.ValidateIamPermissions(ctx, tt.permissions, testOptions...) if tt.wantErr { require.ErrorContains(t, err, tt.expectedErr) require.Nil(t, permissions) return } require.NoError(t, err) require.Equal(t, tt.expectedPermissions, permissions) }) } } func TestValidateServiceAccountKey(t *testing.T) { ctx := context.Background() testResourceServer := &testResourceServer{} testValidateTimeout := 1 * time.Second tests := []struct { name string config *Config permissions []string validateCredsCallback ValidateCredsCallback testIamPermissionsError error expectedError error }{ { name: "Successful validation", config: &Config{ ProjectId: "test-project-id", PrivateKey: "test-private-key", ClientEmail: "test@test.com", validateServiceAccountKeyTimeout: testValidateTimeout, }, permissions: []string{ ComputeInstancesListPermission, IAMServiceAccountKeysCreatePermission, IAMServiceAccountKeysDeletePermission, }, validateCredsCallback: func(c *Config, opts ...googleOption.ClientOption) error { return nil }, expectedError: nil, }, { name: "Validation callback returns error", config: &Config{ ProjectId: "test-project-id", PrivateKey: "test-private-key", ClientEmail: "test@test.com", validateServiceAccountKeyTimeout: testValidateTimeout, }, permissions: []string{ ComputeInstancesListPermission, IAMServiceAccountKeysCreatePermission, IAMServiceAccountKeysDeletePermission, }, validateCredsCallback: func(c *Config, opts ...googleOption.ClientOption) error { return errors.New("validation callback error") }, expectedError: errors.New("validation callback error"), }, { name: "IAM permissions validation returns error", config: &Config{ ProjectId: "test-project-id", PrivateKey: "test-private-key", ClientEmail: "test@test.com", validateServiceAccountKeyTimeout: testValidateTimeout, }, permissions: []string{ ComputeInstancesListPermission, IAMServiceAccountKeysCreatePermission, IAMServiceAccountKeysDeletePermission, }, testIamPermissionsError: errors.New("IAM permissions error"), expectedError: errors.New("failed to validate IAM permissions"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { testResourceServer.testIamPermissionsResponse = &iampb.TestIamPermissionsResponse{ Permissions: tt.permissions, } testResourceServer.testIamPermissionsError = tt.testIamPermissionsError gsrv := NewGRPCServer() resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer) addr, err := gsrv.Start() require.NoError(t, err) testOptions := []googleOption.ClientOption{ googleOption.WithEndpoint(addr), googleOption.WithoutAuthentication(), googleOption.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), googleOption.WithTokenSource(nil), } err = tt.config.ValidateServiceAccountKey(ctx, tt.permissions, tt.validateCredsCallback, testOptions...) if tt.expectedError != nil { require.ErrorContains(t, err, tt.expectedError.Error()) return } require.NoError(t, err) }) } } func TestDeletePrivateKey(t *testing.T) { testIAMAdminServer := &testIAMAdminServer{} tests := []struct { name string config *Config setup func() expectedError error }{ { name: "PrivateKey is empty", config: &Config{ PrivateKey: "", }, expectedError: status.Error(codes.InvalidArgument, "cannot delete credentials when private key is not set"), }, { name: "PrivateKeyId is empty", config: &Config{ PrivateKey: "some-private-key", PrivateKeyId: "", }, expectedError: status.Error(codes.InvalidArgument, "cannot delete credentials when private key ID is not set"), }, { name: "ClientEmail is empty", config: &Config{ PrivateKey: "some-private-key", PrivateKeyId: "some-private-key-id", ClientEmail: "", }, expectedError: status.Error(codes.InvalidArgument, "cannot delete credentials when client email is not set"), }, { name: "GenerateCredentials returns error", config: &Config{ PrivateKey: "some-private-key", PrivateKeyId: "some-private-key-id", ClientEmail: "client@example.com", TargetServiceAccountId: "test-service-account-id", }, expectedError: status.Errorf(codes.Unauthenticated, "error generating credentials"), }, { name: "DeleteServiceAccountKey returns error", config: &Config{ PrivateKey: "some-private-key", PrivateKeyId: "some-private-key-id", ClientEmail: "client@example.com", }, setup: func() { testIAMAdminServer.testDeleteServiceAccountKeyError = errors.New("delete key error") }, expectedError: status.Errorf(codes.Unknown, "error deleting service account key"), }, { name: "Successful deletion", config: &Config{ PrivateKey: "some-private-key", PrivateKeyId: "some-private-key-id", ClientEmail: "client@example.com", }, setup: func() { testIAMAdminServer.testDeleteServiceAccountKeyError = nil }, expectedError: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gsrv := NewGRPCServer() adminpb.RegisterIAMServer(gsrv.Server, testIAMAdminServer) addr, err := gsrv.Start() require.NoError(t, err) ctx := context.Background() if tt.setup != nil { tt.setup() } testOptions := []googleOption.ClientOption{ googleOption.WithEndpoint(addr), googleOption.WithoutAuthentication(), googleOption.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), googleOption.WithTokenSource(nil), } err = tt.config.DeletePrivateKey(ctx, testOptions...) if tt.expectedError != nil { require.ErrorContains(t, err, tt.expectedError.Error()) return } require.NoError(t, err) }) } }