[codex] Secure Resilience simulate response endpoint (#7505)
* Secure simulate response endpoint * Address simulate response review feedback * Clamp simulate response session index * Isolate simulate response security tests * Address simulate response review feedback * Address simulate response review feedback
This commit is contained in:
parent
163d6e6f8f
commit
58719078d0
|
|
@ -1,55 +1,159 @@
|
|||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FastEndpoints;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Elsa.Abstractions;
|
||||
using Elsa.Resilience.Options;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using static Elsa.Resilience.Endpoints.SimulateResponse.StatusCodeMessageLookup;
|
||||
|
||||
namespace Elsa.Resilience.Endpoints.SimulateResponse;
|
||||
|
||||
public class SimulateResponseEndpoint(IMemoryCache memoryCache) : EndpointWithoutRequest<SimulatedResponse>
|
||||
public class SimulateResponseEndpoint(SimulateResponseSessionStore sessionStore, IOptions<SimulateResponseOptions> options) : ElsaEndpointWithoutRequest<SimulatedResponse>
|
||||
{
|
||||
private static readonly TimeSpan SlidingExpirationTimeSpan = TimeSpan.FromMinutes(15);
|
||||
private static readonly int[] DefaultCodes = [429, 503, 200];
|
||||
private readonly SimulateResponseOptions _options = options.Value;
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/simulate-response");
|
||||
AllowAnonymous();
|
||||
ConfigurePermissions("exec:*", "exec:resilience", "exec:resilience:simulate-response");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var sessionId = HttpContext.Request.Query["sessionId"].FirstOrDefault() ?? "default";
|
||||
var codes = GetCodes();
|
||||
var cacheKey = $"status-simulation-session-{sessionId}";
|
||||
var nextIndex = memoryCache.GetOrCreate(cacheKey, entry =>
|
||||
if (!TryGetSessionId(out var sessionId, out var error) || !TryGetCodes(out var codes, out error))
|
||||
{
|
||||
entry.SlidingExpiration = SlidingExpirationTimeSpan;
|
||||
return 0;
|
||||
});
|
||||
AddError(error);
|
||||
await Send.ErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var currentCode = nextIndex < codes.Length ? codes[nextIndex] : codes[^1];
|
||||
var scopedSessionId = CreateScopedSessionId(sessionId);
|
||||
if (!sessionStore.TryGetNextIndex(scopedSessionId, codes.Length, out var nextIndex))
|
||||
{
|
||||
AddError($"The maximum number of active simulate-response sessions ({_options.SessionCapacity}) has been reached.");
|
||||
await Send.ErrorsAsync(StatusCodes.Status429TooManyRequests, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var currentCode = codes[nextIndex];
|
||||
var message = StatusMessages.TryGetValue(currentCode, out var reason)
|
||||
? reason
|
||||
: $"Status Code {currentCode}";
|
||||
|
||||
if (nextIndex + 1 >= codes.Length)
|
||||
{
|
||||
memoryCache.Remove(cacheKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
memoryCache.Set(cacheKey, nextIndex + 1, new MemoryCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = SlidingExpirationTimeSpan
|
||||
});
|
||||
}
|
||||
|
||||
await Send.ResponseAsync(new(message), currentCode, ct);
|
||||
}
|
||||
|
||||
private int[] GetCodes()
|
||||
private bool TryGetSessionId(out string sessionId, out string error)
|
||||
{
|
||||
var codesParam = HttpContext.Request.Query["codes"].FirstOrDefault();
|
||||
int[] defaultCodes = [429, 503, 200];
|
||||
return string.IsNullOrWhiteSpace(codesParam) ? defaultCodes : JsonSerializer.Deserialize<int[]>(codesParam)!;
|
||||
sessionId = "default";
|
||||
error = "";
|
||||
var sessionIdParam = HttpContext.Request.Query["sessionId"].FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sessionIdParam))
|
||||
return true;
|
||||
|
||||
if (sessionIdParam.Length > _options.MaxSessionIdLength)
|
||||
{
|
||||
error = $"The sessionId query parameter must be {_options.MaxSessionIdLength} characters or fewer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
sessionId = sessionIdParam;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCodes(out int[] codes, out string error)
|
||||
{
|
||||
codes = DefaultCodes;
|
||||
error = "";
|
||||
var codesParam = HttpContext.Request.Query["codes"].FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(codesParam))
|
||||
return true;
|
||||
|
||||
if (codesParam.Length > _options.MaxCodesQueryLength)
|
||||
{
|
||||
error = $"The codes query parameter must be {_options.MaxCodesQueryLength} characters or fewer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(codesParam);
|
||||
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
error = "The codes query parameter must be a JSON array of HTTP status codes.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var parsedCodes = new List<int>();
|
||||
foreach (var codeElement in document.RootElement.EnumerateArray())
|
||||
{
|
||||
if (parsedCodes.Count >= _options.MaxCodes)
|
||||
{
|
||||
error = $"The codes query parameter can contain at most {_options.MaxCodes} status codes.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (codeElement.ValueKind != JsonValueKind.Number || !codeElement.TryGetInt32(out var code) || code is < 100 or > 599)
|
||||
{
|
||||
error = "The codes query parameter must contain HTTP status codes between 100 and 599.";
|
||||
return false;
|
||||
}
|
||||
|
||||
parsedCodes.Add(code);
|
||||
}
|
||||
|
||||
if (parsedCodes.Count == 0)
|
||||
{
|
||||
error = "The codes query parameter must contain at least one status code.";
|
||||
return false;
|
||||
}
|
||||
|
||||
codes = parsedCodes.ToArray();
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
error = "The codes query parameter must be valid JSON.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string CreateScopedSessionId(string sessionId)
|
||||
{
|
||||
var key = $"{GetAuthenticatedIdentityKey()}\0{sessionId}";
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key)));
|
||||
}
|
||||
|
||||
private string GetAuthenticatedIdentityKey()
|
||||
{
|
||||
var user = HttpContext.User;
|
||||
var identityClaim = user.FindFirst(ClaimTypes.NameIdentifier)
|
||||
?? user.FindFirst("sub")
|
||||
?? user.FindFirst("client_id")
|
||||
?? user.FindFirst("name")
|
||||
?? user.FindFirst(ClaimTypes.Name);
|
||||
|
||||
if (identityClaim != null)
|
||||
return $"{identityClaim.Type}:{identityClaim.Value}";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.Identity?.Name))
|
||||
return $"name:{user.Identity.Name}";
|
||||
|
||||
var claimsKey = string.Join('\u001e', user.Claims
|
||||
.Where(x => x.Type != "permissions")
|
||||
.OrderBy(x => x.Type, StringComparer.Ordinal)
|
||||
.ThenBy(x => x.Issuer, StringComparer.Ordinal)
|
||||
.ThenBy(x => x.Value, StringComparer.Ordinal)
|
||||
.Select(x => $"{x.Type}:{x.Issuer}:{x.Value}"));
|
||||
|
||||
return !string.IsNullOrEmpty(claimsKey)
|
||||
? claimsKey
|
||||
: $"auth:{user.Identity?.AuthenticationType ?? "unknown"}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
using Elsa.Resilience.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Resilience.Endpoints.SimulateResponse;
|
||||
|
||||
public class SimulateResponseSessionStore(IOptions<SimulateResponseOptions> options, TimeProvider timeProvider)
|
||||
{
|
||||
private readonly Dictionary<string, SessionState> _sessions = new(StringComparer.Ordinal);
|
||||
private readonly object _lock = new();
|
||||
private readonly SimulateResponseOptions _options = options.Value;
|
||||
|
||||
public bool TryGetNextIndex(string sessionId, int statusCodeCount, out int nextIndex)
|
||||
{
|
||||
if (statusCodeCount <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(statusCodeCount), "Status code count must be greater than zero.");
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(_options.SessionSlidingExpiration);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
PruneExpired(now);
|
||||
|
||||
nextIndex = _sessions.TryGetValue(sessionId, out var state) ? Math.Min(state.NextIndex, statusCodeCount - 1) : 0;
|
||||
|
||||
if (nextIndex + 1 >= statusCodeCount)
|
||||
{
|
||||
_sessions.Remove(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_sessions.ContainsKey(sessionId) && _sessions.Count >= _options.SessionCapacity)
|
||||
return false;
|
||||
|
||||
_sessions[sessionId] = new SessionState(nextIndex + 1, expiresAt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void PruneExpired(DateTimeOffset now)
|
||||
{
|
||||
foreach (var session in _sessions.Where(x => x.Value.ExpiresAt <= now).Select(x => x.Key).ToList())
|
||||
_sessions.Remove(session);
|
||||
}
|
||||
|
||||
private sealed record SessionState(int NextIndex, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ using Elsa.Expressions.Options;
|
|||
using Elsa.Extensions;
|
||||
using Elsa.Features.Abstractions;
|
||||
using Elsa.Features.Services;
|
||||
using Elsa.Resilience.Endpoints.SimulateResponse;
|
||||
using Elsa.Resilience.Entities;
|
||||
using Elsa.Resilience.Modifiers;
|
||||
using Elsa.Resilience.Options;
|
||||
|
|
@ -10,6 +11,7 @@ using Elsa.Resilience.Serialization;
|
|||
using Elsa.Resilience.StrategySources;
|
||||
using Elsa.Workflows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Elsa.Resilience.Features;
|
||||
|
||||
|
|
@ -69,9 +71,12 @@ public class ResilienceFeature(IModule module) : FeatureBase(module)
|
|||
public override void Apply()
|
||||
{
|
||||
Services.AddOptions<ResilienceOptions>();
|
||||
Services.AddOptions<SimulateResponseOptions>();
|
||||
Services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
Services
|
||||
.AddSingleton<ResilienceStrategySerializer>()
|
||||
.AddSingleton<SimulateResponseSessionStore>()
|
||||
.AddSingleton<IActivityDescriptorModifier, ResilientActivityDescriptorModifier>()
|
||||
.AddScoped<IResilienceStrategyCatalog, ResilienceStrategyCatalog>()
|
||||
.AddScoped<IResilienceStrategyConfigEvaluator, ResilienceStrategyConfigEvaluator>()
|
||||
|
|
@ -90,4 +95,4 @@ public class ResilienceFeature(IModule module) : FeatureBase(module)
|
|||
.AddSingleton<ITransientExceptionStrategy, DefaultTransientExceptionStrategy>()
|
||||
.AddSingleton<ITransientExceptionDetector, TransientExceptionDetector>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
namespace Elsa.Resilience.Options;
|
||||
|
||||
public class SimulateResponseOptions
|
||||
{
|
||||
public int SessionCapacity { get; set; } = 1_000;
|
||||
public TimeSpan SessionSlidingExpiration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
public int MaxSessionIdLength { get; set; } = 128;
|
||||
public int MaxCodesQueryLength { get; set; } = 1_024;
|
||||
public int MaxCodes { get; set; } = 32;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ using CShells.FastEndpoints.Features;
|
|||
using CShells.Features;
|
||||
using Elsa.Expressions.Options;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Resilience.Endpoints.SimulateResponse;
|
||||
using Elsa.Resilience.Entities;
|
||||
using Elsa.Resilience.Modifiers;
|
||||
using Elsa.Resilience.Options;
|
||||
|
|
@ -10,6 +11,7 @@ using Elsa.Resilience.Serialization;
|
|||
using Elsa.Resilience.StrategySources;
|
||||
using Elsa.Workflows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Elsa.Resilience.ShellFeatures;
|
||||
|
||||
|
|
@ -26,9 +28,12 @@ public class ResilienceFeature : IFastEndpointsShellFeature
|
|||
});
|
||||
|
||||
services.AddOptions<ResilienceOptions>();
|
||||
services.AddOptions<SimulateResponseOptions>();
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
services
|
||||
.AddSingleton<ResilienceStrategySerializer>()
|
||||
.AddSingleton<SimulateResponseSessionStore>()
|
||||
.AddSingleton<IActivityDescriptorModifier, ResilientActivityDescriptorModifier>()
|
||||
.AddScoped<IResilienceStrategyCatalog, ResilienceStrategyCatalog>()
|
||||
.AddScoped<IResilienceStrategyConfigEvaluator, ResilienceStrategyConfigEvaluator>()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Elsa;
|
||||
using Elsa.Resilience.Endpoints.SimulateResponse;
|
||||
using Elsa.Resilience.Features;
|
||||
using Elsa.Resilience.Options;
|
||||
using Elsa.Resilience.Serialization;
|
||||
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;
|
||||
|
||||
namespace Elsa.Resilience.IntegrationTests;
|
||||
|
||||
[Collection(nameof(EndpointSecurityCollection))]
|
||||
public class SimulateResponseEndpointTests : IAsyncLifetime
|
||||
{
|
||||
private readonly TestTimeProvider _timeProvider = new();
|
||||
private WebApplication? _app;
|
||||
private bool _wasSecurityEnabled;
|
||||
|
||||
private HttpClient HttpClient { get; set; } = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_wasSecurityEnabled = EndpointSecurityOptions.SecurityIsEnabled;
|
||||
EndpointSecurityOptions.SecurityIsEnabled = true;
|
||||
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
builder.Services.AddAuthentication(TestAuthenticationHandler.AuthenticationScheme)
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(TestAuthenticationHandler.AuthenticationScheme, _ => { });
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddFastEndpoints(o =>
|
||||
{
|
||||
o.Assemblies = [typeof(ResilienceFeature).Assembly];
|
||||
o.DisableAutoDiscovery = true;
|
||||
});
|
||||
builder.Services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
builder.Services.AddOptions<ResilienceOptions>();
|
||||
builder.Services.AddOptions<SimulateResponseOptions>().Configure(options =>
|
||||
{
|
||||
options.SessionCapacity = 2;
|
||||
options.SessionSlidingExpiration = TimeSpan.FromSeconds(1);
|
||||
options.MaxCodes = 3;
|
||||
options.MaxCodesQueryLength = 32;
|
||||
options.MaxSessionIdLength = 16;
|
||||
});
|
||||
builder.Services.AddSingleton<ResilienceStrategySerializer>();
|
||||
builder.Services.AddSingleton<SimulateResponseSessionStore>();
|
||||
builder.Services.AddScoped<IRetryAttemptReader>(_ => VoidRetryAttemptReader.Instance);
|
||||
builder.Services.AddScoped(_ =>
|
||||
{
|
||||
var catalog = Substitute.For<IResilienceStrategyCatalog>();
|
||||
catalog.ListAsync(Arg.Any<CancellationToken>()).Returns([]);
|
||||
return catalog;
|
||||
});
|
||||
|
||||
_app = builder.Build();
|
||||
_app.UseAuthentication();
|
||||
_app.UseAuthorization();
|
||||
_app.UseFastEndpoints();
|
||||
|
||||
await _app.StartAsync();
|
||||
HttpClient = _app.GetTestClient();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
EndpointSecurityOptions.SecurityIsEnabled = _wasSecurityEnabled;
|
||||
HttpClient.Dispose();
|
||||
|
||||
if (_app != null)
|
||||
{
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_WhenAnonymousAndSecurityEnabled_DoesNotCreateSessionState()
|
||||
{
|
||||
var response = await HttpClient.GetAsync("/simulate-response?sessionId=anon&codes=[500,200]");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
|
||||
var authenticatedResponse = await GetAuthenticatedAsync("/simulate-response?sessionId=anon&codes=[500,200]");
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, authenticatedResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_WhenCodesAreMalformed_ReturnsBadRequest()
|
||||
{
|
||||
var response = await GetAuthenticatedAsync("/simulate-response?codes=not-json");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_WhenSessionIdExceedsMaxLength_ReturnsBadRequest()
|
||||
{
|
||||
var response = await GetAuthenticatedAsync("/simulate-response?sessionId=exceeds-sixteen-chars");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_WhenSessionIdIsAtMaxLength_AcceptsRequest()
|
||||
{
|
||||
var response = await GetAuthenticatedAsync("/simulate-response?sessionId=1234567890123456&codes=[200]");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_WhenCodesQueryExceedsMaxLength_ReturnsBadRequest()
|
||||
{
|
||||
var response = await GetAuthenticatedAsync($"/simulate-response?codes={new string('1', 33)}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_WhenCodesExceedMaxCount_ReturnsBadRequest()
|
||||
{
|
||||
var response = await GetAuthenticatedAsync("/simulate-response?codes=[500,503,200,201]");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(99)]
|
||||
[InlineData(600)]
|
||||
public async Task Get_WhenCodesAreOutOfRange_ReturnsBadRequest(int statusCode)
|
||||
{
|
||||
var response = await GetAuthenticatedAsync($"/simulate-response?codes=[{statusCode}]");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_WhenSessionCapacityIsReached_ReturnsTooManyRequestsUntilStateExpires()
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, (await GetAuthenticatedAsync("/simulate-response?sessionId=first&codes=[500,200]")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, (await GetAuthenticatedAsync("/simulate-response?sessionId=second&codes=[500,200]")).StatusCode);
|
||||
|
||||
var rejected = await GetAuthenticatedAsync("/simulate-response?sessionId=third&codes=[500,200]");
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(2));
|
||||
|
||||
var acceptedAfterExpiration = await GetAuthenticatedAsync("/simulate-response?sessionId=third&codes=[500,200]");
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, acceptedAfterExpiration.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_WhenExistingSessionUsesShorterCodes_DoesNotReadBeyondCodes()
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, (await GetAuthenticatedAsync("/simulate-response?sessionId=reused&codes=[500,503,200]")).StatusCode);
|
||||
|
||||
var response = await GetAuthenticatedAsync("/simulate-response?sessionId=reused&codes=[200]");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_WhenDifferentIdentitiesUseSameSessionId_TracksSessionsIndependently()
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, (await GetAuthenticatedAsync("/simulate-response?sessionId=shared&codes=[500,200]", "alice")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, (await GetAuthenticatedAsync("/simulate-response?sessionId=shared&codes=[500,200]", "bob")).StatusCode);
|
||||
|
||||
var response = await GetAuthenticatedAsync("/simulate-response?sessionId=shared&codes=[500,200]", "alice");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> GetAuthenticatedAsync(string requestUri, string identity = "test-user")
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
request.Headers.Add(TestAuthenticationHandler.PermissionHeader, "*");
|
||||
request.Headers.Add(TestAuthenticationHandler.IdentityHeader, identity);
|
||||
return await HttpClient.SendAsync(request);
|
||||
}
|
||||
|
||||
private sealed class TestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.Parse("2026-05-20T00:00:00Z");
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan timeSpan)
|
||||
{
|
||||
_now = _now.Add(timeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string AuthenticationScheme = "Test";
|
||||
public const string IdentityHeader = "X-Test-Identity";
|
||||
public const string PermissionHeader = "X-Test-Permissions";
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(PermissionHeader, out var permissionHeader))
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
|
||||
var identity = Request.Headers.TryGetValue(IdentityHeader, out var identityHeader)
|
||||
? identityHeader.FirstOrDefault()
|
||||
: null;
|
||||
var claims = permissionHeader
|
||||
.SelectMany(x => x?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
||||
.Select(x => new Claim("permissions", x))
|
||||
.ToList();
|
||||
|
||||
claims.Add(new Claim(ClaimTypes.NameIdentifier, identity ?? "test-user"));
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims, AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
var ticket = new AuthenticationTicket(principal, AuthenticationScheme);
|
||||
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(nameof(EndpointSecurityCollection), DisableParallelization = true)]
|
||||
public class EndpointSecurityCollection;
|
||||
Loading…
Reference in a new issue