using System.Net; using System.Net.Http.Json; using System.Security.Claims; using System.Text.Encodings.Web; using System.Text.Json; using Elsa.Identity.Contracts; using Elsa.Identity.Constants; using Elsa.Identity.Entities; using Elsa.Identity.Models; using FastEndpoints; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; using Elsa.ExternalAuthentication.IntegrationTests.Fixtures; namespace Elsa.ExternalAuthentication.IntegrationTests.Compatibility; /// /// Protects the existing direct local-credential contracts while the broker-local flow remains additive. /// [Collection(nameof(EndpointSecurityCollection))] public sealed class LegacyIdentityEndpointTests : IAsyncLifetime { private readonly IUserCredentialsValidator _credentialsValidator = Substitute.For(); private readonly IUserProvider _userProvider = Substitute.For(); private readonly IAccessTokenIssuer _tokenIssuer = Substitute.For(); private WebApplication? _app; private HttpClient? _client; private bool _wasSecurityEnabled; public async Task InitializeAsync() { _wasSecurityEnabled = EndpointSecurityOptions.SecurityIsEnabled; EndpointSecurityOptions.SecurityIsEnabled = false; var builder = WebApplication.CreateSlimBuilder(); builder.WebHost.UseTestServer(); builder.Services.AddFastEndpoints(options => { options.Assemblies = [typeof(Elsa.Identity.Features.IdentityFeature).Assembly]; options.Filter = endpoint => endpoint.Namespace is "Elsa.Identity.Endpoints.Login" or "Elsa.Identity.Endpoints.RefreshToken"; }); builder.Services.AddSingleton(_credentialsValidator); builder.Services.AddSingleton(_userProvider); builder.Services.AddSingleton(_tokenIssuer); builder.Services .AddAuthentication() .AddScheme(IdentityAuthenticationSchemes.RefreshToken, _ => { }); builder.Services.AddAuthorization(); _app = builder.Build(); _app.Use(async (context, next) => { context.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "admin")], "legacy-refresh")); await next(context); }); _app.UseAuthorization(); _app.UseFastEndpoints(); await _app.StartAsync(); _client = _app.GetTestClient(); } public async Task DisposeAsync() { EndpointSecurityOptions.SecurityIsEnabled = _wasSecurityEnabled; _client?.Dispose(); if (_app is not null) { await _app.StopAsync(); await _app.DisposeAsync(); } } [Fact] public async Task IdentityLoginRetainsItsRouteCredentialValidationAndResponseShape() { var user = new User { Id = "user-a", Name = "admin" }; _credentialsValidator.ValidateAsync("admin", "password", Arg.Any()).Returns(user); _tokenIssuer.IssueTokensAsync(user, Arg.Any()).Returns(new IssuedTokens("access-a", "refresh-a")); var response = await _client!.PostAsJsonAsync("/identity/login", new { username = " admin ", password = " password " }); var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.True(document.RootElement.GetProperty("isAuthenticated").GetBoolean()); Assert.Equal("access-a", document.RootElement.GetProperty("accessToken").GetString()); Assert.Equal("refresh-a", document.RootElement.GetProperty("refreshToken").GetString()); await _credentialsValidator.Received(1).ValidateAsync("admin", "password", Arg.Any()); } [Fact] public async Task IdentityLoginRetainsItsGenericUnauthenticatedResponse() { _credentialsValidator.ValidateAsync("unknown", "wrong", Arg.Any()).Returns((User?)null); var response = await _client!.PostAsJsonAsync("/identity/login", new { username = "unknown", password = "wrong" }); var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.False(document.RootElement.GetProperty("isAuthenticated").GetBoolean()); Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("accessToken").ValueKind); Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("refreshToken").ValueKind); } [Fact] public async Task IdentityRefreshTokenRetainsItsRouteAndLocalTokenContract() { var user = new User { Id = "user-a", Name = "admin" }; _userProvider.FindAsync(Arg.Is(filter => filter.Name == "admin"), Arg.Any()).Returns(user); _tokenIssuer.IssueTokensAsync(user, Arg.Any()).Returns(new IssuedTokens("access-b", "refresh-b")); var response = await _client!.PostAsync("/identity/refresh-token", null); var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.True(document.RootElement.GetProperty("isAuthenticated").GetBoolean()); Assert.Equal("access-b", document.RootElement.GetProperty("accessToken").GetString()); Assert.Equal("refresh-b", document.RootElement.GetProperty("refreshToken").GetString()); } private sealed class RefreshTokenAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) { protected override Task HandleAuthenticateAsync() { var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, "admin")], Scheme.Name); var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name); return Task.FromResult(AuthenticateResult.Success(ticket)); } } }