diff --git a/.autover/changes/ebd7f49a-72cd-4c5b-83f4-2790a2560e94.json b/.autover/changes/ebd7f49a-72cd-4c5b-83f4-2790a2560e94.json new file mode 100644 index 000000000..df1f60a88 --- /dev/null +++ b/.autover/changes/ebd7f49a-72cd-4c5b-83f4-2790a2560e94.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "AWS.Deploy.CLI", + "Type": "Minor", + "ChangelogMessages": [ + "Read region value for non default profiles" + ] + } + ] +} \ No newline at end of file diff --git a/src/AWS.Deploy.CLI/AWSCredentialsFactory.cs b/src/AWS.Deploy.CLI/AWSCredentialsFactory.cs new file mode 100644 index 000000000..d9ac35223 --- /dev/null +++ b/src/AWS.Deploy.CLI/AWSCredentialsFactory.cs @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Runtime; + +namespace AWS.Deploy.CLI +{ + /// + public class AWSCredentialsFactory : IAWSCredentialsFactory + { + /// + public AWSCredentials Create() + { + return FallbackCredentialsFactory.GetCredentials(); + } + } +} diff --git a/src/AWS.Deploy.CLI/AWSUtilities.cs b/src/AWS.Deploy.CLI/AWSUtilities.cs index 9a3376576..997773e3a 100644 --- a/src/AWS.Deploy.CLI/AWSUtilities.cs +++ b/src/AWS.Deploy.CLI/AWSUtilities.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Threading.Tasks; using Amazon.Runtime; -using Amazon.Runtime.CredentialManagement; using AWS.Deploy.CLI.Utilities; using AWS.Deploy.Common; using AWS.Deploy.Common.IO; @@ -17,7 +16,7 @@ namespace AWS.Deploy.CLI { public interface IAWSUtilities { - Task ResolveAWSCredentials(string? profileName); + Task> ResolveAWSCredentials(string? profileName); string ResolveAWSRegion(string? region, string? lastRegionUsed = null); } @@ -28,27 +27,43 @@ public class AWSUtilities : IAWSUtilities private readonly IDirectoryManager _directoryManager; private readonly IOptionSettingHandler _optionSettingHandler; private readonly IServiceProvider _serviceProvider; + private readonly ICredentialProfileStoreChainFactory _credentialChainFactory; + private readonly ISharedCredentialsFileFactory _sharedCredentialsFileFactory; + private readonly IAWSCredentialsFactory _awsCredentialsFactory; public AWSUtilities( IServiceProvider serviceProvider, IToolInteractiveService toolInteractiveService, IConsoleUtilities consoleUtilities, IDirectoryManager directoryManager, - IOptionSettingHandler optionSettingHandler) + IOptionSettingHandler optionSettingHandler, + ICredentialProfileStoreChainFactory credentialChainFactory, + ISharedCredentialsFileFactory sharedCredentialsFileFactory, + IAWSCredentialsFactory awsCredentialsFactory) { _serviceProvider = serviceProvider; _toolInteractiveService = toolInteractiveService; _consoleUtilities = consoleUtilities; _directoryManager = directoryManager; _optionSettingHandler = optionSettingHandler; + _credentialChainFactory = credentialChainFactory; + _sharedCredentialsFileFactory = sharedCredentialsFileFactory; + _awsCredentialsFactory = awsCredentialsFactory; } - public async Task ResolveAWSCredentials(string? profileName) + + /// + /// At a high level there are 2 possible return values for this function: + /// 1. In this case, both the credentials and region were able to be read from the profile. + /// 2. : In this case, the region was not able to be read from the profile, so we return null for it. The null case will be handled later on by . + /// + public async Task> ResolveAWSCredentials(string? profileName) { - async Task Resolve() + async Task> Resolve() { - var chain = new CredentialProfileStoreChain(); + var chain = _credentialChainFactory.Create(); + // Use provided profile to read credentials if (!string.IsNullOrEmpty(profileName)) { if (chain.TryGetAWSCredentials(profileName, out var profileCredentials) && @@ -56,7 +71,10 @@ async Task Resolve() (profileCredentials is AssumeRoleAWSCredentials || await CanLoadCredentials(profileCredentials))) { _toolInteractiveService.WriteLine($"Configuring AWS Credentials from Profile {profileName}."); - return profileCredentials; + chain.TryGetProfile(profileName, out var profile); + // Return the credentials since they must be found at this point. + // For region, we try to read it from the profile. If it's not found in the profile, then return null and region selection will be handled later on by ResolveAWSRegion. + return Tuple.Create(profileCredentials, profile.Region?.SystemName); } else { @@ -65,14 +83,17 @@ async Task Resolve() } } + // Use default credentials try { - var fallbackCredentials = FallbackCredentialsFactory.GetCredentials(); + var fallbackCredentials = _awsCredentialsFactory.Create(); if (await CanLoadCredentials(fallbackCredentials)) { + // Always return the credentials since they must be found at this point. + // For region, we return null here, since it will read from default region in ResolveAWSRegion _toolInteractiveService.WriteLine("Configuring AWS Credentials using AWS SDK credential search."); - return fallbackCredentials; + return Tuple.Create(fallbackCredentials, null); } } catch (AmazonServiceException ex) @@ -82,7 +103,8 @@ async Task Resolve() _toolInteractiveService.WriteDebugLine(ex.PrettyPrint()); } - var sharedCredentials = new SharedCredentialsFile(); + // Use Shared Credentials + var sharedCredentials = _sharedCredentialsFileFactory.Create(); if (sharedCredentials.ListProfileNames().Count == 0) { throw new NoAWSCredentialsFoundException(DeployToolErrorCode.UnableToResolveAWSCredentials, "Unable to resolve AWS credentials to access AWS."); @@ -93,21 +115,24 @@ async Task Resolve() if (chain.TryGetAWSCredentials(selectedProfileName, out var selectedProfileCredentials) && (await CanLoadCredentials(selectedProfileCredentials))) { - return selectedProfileCredentials; + // Return the credentials since they must be found at this point. + // For region, we try to read it from the profile. If it's not found in the profile, then return null and region selection will be handled later on by ResolveAWSRegion. + chain.TryGetProfile(selectedProfileName, out var profile); + return Tuple.Create(selectedProfileCredentials, profile.Region?.SystemName); } throw new NoAWSCredentialsFoundException(DeployToolErrorCode.UnableToCreateAWSCredentials, $"Unable to create AWS credentials for profile {selectedProfileName}."); } - var credentials = await Resolve(); + var credentialsAndRegion = await Resolve(); - if (credentials is AssumeRoleAWSCredentials assumeRoleAWSCredentials) + if (credentialsAndRegion.Item1 is AssumeRoleAWSCredentials assumeRoleAWSCredentials) { var assumeOptions = assumeRoleAWSCredentials.Options; assumeOptions.MfaTokenCodeCallback = ActivatorUtilities.CreateInstance(_serviceProvider, assumeOptions).Execute; } - return credentials; + return credentialsAndRegion; } private async Task CanLoadCredentials(AWSCredentials credentials) diff --git a/src/AWS.Deploy.CLI/Commands/CommandFactory.cs b/src/AWS.Deploy.CLI/Commands/CommandFactory.cs index 2240d8a1b..5e30341e5 100644 --- a/src/AWS.Deploy.CLI/Commands/CommandFactory.cs +++ b/src/AWS.Deploy.CLI/Commands/CommandFactory.cs @@ -209,8 +209,8 @@ private Command BuildDeployCommand() deploymentSettings = await _deploymentSettingsHandler.ReadSettings(applyPath); } - var awsCredentials = await _awsUtilities.ResolveAWSCredentials(input.Profile ?? deploymentSettings?.AWSProfile); - var awsRegion = _awsUtilities.ResolveAWSRegion(input.Region ?? deploymentSettings?.AWSRegion); + var (awsCredentials, regionFromProfile) = await _awsUtilities.ResolveAWSCredentials(input.Profile ?? deploymentSettings?.AWSProfile); + var awsRegion = _awsUtilities.ResolveAWSRegion(input.Region ?? deploymentSettings?.AWSRegion ?? regionFromProfile); _commandLineWrapper.RegisterAWSContext(awsCredentials, awsRegion); _awsClientFactory.RegisterAWSContext(awsCredentials, awsRegion); @@ -318,8 +318,8 @@ private Command BuildDeleteCommand() _toolInteractiveService.Diagnostics = input.Diagnostics; _toolInteractiveService.DisableInteractive = input.Silent; - var awsCredentials = await _awsUtilities.ResolveAWSCredentials(input.Profile); - var awsRegion = _awsUtilities.ResolveAWSRegion(input.Region); + var (awsCredentials, regionFromProfile) = await _awsUtilities.ResolveAWSCredentials(input.Profile); + var awsRegion = _awsUtilities.ResolveAWSRegion(input.Region ?? regionFromProfile); _awsClientFactory.ConfigureAWSOptions(awsOption => { @@ -401,8 +401,8 @@ private Command BuildListCommand() { _toolInteractiveService.Diagnostics = input.Diagnostics; - var awsCredentials = await _awsUtilities.ResolveAWSCredentials(input.Profile); - var awsRegion = _awsUtilities.ResolveAWSRegion(input.Region); + var (awsCredentials, regionFromProfile) = await _awsUtilities.ResolveAWSCredentials(input.Profile); + var awsRegion = _awsUtilities.ResolveAWSRegion(input.Region ?? regionFromProfile); _awsClientFactory.ConfigureAWSOptions(awsOptions => { diff --git a/src/AWS.Deploy.CLI/CredentialProfileStoreChainFactory.cs b/src/AWS.Deploy.CLI/CredentialProfileStoreChainFactory.cs new file mode 100644 index 000000000..4cf75c408 --- /dev/null +++ b/src/AWS.Deploy.CLI/CredentialProfileStoreChainFactory.cs @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Runtime.CredentialManagement; + +namespace AWS.Deploy.CLI +{ + /// + public class CredentialProfileStoreChainFactory : ICredentialProfileStoreChainFactory + { + /// + public CredentialProfileStoreChain Create() + { + return new CredentialProfileStoreChain(); + } + } +} diff --git a/src/AWS.Deploy.CLI/Extensions/CustomServiceCollectionExtension.cs b/src/AWS.Deploy.CLI/Extensions/CustomServiceCollectionExtension.cs index fd3f77977..f19a7bea3 100644 --- a/src/AWS.Deploy.CLI/Extensions/CustomServiceCollectionExtension.cs +++ b/src/AWS.Deploy.CLI/Extensions/CustomServiceCollectionExtension.cs @@ -71,6 +71,9 @@ public static void AddCustomServices(this IServiceCollection serviceCollection, serviceCollection.TryAdd(new ServiceDescriptor(typeof(IEnvironmentVariableManager), typeof(EnvironmentVariableManager), lifetime)); serviceCollection.TryAdd(new ServiceDescriptor(typeof(IDeployToolWorkspaceMetadata), typeof(DeployToolWorkspaceMetadata), lifetime)); serviceCollection.TryAdd(new ServiceDescriptor(typeof(IDeploymentSettingsHandler), typeof(DeploymentSettingsHandler), lifetime)); + serviceCollection.TryAdd(new ServiceDescriptor(typeof(ICredentialProfileStoreChainFactory), typeof(CredentialProfileStoreChainFactory), lifetime)); + serviceCollection.TryAdd(new ServiceDescriptor(typeof(ISharedCredentialsFileFactory), typeof(SharedCredentialsFileFactory), lifetime)); + serviceCollection.TryAdd(new ServiceDescriptor(typeof(IAWSCredentialsFactory), typeof(AWSCredentialsFactory), lifetime)); var packageJsonTemplate = typeof(PackageJsonGenerator).Assembly.ReadEmbeddedFile(PackageJsonGenerator.TemplateIdentifier); serviceCollection.TryAdd(new ServiceDescriptor(typeof(IPackageJsonGenerator), (serviceProvider) => new PackageJsonGenerator(packageJsonTemplate), lifetime)); diff --git a/src/AWS.Deploy.CLI/IAWSCredentialsFactory.cs b/src/AWS.Deploy.CLI/IAWSCredentialsFactory.cs new file mode 100644 index 000000000..2d2c9ed4b --- /dev/null +++ b/src/AWS.Deploy.CLI/IAWSCredentialsFactory.cs @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Runtime; + +namespace AWS.Deploy.CLI +{ + /// + /// Represents a factory for creating + /// + public interface IAWSCredentialsFactory + { + /// + /// Creates + /// + AWSCredentials Create(); + } +} diff --git a/src/AWS.Deploy.CLI/ICredentialProfileStoreChainFactory.cs b/src/AWS.Deploy.CLI/ICredentialProfileStoreChainFactory.cs new file mode 100644 index 000000000..4cb1c7d6c --- /dev/null +++ b/src/AWS.Deploy.CLI/ICredentialProfileStoreChainFactory.cs @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Runtime.CredentialManagement; + +namespace AWS.Deploy.CLI +{ + /// + /// Represents a factory for creating + /// + public interface ICredentialProfileStoreChainFactory + { + /// + /// Creates a + /// + CredentialProfileStoreChain Create(); + } +} diff --git a/src/AWS.Deploy.CLI/ISharedCredentialsFileFactory.cs b/src/AWS.Deploy.CLI/ISharedCredentialsFileFactory.cs new file mode 100644 index 000000000..b81529079 --- /dev/null +++ b/src/AWS.Deploy.CLI/ISharedCredentialsFileFactory.cs @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Runtime.CredentialManagement; + +namespace AWS.Deploy.CLI +{ + /// + /// Represents a factory for creating + /// + public interface ISharedCredentialsFileFactory + { + /// + /// Creates + /// + SharedCredentialsFile Create(); + } +} diff --git a/src/AWS.Deploy.CLI/SharedCredentialsFileFactory.cs b/src/AWS.Deploy.CLI/SharedCredentialsFileFactory.cs new file mode 100644 index 000000000..eaf96f825 --- /dev/null +++ b/src/AWS.Deploy.CLI/SharedCredentialsFileFactory.cs @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Runtime.CredentialManagement; + +namespace AWS.Deploy.CLI +{ + /// + public class SharedCredentialsFileFactory : ISharedCredentialsFileFactory + { + /// + public SharedCredentialsFile Create() + { + return new SharedCredentialsFile(); + } + } +} diff --git a/test/AWS.Deploy.CLI.UnitTests/AWSCredentialsFactoryTests.cs b/test/AWS.Deploy.CLI.UnitTests/AWSCredentialsFactoryTests.cs new file mode 100644 index 000000000..a9cf16653 --- /dev/null +++ b/test/AWS.Deploy.CLI.UnitTests/AWSCredentialsFactoryTests.cs @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Runtime; +using Xunit; + +namespace AWS.Deploy.CLI.UnitTests +{ + public class AWSCredentialsFactoryTests + { + [Fact] + public void Create_ReturnsAWSCredentialsInstance() + { + // Arrange + var factory = new AWSCredentialsFactory(); + + // Act + var result = factory.Create(); + + // Assert + Assert.NotNull(result); + Assert.IsAssignableFrom(result); + } + } +} diff --git a/test/AWS.Deploy.CLI.UnitTests/AWSUtilitiesTests.cs b/test/AWS.Deploy.CLI.UnitTests/AWSUtilitiesTests.cs new file mode 100644 index 000000000..ab6160591 --- /dev/null +++ b/test/AWS.Deploy.CLI.UnitTests/AWSUtilitiesTests.cs @@ -0,0 +1,277 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Amazon; +using Amazon.Runtime; +using Amazon.Runtime.CredentialManagement; +using AWS.Deploy.CLI.Common.UnitTests.IO; +using AWS.Deploy.Common.IO; +using AWS.Deploy.Common.Recipes; +using Moq; +using Xunit; + +namespace AWS.Deploy.CLI.UnitTests +{ + public class AWSUtilitiesTests : IDisposable + { + private readonly IDirectoryManager _directoryManager; + private readonly IOptionSettingHandler _optionSettingHandler; + private readonly Mock _mockToolInteractiveService; + private readonly Mock _mockConsoleUtilities; + private readonly Mock _mockServiceProvider; + private readonly Mock _mockCredentialChainFactory; + private readonly Mock _mockSharedCredentialsFileFactory; + private readonly Mock _mockAWSCredentialsFactory; + private CredentialProfileStoreChain _credentialProfileStoreChain; + + private readonly string _tempCredentialsFile; + private SharedCredentialsFile _sharedCredentialsFile; + + public AWSUtilitiesTests() + { + _directoryManager = new TestDirectoryManager(); + _mockToolInteractiveService = new Mock(); + _mockConsoleUtilities = new Mock(); + _mockServiceProvider = new Mock(); + _optionSettingHandler = new Mock().Object; + _mockCredentialChainFactory = new Mock(); + _mockSharedCredentialsFileFactory = new Mock(); + _mockAWSCredentialsFactory = new Mock(); + + _credentialProfileStoreChain = new CredentialProfileStoreChain(); + + _mockCredentialChainFactory + .Setup(f => f.Create()) + .Returns(_credentialProfileStoreChain); + + // Create a temporary credentials file + _tempCredentialsFile = Path.GetTempFileName(); + Environment.SetEnvironmentVariable("AWS_SHARED_CREDENTIALS_FILE", _tempCredentialsFile); + + // Create a real SharedCredentialsFile instance + _sharedCredentialsFile = new SharedCredentialsFile(_tempCredentialsFile); + + _mockSharedCredentialsFileFactory + .Setup(f => f.Create()) + .Returns(_sharedCredentialsFile); + } + + private AWSUtilities CreateAWSUtilities() + { + return new AWSUtilities( + _mockServiceProvider.Object, + _mockToolInteractiveService.Object, + _mockConsoleUtilities.Object, + _directoryManager, + _optionSettingHandler, + _mockCredentialChainFactory.Object, + _mockSharedCredentialsFileFactory.Object, + _mockAWSCredentialsFactory.Object + ); + } + + public void Dispose() + { + // Clean up the temporary file + if (File.Exists(_tempCredentialsFile)) + { + File.Delete(_tempCredentialsFile); + } + Environment.SetEnvironmentVariable("AWS_SHARED_CREDENTIALS_FILE", null); + } + + private void SetupCredentialsFile(params string[] profileNames) + { + var contents = new StringBuilder(); + foreach (var profileName in profileNames) + { + contents.AppendLine($"[{profileName}]"); + contents.AppendLine("aws_access_key_id = 123"); + contents.AppendLine("aws_secret_access_key = abc"); + contents.AppendLine(); + } + File.WriteAllText(_tempCredentialsFile, contents.ToString()); + + // Re-create SharedCredentialsFile to pick up the changes + _sharedCredentialsFile = new SharedCredentialsFile(_tempCredentialsFile); + _mockSharedCredentialsFileFactory + .Setup(f => f.Create()) + .Returns(_sharedCredentialsFile); + } + + + [Fact] + public async Task ResolveAWSCredentials_WithValidProfileName_ReturnsCredentials() + { + // Arrange + var awsUtilities = CreateAWSUtilities(); + var profileName = "valid-profile"; + var options = new CredentialProfileOptions + { + AccessKey = "abc", + SecretKey = "123" + }; + var mockProfile = new CredentialProfile(profileName, options) + { + Region = RegionEndpoint.USEast1 + }; + + var store = new CredentialProfileStoreChain(); + store.RegisterProfile(mockProfile); + _credentialProfileStoreChain = store; + + _mockCredentialChainFactory + .Setup(f => f.Create()) + .Returns(_credentialProfileStoreChain); + + // Act + var result = await awsUtilities.ResolveAWSCredentials(profileName); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Item1); + Assert.Equal("us-east-1", result.Item2); + } + + [Fact] + public async Task ResolveAWSCredentials_WithValidProfileNameAndNullRegion_ReturnsCredentialsAndNullRegion() + { + // Arrange + var awsUtilities = CreateAWSUtilities(); + var profileName = "valid-profile-no-region"; + var options = new CredentialProfileOptions + { + AccessKey = "123", + SecretKey = "abc" + }; + var mockProfile = new CredentialProfile(profileName, options) + { + Region = null + }; + + var store = new CredentialProfileStoreChain(); + store.RegisterProfile(mockProfile); + _credentialProfileStoreChain = store; + + _mockCredentialChainFactory + .Setup(f => f.Create()) + .Returns(_credentialProfileStoreChain); + + // Act + var result = await awsUtilities.ResolveAWSCredentials(profileName); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Item1); + Assert.Null(result.Item2); + } + + [Fact] + public async Task ResolveAWSCredentials_WithInvalidProfileName_ThrowsException() + { + // Arrange + var awsUtilities = CreateAWSUtilities(); + var profileName = "invalid-profile"; + + var store = new CredentialProfileStoreChain(); + _credentialProfileStoreChain = store; + + _mockCredentialChainFactory + .Setup(f => f.Create()) + .Returns(_credentialProfileStoreChain); + + // Act & Assert + await Assert.ThrowsAsync(() => awsUtilities.ResolveAWSCredentials(profileName)); + } + + [Fact] + public async Task ResolveAWSCredentials_WithNullProfileName_UsesFallbackCredentials() + { + // Arrange + var awsUtilities = CreateAWSUtilities(); + var mockAWSCredentials = new Mock().Object; + + _mockAWSCredentialsFactory + .Setup(f => f.Create()) + .Returns(mockAWSCredentials); + + // Act + var result = await awsUtilities.ResolveAWSCredentials(null); + + // Assert + Assert.NotNull(result); + Assert.Equal(mockAWSCredentials, result.Item1); + Assert.Null(result.Item2); + } + + [Fact] + public async Task ResolveAWSCredentials_WithNullProfileNameAndFallbackException_PromptsUserToChooseProfile() + { + // Arrange + var awsUtilities = CreateAWSUtilities(); + var profileNames = new List { "profile1", "profile2" }; + var selectedProfileName = "profile1"; + + SetupCredentialsFile(profileNames.ToArray()); + + _mockAWSCredentialsFactory + .Setup(f => f.Create()) + .Throws(new AmazonServiceException("No credentials found")); + + _mockConsoleUtilities + .Setup(c => c.AskUserToChoose(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(selectedProfileName); + + var store = new CredentialProfileStoreChain(_tempCredentialsFile); + _credentialProfileStoreChain = store; + + _mockCredentialChainFactory + .Setup(f => f.Create()) + .Returns(_credentialProfileStoreChain); + + // Act + var result = await awsUtilities.ResolveAWSCredentials(null); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Item1); + Assert.Null(result.Item2); // Region will be null as we didn't set it in the file + _mockConsoleUtilities.Verify(c => c.AskUserToChoose(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ResolveAWSCredentials_WithNoProfiles_ThrowsNoAWSCredentialsFoundException() + { + // Arrange + var awsUtilities = CreateAWSUtilities(); + + SetupCredentialsFile(); // Empty file, no profiles + + _mockAWSCredentialsFactory + .Setup(f => f.Create()) + .Throws(new AmazonServiceException("No credentials found")); + + // Act & Assert + await Assert.ThrowsAsync(() => awsUtilities.ResolveAWSCredentials(null)); + } + + [Fact] + public void ResolveAWSRegion_WithNonNullRegion_ReturnsRegion() + { + // Arrange + var awsUtilities = CreateAWSUtilities(); + var region = "us-west-2"; + + // Act + var result = awsUtilities.ResolveAWSRegion(region); + + // Assert + Assert.Equal(region, result); + } + } +} diff --git a/test/AWS.Deploy.CLI.UnitTests/Commands/CommandFactoryTest.cs b/test/AWS.Deploy.CLI.UnitTests/Commands/CommandFactoryTest.cs new file mode 100644 index 000000000..35af3992c --- /dev/null +++ b/test/AWS.Deploy.CLI.UnitTests/Commands/CommandFactoryTest.cs @@ -0,0 +1,576 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Linq; +using Amazon.Runtime; +using System.Threading.Tasks; +using AWS.Deploy.CLI.Commands; +using AWS.Deploy.CLI.Commands.CommandHandlerInput; +using AWS.Deploy.CLI.Commands.TypeHints; +using AWS.Deploy.CLI.Utilities; +using AWS.Deploy.Common; +using AWS.Deploy.Common.Data; +using AWS.Deploy.Common.DeploymentManifest; +using AWS.Deploy.Common.IO; +using AWS.Deploy.Common.Recipes; +using AWS.Deploy.Common.Recipes.Validation; +using AWS.Deploy.Orchestration; +using AWS.Deploy.Orchestration.CDK; +using AWS.Deploy.Orchestration.DisplayedResources; +using AWS.Deploy.Orchestration.LocalUserSettings; +using AWS.Deploy.Orchestration.ServiceHandlers; +using AWS.Deploy.Orchestration.Utilities; +using Moq; +using Xunit; +using Amazon.SecurityToken.Model; +using System.Collections.Generic; +using Amazon.Extensions.NETCore.Setup; + +namespace AWS.Deploy.CLI.UnitTests +{ + public class CommandFactoryTests + { + private readonly Mock _mockServiceProvider; + private readonly Mock _mockToolInteractiveService; + private readonly Mock _mockOrchestratorInteractiveService; + private readonly Mock _mockCdkManager; + private readonly Mock _mockSystemCapabilityEvaluator; + private readonly Mock _mockCloudApplicationNameGenerator; + private readonly Mock _mockAwsUtilities; + private readonly Mock _mockAwsClientFactory; + private readonly Mock _mockAwsResourceQueryer; + private readonly Mock _mockProjectParserUtility; + private readonly Mock _mockCommandLineWrapper; + private readonly Mock _mockCdkProjectHandler; + private readonly Mock _mockDeploymentBundleHandler; + private readonly Mock _mockCloudFormationTemplateReader; + private readonly Mock _mockDeployedApplicationQueryer; + private readonly Mock _mockTypeHintCommandFactory; + private readonly Mock _mockDisplayedResourceHandler; + private readonly Mock _mockConsoleUtilities; + private readonly Mock _mockDirectoryManager; + private readonly Mock _mockFileManager; + private readonly Mock _mockDeploymentManifestEngine; + private readonly Mock _mockLocalUserSettingsEngine; + private readonly Mock _mockCdkVersionDetector; + private readonly Mock _mockAwsServiceHandler; + private readonly Mock _mockOptionSettingHandler; + private readonly Mock _mockValidatorFactory; + private readonly Mock _mockRecipeHandler; + private readonly Mock _mockDeployToolWorkspaceMetadata; + private readonly Mock _mockDeploymentSettingsHandler; + + public CommandFactoryTests() + { + _mockServiceProvider = new Mock(); + _mockToolInteractiveService = new Mock(); + _mockOrchestratorInteractiveService = new Mock(); + _mockCdkManager = new Mock(); + _mockSystemCapabilityEvaluator = new Mock(); + _mockCloudApplicationNameGenerator = new Mock(); + _mockAwsUtilities = new Mock(); + _mockAwsClientFactory = new Mock(); + _mockAwsResourceQueryer = new Mock(); + _mockProjectParserUtility = new Mock(); + _mockCommandLineWrapper = new Mock(); + _mockCdkProjectHandler = new Mock(); + _mockDeploymentBundleHandler = new Mock(); + _mockCloudFormationTemplateReader = new Mock(); + _mockDeployedApplicationQueryer = new Mock(); + _mockTypeHintCommandFactory = new Mock(); + _mockDisplayedResourceHandler = new Mock(); + _mockConsoleUtilities = new Mock(); + _mockDirectoryManager = new Mock(); + _mockFileManager = new Mock(); + _mockDeploymentManifestEngine = new Mock(); + _mockLocalUserSettingsEngine = new Mock(); + _mockCdkVersionDetector = new Mock(); + _mockAwsServiceHandler = new Mock(); + _mockOptionSettingHandler = new Mock(); + _mockValidatorFactory = new Mock(); + _mockRecipeHandler = new Mock(); + _mockDeployToolWorkspaceMetadata = new Mock(); + _mockDeploymentSettingsHandler = new Mock(); + } + + private CommandFactory CreateCommandFactory() + { + return new CommandFactory( + _mockServiceProvider.Object, + _mockToolInteractiveService.Object, + _mockOrchestratorInteractiveService.Object, + _mockCdkManager.Object, + _mockSystemCapabilityEvaluator.Object, + _mockCloudApplicationNameGenerator.Object, + _mockAwsUtilities.Object, + _mockAwsClientFactory.Object, + _mockAwsResourceQueryer.Object, + _mockProjectParserUtility.Object, + _mockCommandLineWrapper.Object, + _mockCdkProjectHandler.Object, + _mockDeploymentBundleHandler.Object, + _mockCloudFormationTemplateReader.Object, + _mockDeployedApplicationQueryer.Object, + _mockTypeHintCommandFactory.Object, + _mockDisplayedResourceHandler.Object, + _mockConsoleUtilities.Object, + _mockDirectoryManager.Object, + _mockFileManager.Object, + _mockDeploymentManifestEngine.Object, + _mockLocalUserSettingsEngine.Object, + _mockCdkVersionDetector.Object, + _mockAwsServiceHandler.Object, + _mockOptionSettingHandler.Object, + _mockValidatorFactory.Object, + _mockRecipeHandler.Object, + _mockDeployToolWorkspaceMetadata.Object, + _mockDeploymentSettingsHandler.Object + ); + } + + [Fact] + public void BuildRootCommand_ReturnsRootCommandWithExpectedSubcommands() + { + // Arrange + var commandFactory = CreateCommandFactory(); + + // Act + var rootCommand = commandFactory.BuildRootCommand(); + + // Assert + Assert.NotNull(rootCommand); + Assert.Equal("dotnet-aws", rootCommand.Name); + Assert.Contains(rootCommand.Options, o => o.Name == "version"); + Assert.Contains(rootCommand.Children, c => c.Name == "deploy"); + Assert.Contains(rootCommand.Children, c => c.Name == "list-deployments"); + Assert.Contains(rootCommand.Children, c => c.Name == "delete-deployment"); + Assert.Contains(rootCommand.Children, c => c.Name == "deployment-project"); + Assert.Contains(rootCommand.Children, c => c.Name == "server-mode"); + } + + [Fact] + public void BuildRootCommand_DeployCommandHasExpectedOptions() + { + // Arrange + var commandFactory = CreateCommandFactory(); + + // Act + var rootCommand = commandFactory.BuildRootCommand(); + var deployCommand = rootCommand.Children.First(c => c.Name == "deploy") as Command; + + // Assert + Assert.NotNull(deployCommand); + Assert.Contains(deployCommand.Options, o => o.Name == "profile"); + Assert.Contains(deployCommand.Options, o => o.Name == "region"); + Assert.Contains(deployCommand.Options, o => o.Name == "project-path"); + Assert.Contains(deployCommand.Options, o => o.Name == "application-name"); + Assert.Contains(deployCommand.Options, o => o.Name == "apply"); + Assert.Contains(deployCommand.Options, o => o.Name == "diagnostics"); + Assert.Contains(deployCommand.Options, o => o.Name == "silent"); + Assert.Contains(deployCommand.Options, o => o.Name == "deployment-project"); + Assert.Contains(deployCommand.Options, o => o.Name == "save-settings"); + Assert.Contains(deployCommand.Options, o => o.Name == "save-all-settings"); + } + + // Add more tests for other commands and their options... + + [Fact] + public void BuildRootCommand_ServerModeCommandHasExpectedOptions() + { + // Arrange + var commandFactory = CreateCommandFactory(); + + // Act + var rootCommand = commandFactory.BuildRootCommand(); + var serverModeCommand = rootCommand.Children.First(c => c.Name == "server-mode") as Command; + + // Assert + Assert.NotNull(serverModeCommand); + Assert.Contains(serverModeCommand.Options, o => o.Name == "port"); + Assert.Contains(serverModeCommand.Options, o => o.Name == "parent-pid"); + Assert.Contains(serverModeCommand.Options, o => o.Name == "unsecure-mode"); + Assert.Contains(serverModeCommand.Options, o => o.Name == "diagnostics"); + } + + [Fact] + public async Task DeployCommand_UsesRegionFromCLIWhenProvided() + { + // Arrange + var commandFactory = CreateCommandFactory(); + var mockCredentials = new Mock(); + var testProfile = "test-profile"; + var regionFromCLI = "us-west-2"; + var regionFromProfile = "us-east-1"; + var testProjectPath = "/path/to/project"; + + // Mock ProjectDefinition + var mockProjectDefinition = new ProjectDefinition( + new System.Xml.XmlDocument(), + testProjectPath, + testProjectPath, + "123"); + + _mockProjectParserUtility + .Setup(x => x.Parse(It.IsAny())) + .ReturnsAsync(mockProjectDefinition); + + _mockAwsUtilities + .Setup(x => x.ResolveAWSCredentials(It.IsAny())) + .Returns(Task.FromResult((Tuple.Create(mockCredentials.Object, regionFromProfile)))); + + _mockAwsUtilities + .Setup(x => x.ResolveAWSRegion(It.IsAny(), It.IsAny())) + .Returns(regionFromCLI); + + // Create a mock DeploymentSettings + var mockDeploymentSettings = new DeploymentSettings + { + AWSProfile = "deployment-profile", + AWSRegion = "deployment-region" + }; + + _mockDeploymentSettingsHandler + .Setup(x => x.ReadSettings(It.IsAny())) + .ReturnsAsync(mockDeploymentSettings); + + _mockAwsResourceQueryer + .Setup(x => x.GetCallerIdentity(It.IsAny())) + .ReturnsAsync(new GetCallerIdentityResponse { Account = "123456789012" }); + + + // Act + var result = await InvokeDeployCommandHandler(new DeployCommandHandlerInput + { + Profile = testProfile, + Region = regionFromCLI, + Apply = "some-settings-file.json", + ProjectPath = testProjectPath + }); + + // Assert + _mockProjectParserUtility.Verify(x => x.Parse(testProjectPath), Times.Once); + _mockAwsUtilities.Verify(x => x.ResolveAWSCredentials(testProfile), Times.Once); + _mockAwsUtilities.Verify(x => x.ResolveAWSRegion(regionFromCLI, null), Times.Once); + } + + [Fact] + public async Task DeployCommand_UsesRegionFromProfileWhenCLIRegionNotProvided() + { + // Arrange + var commandFactory = CreateCommandFactory(); + var mockCredentials = new Mock(); + var testProfile = "test-profile"; + var regionFromProfile = "us-east-1"; + var testProjectPath = "/path/to/project"; + + // Mock ProjectDefinition + var mockProjectDefinition = new ProjectDefinition( + new System.Xml.XmlDocument(), + testProjectPath, + testProjectPath, + "123"); + + _mockProjectParserUtility + .Setup(x => x.Parse(It.IsAny())) + .ReturnsAsync(mockProjectDefinition); + + _mockAwsUtilities + .Setup(x => x.ResolveAWSCredentials(It.IsAny())) + .Returns(Task.FromResult((Tuple.Create(mockCredentials.Object, regionFromProfile)))); + + _mockAwsUtilities + .Setup(x => x.ResolveAWSRegion(It.IsAny(), It.IsAny())) + .Returns((string r, string f) => r); + + // Create a mock DeploymentSettings + var mockDeploymentSettings = new DeploymentSettings + { + AWSProfile = "deployment-profile", + AWSRegion = null // Ensure this is null to test our scenario + }; + + _mockDeploymentSettingsHandler + .Setup(x => x.ReadSettings(It.IsAny())) + .ReturnsAsync(mockDeploymentSettings); + + _mockAwsResourceQueryer + .Setup(x => x.GetCallerIdentity(It.IsAny())) + .ReturnsAsync(new GetCallerIdentityResponse { Account = "123456789012" }); + + // Act + var result = await InvokeDeployCommandHandler(new DeployCommandHandlerInput + { + Profile = testProfile, + Region = null, // Not providing a region via CLI + Apply = "some-settings-file.json", + ProjectPath = testProjectPath + }); + + // Assert + _mockProjectParserUtility.Verify(x => x.Parse(testProjectPath), Times.Once); + _mockAwsUtilities.Verify(x => x.ResolveAWSCredentials(testProfile), Times.Once); + _mockAwsUtilities.Verify(x => x.ResolveAWSRegion(regionFromProfile, null), Times.Once); + + // Verify that the region from the profile is used + _mockAwsResourceQueryer.Verify(x => x.GetCallerIdentity(regionFromProfile), Times.Once); + } + + [Fact] + public void BuildRootCommand_DeleteCommandHasExpectedOptions() + { + // Arrange + var commandFactory = CreateCommandFactory(); + + // Act + var rootCommand = commandFactory.BuildRootCommand(); + var deleteCommand = rootCommand.Children.First(c => c.Name == "delete-deployment") as Command; + + // Assert + Assert.NotNull(deleteCommand); + Assert.Contains(deleteCommand.Options, o => o.Name == "profile"); + Assert.Contains(deleteCommand.Options, o => o.Name == "region"); + Assert.Contains(deleteCommand.Options, o => o.Name == "project-path"); + Assert.Contains(deleteCommand.Options, o => o.Name == "diagnostics"); + Assert.Contains(deleteCommand.Options, o => o.Name == "silent"); + + // Verify that the delete command has a deployment-name argument + Assert.Contains(deleteCommand.Arguments, a => a.Name == "deployment-name"); + } + + [Fact] + public void BuildRootCommand_ListCommandHasExpectedOptions() + { + // Arrange + var commandFactory = CreateCommandFactory(); + + // Act + var rootCommand = commandFactory.BuildRootCommand(); + var listCommand = rootCommand.Children.First(c => c.Name == "list-deployments") as Command; + + // Assert + Assert.NotNull(listCommand); + Assert.Contains(listCommand.Options, o => o.Name == "profile"); + Assert.Contains(listCommand.Options, o => o.Name == "region"); + Assert.Contains(listCommand.Options, o => o.Name == "diagnostics"); + } + + [Fact] + public async Task DeleteCommand_UsesRegionFromCLIWhenProvided() + { + // Arrange + var commandFactory = CreateCommandFactory(); + var mockCredentials = new Mock(); + var testProfile = "test-profile"; + var regionFromCLI = "us-west-2"; + var regionFromProfile = "us-east-1"; + var deploymentName = "test-deployment"; + + _mockAwsUtilities + .Setup(x => x.ResolveAWSCredentials(It.IsAny())) + .Returns(Task.FromResult((Tuple.Create(mockCredentials.Object, regionFromProfile)))); + + _mockAwsUtilities + .Setup(x => x.ResolveAWSRegion(It.IsAny(), It.IsAny())) + .Returns(regionFromCLI); + + _mockAwsClientFactory + .Setup(x => x.ConfigureAWSOptions(It.IsAny>())) + .Callback>(action => + { + var options = new AWSOptions(); + action(options); + Assert.Equal(regionFromCLI, options.Region.SystemName); + }); + + // Act + var result = await InvokeDeleteCommandHandler(new DeleteCommandHandlerInput + { + Profile = testProfile, + Region = regionFromCLI, + DeploymentName = deploymentName + }); + + // Assert + _mockAwsUtilities.Verify(x => x.ResolveAWSCredentials(testProfile), Times.Once); + _mockAwsUtilities.Verify(x => x.ResolveAWSRegion(regionFromCLI, null), Times.Once); + _mockAwsClientFactory.Verify(x => x.ConfigureAWSOptions(It.IsAny>()), Times.Once); + } + + [Fact] + public async Task DeleteCommand_UsesRegionFromProfileWhenCLIRegionNotProvided() + { + // Arrange + var commandFactory = CreateCommandFactory(); + var mockCredentials = new Mock(); + var testProfile = "test-profile"; + var regionFromProfile = "us-east-1"; + var deploymentName = "test-deployment"; + + _mockAwsUtilities + .Setup(x => x.ResolveAWSCredentials(It.IsAny())) + .Returns(Task.FromResult((Tuple.Create(mockCredentials.Object, regionFromProfile)))); + + _mockAwsUtilities + .Setup(x => x.ResolveAWSRegion(It.IsAny(), It.IsAny())) + .Returns((string r, string f) => f); + + _mockAwsClientFactory + .Setup(x => x.ConfigureAWSOptions(It.IsAny>())) + .Callback>(action => + { + var options = new AWSOptions(); + action(options); + Assert.Equal(regionFromProfile, options.Region.SystemName); + }); + + // Act + var result = await InvokeDeleteCommandHandler(new DeleteCommandHandlerInput + { + Profile = testProfile, + Region = null, // Not providing a region via CLI + DeploymentName = deploymentName + }); + + // Assert + _mockAwsUtilities.Verify(x => x.ResolveAWSCredentials(testProfile), Times.Once); + _mockAwsUtilities.Verify(x => x.ResolveAWSRegion(regionFromProfile, null), Times.Once); + _mockAwsClientFactory.Verify(x => x.ConfigureAWSOptions(It.IsAny>()), Times.Once); + } + + [Fact] + public async Task ListCommand_UsesRegionFromCLIWhenProvided() + { + // Arrange + var commandFactory = CreateCommandFactory(); + var mockCredentials = new Mock(); + var testProfile = "test-profile"; + var regionFromCLI = "us-west-2"; + var regionFromProfile = "us-east-1"; + + _mockAwsUtilities + .Setup(x => x.ResolveAWSCredentials(It.IsAny())) + .Returns(Task.FromResult((Tuple.Create(mockCredentials.Object, regionFromProfile)))); + + _mockAwsUtilities + .Setup(x => x.ResolveAWSRegion(It.IsAny(), It.IsAny())) + .Returns(regionFromCLI); + + _mockAwsClientFactory + .Setup(x => x.ConfigureAWSOptions(It.IsAny>())) + .Callback>(action => + { + var options = new AWSOptions(); + action(options); + Assert.Equal(regionFromCLI, options.Region.SystemName); + }); + + // Act + var result = await InvokeListCommandHandler(new ListCommandHandlerInput + { + Profile = testProfile, + Region = regionFromCLI + }); + + // Assert + _mockAwsUtilities.Verify(x => x.ResolveAWSCredentials(testProfile), Times.Once); + _mockAwsUtilities.Verify(x => x.ResolveAWSRegion(regionFromCLI, null), Times.Once); + _mockAwsClientFactory.Verify(x => x.ConfigureAWSOptions(It.IsAny>()), Times.Once); + } + + [Fact] + public async Task ListCommand_UsesRegionFromProfileWhenCLIRegionNotProvided() + { + // Arrange + var commandFactory = CreateCommandFactory(); + var mockCredentials = new Mock(); + var testProfile = "test-profile"; + var regionFromProfile = "us-east-1"; + + _mockAwsUtilities + .Setup(x => x.ResolveAWSCredentials(It.IsAny())) + .Returns(Task.FromResult((Tuple.Create(mockCredentials.Object, regionFromProfile)))); + + _mockAwsUtilities + .Setup(x => x.ResolveAWSRegion(It.IsAny(), It.IsAny())) + .Returns((string r, string f) => f); + + _mockAwsClientFactory + .Setup(x => x.ConfigureAWSOptions(It.IsAny>())) + .Callback>(action => + { + var options = new AWSOptions(); + action(options); + Assert.Equal(regionFromProfile, options.Region.SystemName); + }); + + // Act + var result = await InvokeListCommandHandler(new ListCommandHandlerInput + { + Profile = testProfile, + Region = null // Not providing a region via CLI + }); + + // Assert + _mockAwsUtilities.Verify(x => x.ResolveAWSCredentials(testProfile), Times.Once); + _mockAwsUtilities.Verify(x => x.ResolveAWSRegion(regionFromProfile, null), Times.Once); + _mockAwsClientFactory.Verify(x => x.ConfigureAWSOptions(It.IsAny>()), Times.Once); + } + + private async Task InvokeDeployCommandHandler(DeployCommandHandlerInput input) + { + var args = new List { "deploy" }; + + if (!string.IsNullOrEmpty(input.Profile)) + args.AddRange(new[] { "--profile", input.Profile }); + + if (!string.IsNullOrEmpty(input.Region)) + args.AddRange(new[] { "--region", input.Region }); + + if (!string.IsNullOrEmpty(input.Apply)) + args.AddRange(new[] { "--apply", input.Apply }); + + if (!string.IsNullOrEmpty(input.ProjectPath)) + args.AddRange(new[] { "--project-path", input.ProjectPath }); + + var rootCommand = CreateCommandFactory().BuildRootCommand(); + return await rootCommand.InvokeAsync(args.ToArray()); + } + + + private async Task InvokeDeleteCommandHandler(DeleteCommandHandlerInput input) + { + var args = new List { "delete-deployment" }; + + if (!string.IsNullOrEmpty(input.Profile)) + args.AddRange(new[] { "--profile", input.Profile }); + + if (!string.IsNullOrEmpty(input.Region)) + args.AddRange(new[] { "--region", input.Region }); + + if (!string.IsNullOrEmpty(input.DeploymentName)) + args.Add(input.DeploymentName); + + var rootCommand = CreateCommandFactory().BuildRootCommand(); + return await rootCommand.InvokeAsync(args.ToArray()); + } + + private async Task InvokeListCommandHandler(ListCommandHandlerInput input) + { + var args = new List { "list-deployments" }; + + if (!string.IsNullOrEmpty(input.Profile)) + args.AddRange(new[] { "--profile", input.Profile }); + + if (!string.IsNullOrEmpty(input.Region)) + args.AddRange(new[] { "--region", input.Region }); + + var rootCommand = CreateCommandFactory().BuildRootCommand(); + return await rootCommand.InvokeAsync(args.ToArray()); + } + + + } +} diff --git a/test/AWS.Deploy.CLI.UnitTests/CredentialProfileStoreChainFactoryTests.cs b/test/AWS.Deploy.CLI.UnitTests/CredentialProfileStoreChainFactoryTests.cs new file mode 100644 index 000000000..8da6f39aa --- /dev/null +++ b/test/AWS.Deploy.CLI.UnitTests/CredentialProfileStoreChainFactoryTests.cs @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Runtime.CredentialManagement; +using Xunit; + +namespace AWS.Deploy.CLI.UnitTests +{ + public class CredentialProfileStoreChainFactoryTests + { + [Fact] + public void Create_ReturnsCredentialProfileStoreChainInstance() + { + // Arrange + var factory = new CredentialProfileStoreChainFactory(); + + // Act + var result = factory.Create(); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + } +} diff --git a/test/AWS.Deploy.CLI.UnitTests/SharedCredentialsFileFactoryTests.cs b/test/AWS.Deploy.CLI.UnitTests/SharedCredentialsFileFactoryTests.cs new file mode 100644 index 000000000..5d8db76a7 --- /dev/null +++ b/test/AWS.Deploy.CLI.UnitTests/SharedCredentialsFileFactoryTests.cs @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Runtime.CredentialManagement; +using Xunit; + +namespace AWS.Deploy.CLI.UnitTests +{ + public class SharedCredentialsFileFactoryTests + { + [Fact] + public void Create_ReturnsSharedCredentialsFileInstance() + { + // Arrange + var factory = new SharedCredentialsFileFactory(); + + // Act + var result = factory.Create(); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + } +}