diff --git a/.editorconfig b/.editorconfig index 1fe3674..c4bec38 100644 --- a/.editorconfig +++ b/.editorconfig @@ -104,7 +104,15 @@ dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, meth dotnet_naming_symbols.non_field_members.required_modifiers = dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum -dotnet_naming_symbols.types.required_modifiers = +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_rule.non_public_constants_must_be_pascal_case.symbols = non_public_constants +dotnet_naming_rule.non_public_constants_must_be_pascal_case.style = pascal_case +dotnet_naming_rule.non_public_constants_must_be_pascal_case.severity = suggestion + +dotnet_naming_symbols.non_public_constants.applicable_kinds = field +dotnet_naming_symbols.non_public_constants.required_modifiers = const +dotnet_naming_symbols.non_public_constants.applicable_accessibilities = internal, private, protected, protected_internal ; lang style csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent diff --git a/Directory.Build.props b/Directory.Build.props index 8feb496..7a37fdb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,7 +9,8 @@ - + + 9.* all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Ellosoft.AwsCredentialsManager/Ellosoft.AwsCredentialsManager.csproj b/src/Ellosoft.AwsCredentialsManager/Ellosoft.AwsCredentialsManager.csproj index 83e8bec..502c361 100644 --- a/src/Ellosoft.AwsCredentialsManager/Ellosoft.AwsCredentialsManager.csproj +++ b/src/Ellosoft.AwsCredentialsManager/Ellosoft.AwsCredentialsManager.csproj @@ -44,10 +44,11 @@ - - - - + + + + + @@ -56,7 +57,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/Ellosoft.AwsCredentialsManager/Infrastructure/Cli/TypeRegistrar.cs b/src/Ellosoft.AwsCredentialsManager/Infrastructure/Cli/TypeRegistrar.cs index 1c8d16c..b121871 100644 --- a/src/Ellosoft.AwsCredentialsManager/Infrastructure/Cli/TypeRegistrar.cs +++ b/src/Ellosoft.AwsCredentialsManager/Infrastructure/Cli/TypeRegistrar.cs @@ -4,43 +4,33 @@ namespace Ellosoft.AwsCredentialsManager.Infrastructure.Cli; -public class TypeRegistrar : ITypeRegistrar +public class TypeRegistrar(IServiceCollection services) : ITypeRegistrar { - private readonly IServiceCollection _builder; + public ITypeResolver Build() => new TypeResolver(services.BuildServiceProvider()); - public TypeRegistrar(IServiceCollection builder) => _builder = builder; + public void Register(Type service, Type implementation) => services.AddSingleton(service, new TypeWithPublicConstructors(implementation).Type); - public ITypeResolver Build() => new TypeResolver(_builder.BuildServiceProvider()); - - public void Register(Type service, Type implementation) => _builder.AddSingleton(service, new TypeWithPublicConstructors(implementation).Type); - - public void RegisterInstance(Type service, object implementation) => _builder.AddSingleton(service, implementation); + public void RegisterInstance(Type service, object implementation) => services.AddSingleton(service, implementation); public void RegisterLazy(Type service, Func factory) { ArgumentNullException.ThrowIfNull(factory); - _builder.AddSingleton(service, _ => factory()); + services.AddSingleton(service, _ => factory()); } - private sealed class TypeWithPublicConstructors + private sealed class TypeWithPublicConstructors(Type type) { [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] - public Type Type { get; } - - public TypeWithPublicConstructors(Type type) => Type = type; + public Type Type { get; } = type; } - private sealed class TypeResolver : ITypeResolver, IDisposable + private sealed class TypeResolver(IServiceProvider provider) : ITypeResolver, IDisposable { - private readonly IServiceProvider _provider; - - public TypeResolver(IServiceProvider provider) => _provider = provider; - - public object? Resolve(Type? type) => type is not null ? _provider.GetService(type) : null; + public object? Resolve(Type? type) => type is not null ? provider.GetService(type) : null; public void Dispose() { - if (_provider is IDisposable disposable) + if (provider is IDisposable disposable) { disposable.Dispose(); } diff --git a/src/Ellosoft.AwsCredentialsManager/Program.cs b/src/Ellosoft.AwsCredentialsManager/Program.cs index 04feb4a..b60768a 100644 --- a/src/Ellosoft.AwsCredentialsManager/Program.cs +++ b/src/Ellosoft.AwsCredentialsManager/Program.cs @@ -27,7 +27,7 @@ var services = new ServiceCollection() .SetupLogging(logger) - .RegisterAppServices(); + .AddAppServices(); var registrar = new TypeRegistrar(services); var app = new CommandApp(registrar); @@ -69,7 +69,7 @@ config.ValidateExamples(); if (System.Diagnostics.Debugger.IsAttached) - args = "rds pwd local".Split(' '); + args = "okta setup".Split(' '); #endif }); diff --git a/src/Ellosoft.AwsCredentialsManager/ServiceRegistration.cs b/src/Ellosoft.AwsCredentialsManager/ServiceRegistration.cs index d56de1a..dccbc40 100644 --- a/src/Ellosoft.AwsCredentialsManager/ServiceRegistration.cs +++ b/src/Ellosoft.AwsCredentialsManager/ServiceRegistration.cs @@ -19,7 +19,7 @@ namespace Ellosoft.AwsCredentialsManager; public static class ServiceRegistration { - public static IServiceCollection RegisterAppServices(this IServiceCollection services) + public static IServiceCollection AddAppServices(this IServiceCollection services) { // core services services @@ -44,8 +44,9 @@ public static IServiceCollection RegisterAppServices(this IServiceCollection ser .AddSingleton() .AddSingleton(); - services - .AddKeyedSingleton(nameof(OktaHttpClientFactory), OktaHttpClientFactory.CreateHttpClient()); + services.AddHttpClient(nameof(OktaHttpClient), OktaHttpClient.Configure); + services.AddKeyedSingleton(nameof(OktaHttpClient), (provider, _) + => provider.GetRequiredService().CreateClient(nameof(OktaHttpClient))); services .AddSingleton() @@ -79,10 +80,10 @@ private static void RegisterWindowsServices(IServiceCollection services) [SupportedOSPlatform("macos")] private static void RegisterMacOSServices(IServiceCollection services) { - services.AddSingleton(); + services.TryAddSingleton(); // platform services - services.AddSingleton(); - services.AddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); } } diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/Interactive/OktaLoginService.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/Interactive/OktaLoginService.cs index d5f9f9d..405df07 100644 --- a/src/Ellosoft.AwsCredentialsManager/Services/Okta/Interactive/OktaLoginService.cs +++ b/src/Ellosoft.AwsCredentialsManager/Services/Okta/Interactive/OktaLoginService.cs @@ -23,6 +23,7 @@ Task Login(Uri oktaDomain, UserCredentials userCredentials } public class OktaLoginService( + IAnsiConsole console, IConfigManager configManager, IUserCredentialsManager userCredentialsManager, IOktaClassicAuthenticator classicAuthenticator) @@ -74,14 +75,14 @@ private void SaveUserCredentials(string userProfileKey, UserCredentials userCred if (savedCredentials || !userCredentialsManager.SupportCredentialsStore) return; - if (AnsiConsole.Confirm("Do you want to save your Okta username and password for future logins ?")) + if (console.Confirm("Do you want to save your Okta username and password for future logins ?")) { userCredentialsManager.SaveUserCredentials(userProfileKey, userCredentials); return; } - AnsiConsole.MarkupLine("[yellow]Ok... :([/]"); + console.MarkupLine("[yellow]Ok... :([/]"); } private UserCredentials GetUserCredentials(string userProfileKey, out bool savedCredentials) diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/MfaHandlers/OktaPushFactorHandler.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/MfaHandlers/OktaPushFactorHandler.cs index 5a498bf..766edec 100644 --- a/src/Ellosoft.AwsCredentialsManager/Services/Okta/MfaHandlers/OktaPushFactorHandler.cs +++ b/src/Ellosoft.AwsCredentialsManager/Services/Okta/MfaHandlers/OktaPushFactorHandler.cs @@ -22,7 +22,7 @@ public override async Task VerifyFactorAsync(Uri okt while (factorResult == FactorResult.Waiting) { - await Task.Delay(5_000); + await Task.Delay(2_000); mfaAuthResponse = await VerifyFactorAsync(oktaDomain, factor.Id, verifyFactorRequest, Default.VerifyPushFactorRequest, Default.FactorVerificationResponsePushOktaFactor); diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaClassicAuthenticator.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaClassicAuthenticator.cs index d792229..7f22997 100644 --- a/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaClassicAuthenticator.cs +++ b/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaClassicAuthenticator.cs @@ -21,7 +21,7 @@ public interface IOktaClassicAuthenticator public class OktaClassicAuthenticator( ILogger logger, - [FromKeyedServices(nameof(OktaHttpClientFactory))] HttpClient httpClient, + [FromKeyedServices(nameof(OktaHttpClient))] HttpClient httpClient, IOktaMfaFactorSelector mfaFactorSelector) : IOktaClassicAuthenticator { private readonly MfaHandlerProvider _mfaHandlerProvider = new(); diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaHttpClient.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaHttpClient.cs new file mode 100644 index 0000000..cbec85a --- /dev/null +++ b/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaHttpClient.cs @@ -0,0 +1,13 @@ +// Copyright (c) 2023 Ellosoft Limited. All rights reserved. + +namespace Ellosoft.AwsCredentialsManager.Services.Okta; + +public static class OktaHttpClient +{ + public static void Configure(HttpClient httpClient) + { + const string USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0"; + + httpClient.DefaultRequestHeaders.Add("User-Agent", USER_AGENT); + } +} diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaHttpClientFactory.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaHttpClientFactory.cs deleted file mode 100644 index bc46e05..0000000 --- a/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaHttpClientFactory.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2023 Ellosoft Limited. All rights reserved. - -namespace Ellosoft.AwsCredentialsManager.Services.Okta; - -public static class OktaHttpClientFactory -{ - /// - /// Create an HTTP client with cookie support to be used on Okta Classic authentication calls (PKCE auth flow) - /// - /// - public static HttpClient CreateHttpClient() - { - const string USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0"; - - var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Add("User-Agent", USER_AGENT); - - return httpClient; - } -} diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/IntPtrExtensions.cs b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/IntPtrExtensions.cs deleted file mode 100644 index f8bacf0..0000000 --- a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/IntPtrExtensions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Runtime.InteropServices; -using Serilog; - -namespace Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS; - -public static class IntPtrExtensions -{ - public static void SafeReleaseIntPrtMem(this IntPtr handle) - { - try - { - if (handle == IntPtr.Zero) - return; - - Marshal.FreeHGlobal(handle); - } - catch (Exception e) - { - Log.Logger.Error(e, "Unable to release memory"); - } - } -} diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSObject.cs b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSObject.cs index b03f414..5d16d7c 100644 --- a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSObject.cs +++ b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSObject.cs @@ -13,9 +13,6 @@ public void Dispose() protected virtual void Dispose(bool disposing) { - if (!disposing) return; - - Handle.SafeReleaseIntPrtMem(); } protected static IntPtr GetClass(string name) => ObjectiveCRuntimeInterop.Instance.GetClass(name); diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Ellosoft.AwsCredentialsManager.Tests.csproj b/test/Ellosoft.AwsCredentialsManager.Tests/Ellosoft.AwsCredentialsManager.Tests.csproj index 4d3b4d7..5f657df 100644 --- a/test/Ellosoft.AwsCredentialsManager.Tests/Ellosoft.AwsCredentialsManager.Tests.csproj +++ b/test/Ellosoft.AwsCredentialsManager.Tests/Ellosoft.AwsCredentialsManager.Tests.csproj @@ -8,16 +8,18 @@ - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -27,9 +29,13 @@ all - + + + + + - + diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Commands/Credentials/CreateCredentialsProfileTests.cs b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Commands/Credentials/CreateCredentialsProfileTests.cs new file mode 100644 index 0000000..202a87e --- /dev/null +++ b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Commands/Credentials/CreateCredentialsProfileTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2024 Ellosoft Limited. All rights reserved. + +using Ellosoft.AwsCredentialsManager.Commands.Credentials; +using Ellosoft.AwsCredentialsManager.Infrastructure.Cli; +using Ellosoft.AwsCredentialsManager.Services.Configuration.Interactive; +using Microsoft.Extensions.DependencyInjection; + +namespace Ellosoft.AwsCredentialsManager.Tests.Integration.Commands.Credentials; + +public class CreateCredentialsProfileTests(ITestOutputHelper outputHelper, TestFixture testFixture) : IntegrationTest(outputHelper, testFixture) +{ + [Fact(Skip = "WIP")] + public void CreateCredentialsProfile_Interactive_ShouldCreateNewProfile() + { + App.Configure(config => + config.AddBranch(cred => + cred.AddCommand())); + + var profileName = Guid.NewGuid().ToString("N"); + + var (exitCode, output) = App.Run("new", profileName); + + exitCode.Should().Be(0); + output.Should().Contain($"'{profileName}' credentials created"); + + var credentialsManager = TestFixture.WebApp.Services.GetRequiredService(); + credentialsManager.TryGetCredential(profileName, out var credentialsConfig); + + credentialsConfig.Should().NotBeNull(); + // credentialsConfig.RoleArn.Should().Be(awsRoleArn); + // credentialsConfig.OktaProfile.Should().Be(profileName); + // credentialsConfig.OktaAppUrl.Should().Be(oktaAppUrl); + + } +} diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Commands/Okta/OktaSetupTests.cs b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Commands/Okta/OktaSetupTests.cs new file mode 100644 index 0000000..a0e450b --- /dev/null +++ b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Commands/Okta/OktaSetupTests.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2024 Ellosoft Limited. All rights reserved. + +using Ellosoft.AwsCredentialsManager.Commands.Okta; +using Ellosoft.AwsCredentialsManager.Infrastructure.Cli; +using Ellosoft.AwsCredentialsManager.Services.Okta.Models.HttpModels; +using Ellosoft.AwsCredentialsManager.Services.Security; +using Ellosoft.AwsCredentialsManager.Tests.Integration.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Ellosoft.AwsCredentialsManager.Tests.Integration.Commands.Okta; + +public sealed class OktaSetupTests(ITestOutputHelper outputHelper, TestFixture testFixture) + : IntegrationTest(outputHelper, testFixture), IDisposable +{ + private readonly string _profileName = Guid.NewGuid().ToString("N"); + + [Fact] + public void OktaSetup_Interactive_ShouldCreateNewProfile() + { + App.Configure(config => + config.AddBranch(okta => + okta.AddCommand())); + + var domain = $"https://{Faker.Internet.DomainWord()}.okta.com"; + var username = Faker.Internet.UserName(); + var password = Faker.Internet.Password(); + + App.Console.Input.PushTextWithEnter(domain); + App.Console.Input.PushTextWithEnter(username); + App.Console.Input.PushTextWithEnter(password); + App.Console.Input.PushTextWithEnter("Y"); + + var (exitCode, output) = App.Run("okta", "setup", _profileName); + + exitCode.Should().Be(0); + output.Should().Contain("All good"); + + TestRequestsFilter.Requests.Should().ContainKey(TestCorrelationId); + TestRequestsFilter.Requests[TestCorrelationId][0] + .RequestModel.Should().BeEquivalentTo(new AuthenticationRequest + { + Username = username, Password = password + }); + + TestRequestsFilter.Requests[TestCorrelationId][0] + .Request.RequestUri.Should().BeEquivalentTo(new Uri($"{domain}/api/v1/authn")); + + var userCredentialsService = TestFixture.WebApp.Services.GetRequiredService(); + + var userCredentials = userCredentialsService.GetUserCredentials(_profileName); + + userCredentials.Should().NotBeNull(); + userCredentials!.Username.Should().Be(username); + userCredentials.Password.Should().Be(password); + } + + public void Dispose() + { + var secureStorage = TestFixture.WebApp.Services.GetRequiredService(); + secureStorage.DeleteSecret(_profileName); + } +} diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Integration/FakeApis/AwsController.cs b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/FakeApis/AwsController.cs new file mode 100644 index 0000000..62ada54 --- /dev/null +++ b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/FakeApis/AwsController.cs @@ -0,0 +1,22 @@ +// Copyright (c) 2024 Ellosoft Limited. All rights reserved. + +using Microsoft.AspNetCore.Mvc; + +namespace Ellosoft.AwsCredentialsManager.Tests.Integration.FakeApis; + +[ApiController] +[Route("aws")] +public class AwsController : ControllerBase +{ + [HttpPost("saml")] + public IActionResult ProcessSamlResponse() + { + return Ok(new + { + Roles = new[] + { + new { RoleName = "TestRole", AccountName = "Test Account" } + } + }); + } +} diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Integration/FakeApis/OktaController.cs b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/FakeApis/OktaController.cs new file mode 100644 index 0000000..c658543 --- /dev/null +++ b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/FakeApis/OktaController.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2024 Ellosoft Limited. All rights reserved. + +using Ellosoft.AwsCredentialsManager.Services.Okta.Models.HttpModels; +using Microsoft.AspNetCore.Mvc; + +namespace Ellosoft.AwsCredentialsManager.Tests.Integration.FakeApis; + +[ApiController] +[Route("/api/v1/")] +public class OktaController : ControllerBase +{ + [HttpPost("authn")] + public ActionResult Authenticate([FromBody] AuthenticationRequest request) + { + return Ok(new AuthenticationResponse + { + Status = "SUCCESS", + SessionToken = "session_token", + ExpiresAt = DateTime.UtcNow.AddMinutes(10), + }); + } + + [HttpGet("users/me/appLinks")] + public IActionResult GetAppLinks() + { + return Ok(new + { + Links = new[] + { + new + { + Label = "amazon_aws", + LinkUrl = "https://xyz.okta.com/home/amazon_aws/abc/272" + } + } + }); + } + + [HttpGet("app/amazon_aws/{appId}/sso/saml")] + public IActionResult GetSamlAssertion() + { + return Ok("fake-saml-assertion"); + } +} diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Integration/IntegrationTest.cs b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/IntegrationTest.cs new file mode 100644 index 0000000..87093c8 --- /dev/null +++ b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/IntegrationTest.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2024 Ellosoft Limited. All rights reserved. + +using Bogus; +using Ellosoft.AwsCredentialsManager.Tests.Integration.Utils; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Http; +using Serilog; + +namespace Ellosoft.AwsCredentialsManager.Tests.Integration; + +[Collection(nameof(IntegrationTest))] +public class IntegrationTest +{ + protected IntegrationTest(ITestOutputHelper outputHelper, TestFixture fixture) + { + TestFixture = fixture; + TestFixture.TestOutputHelper = outputHelper; + + var services = new ServiceCollection(); + ConfigureTestServices(services); + + services.AddAppServices(); + + App = new TestCommandApp(services); + } + + protected string TestCorrelationId { get; } = Guid.NewGuid().ToString(); + + protected Faker Faker { get; } = new(); + + protected TestFixture TestFixture { get; } + + protected TestCommandApp App { get; } + + private void ConfigureTestServices(ServiceCollection services) + { + services.AddLogging(config => config.AddSerilog()); + AddIntegrationTestHttpClient(TestFixture.WebApp, services); + } + + private void AddIntegrationTestHttpClient(IHost app, ServiceCollection services) + { + services.AddTransient(_ => + new TestHttpMessageHandlerBuilder(app.Services, app.GetTestServer(), TestCorrelationId)); + + services.AddHttpClient(); + } +} + +[CollectionDefinition(nameof(IntegrationTest))] +public class IntegrationTestsCollection : ICollectionFixture +{ + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. +} diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Integration/TestFixture.cs b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/TestFixture.cs new file mode 100644 index 0000000..e2dc086 --- /dev/null +++ b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/TestFixture.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2024 Ellosoft Limited. All rights reserved. + +using Ellosoft.AwsCredentialsManager.Tests.Integration.Utils; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Serilog; +using Serilog.Events; +using Spectre.Console; + +namespace Ellosoft.AwsCredentialsManager.Tests.Integration; + +public class TestFixture : IAsyncLifetime +{ + public ITestOutputHelper? TestOutputHelper { get; set; } + + public WebApplication WebApp { get; private set; } = null!; + + public IServiceProvider Services => WebApp.Services; + + public async Task InitializeAsync() + { + WebApp = CreateTestApp(); + WebApp.MapControllers(); + await WebApp.StartAsync(); + } + + public async Task DisposeAsync() + { + await Log.CloseAndFlushAsync(); + await WebApp.DisposeAsync(); + } + + private WebApplication CreateTestApp() + { + var builder = WebApplication.CreateSlimBuilder(); + + builder.Services.AddAppServices(); + builder.Services.AddSingleton(_ => Substitute.For()); + + ConfigureTestServices(builder.Services); + + // configure test app setup + builder.WebHost.UseTestServer(); + + // configure logging + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Warning() + .MinimumLevel.Override("Ellosoft", LogEventLevel.Verbose) + .WriteTo.Sink(new TestLogEventSink(() => TestOutputHelper)) + .CreateLogger(); + + builder.Logging.AddSerilog(Log.Logger); + + return builder.Build(); + } + + private static void ConfigureTestServices(IServiceCollection services) + { + services + .AddControllers(opt => opt.Filters.Add()) + .AddApplicationPart(typeof(IntegrationTest).Assembly); + } +} diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/ActionExecutingContextAssertions.cs b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/ActionExecutingContextAssertions.cs new file mode 100644 index 0000000..1ad7d89 --- /dev/null +++ b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/ActionExecutingContextAssertions.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2024 Ellosoft Limited. All rights reserved. + +using FluentAssertions.Execution; +using FluentAssertions.Primitives; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Ellosoft.AwsCredentialsManager.Tests.Integration.Utils; + +public static class ActionExecutingContextAssertionsExtensions +{ + public static ActionExecutingContextAssertions Should(this ActionExecutingContext context) => new(context); +} + +public class ActionExecutingContextAssertions(ActionExecutingContext context) + : ReferenceTypeAssertions(context) +{ + protected override string Identifier => "ActionExecutingContext"; + + public AndConstraint HaveValidHttpCall( + string expectedHttpMethod, + string expectedPath, + Action? modelAssertions = null) + { + // TODO This is wrong it should validate a custom model instead of the context + Execute.Assertion + .ForCondition(Subject.HttpContext.Request.Method.Equals(expectedHttpMethod, StringComparison.OrdinalIgnoreCase)) + .FailWith("Expected HTTP method to be {0}, but found {1}.", expectedHttpMethod, Subject.HttpContext.Request.Method); + + Execute.Assertion + .ForCondition(Subject.HttpContext.Request.Path.Value?.Equals(expectedPath, StringComparison.OrdinalIgnoreCase) == true) + .FailWith("Expected path to be {0}, but found {1}.", expectedPath, Subject.HttpContext.Request.Path.Value); + + if (modelAssertions == null) + return new AndConstraint(this); + + var model = Subject.ActionArguments.Values.OfType().FirstOrDefault(); + + Execute.Assertion + .ForCondition(model is not null) + .FailWith("Expected model of type {0}, but it was not found in the action arguments.", typeof(TModel).Name); + + modelAssertions(model!); + + return new AndConstraint(this); + } +} diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestCommandApp.cs b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestCommandApp.cs new file mode 100644 index 0000000..62767d4 --- /dev/null +++ b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestCommandApp.cs @@ -0,0 +1,55 @@ +// Copyright (c) 2024 Ellosoft Limited. All rights reserved. + +using Ellosoft.AwsCredentialsManager.Infrastructure.Cli; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; +using Spectre.Console.Cli; +using Spectre.Console.Testing; + +namespace Ellosoft.AwsCredentialsManager.Tests.Integration.Utils; + +public class TestCommandApp +{ + private readonly CommandApp _app; + + public TestCommandApp(IServiceCollection services) + { + Console = new TestConsole().EmitAnsiSequences().Interactive().Width(int.MaxValue); + + _app = new CommandApp(new TypeRegistrar(services)); + _app.Configure(config => + { + config.PropagateExceptions(); + config.ConfigureConsole(Console); + + config.SetInterceptor(new CallbackCommandInterceptor((ctx, s) => + { + Context = ctx; + Settings = s; + })); + }); + + // TODO: Remove this once all commands start using the IAnsiConsole interface + AnsiConsole.Console = Console; + } + + public CommandSettings Settings { get; set; } = null!; + + public CommandContext Context { get; set; } = null!; + + public TestConsole Console { get; } + + public void Configure(Action configure) => _app.Configure(configure); + + public (int exitCode, string output) Run(params string[] args) + { + var result = _app.Run(args); + + var output = Console.Output + .NormalizeLineEndings() + .TrimLines() + .Trim(); + + return (result, output); + } +} diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestHttpMessageHandlerBuilder.cs b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestHttpMessageHandlerBuilder.cs new file mode 100644 index 0000000..e424a05 --- /dev/null +++ b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestHttpMessageHandlerBuilder.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2024 Ellosoft Limited. All rights reserved. + +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Http; + +namespace Ellosoft.AwsCredentialsManager.Tests.Integration.Utils; + +public class TestHttpMessageHandlerBuilder(IServiceProvider serviceProvider, IServer server, string correlationId) : HttpMessageHandlerBuilder +{ + private readonly HttpMessageHandler _testHandler = (server as TestServer)!.CreateHandler(); + + public override IServiceProvider Services => serviceProvider; + + public override IList AdditionalHandlers { get; } = [new CorrelationIdMessageHandler(correlationId)]; + + public override string? Name { get; set; } + + public override HttpMessageHandler PrimaryHandler + { + get => _testHandler; + set => _ = value; + } + + public override HttpMessageHandler Build() => CreateHandlerPipeline(PrimaryHandler, AdditionalHandlers); +} + +public class CorrelationIdMessageHandler(string correlationId) : DelegatingHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Headers.Add("Correlation-Id", correlationId); + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestLogEventSink.cs b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestLogEventSink.cs new file mode 100644 index 0000000..d1f1790 --- /dev/null +++ b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestLogEventSink.cs @@ -0,0 +1,48 @@ + +// Copyright (c) 2024 Ellosoft Limited. All rights reserved. + +using Serilog.Core; +using Serilog.Events; +using Serilog.Formatting.Display; + +namespace Ellosoft.AwsCredentialsManager.Tests.Integration.Utils; + +public class TestLogEventSink(Func testOutputHelperProvider) : ILogEventSink +{ + private readonly List _preInitializedMessages = []; + private readonly MessageTemplateTextFormatter _messageFormatter = new("[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}"); + + public void Emit(LogEvent logEvent) + { + using var writer = new StringWriter(); + _messageFormatter.Format(logEvent, writer); + var logMessage = writer.ToString(); + + if (logEvent.Exception is not null) + logMessage += Environment.NewLine + logEvent.Exception; + + var testOutputHelper = testOutputHelperProvider(); + + if (testOutputHelper is null) + { + _preInitializedMessages.Add(logMessage); + + return; + } + + if (_preInitializedMessages.Count > 0) + { + _preInitializedMessages.ForEach(testOutputHelper.WriteLine); + _preInitializedMessages.Clear(); + } + + try + { + testOutputHelper.WriteLine(logMessage); + } + catch + { + // ignore log message if the test output helper is not available + } + } +} diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestRequestContext.cs b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestRequestContext.cs new file mode 100644 index 0000000..48991a6 --- /dev/null +++ b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestRequestContext.cs @@ -0,0 +1,14 @@ +// Copyright (c) 2024 Ellosoft Limited. All rights reserved. + +using Microsoft.AspNetCore.Http; + +namespace Ellosoft.AwsCredentialsManager.Tests.Integration.Utils; + +public class TestRequestContext +{ + public HttpRequestMessage Request { get; set; } = null!; + + public HttpResponse Response { get; set; } = null!; + + public object? RequestModel { get; set; } +} diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestRequestsFilter.cs b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestRequestsFilter.cs new file mode 100644 index 0000000..e50a95b --- /dev/null +++ b/test/Ellosoft.AwsCredentialsManager.Tests/Integration/Utils/TestRequestsFilter.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2024 Ellosoft Limited. All rights reserved. + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Ellosoft.AwsCredentialsManager.Tests.Integration.Utils; + +public class TestRequestsFilter : IAsyncActionFilter +{ + public static readonly Dictionary> Requests = []; + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var contextData = GetRequestContextList(context); + + var actionExecutedContext = await next(); + + contextData.Add( + new TestRequestContext + { + Request = CloneRequest(context.HttpContext.Request), + Response = actionExecutedContext.HttpContext.Response, + RequestModel = context.ActionArguments.Values.FirstOrDefault() + }); + } + + private static List GetRequestContextList(FilterContext context) + { + var correlationId = context.HttpContext.Request.Headers["Correlation-Id"].ToString(); + + if (!Requests.TryGetValue(correlationId, out var requestContext)) + { + requestContext = []; + Requests.Add(correlationId, requestContext); + } + + return requestContext; + } + + private static HttpRequestMessage CloneRequest(HttpRequest request) + { + var requestMessage = new HttpRequestMessage + { + Method = new HttpMethod(request.Method), + RequestUri = new Uri($"{request.Scheme}://{request.Host}{request.Path}{request.QueryString}") + }; + + foreach (var header in request.Headers) + { + if (!requestMessage.Headers.TryAddWithoutValidation(header.Key, (IEnumerable)header.Value)) + { + requestMessage.Content?.Headers.TryAddWithoutValidation(header.Key, (IEnumerable)header.Value); + } + } + + return requestMessage; + } +} diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/SemanticVersionTests.cs b/test/Ellosoft.AwsCredentialsManager.Tests/SemanticVersionTests.cs index 99fcb9d..5cdff1c 100644 --- a/test/Ellosoft.AwsCredentialsManager.Tests/SemanticVersionTests.cs +++ b/test/Ellosoft.AwsCredentialsManager.Tests/SemanticVersionTests.cs @@ -1,7 +1,6 @@ // Copyright (c) 2023 Ellosoft Limited. All rights reserved. using Ellosoft.AwsCredentialsManager.Infrastructure; -using FluentAssertions; namespace Ellosoft.AwsCredentialsManager.Tests; @@ -20,8 +19,8 @@ public void TryParse_Valid_Tests(string versionValue) { var result = SemanticVersion.TryParse(versionValue, out var actualVersion); - Assert.True(result); - Assert.Equal(versionValue, actualVersion!.ToString()); + result.Should().BeTrue(); + actualVersion!.ToString().Should().BeEquivalentTo(versionValue); } [Theory] @@ -30,9 +29,9 @@ public void TryParse_Valid_Tests(string versionValue) [InlineData("0.0.1-alpha.0.1")] public void TryParse_Invalid_Tests(string versionValue) { - var result = SemanticVersion.TryParse(versionValue, out var actualVersion); + var result = SemanticVersion.TryParse(versionValue, out _); - Assert.False(result); + result.Should().BeFalse(); } [Theory] @@ -54,7 +53,7 @@ public void CompareTo_Tests(string version1, string version2, int expectedResult var result = v1!.CompareTo(v2); - Assert.Equal(expectedResult, result); + result.Should().Be(expectedResult); } [Fact] @@ -96,6 +95,6 @@ public void OrderBy_WhenVersionsAreUnsorted_ShouldSortVersions() public void Equal_Tests(string v1, string v2) { var result = new SemanticVersion(v1) == new SemanticVersion(v2); - Assert.True(result); + result.Should().BeTrue(); } } diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/ServiceRegistrationTests.cs b/test/Ellosoft.AwsCredentialsManager.Tests/ServiceRegistrationTests.cs index 544f1d6..6732a9b 100644 --- a/test/Ellosoft.AwsCredentialsManager.Tests/ServiceRegistrationTests.cs +++ b/test/Ellosoft.AwsCredentialsManager.Tests/ServiceRegistrationTests.cs @@ -5,7 +5,8 @@ using Microsoft.Extensions.DependencyInjection; using Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS.Security; using Ellosoft.AwsCredentialsManager.Services.Security; -using FluentAssertions; +using NSubstitute; +using Spectre.Console; using Spectre.Console.Cli; namespace Ellosoft.AwsCredentialsManager.Tests; @@ -19,8 +20,9 @@ public ServiceRegistrationTests() { _services = new ServiceCollection(); + _services.AddSingleton(_ => Substitute.For()); _services.AddLogging(); - _services.RegisterAppServices(); + _services.AddAppServices(); _serviceProvider = _services.BuildServiceProvider(); } diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Usings.cs b/test/Ellosoft.AwsCredentialsManager.Tests/Usings.cs index 9e22f1a..09a1204 100644 --- a/test/Ellosoft.AwsCredentialsManager.Tests/Usings.cs +++ b/test/Ellosoft.AwsCredentialsManager.Tests/Usings.cs @@ -1,3 +1,5 @@ -// Copyright (c) 2023 Ellosoft Limited. All rights reserved. +// Copyright (c) 2024 Ellosoft Limited. All rights reserved. global using Xunit; +global using Xunit.Abstractions; +global using FluentAssertions;