Add determinism tests for verdict artifact generation and update SHA256 sums script

- Implemented comprehensive tests for verdict artifact generation to ensure deterministic outputs across various scenarios, including identical inputs, parallel execution, and change ordering.
- Created helper methods for generating sample verdict inputs and computing canonical hashes.
- Added tests to validate the stability of canonical hashes, proof spine ordering, and summary statistics.
- Introduced a new PowerShell script to update SHA256 sums for files, ensuring accurate hash generation and file integrity checks.
This commit is contained in:
StellaOps Bot
2025-12-24 02:17:34 +02:00
parent e59921374e
commit 7503c19b8f
390 changed files with 37389 additions and 5380 deletions

View File

@@ -0,0 +1,425 @@
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice.AspNetCore;
/// <summary>
/// Discovers ASP.NET Core endpoints and converts them to Router endpoint descriptors.
/// </summary>
public sealed partial class AspNetCoreEndpointDiscoveryProvider : IAspNetEndpointDiscoveryProvider
{
private static readonly string[] MethodOrder =
["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"];
private readonly EndpointDataSource _endpointDataSource;
private readonly StellaRouterBridgeOptions _options;
private readonly IAuthorizationClaimMapper _authMapper;
private readonly ILogger<AspNetCoreEndpointDiscoveryProvider> _logger;
private IReadOnlyList<AspNetEndpointDescriptor>? _cachedEndpoints;
private readonly object _cacheLock = new();
public AspNetCoreEndpointDiscoveryProvider(
EndpointDataSource endpointDataSource,
StellaRouterBridgeOptions options,
IAuthorizationClaimMapper authMapper,
ILogger<AspNetCoreEndpointDiscoveryProvider> logger)
{
_endpointDataSource = endpointDataSource ?? throw new ArgumentNullException(nameof(endpointDataSource));
_options = options ?? throw new ArgumentNullException(nameof(options));
_authMapper = authMapper ?? throw new ArgumentNullException(nameof(authMapper));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public IReadOnlyList<EndpointDescriptor> DiscoverEndpoints()
{
return DiscoverAspNetEndpoints()
.Select(e => e.ToEndpointDescriptor())
.ToList();
}
/// <inheritdoc />
public IReadOnlyList<AspNetEndpointDescriptor> DiscoverAspNetEndpoints()
{
lock (_cacheLock)
{
if (_cachedEndpoints is not null)
{
return _cachedEndpoints;
}
_cachedEndpoints = DiscoverEndpointsCore();
return _cachedEndpoints;
}
}
/// <inheritdoc />
public void RefreshEndpoints()
{
lock (_cacheLock)
{
_cachedEndpoints = null;
}
}
private IReadOnlyList<AspNetEndpointDescriptor> DiscoverEndpointsCore()
{
var descriptors = new List<AspNetEndpointDescriptor>();
var seenEndpoints = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var endpoint in _endpointDataSource.Endpoints.OfType<RouteEndpoint>())
{
// Skip endpoints without HTTP method metadata
var httpMethodMetadata = endpoint.Metadata.GetMetadata<HttpMethodMetadata>();
if (httpMethodMetadata?.HttpMethods is not { Count: > 0 })
{
continue;
}
// Apply custom filter if configured
if (_options.EndpointFilter is { } filter && !filter(endpoint))
{
_logger.LogDebug(
"Endpoint {DisplayName} excluded by custom filter",
endpoint.DisplayName);
continue;
}
// Normalize the route pattern
var normalizedPath = NormalizeRoutePattern(endpoint.RoutePattern);
if (normalizedPath is null)
{
_logger.LogWarning(
"Could not normalize route pattern for endpoint {DisplayName}",
endpoint.DisplayName);
continue;
}
// Check excluded path prefixes
if (IsExcludedPath(normalizedPath))
{
_logger.LogDebug(
"Endpoint {Path} excluded by path prefix filter",
normalizedPath);
continue;
}
// Process each HTTP method
foreach (var method in httpMethodMetadata.HttpMethods)
{
var key = $"{method.ToUpperInvariant()}:{normalizedPath}";
if (!seenEndpoints.Add(key))
{
_logger.LogDebug(
"Duplicate endpoint {Method} {Path} skipped",
method, normalizedPath);
continue;
}
var descriptor = BuildDescriptor(endpoint, method, normalizedPath);
if (descriptor is not null)
{
descriptors.Add(descriptor);
}
}
}
// Sort for deterministic ordering
return descriptors
.OrderBy(e => e.Path, StringComparer.OrdinalIgnoreCase)
.ThenBy(e => GetMethodOrder(e.Method))
.ThenBy(e => e.OperationId ?? "")
.ToList();
}
private AspNetEndpointDescriptor? BuildDescriptor(
RouteEndpoint endpoint,
string method,
string normalizedPath)
{
// Map authorization
var authResult = _authMapper.Map(endpoint);
// Check authorization requirements based on configuration
if (!authResult.HasAuthorization && !authResult.AllowAnonymous)
{
switch (_options.OnMissingAuthorization)
{
case MissingAuthorizationBehavior.RequireExplicit:
_logger.LogError(
"Endpoint {Method} {Path} has no authorization metadata. " +
"Add [Authorize] or [AllowAnonymous], or configure YAML override.",
method, normalizedPath);
throw new InvalidOperationException(
$"Endpoint {method} {normalizedPath} has no authorization metadata. " +
"Configure OnMissingAuthorization to allow or add authorization.");
case MissingAuthorizationBehavior.WarnAndAllow:
_logger.LogWarning(
"Endpoint {Method} {Path} has no authorization metadata. " +
"It will require authentication but no specific claims.",
method, normalizedPath);
break;
case MissingAuthorizationBehavior.AllowAuthenticated:
// Silent - this is expected behavior
break;
}
}
// Extract parameters
var parameters = ExtractParameters(endpoint);
// Extract responses
var responses = ExtractResponses(endpoint);
// Extract OpenAPI metadata
var (operationId, summary, description, tags) = ExtractOpenApiMetadata(endpoint);
return new AspNetEndpointDescriptor
{
ServiceName = _options.ServiceName,
Version = _options.Version,
Method = method.ToUpperInvariant(),
Path = normalizedPath,
DefaultTimeout = _options.DefaultTimeout,
SupportsStreaming = _options.EnableStreaming && HasStreamingResponse(endpoint),
RequiringClaims = authResult.Claims,
AuthorizationPolicies = authResult.Policies,
Roles = authResult.Roles,
AllowAnonymous = authResult.AllowAnonymous,
AuthorizationSource = authResult.Source,
Parameters = parameters,
Responses = responses,
OperationId = operationId,
Summary = summary,
Description = description,
Tags = tags,
OriginalEndpoint = endpoint,
OriginalRoutePattern = endpoint.RoutePattern.RawText
};
}
private string? NormalizeRoutePattern(RoutePattern pattern)
{
string raw;
if (!string.IsNullOrWhiteSpace(pattern.RawText))
{
raw = pattern.RawText;
}
else
{
// Build from segments
var segments = new List<string>();
foreach (var segment in pattern.PathSegments)
{
var parts = new List<string>();
foreach (var part in segment.Parts)
{
switch (part)
{
case RoutePatternLiteralPart literal:
parts.Add(literal.Content);
break;
case RoutePatternParameterPart param:
var prefix = param.ParameterKind == RoutePatternParameterKind.CatchAll ? "*" : "";
parts.Add($"{{{prefix}{param.Name}}}");
break;
}
}
segments.Add(string.Concat(parts));
}
raw = "/" + string.Join('/', segments);
}
// 1. Ensure leading slash
if (!raw.StartsWith('/'))
{
raw = "/" + raw;
}
// 2. Strip constraints: {id:int} → {id}, {**path:regex} → {path}
raw = ConstraintPattern().Replace(raw, "{$2}");
// 3. Normalize catch-all: {**path} → {path}
raw = raw.Replace("**", "", StringComparison.Ordinal);
raw = raw.Replace("{*", "{", StringComparison.Ordinal);
// 4. Remove trailing slash
raw = raw.TrimEnd('/');
// 5. Empty path becomes "/"
return string.IsNullOrEmpty(raw) ? "/" : raw;
}
[GeneratedRegex(@"\{(\*{0,2})([A-Za-z0-9_]+)(:[^}]+)?\}", RegexOptions.Compiled)]
private static partial Regex ConstraintPattern();
private bool IsExcludedPath(string path)
{
if (_options.IncludeExcludedPathsInRouter)
{
return false;
}
foreach (var prefix in _options.ExcludedPathPrefixes)
{
if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static int GetMethodOrder(string method)
{
var index = Array.IndexOf(MethodOrder, method.ToUpperInvariant());
return index >= 0 ? index : MethodOrder.Length;
}
private IReadOnlyList<ParameterDescriptor> ExtractParameters(RouteEndpoint endpoint)
{
var parameters = new List<ParameterDescriptor>();
// Extract route parameters from pattern
foreach (var param in endpoint.RoutePattern.Parameters)
{
parameters.Add(new ParameterDescriptor
{
Name = param.Name,
Source = ParameterSource.Route,
Type = typeof(string), // Route params are strings by default
IsRequired = !param.IsOptional && param.Default is null,
DefaultValue = param.Default,
JsonSchemaType = "string"
});
}
// Try to extract from endpoint metadata (IAcceptsMetadata for body)
var acceptsMetadata = endpoint.Metadata.GetMetadata<IAcceptsMetadata>();
if (acceptsMetadata?.RequestType is { } bodyType)
{
parameters.Add(new ParameterDescriptor
{
Name = "body",
Source = ParameterSource.Body,
Type = bodyType,
IsRequired = !acceptsMetadata.IsOptional,
JsonSchemaType = GetJsonSchemaType(bodyType)
});
}
return parameters;
}
private IReadOnlyList<ResponseDescriptor> ExtractResponses(RouteEndpoint endpoint)
{
var responses = new List<ResponseDescriptor>();
// Extract from IProducesResponseTypeMetadata
var producesMetadata = endpoint.Metadata.GetOrderedMetadata<IProducesResponseTypeMetadata>();
foreach (var produces in producesMetadata)
{
responses.Add(new ResponseDescriptor
{
StatusCode = produces.StatusCode,
ResponseType = produces.Type,
ContentType = produces.ContentTypes.FirstOrDefault() ?? "application/json",
SchemaRef = produces.Type?.FullName
});
}
// If no explicit responses, add default 200 OK
if (responses.Count == 0)
{
responses.Add(new ResponseDescriptor
{
StatusCode = StatusCodes.Status200OK,
Description = "Success"
});
}
return responses
.OrderBy(r => r.StatusCode)
.ToList();
}
private (string? OperationId, string? Summary, string? Description, IReadOnlyList<string> Tags)
ExtractOpenApiMetadata(RouteEndpoint endpoint)
{
if (!_options.ExtractOpenApiMetadata)
{
return (null, null, null, []);
}
// Operation ID from IEndpointNameMetadata or display name
var nameMetadata = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>();
var operationId = nameMetadata?.EndpointName
?? endpoint.Metadata.GetMetadata<RouteNameMetadata>()?.RouteName;
// Summary
var summaryMetadata = endpoint.Metadata.GetMetadata<IEndpointSummaryMetadata>();
var summary = summaryMetadata?.Summary ?? endpoint.DisplayName;
// Description
var descriptionMetadata = endpoint.Metadata.GetMetadata<IEndpointDescriptionMetadata>();
var description = descriptionMetadata?.Description;
// Tags
var tagsMetadata = endpoint.Metadata.GetMetadata<ITagsMetadata>();
var tags = tagsMetadata?.Tags.ToList() ?? new List<string>();
return (operationId, summary, description, tags);
}
private static bool HasStreamingResponse(RouteEndpoint endpoint)
{
// Check for streaming indicators in response metadata
var produces = endpoint.Metadata.GetOrderedMetadata<IProducesResponseTypeMetadata>();
foreach (var p in produces)
{
if (p.ContentTypes.Any(ct =>
ct.Contains("stream", StringComparison.OrdinalIgnoreCase) ||
ct.Contains("octet", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
return false;
}
private static string GetJsonSchemaType(Type type)
{
var underlying = Nullable.GetUnderlyingType(type) ?? type;
return underlying switch
{
_ when underlying == typeof(string) => "string",
_ when underlying == typeof(int) => "integer",
_ when underlying == typeof(long) => "integer",
_ when underlying == typeof(short) => "integer",
_ when underlying == typeof(byte) => "integer",
_ when underlying == typeof(float) => "number",
_ when underlying == typeof(double) => "number",
_ when underlying == typeof(decimal) => "number",
_ when underlying == typeof(bool) => "boolean",
_ when underlying == typeof(DateTime) => "string",
_ when underlying == typeof(DateTimeOffset) => "string",
_ when underlying == typeof(Guid) => "string",
_ when underlying.IsArray => "array",
_ when underlying.IsGenericType &&
underlying.GetGenericTypeDefinition() == typeof(IEnumerable<>) => "array",
_ => "object"
};
}
}

View File

@@ -0,0 +1,281 @@
using Microsoft.AspNetCore.Routing;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice.AspNetCore;
/// <summary>
/// Extended endpoint descriptor with full ASP.NET metadata.
/// Captures all discoverable information from ASP.NET endpoints for Router registration.
/// </summary>
public sealed record AspNetEndpointDescriptor
{
// === Core Identity (compatible with EndpointDescriptor) ===
/// <summary>
/// Name of the service that owns this endpoint.
/// </summary>
public required string ServiceName { get; init; }
/// <summary>
/// Semantic version of the service.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// HTTP method (GET, POST, PUT, PATCH, DELETE, etc.).
/// </summary>
public required string Method { get; init; }
/// <summary>
/// Normalized path template (e.g., "/api/scans/{id}").
/// Constraints are stripped; catch-all markers are normalized.
/// </summary>
public required string Path { get; init; }
/// <summary>
/// Default timeout for this endpoint.
/// </summary>
public TimeSpan DefaultTimeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Whether this endpoint supports streaming responses.
/// </summary>
public bool SupportsStreaming { get; init; }
// === Authorization ===
/// <summary>
/// Claim requirements for authorization, derived from ASP.NET metadata and/or YAML.
/// </summary>
public IReadOnlyList<ClaimRequirement> RequiringClaims { get; init; } = [];
/// <summary>
/// Named authorization policies applied to this endpoint.
/// </summary>
public IReadOnlyList<string> AuthorizationPolicies { get; init; } = [];
/// <summary>
/// Role names required for this endpoint (from [Authorize(Roles = "...")]).
/// </summary>
public IReadOnlyList<string> Roles { get; init; } = [];
/// <summary>
/// Whether anonymous access is explicitly allowed ([AllowAnonymous]).
/// </summary>
public bool AllowAnonymous { get; init; }
/// <summary>
/// Source of the authorization metadata.
/// </summary>
public AuthorizationSource AuthorizationSource { get; init; }
// === Parameters ===
/// <summary>
/// Parameter metadata for this endpoint (route, query, header, body).
/// </summary>
public IReadOnlyList<ParameterDescriptor> Parameters { get; init; } = [];
// === Responses ===
/// <summary>
/// Response metadata for this endpoint (status codes, types).
/// </summary>
public IReadOnlyList<ResponseDescriptor> Responses { get; init; } = [];
// === OpenAPI Metadata ===
/// <summary>
/// Operation ID for OpenAPI (from .WithName() or endpoint name).
/// </summary>
public string? OperationId { get; init; }
/// <summary>
/// Summary for OpenAPI (from .WithSummary() or display name).
/// </summary>
public string? Summary { get; init; }
/// <summary>
/// Description for OpenAPI (from .WithDescription()).
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Tags for OpenAPI grouping (from .WithTags()).
/// </summary>
public IReadOnlyList<string> Tags { get; init; } = [];
// === Schema ===
/// <summary>
/// Schema information for request/response validation.
/// </summary>
public EndpointSchemaInfo? SchemaInfo { get; init; }
// === Internal (not serialized to HELLO) ===
/// <summary>
/// Reference to the original ASP.NET RouteEndpoint for dispatch.
/// </summary>
internal RouteEndpoint? OriginalEndpoint { get; init; }
/// <summary>
/// Original route pattern before normalization (for debugging).
/// </summary>
internal string? OriginalRoutePattern { get; init; }
/// <summary>
/// Convert to standard Router EndpointDescriptor for HELLO payload.
/// </summary>
public EndpointDescriptor ToEndpointDescriptor() => new()
{
ServiceName = ServiceName,
Version = Version,
Method = Method,
Path = Path,
DefaultTimeout = DefaultTimeout,
SupportsStreaming = SupportsStreaming,
RequiringClaims = RequiringClaims,
SchemaInfo = SchemaInfo
};
}
/// <summary>
/// Describes a parameter for an endpoint.
/// </summary>
public sealed record ParameterDescriptor
{
/// <summary>
/// Parameter name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Source of the parameter value (route, query, header, body, services).
/// </summary>
public required ParameterSource Source { get; init; }
/// <summary>
/// CLR type of the parameter.
/// </summary>
public required Type Type { get; init; }
/// <summary>
/// Whether the parameter is required.
/// </summary>
public bool IsRequired { get; init; } = true;
/// <summary>
/// Default value if the parameter is optional.
/// </summary>
public object? DefaultValue { get; init; }
/// <summary>
/// Description for documentation.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// JSON Schema type name (for OpenAPI).
/// </summary>
public string? JsonSchemaType { get; init; }
}
/// <summary>
/// Source of a parameter value.
/// </summary>
public enum ParameterSource
{
/// <summary>
/// From route template (e.g., {id} in /api/items/{id}).
/// </summary>
Route,
/// <summary>
/// From query string (e.g., ?page=1).
/// </summary>
Query,
/// <summary>
/// From request header.
/// </summary>
Header,
/// <summary>
/// From request body (JSON deserialization).
/// </summary>
Body,
/// <summary>
/// From dependency injection container.
/// </summary>
Services
}
/// <summary>
/// Describes a response for an endpoint.
/// </summary>
public sealed record ResponseDescriptor
{
/// <summary>
/// HTTP status code.
/// </summary>
public required int StatusCode { get; init; }
/// <summary>
/// CLR type of the response body (null for no content).
/// </summary>
public Type? ResponseType { get; init; }
/// <summary>
/// Description for documentation.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Content type (default: application/json).
/// </summary>
public string ContentType { get; init; } = "application/json";
/// <summary>
/// JSON Schema reference for the response type.
/// </summary>
public string? SchemaRef { get; init; }
}
/// <summary>
/// Result of authorization mapping for an endpoint.
/// </summary>
public sealed record AuthorizationMappingResult
{
/// <summary>
/// Claim requirements for Router authorization.
/// </summary>
public IReadOnlyList<ClaimRequirement> Claims { get; init; } = [];
/// <summary>
/// Named policies found on the endpoint.
/// </summary>
public IReadOnlyList<string> Policies { get; init; } = [];
/// <summary>
/// Roles found on the endpoint.
/// </summary>
public IReadOnlyList<string> Roles { get; init; } = [];
/// <summary>
/// Whether [AllowAnonymous] was found.
/// </summary>
public bool AllowAnonymous { get; init; }
/// <summary>
/// Source of the authorization metadata.
/// </summary>
public AuthorizationSource Source { get; init; }
/// <summary>
/// Whether any authorization metadata was found.
/// </summary>
public bool HasAuthorization =>
AllowAnonymous || Policies.Count > 0 || Roles.Count > 0 || Claims.Count > 0;
}

View File

@@ -0,0 +1,539 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Matching;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Frames;
namespace StellaOps.Microservice.AspNetCore;
/// <summary>
/// Dispatches Router request frames through the ASP.NET Core pipeline.
/// </summary>
public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatcher
{
private readonly IServiceProvider _serviceProvider;
private readonly EndpointDataSource _endpointDataSource;
private readonly StellaRouterBridgeOptions _options;
private readonly ILogger<AspNetRouterRequestDispatcher> _logger;
// Standard StellaOps identity header names
private const string ActorHeader = "X-StellaOps-Actor";
private const string TenantHeader = "X-StellaOps-Tenant";
private const string ScopesHeader = "X-StellaOps-Scopes";
private const string RolesHeader = "X-StellaOps-Roles";
private const string SessionHeader = "X-StellaOps-Session";
// Headers that should not be forwarded to the response
private static readonly HashSet<string> ExcludedResponseHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"Transfer-Encoding",
"Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"TE",
"Trailer",
"Upgrade"
};
public AspNetRouterRequestDispatcher(
IServiceProvider serviceProvider,
EndpointDataSource endpointDataSource,
StellaRouterBridgeOptions options,
ILogger<AspNetRouterRequestDispatcher> logger)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_endpointDataSource = endpointDataSource ?? throw new ArgumentNullException(nameof(endpointDataSource));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<ResponseFrame> DispatchAsync(
RequestFrame request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
_logger.LogDebug(
"Dispatching Router request {RequestId}: {Method} {Path}",
request.RequestId, request.Method, request.Path);
// Create a scoped DI container for this request
await using var scope = _serviceProvider.CreateAsyncScope();
try
{
// Build HttpContext
var httpContext = BuildHttpContext(scope.ServiceProvider, request, cancellationToken);
// Match endpoint
var endpoint = await MatchEndpointAsync(httpContext).ConfigureAwait(false);
if (endpoint is null)
{
_logger.LogDebug(
"No endpoint matched for {Method} {Path}",
request.Method, request.Path);
return CreateNotFoundResponse(request.RequestId);
}
// Set the matched endpoint
httpContext.SetEndpoint(endpoint);
// Execute the endpoint's RequestDelegate
if (endpoint.RequestDelegate is null)
{
_logger.LogError(
"Matched endpoint {DisplayName} has no RequestDelegate",
endpoint.DisplayName);
return CreateErrorResponse(request.RequestId, 500, "Endpoint has no handler");
}
await endpoint.RequestDelegate(httpContext).ConfigureAwait(false);
// Capture the response
return await CaptureResponseAsync(httpContext, request.RequestId).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogDebug(
"Request {RequestId} was cancelled",
request.RequestId);
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error dispatching request {RequestId}: {Method} {Path}",
request.RequestId, request.Method, request.Path);
return CreateExceptionResponse(request.RequestId, ex);
}
}
private DefaultHttpContext BuildHttpContext(
IServiceProvider requestServices,
RequestFrame request,
CancellationToken cancellationToken)
{
var httpContext = new DefaultHttpContext
{
RequestServices = requestServices
};
// Link cancellation tokens
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
httpContext.RequestAborted = linkedCts.Token;
// Set trace identifier
httpContext.TraceIdentifier = request.CorrelationId ?? request.RequestId;
// Parse path and query string
var (path, queryString) = ParsePathAndQuery(request.Path);
// Populate request
var httpRequest = httpContext.Request;
httpRequest.Method = request.Method;
httpRequest.Path = path;
httpRequest.QueryString = queryString;
httpRequest.Scheme = "https"; // Router always uses secure transport conceptually
httpRequest.Host = new HostString(_options.ServiceName);
// Copy headers
foreach (var (key, value) in request.Headers)
{
httpRequest.Headers[key] = value;
}
// Set body
if (!request.Payload.IsEmpty)
{
httpRequest.Body = new MemoryStream(request.Payload.ToArray());
httpRequest.ContentLength = request.Payload.Length;
// Try to set Content-Type from headers
if (request.Headers.TryGetValue("Content-Type", out var contentType))
{
httpRequest.ContentType = contentType;
}
}
// Populate identity from StellaOps headers
PopulateIdentity(httpContext, request.Headers);
// Set up response body capture
httpContext.Response.Body = new MemoryStream();
return httpContext;
}
private static (PathString Path, QueryString Query) ParsePathAndQuery(string fullPath)
{
var queryIndex = fullPath.IndexOf('?');
if (queryIndex < 0)
{
return (new PathString(fullPath), QueryString.Empty);
}
var path = fullPath[..queryIndex];
var query = fullPath[queryIndex..];
return (new PathString(path), new QueryString(query));
}
private void PopulateIdentity(HttpContext httpContext, IReadOnlyDictionary<string, string> headers)
{
var claims = new List<Claim>();
// Actor (subject/user ID)
if (headers.TryGetValue(ActorHeader, out var actor) && !string.IsNullOrEmpty(actor))
{
claims.Add(new Claim(ClaimTypes.NameIdentifier, actor));
claims.Add(new Claim("sub", actor));
}
// Tenant
if (headers.TryGetValue(TenantHeader, out var tenant) && !string.IsNullOrEmpty(tenant))
{
claims.Add(new Claim("tenant", tenant));
}
// Session
if (headers.TryGetValue(SessionHeader, out var session) && !string.IsNullOrEmpty(session))
{
claims.Add(new Claim("session", session));
}
// Scopes (space-separated)
if (headers.TryGetValue(ScopesHeader, out var scopes) && !string.IsNullOrEmpty(scopes))
{
foreach (var scope in scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
claims.Add(new Claim("scope", scope));
}
}
// Roles (space-separated)
if (headers.TryGetValue(RolesHeader, out var roles) && !string.IsNullOrEmpty(roles))
{
foreach (var role in roles.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
}
if (claims.Count > 0)
{
var identity = new ClaimsIdentity(claims, "StellaRouter", ClaimTypes.NameIdentifier, ClaimTypes.Role);
httpContext.User = new ClaimsPrincipal(identity);
}
}
private async Task<RouteEndpoint?> MatchEndpointAsync(HttpContext httpContext)
{
// Use the endpoint selector if available
var selector = httpContext.RequestServices.GetService<EndpointSelector>();
if (selector is not null)
{
// Build candidate set from data source
var endpoints = _endpointDataSource.Endpoints
.OfType<RouteEndpoint>()
.ToArray();
// Simple matching: find endpoint that matches path and method
var (path, _) = ParsePathAndQuery(httpContext.Request.Path.Value ?? "/");
foreach (var endpoint in endpoints)
{
if (IsEndpointMatch(endpoint, httpContext.Request.Method, path.Value ?? "/"))
{
// Populate route values from path
PopulateRouteValues(httpContext, endpoint, path.Value ?? "/");
return endpoint;
}
}
}
return null;
}
private static bool IsEndpointMatch(RouteEndpoint endpoint, string method, string path)
{
// Check HTTP method
var methodMetadata = endpoint.Metadata.GetMetadata<HttpMethodMetadata>();
if (methodMetadata?.HttpMethods is not { } methods)
{
return false;
}
if (!methods.Contains(method, StringComparer.OrdinalIgnoreCase))
{
return false;
}
// Match path pattern
var matcher = new TemplateMatcher(endpoint.RoutePattern);
return matcher.TryMatch(path, out _);
}
private static void PopulateRouteValues(HttpContext httpContext, RouteEndpoint endpoint, string path)
{
var matcher = new TemplateMatcher(endpoint.RoutePattern);
if (matcher.TryMatch(path, out var routeValues))
{
var routeValuesFeature = httpContext.Features.Get<IRouteValuesFeature>()
?? new RouteValuesFeature();
foreach (var (key, value) in routeValues)
{
routeValuesFeature.RouteValues[key] = value;
}
httpContext.Features.Set(routeValuesFeature);
}
}
private async Task<ResponseFrame> CaptureResponseAsync(HttpContext httpContext, string requestId)
{
// Ensure response body is at the beginning
if (httpContext.Response.Body is MemoryStream ms)
{
ms.Position = 0;
var body = ms.ToArray();
// Capture headers
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var header in httpContext.Response.Headers)
{
if (!ExcludedResponseHeaders.Contains(header.Key))
{
headers[header.Key] = header.Value.ToString();
}
}
return new ResponseFrame
{
RequestId = requestId,
StatusCode = httpContext.Response.StatusCode,
Headers = headers,
Payload = body,
HasMoreChunks = false
};
}
return new ResponseFrame
{
RequestId = requestId,
StatusCode = httpContext.Response.StatusCode,
Headers = new Dictionary<string, string>(),
Payload = ReadOnlyMemory<byte>.Empty,
HasMoreChunks = false
};
}
private static ResponseFrame CreateNotFoundResponse(string requestId)
{
var body = JsonSerializer.SerializeToUtf8Bytes(new
{
type = "https://tools.ietf.org/html/rfc7231#section-6.5.4",
title = "Not Found",
status = 404,
detail = "No endpoint matched the request."
});
return new ResponseFrame
{
RequestId = requestId,
StatusCode = 404,
Headers = new Dictionary<string, string>
{
["Content-Type"] = "application/problem+json"
},
Payload = body,
HasMoreChunks = false
};
}
private static ResponseFrame CreateErrorResponse(string requestId, int statusCode, string message)
{
var body = JsonSerializer.SerializeToUtf8Bytes(new
{
type = "https://tools.ietf.org/html/rfc7231#section-6.6.1",
title = "Internal Server Error",
status = statusCode,
detail = message
});
return new ResponseFrame
{
RequestId = requestId,
StatusCode = statusCode,
Headers = new Dictionary<string, string>
{
["Content-Type"] = "application/problem+json"
},
Payload = body,
HasMoreChunks = false
};
}
private static ResponseFrame CreateExceptionResponse(string requestId, Exception ex)
{
var statusCode = ex switch
{
UnauthorizedAccessException => 403,
ArgumentException => 400,
InvalidOperationException => 400,
KeyNotFoundException => 404,
NotImplementedException => 501,
_ => 500
};
var body = JsonSerializer.SerializeToUtf8Bytes(new
{
type = statusCode switch
{
400 => "https://tools.ietf.org/html/rfc7231#section-6.5.1",
403 => "https://tools.ietf.org/html/rfc7231#section-6.5.3",
404 => "https://tools.ietf.org/html/rfc7231#section-6.5.4",
501 => "https://tools.ietf.org/html/rfc7231#section-6.6.2",
_ => "https://tools.ietf.org/html/rfc7231#section-6.6.1"
},
title = statusCode switch
{
400 => "Bad Request",
403 => "Forbidden",
404 => "Not Found",
501 => "Not Implemented",
_ => "Internal Server Error"
},
status = statusCode,
detail = statusCode >= 500
? "An internal error occurred."
: ex.Message
});
return new ResponseFrame
{
RequestId = requestId,
StatusCode = statusCode,
Headers = new Dictionary<string, string>
{
["Content-Type"] = "application/problem+json"
},
Payload = body,
HasMoreChunks = false
};
}
/// <summary>
/// Simple template matcher for route patterns.
/// </summary>
private sealed class TemplateMatcher
{
private readonly RoutePattern _pattern;
private readonly List<(string Segment, bool IsParameter, string? ParameterName)> _segments;
public TemplateMatcher(RoutePattern pattern)
{
_pattern = pattern;
_segments = new List<(string, bool, string?)>();
foreach (var segment in pattern.PathSegments)
{
if (segment.Parts.Count == 1)
{
switch (segment.Parts[0])
{
case RoutePatternLiteralPart literal:
_segments.Add((literal.Content, false, null));
break;
case RoutePatternParameterPart param:
_segments.Add((param.Name, true, param.Name));
break;
}
}
else
{
// Complex segment - treat as literal for simplicity
var combined = string.Concat(segment.Parts.Select(p => p switch
{
RoutePatternLiteralPart lit => lit.Content,
RoutePatternParameterPart par => $"{{{par.Name}}}",
_ => ""
}));
_segments.Add((combined, false, null));
}
}
}
public bool TryMatch(string path, out Dictionary<string, object?> routeValues)
{
routeValues = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
var pathSegments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (pathSegments.Length != _segments.Count)
{
// Check for catch-all
if (_segments.Count > 0 &&
_segments[^1].IsParameter &&
pathSegments.Length >= _segments.Count - 1)
{
// Handle catch-all: remaining path goes into last parameter
for (var i = 0; i < _segments.Count - 1; i++)
{
var (segment, isParam, paramName) = _segments[i];
if (isParam)
{
routeValues[paramName!] = pathSegments[i];
}
else if (!string.Equals(segment, pathSegments[i], StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
var catchAllName = _segments[^1].ParameterName!;
routeValues[catchAllName] = string.Join('/', pathSegments.Skip(_segments.Count - 1));
return true;
}
return false;
}
for (var i = 0; i < _segments.Count; i++)
{
var (segment, isParam, paramName) = _segments[i];
if (isParam)
{
routeValues[paramName!] = pathSegments[i];
}
else if (!string.Equals(segment, pathSegments[i], StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
return true;
}
}
/// <summary>
/// Route values feature implementation.
/// </summary>
private sealed class RouteValuesFeature : IRouteValuesFeature
{
public RouteValueDictionary RouteValues { get; set; } = new();
}
}

View File

@@ -0,0 +1,283 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice.AspNetCore;
/// <summary>
/// Default implementation of IAuthorizationClaimMapper.
/// Extracts authorization metadata from ASP.NET endpoints and maps to Router claims.
/// </summary>
public sealed class DefaultAuthorizationClaimMapper : IAuthorizationClaimMapper
{
private readonly IAuthorizationPolicyProvider _policyProvider;
private readonly ILogger<DefaultAuthorizationClaimMapper> _logger;
public DefaultAuthorizationClaimMapper(
IAuthorizationPolicyProvider policyProvider,
ILogger<DefaultAuthorizationClaimMapper> logger)
{
_policyProvider = policyProvider ?? throw new ArgumentNullException(nameof(policyProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<AuthorizationMappingResult> MapAsync(
RouteEndpoint endpoint,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(endpoint);
var claims = new List<ClaimRequirement>();
var policies = new List<string>();
var roles = new List<string>();
// Check for [AllowAnonymous]
var allowAnonymousMetadata = endpoint.Metadata.GetMetadata<IAllowAnonymous>();
if (allowAnonymousMetadata is not null)
{
return new AuthorizationMappingResult
{
Claims = claims,
Policies = policies,
Roles = roles,
AllowAnonymous = true,
Source = AuthorizationSource.AspNetMetadata
};
}
// Get all IAuthorizeData metadata
var authorizeDataItems = endpoint.Metadata.GetOrderedMetadata<IAuthorizeData>();
foreach (var authData in authorizeDataItems)
{
// Extract roles
if (!string.IsNullOrWhiteSpace(authData.Roles))
{
var roleList = authData.Roles
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var role in roleList)
{
if (!roles.Contains(role, StringComparer.OrdinalIgnoreCase))
{
roles.Add(role);
claims.Add(new ClaimRequirement
{
Type = ClaimTypes.Role,
Value = role
});
}
}
}
// Extract policy
if (!string.IsNullOrWhiteSpace(authData.Policy))
{
var policyName = authData.Policy;
if (!policies.Contains(policyName, StringComparer.OrdinalIgnoreCase))
{
policies.Add(policyName);
// Resolve policy to claims
var policyClaims = await ResolvePolicyClaimsAsync(policyName, cancellationToken)
.ConfigureAwait(false);
foreach (var claim in policyClaims)
{
if (!claims.Any(c => c.Type == claim.Type && c.Value == claim.Value))
{
claims.Add(claim);
}
}
}
}
// Extract authentication schemes (not directly mapped to claims)
// but we note their presence for documentation
if (!string.IsNullOrWhiteSpace(authData.AuthenticationSchemes))
{
_logger.LogDebug(
"Endpoint has authentication schemes: {Schemes}",
authData.AuthenticationSchemes);
}
}
var source = authorizeDataItems.Any()
? AuthorizationSource.AspNetMetadata
: AuthorizationSource.None;
return new AuthorizationMappingResult
{
Claims = claims,
Policies = policies,
Roles = roles,
AllowAnonymous = false,
Source = source
};
}
/// <inheritdoc />
public AuthorizationMappingResult Map(RouteEndpoint endpoint)
{
// Synchronous version - cannot resolve policies, only extracts basic metadata
ArgumentNullException.ThrowIfNull(endpoint);
var claims = new List<ClaimRequirement>();
var policies = new List<string>();
var roles = new List<string>();
// Check for [AllowAnonymous]
var allowAnonymousMetadata = endpoint.Metadata.GetMetadata<IAllowAnonymous>();
if (allowAnonymousMetadata is not null)
{
return new AuthorizationMappingResult
{
Claims = claims,
Policies = policies,
Roles = roles,
AllowAnonymous = true,
Source = AuthorizationSource.AspNetMetadata
};
}
// Get all IAuthorizeData metadata
var authorizeDataItems = endpoint.Metadata.GetOrderedMetadata<IAuthorizeData>();
foreach (var authData in authorizeDataItems)
{
// Extract roles
if (!string.IsNullOrWhiteSpace(authData.Roles))
{
var roleList = authData.Roles
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var role in roleList)
{
if (!roles.Contains(role, StringComparer.OrdinalIgnoreCase))
{
roles.Add(role);
claims.Add(new ClaimRequirement
{
Type = ClaimTypes.Role,
Value = role
});
}
}
}
// Extract policy (cannot resolve without async)
if (!string.IsNullOrWhiteSpace(authData.Policy))
{
if (!policies.Contains(authData.Policy, StringComparer.OrdinalIgnoreCase))
{
policies.Add(authData.Policy);
// Note: Policy claims cannot be resolved synchronously
_logger.LogDebug(
"Policy '{Policy}' found but cannot resolve claims synchronously",
authData.Policy);
}
}
}
var source = authorizeDataItems.Any()
? AuthorizationSource.AspNetMetadata
: AuthorizationSource.None;
return new AuthorizationMappingResult
{
Claims = claims,
Policies = policies,
Roles = roles,
AllowAnonymous = false,
Source = source
};
}
private async Task<IReadOnlyList<ClaimRequirement>> ResolvePolicyClaimsAsync(
string policyName,
CancellationToken cancellationToken)
{
var claims = new List<ClaimRequirement>();
try
{
var policy = await _policyProvider.GetPolicyAsync(policyName).ConfigureAwait(false);
if (policy is null)
{
_logger.LogWarning("Authorization policy '{Policy}' not found", policyName);
return claims;
}
foreach (var requirement in policy.Requirements)
{
switch (requirement)
{
case ClaimsAuthorizationRequirement claimsReq:
if (claimsReq.AllowedValues is { } allowedValues && allowedValues.Any())
{
foreach (var value in allowedValues)
{
claims.Add(new ClaimRequirement
{
Type = claimsReq.ClaimType,
Value = value
});
}
}
else
{
// Claim type required but any value is acceptable
claims.Add(new ClaimRequirement
{
Type = claimsReq.ClaimType,
Value = null
});
}
break;
case RolesAuthorizationRequirement rolesReq:
foreach (var role in rolesReq.AllowedRoles)
{
claims.Add(new ClaimRequirement
{
Type = ClaimTypes.Role,
Value = role
});
}
break;
case NameAuthorizationRequirement nameReq:
if (!string.IsNullOrEmpty(nameReq.RequiredName))
{
claims.Add(new ClaimRequirement
{
Type = ClaimTypes.Name,
Value = nameReq.RequiredName
});
}
break;
default:
// For custom requirements, log a warning
_logger.LogDebug(
"Custom authorization requirement type '{RequirementType}' in policy '{Policy}' cannot be mapped to claims",
requirement.GetType().Name,
policyName);
break;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to resolve authorization policy '{Policy}'",
policyName);
}
return claims;
}
}

View File

@@ -0,0 +1,23 @@
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice.AspNetCore;
/// <summary>
/// Provides ASP.NET endpoint discovery with full metadata extraction.
/// Extends the base IEndpointDiscoveryProvider to include ASP.NET-specific details.
/// </summary>
public interface IAspNetEndpointDiscoveryProvider : IEndpointDiscoveryProvider
{
/// <summary>
/// Discover all ASP.NET endpoints with full metadata.
/// This includes authorization, parameters, responses, and OpenAPI metadata.
/// </summary>
/// <returns>List of discovered endpoints with full ASP.NET metadata.</returns>
IReadOnlyList<AspNetEndpointDescriptor> DiscoverAspNetEndpoints();
/// <summary>
/// Force a refresh of the discovered endpoints.
/// Call this after endpoints are registered (e.g., after MapControllers).
/// </summary>
void RefreshEndpoints();
}

View File

@@ -0,0 +1,19 @@
using StellaOps.Router.Common.Frames;
namespace StellaOps.Microservice.AspNetCore;
/// <summary>
/// Dispatches Router request frames through the ASP.NET Core pipeline.
/// </summary>
public interface IAspNetRouterRequestDispatcher
{
/// <summary>
/// Dispatch a Router request frame through the ASP.NET pipeline.
/// </summary>
/// <param name="request">The incoming Router request frame.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The response frame after ASP.NET processing.</returns>
Task<ResponseFrame> DispatchAsync(
RequestFrame request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Routing;
namespace StellaOps.Microservice.AspNetCore;
/// <summary>
/// Maps ASP.NET authorization metadata to Router claim requirements.
/// </summary>
public interface IAuthorizationClaimMapper
{
/// <summary>
/// Map ASP.NET authorization metadata from a RouteEndpoint to Router claims.
/// </summary>
/// <param name="endpoint">The ASP.NET RouteEndpoint to analyze.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The authorization mapping result with claims, policies, and roles.</returns>
Task<AuthorizationMappingResult> MapAsync(
RouteEndpoint endpoint,
CancellationToken cancellationToken = default);
/// <summary>
/// Synchronous version of MapAsync for scenarios where async is not needed.
/// </summary>
/// <param name="endpoint">The ASP.NET RouteEndpoint to analyze.</param>
/// <returns>The authorization mapping result.</returns>
AuthorizationMappingResult Map(RouteEndpoint endpoint);
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Microservice.AspNetCore</RootNamespace>
<Description>ASP.NET Core endpoint bridge for StellaOps Router SDK. Enables ASP.NET endpoints to be automatically registered and dispatched via the Router.</Description>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Microservice\StellaOps.Microservice.csproj" />
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,198 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
namespace StellaOps.Microservice.AspNetCore;
/// <summary>
/// Extension methods for configuring the StellaOps Router ASP.NET Core bridge.
/// </summary>
public static class StellaRouterBridgeExtensions
{
/// <summary>
/// Adds StellaOps Router bridge services to the service collection.
/// This enables ASP.NET Core endpoints to be automatically registered and dispatched via the Router.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Action to configure the bridge options.</param>
/// <param name="registerMicroserviceServices">
/// If true (default), also registers the base microservice services.
/// Set to false when using AddStellaRouter() which handles this separately.
/// </param>
/// <returns>The service collection for chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown if services or configure is null.</exception>
/// <exception cref="InvalidOperationException">Thrown if required options are not configured.</exception>
public static IServiceCollection AddStellaRouterBridge(
this IServiceCollection services,
Action<StellaRouterBridgeOptions> configure,
bool registerMicroserviceServices = true)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
// Create and validate options
var options = new StellaRouterBridgeOptions
{
ServiceName = "",
Version = "",
Region = ""
};
configure(options);
ValidateOptions(options);
// Generate instance ID if not provided
options.InstanceId ??= $"{options.ServiceName}-{Guid.NewGuid():N}"[..36];
// Register options as singleton
services.AddSingleton(options);
// Register authorization claim mapper
services.TryAddSingleton<IAuthorizationClaimMapper, DefaultAuthorizationClaimMapper>();
// Register endpoint discovery provider
services.TryAddSingleton<IAspNetEndpointDiscoveryProvider, AspNetCoreEndpointDiscoveryProvider>();
// Register as base interface for Router SDK integration
services.TryAddSingleton<IEndpointDiscoveryProvider>(sp =>
sp.GetRequiredService<IAspNetEndpointDiscoveryProvider>());
// Register request dispatcher
services.TryAddSingleton<IAspNetRouterRequestDispatcher, AspNetRouterRequestDispatcher>();
// Wire into Router SDK by adding microservice services (unless disabled)
if (registerMicroserviceServices)
{
services.AddStellaMicroservice(microserviceOptions =>
{
microserviceOptions.ServiceName = options.ServiceName;
microserviceOptions.Version = options.Version;
microserviceOptions.Region = options.Region;
microserviceOptions.InstanceId = options.InstanceId;
});
}
return services;
}
/// <summary>
/// Adds StellaOps Router bridge services with a custom authorization claim mapper.
/// </summary>
/// <typeparam name="TMapper">The custom authorization claim mapper type.</typeparam>
/// <param name="services">The service collection.</param>
/// <param name="configure">Action to configure the bridge options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddStellaRouterBridge<TMapper>(
this IServiceCollection services,
Action<StellaRouterBridgeOptions> configure)
where TMapper : class, IAuthorizationClaimMapper
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
// Register custom mapper first
services.AddSingleton<IAuthorizationClaimMapper, TMapper>();
// Then add the rest
return services.AddStellaRouterBridge(configure);
}
/// <summary>
/// Enables the StellaOps Router bridge middleware.
/// This should be called after UseRouting() and before endpoint registration.
/// </summary>
/// <param name="app">The application builder.</param>
/// <returns>The application builder for chaining.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown if called before UseRouting() or if bridge services are not configured.
/// </exception>
public static IApplicationBuilder UseStellaRouterBridge(this IApplicationBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
// Validate that routing is configured
var endpointDataSource = app.ApplicationServices.GetService<EndpointDataSource>();
if (endpointDataSource is null)
{
throw new InvalidOperationException(
"UseStellaRouterBridge() must be called after UseRouting(). " +
"No EndpointDataSource was found in the application services.");
}
// Validate that bridge services are configured
var options = app.ApplicationServices.GetService<StellaRouterBridgeOptions>();
if (options is null)
{
throw new InvalidOperationException(
"UseStellaRouterBridge() requires AddStellaRouterBridge() to be called first. " +
"No StellaRouterBridgeOptions was found in the application services.");
}
var logger = app.ApplicationServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("StellaOps.Microservice.AspNetCore");
logger.LogInformation(
"StellaOps Router bridge enabled for service {ServiceName} v{Version} in region {Region}",
options.ServiceName, options.Version, options.Region);
// The actual dispatch is handled by the Router SDK's hosted service
// This middleware registration is for validation and logging
return app;
}
/// <summary>
/// Refreshes the endpoint discovery cache.
/// Call this after all endpoints are registered (e.g., after MapControllers).
/// </summary>
/// <param name="app">The application builder.</param>
/// <returns>The application builder for chaining.</returns>
public static IApplicationBuilder RefreshStellaRouterEndpoints(this IApplicationBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
var discoveryProvider = app.ApplicationServices.GetService<IAspNetEndpointDiscoveryProvider>();
discoveryProvider?.RefreshEndpoints();
return app;
}
private static void ValidateOptions(StellaRouterBridgeOptions options)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(options.ServiceName))
{
errors.Add("ServiceName is required");
}
if (string.IsNullOrWhiteSpace(options.Version))
{
errors.Add("Version is required");
}
if (string.IsNullOrWhiteSpace(options.Region))
{
errors.Add("Region is required");
}
if (options.DefaultTimeout <= TimeSpan.Zero)
{
errors.Add("DefaultTimeout must be positive");
}
if (options.DefaultTimeout > TimeSpan.FromMinutes(10))
{
errors.Add("DefaultTimeout cannot exceed 10 minutes");
}
if (errors.Count > 0)
{
throw new InvalidOperationException(
$"Invalid StellaRouterBridgeOptions: {string.Join("; ", errors)}");
}
}
}

View File

@@ -0,0 +1,207 @@
using Microsoft.AspNetCore.Routing;
namespace StellaOps.Microservice.AspNetCore;
/// <summary>
/// Configuration options for the ASP.NET Core Router bridge.
/// </summary>
public sealed class StellaRouterBridgeOptions
{
/// <summary>
/// Service name for Router registration. Required.
/// </summary>
public required string ServiceName { get; set; }
/// <summary>
/// Service version (semver). Required.
/// </summary>
public required string Version { get; set; }
/// <summary>
/// Deployment region. Required.
/// </summary>
public required string Region { get; set; }
/// <summary>
/// Unique instance identifier. Auto-generated if not set.
/// </summary>
public string? InstanceId { get; set; }
/// <summary>
/// Service description for documentation.
/// </summary>
public string? ServiceDescription { get; set; }
/// <summary>
/// Strategy for mapping ASP.NET authorization to Router claims.
/// Default: Hybrid (ASP.NET metadata + YAML overrides, YAML wins on conflict).
/// </summary>
public AuthorizationMappingStrategy AuthorizationMapping { get; set; }
= AuthorizationMappingStrategy.Hybrid;
/// <summary>
/// Path to microservice.yaml for endpoint overrides. Optional.
/// </summary>
public string? YamlConfigPath { get; set; }
/// <summary>
/// Extract JSON schemas from Produces/Accepts metadata.
/// Default: true.
/// </summary>
public bool ExtractSchemas { get; set; } = true;
/// <summary>
/// Extract OpenAPI metadata (summary, description, tags).
/// Default: true.
/// </summary>
public bool ExtractOpenApiMetadata { get; set; } = true;
/// <summary>
/// Behavior when endpoint has no authorization metadata and no YAML override.
/// Default: RequireExplicit (fail discovery if no auth metadata).
/// </summary>
public MissingAuthorizationBehavior OnMissingAuthorization { get; set; }
= MissingAuthorizationBehavior.RequireExplicit;
/// <summary>
/// Behavior for unsupported route constraints.
/// Default: WarnAndStrip (log warning, strip constraint, continue).
/// </summary>
public UnsupportedConstraintBehavior OnUnsupportedConstraint { get; set; }
= UnsupportedConstraintBehavior.WarnAndStrip;
/// <summary>
/// Endpoint path filter. Only endpoints matching this predicate are bridged.
/// Default: all endpoints (null).
/// </summary>
public Func<RouteEndpoint, bool>? EndpointFilter { get; set; }
/// <summary>
/// Default timeout for bridged endpoints (overridable per-endpoint via YAML).
/// Default: 30 seconds.
/// </summary>
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Enable streaming support for endpoints marked as streaming-capable.
/// Default: false (all responses are buffered).
/// </summary>
public bool EnableStreaming { get; set; }
/// <summary>
/// List of path prefixes to exclude from bridging.
/// Default: health and metrics endpoints.
/// </summary>
public IList<string> ExcludedPathPrefixes { get; set; } = new List<string>
{
"/health",
"/metrics",
"/swagger",
"/openapi"
};
/// <summary>
/// Whether to include excluded paths in the Router registration.
/// When false (default), excluded endpoints are not registered with Router.
/// </summary>
public bool IncludeExcludedPathsInRouter { get; set; }
}
/// <summary>
/// Strategy for mapping ASP.NET authorization metadata to Router claim requirements.
/// </summary>
public enum AuthorizationMappingStrategy
{
/// <summary>
/// Use only YAML overrides for RequiringClaims. ASP.NET metadata is ignored.
/// Use this when you want full control via configuration.
/// </summary>
YamlOnly,
/// <summary>
/// Extract RequiringClaims from ASP.NET authorization metadata only.
/// YAML overrides are ignored for claims (but still used for timeout, streaming).
/// </summary>
AspNetMetadataOnly,
/// <summary>
/// Merge ASP.NET metadata with YAML overrides.
/// When both specify claims for the same endpoint, YAML takes precedence.
/// This is the recommended default for gradual migration.
/// </summary>
Hybrid
}
/// <summary>
/// Behavior when an endpoint has no authorization metadata.
/// </summary>
public enum MissingAuthorizationBehavior
{
/// <summary>
/// Fail discovery if endpoint has no authorization and no YAML override.
/// This is the safest option to prevent accidental exposure of privileged endpoints.
/// </summary>
RequireExplicit,
/// <summary>
/// Allow endpoint with empty RequiringClaims, meaning only authentication is required.
/// Any authenticated user can access the endpoint.
/// </summary>
AllowAuthenticated,
/// <summary>
/// Log a warning but allow endpoint with empty RequiringClaims.
/// Useful during migration when some endpoints may not have authorization yet.
/// </summary>
WarnAndAllow
}
/// <summary>
/// Behavior for unsupported route constraints.
/// </summary>
public enum UnsupportedConstraintBehavior
{
/// <summary>
/// Fail discovery if route has an unsupported constraint.
/// The constraint cannot be enforced by the Router.
/// </summary>
Fail,
/// <summary>
/// Log a warning, strip the constraint from the normalized path, and continue.
/// The ASP.NET matcher will still enforce the constraint during dispatch.
/// </summary>
WarnAndStrip,
/// <summary>
/// Silently strip the constraint without warning.
/// Use only when you understand the implications.
/// </summary>
SilentStrip
}
/// <summary>
/// Source of authorization metadata for an endpoint.
/// </summary>
public enum AuthorizationSource
{
/// <summary>
/// No authorization metadata found.
/// </summary>
None,
/// <summary>
/// Authorization derived from ASP.NET metadata ([Authorize], .RequireAuthorization()).
/// </summary>
AspNetMetadata,
/// <summary>
/// Authorization specified in YAML override file.
/// </summary>
YamlOverride,
/// <summary>
/// Authorization from both sources, merged according to strategy.
/// </summary>
Hybrid
}

View File

@@ -0,0 +1,26 @@
using StellaOps.Router.Common.Frames;
namespace StellaOps.Microservice;
/// <summary>
/// Abstraction for dispatching Router request frames to handlers.
/// </summary>
public interface IRequestDispatcher
{
/// <summary>
/// Dispatches a REQUEST frame and returns a RESPONSE frame.
/// </summary>
/// <param name="request">The incoming REQUEST frame.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The RESPONSE frame.</returns>
Task<ResponseFrame> DispatchAsync(RequestFrame request, CancellationToken cancellationToken);
/// <summary>
/// Checks if this dispatcher can handle the given request.
/// Used by composite dispatchers to route to the appropriate handler.
/// </summary>
/// <param name="method">HTTP method.</param>
/// <param name="path">Request path (without query string).</param>
/// <returns>True if this dispatcher can handle the request.</returns>
bool CanHandle(string method, string path);
}

View File

@@ -11,8 +11,9 @@ namespace StellaOps.Microservice;
/// <summary>
/// Dispatches incoming REQUEST frames to the appropriate endpoint handlers.
/// This is the default dispatcher for [StellaEndpoint] attributed handlers.
/// </summary>
public sealed class RequestDispatcher
public sealed class RequestDispatcher : IRequestDispatcher
{
private readonly IEndpointRegistry _registry;
private readonly IServiceProvider _serviceProvider;
@@ -50,12 +51,14 @@ public sealed class RequestDispatcher
};
}
/// <summary>
/// Dispatches a REQUEST frame and returns a RESPONSE frame.
/// </summary>
/// <param name="request">The incoming REQUEST frame.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The RESPONSE frame.</returns>
/// <inheritdoc />
public bool CanHandle(string method, string path)
{
var (cleanPath, _) = SplitPathAndQuery(path);
return _registry.TryMatch(method, cleanPath, out _);
}
/// <inheritdoc />
public async Task<ResponseFrame> DispatchAsync(RequestFrame request, CancellationToken cancellationToken)
{
_logger.LogDebug(

View File

@@ -16,7 +16,7 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
{
private readonly StellaMicroserviceOptions _options;
private readonly IEndpointDiscoveryProvider _endpointDiscovery;
private readonly RequestDispatcher _requestDispatcher;
private readonly IRequestDispatcher _requestDispatcher;
private readonly IMicroserviceTransport? _microserviceTransport;
private readonly IGeneratedEndpointProvider? _generatedProvider;
private readonly ILogger<RouterConnectionManager> _logger;
@@ -40,7 +40,7 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
public RouterConnectionManager(
IOptions<StellaMicroserviceOptions> options,
IEndpointDiscoveryProvider endpointDiscovery,
RequestDispatcher requestDispatcher,
IRequestDispatcher requestDispatcher,
IMicroserviceTransport? microserviceTransport,
ILogger<RouterConnectionManager> logger,
IGeneratedEndpointProvider? generatedProvider = null)

View File

@@ -50,6 +50,7 @@ public static class ServiceCollectionExtensions
// Register request dispatcher
services.TryAddSingleton<RequestDispatcher>();
services.TryAddSingleton<IRequestDispatcher>(sp => sp.GetRequiredService<RequestDispatcher>());
// Register schema validation services
RegisterSchemaValidationServices(services);
@@ -105,6 +106,7 @@ public static class ServiceCollectionExtensions
// Register request dispatcher
services.TryAddSingleton<RequestDispatcher>();
services.TryAddSingleton<IRequestDispatcher>(sp => sp.GetRequiredService<RequestDispatcher>());
// Register schema validation services
RegisterSchemaValidationServices(services);

View File

@@ -0,0 +1,285 @@
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
using StellaOps.Microservice.AspNetCore;
using StellaOps.Router.Common.Frames;
namespace StellaOps.Router.AspNet;
/// <summary>
/// Composite request dispatcher that routes requests to either ASP.NET endpoints
/// or Stella native endpoints, with ASP.NET taking priority.
/// </summary>
public sealed class CompositeRequestDispatcher : IRequestDispatcher
{
private readonly IAspNetRouterRequestDispatcher? _aspNetDispatcher;
private readonly RequestDispatcher? _stellaDispatcher;
private readonly IAspNetEndpointDiscoveryProvider? _aspNetDiscovery;
private readonly ILogger<CompositeRequestDispatcher> _logger;
private readonly DispatchStrategy _strategy;
// Cache of ASP.NET endpoint paths for quick lookup
private HashSet<string>? _aspNetEndpointPaths;
private readonly object _cacheLock = new();
public CompositeRequestDispatcher(
ILogger<CompositeRequestDispatcher> logger,
IAspNetRouterRequestDispatcher? aspNetDispatcher = null,
RequestDispatcher? stellaDispatcher = null,
IAspNetEndpointDiscoveryProvider? aspNetDiscovery = null,
DispatchStrategy strategy = DispatchStrategy.AspNetFirst)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_aspNetDispatcher = aspNetDispatcher;
_stellaDispatcher = stellaDispatcher;
_aspNetDiscovery = aspNetDiscovery;
_strategy = strategy;
if (_aspNetDispatcher is null && _stellaDispatcher is null)
{
throw new ArgumentException(
"At least one dispatcher (ASP.NET or Stella) must be provided");
}
}
/// <inheritdoc />
public bool CanHandle(string method, string path)
{
// Check ASP.NET endpoints first (if available)
if (_aspNetDiscovery is not null)
{
EnsureEndpointCachePopulated();
var key = $"{method.ToUpperInvariant()}:{NormalizePath(path)}";
if (_aspNetEndpointPaths?.Contains(key) == true)
{
return true;
}
}
// Check Stella endpoints
if (_stellaDispatcher?.CanHandle(method, path) == true)
{
return true;
}
return false;
}
/// <inheritdoc />
public async Task<ResponseFrame> DispatchAsync(
RequestFrame request,
CancellationToken cancellationToken)
{
var path = ExtractPath(request.Path);
_logger.LogDebug(
"CompositeDispatcher routing {Method} {Path}",
request.Method, path);
switch (_strategy)
{
case DispatchStrategy.AspNetFirst:
return await DispatchAspNetFirstAsync(request, path, cancellationToken);
case DispatchStrategy.StellaFirst:
return await DispatchStellaFirstAsync(request, path, cancellationToken);
case DispatchStrategy.AspNetOnly:
return await DispatchAspNetOnlyAsync(request, cancellationToken);
case DispatchStrategy.StellaOnly:
return await DispatchStellaOnlyAsync(request, cancellationToken);
default:
throw new InvalidOperationException($"Unknown dispatch strategy: {_strategy}");
}
}
private async Task<ResponseFrame> DispatchAspNetFirstAsync(
RequestFrame request,
string path,
CancellationToken cancellationToken)
{
// Try ASP.NET first
if (_aspNetDispatcher is not null && IsAspNetEndpoint(request.Method, path))
{
_logger.LogDebug(
"Dispatching {Method} {Path} to ASP.NET pipeline",
request.Method, path);
return await _aspNetDispatcher.DispatchAsync(request, cancellationToken);
}
// Fall back to Stella
if (_stellaDispatcher is not null)
{
_logger.LogDebug(
"Dispatching {Method} {Path} to Stella endpoint handler",
request.Method, path);
return await _stellaDispatcher.DispatchAsync(request, cancellationToken);
}
return CreateNotFoundResponse(request.RequestId);
}
private async Task<ResponseFrame> DispatchStellaFirstAsync(
RequestFrame request,
string path,
CancellationToken cancellationToken)
{
// Try Stella first
if (_stellaDispatcher?.CanHandle(request.Method, path) == true)
{
_logger.LogDebug(
"Dispatching {Method} {Path} to Stella endpoint handler",
request.Method, path);
return await _stellaDispatcher.DispatchAsync(request, cancellationToken);
}
// Fall back to ASP.NET
if (_aspNetDispatcher is not null)
{
_logger.LogDebug(
"Dispatching {Method} {Path} to ASP.NET pipeline",
request.Method, path);
return await _aspNetDispatcher.DispatchAsync(request, cancellationToken);
}
return CreateNotFoundResponse(request.RequestId);
}
private async Task<ResponseFrame> DispatchAspNetOnlyAsync(
RequestFrame request,
CancellationToken cancellationToken)
{
if (_aspNetDispatcher is null)
{
throw new InvalidOperationException(
"ASP.NET dispatcher not configured but AspNetOnly strategy selected");
}
return await _aspNetDispatcher.DispatchAsync(request, cancellationToken);
}
private async Task<ResponseFrame> DispatchStellaOnlyAsync(
RequestFrame request,
CancellationToken cancellationToken)
{
if (_stellaDispatcher is null)
{
throw new InvalidOperationException(
"Stella dispatcher not configured but StellaOnly strategy selected");
}
return await _stellaDispatcher.DispatchAsync(request, cancellationToken);
}
private bool IsAspNetEndpoint(string method, string path)
{
if (_aspNetDiscovery is null)
{
return false;
}
EnsureEndpointCachePopulated();
var key = $"{method.ToUpperInvariant()}:{NormalizePath(path)}";
return _aspNetEndpointPaths?.Contains(key) == true;
}
private void EnsureEndpointCachePopulated()
{
if (_aspNetEndpointPaths is not null)
{
return;
}
lock (_cacheLock)
{
if (_aspNetEndpointPaths is not null)
{
return;
}
var endpoints = _aspNetDiscovery!.DiscoverAspNetEndpoints();
_aspNetEndpointPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var endpoint in endpoints)
{
var key = $"{endpoint.Method.ToUpperInvariant()}:{endpoint.Path}";
_aspNetEndpointPaths.Add(key);
}
_logger.LogInformation(
"CompositeDispatcher cached {Count} ASP.NET endpoint paths",
_aspNetEndpointPaths.Count);
}
}
private static string ExtractPath(string fullPath)
{
var queryIndex = fullPath.IndexOf('?');
return queryIndex < 0 ? fullPath : fullPath[..queryIndex];
}
private static string NormalizePath(string path)
{
var cleaned = ExtractPath(path);
if (!cleaned.StartsWith('/'))
{
cleaned = "/" + cleaned;
}
return cleaned.TrimEnd('/');
}
private static ResponseFrame CreateNotFoundResponse(string requestId)
{
return new ResponseFrame
{
RequestId = requestId,
StatusCode = 404,
Headers = new Dictionary<string, string>
{
["Content-Type"] = "application/problem+json"
},
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(new
{
type = "https://tools.ietf.org/html/rfc7231#section-6.5.4",
title = "Not Found",
status = 404,
detail = "No endpoint matched the request."
})
};
}
}
/// <summary>
/// Strategy for dispatching requests when both ASP.NET and Stella endpoints are available.
/// </summary>
public enum DispatchStrategy
{
/// <summary>
/// Try ASP.NET endpoints first, fall back to Stella endpoints.
/// This is the recommended default for services migrating from Stella to ASP.NET.
/// </summary>
AspNetFirst,
/// <summary>
/// Try Stella endpoints first, fall back to ASP.NET endpoints.
/// Use this when Stella endpoints should take precedence.
/// </summary>
StellaFirst,
/// <summary>
/// Only use ASP.NET endpoints. Stella endpoints are ignored.
/// Use this for pure ASP.NET services.
/// </summary>
AspNetOnly,
/// <summary>
/// Only use Stella endpoints. ASP.NET endpoints are ignored.
/// Use this for pure Stella services.
/// </summary>
StellaOnly
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Router.AspNet</RootNamespace>
<Description>Integration layer connecting ASP.NET Core WebServices to the StellaOps Router. Provides composite dispatching, automatic endpoint discovery, and seamless Program.cs integration.</Description>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Microservice\StellaOps.Microservice.csproj" />
<ProjectReference Include="..\StellaOps.Microservice.AspNetCore\StellaOps.Microservice.AspNetCore.csproj" />
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,232 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice;
using StellaOps.Microservice.AspNetCore;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.AspNet;
/// <summary>
/// Extension methods for integrating Stella Router with ASP.NET Core services.
/// </summary>
public static class StellaRouterExtensions
{
/// <summary>
/// Adds Stella Router services to an ASP.NET Core application.
/// This integrates ASP.NET endpoints with the Router for automatic discovery and dispatch.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Action to configure the router options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddStellaRouter(
this IServiceCollection services,
Action<StellaRouterOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
var options = new StellaRouterOptions
{
ServiceName = "",
Version = "",
Region = ""
};
configure(options);
ValidateOptions(options);
// Generate instance ID if not provided
options.InstanceId ??= $"{options.ServiceName}-{Guid.NewGuid():N}"[..36];
// Register options
services.AddSingleton(options);
// Configure ASP.NET bridge if enabled
if (options.EnableAspNetBridge)
{
// Register bridge services, but don't auto-register microservice services
// (we'll handle that below with proper configuration)
services.AddStellaRouterBridge(bridgeOptions =>
{
bridgeOptions.ServiceName = options.ServiceName;
bridgeOptions.Version = options.Version;
bridgeOptions.Region = options.Region;
bridgeOptions.InstanceId = options.InstanceId;
bridgeOptions.ServiceDescription = options.ServiceDescription;
bridgeOptions.AuthorizationMapping = options.AuthorizationMapping;
bridgeOptions.YamlConfigPath = options.YamlConfigPath;
bridgeOptions.OnMissingAuthorization = options.OnMissingAuthorization;
bridgeOptions.DefaultTimeout = options.DefaultTimeout;
bridgeOptions.ExcludedPathPrefixes.Clear();
foreach (var prefix in options.ExcludedPathPrefixes)
{
bridgeOptions.ExcludedPathPrefixes.Add(prefix);
}
}, registerMicroserviceServices: false);
}
// Always add microservice services (handles both ASP.NET and Stella endpoints)
services.AddStellaMicroservice(microserviceOptions =>
{
microserviceOptions.ServiceName = options.ServiceName;
microserviceOptions.Version = options.Version;
microserviceOptions.Region = options.Region;
microserviceOptions.InstanceId = options.InstanceId;
microserviceOptions.ServiceDescription = options.ServiceDescription;
microserviceOptions.HeartbeatInterval = options.HeartbeatInterval;
microserviceOptions.ReconnectBackoffInitial = options.ReconnectBackoffInitial;
microserviceOptions.ReconnectBackoffMax = options.ReconnectBackoffMax;
// Add gateway configurations
foreach (var gateway in options.Gateways)
{
microserviceOptions.Routers.Add(new RouterEndpointConfig
{
Host = gateway.Host,
Port = gateway.Port,
TransportType = gateway.TransportType
});
}
});
// Register composite dispatcher if both are enabled
if (options.EnableAspNetBridge && options.EnableStellaEndpoints)
{
// Replace the default dispatcher with composite
services.RemoveAll<IRequestDispatcher>();
services.AddSingleton<IRequestDispatcher>(sp =>
{
var logger = sp.GetRequiredService<ILogger<CompositeRequestDispatcher>>();
var aspNetDispatcher = sp.GetService<IAspNetRouterRequestDispatcher>();
var stellaDispatcher = sp.GetService<RequestDispatcher>();
var aspNetDiscovery = sp.GetService<IAspNetEndpointDiscoveryProvider>();
return new CompositeRequestDispatcher(
logger,
aspNetDispatcher,
stellaDispatcher,
aspNetDiscovery,
options.DispatchStrategy);
});
}
else if (options.EnableAspNetBridge)
{
// ASP.NET only - wire AspNet dispatcher as IRequestDispatcher
services.RemoveAll<IRequestDispatcher>();
services.AddSingleton<IRequestDispatcher>(sp =>
{
var logger = sp.GetRequiredService<ILogger<CompositeRequestDispatcher>>();
var aspNetDispatcher = sp.GetRequiredService<IAspNetRouterRequestDispatcher>();
var aspNetDiscovery = sp.GetService<IAspNetEndpointDiscoveryProvider>();
return new CompositeRequestDispatcher(
logger,
aspNetDispatcher,
stellaDispatcher: null,
aspNetDiscovery,
DispatchStrategy.AspNetOnly);
});
}
// If only Stella endpoints, the default registration is sufficient
return services;
}
/// <summary>
/// Enables the Stella Router middleware.
/// This should be called after UseRouting() and UseAuthorization().
/// </summary>
/// <param name="app">The application builder.</param>
/// <returns>The application builder for chaining.</returns>
public static IApplicationBuilder UseStellaRouter(this IApplicationBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
var options = app.ApplicationServices.GetService<StellaRouterOptions>();
if (options is null)
{
throw new InvalidOperationException(
"UseStellaRouter() requires AddStellaRouter() to be called first.");
}
// Enable the ASP.NET bridge if configured
if (options.EnableAspNetBridge)
{
app.UseStellaRouterBridge();
}
var logger = app.ApplicationServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("StellaOps.Router.AspNet");
logger.LogInformation(
"Stella Router enabled for {ServiceName} v{Version} (ASP.NET: {AspNet}, Stella: {Stella}, Strategy: {Strategy})",
options.ServiceName,
options.Version,
options.EnableAspNetBridge,
options.EnableStellaEndpoints,
options.DispatchStrategy);
return app;
}
/// <summary>
/// Refreshes the Router endpoint discovery cache.
/// Call this after all endpoints are registered.
/// </summary>
/// <param name="app">The application builder.</param>
/// <returns>The application builder for chaining.</returns>
public static IApplicationBuilder RefreshStellaRouterEndpoints(this IApplicationBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
var discoveryProvider = app.ApplicationServices.GetService<IAspNetEndpointDiscoveryProvider>();
discoveryProvider?.RefreshEndpoints();
return app;
}
private static void ValidateOptions(StellaRouterOptions options)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(options.ServiceName))
{
errors.Add("ServiceName is required");
}
if (string.IsNullOrWhiteSpace(options.Version))
{
errors.Add("Version is required");
}
if (string.IsNullOrWhiteSpace(options.Region))
{
errors.Add("Region is required");
}
if (!options.EnableAspNetBridge && !options.EnableStellaEndpoints)
{
errors.Add("At least one of EnableAspNetBridge or EnableStellaEndpoints must be true");
}
if (options.DefaultTimeout <= TimeSpan.Zero)
{
errors.Add("DefaultTimeout must be positive");
}
if (options.HeartbeatInterval <= TimeSpan.Zero)
{
errors.Add("HeartbeatInterval must be positive");
}
if (errors.Count > 0)
{
throw new InvalidOperationException(
$"Invalid StellaRouterOptions: {string.Join("; ", errors)}");
}
}
}

View File

@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Router.Common.Enums;
namespace StellaOps.Router.AspNet;
/// <summary>
/// Helper methods for integrating Stella Router with WebServices.
/// Provides a simplified API for common integration patterns.
/// </summary>
public static class StellaRouterIntegrationHelper
{
/// <summary>
/// Conditionally adds Stella Router services if Router is enabled in options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="serviceName">The service name for Router registration.</param>
/// <param name="version">The service version.</param>
/// <param name="routerOptions">The router options (can be null if disabled).</param>
/// <returns>True if Router was enabled and configured, false otherwise.</returns>
public static bool TryAddStellaRouter(
this IServiceCollection services,
string serviceName,
string version,
StellaRouterOptionsBase? routerOptions)
{
if (routerOptions?.Enabled != true)
{
return false;
}
services.AddStellaRouter(opts =>
{
opts.ServiceName = serviceName;
opts.Version = version;
opts.Region = routerOptions.Region;
opts.EnableAspNetBridge = true;
opts.EnableStellaEndpoints = false;
opts.DefaultTimeout = TimeSpan.FromSeconds(routerOptions.DefaultTimeoutSeconds);
opts.HeartbeatInterval = TimeSpan.FromSeconds(routerOptions.HeartbeatIntervalSeconds);
foreach (var gateway in routerOptions.Gateways)
{
opts.Gateways.Add(new RouterGatewayConfig
{
Host = gateway.Host,
Port = gateway.Port,
TransportType = gateway.TransportType,
UseTls = gateway.UseTls,
CertificatePath = gateway.CertificatePath
});
}
});
return true;
}
/// <summary>
/// Conditionally enables Stella Router middleware if Router is enabled in options.
/// Call after UseAuthorization().
/// </summary>
/// <param name="app">The application builder.</param>
/// <param name="routerOptions">The router options (can be null if disabled).</param>
/// <returns>The application builder for chaining.</returns>
public static IApplicationBuilder TryUseStellaRouter(
this IApplicationBuilder app,
StellaRouterOptionsBase? routerOptions)
{
if (routerOptions?.Enabled == true)
{
app.UseStellaRouter();
}
return app;
}
/// <summary>
/// Conditionally refreshes Stella Router endpoint cache if Router is enabled.
/// Call after all endpoints are mapped.
/// </summary>
/// <param name="app">The application builder.</param>
/// <param name="routerOptions">The router options (can be null if disabled).</param>
/// <returns>The application builder for chaining.</returns>
public static IApplicationBuilder TryRefreshStellaRouterEndpoints(
this IApplicationBuilder app,
StellaRouterOptionsBase? routerOptions)
{
if (routerOptions?.Enabled == true)
{
app.RefreshStellaRouterEndpoints();
}
return app;
}
}

View File

@@ -0,0 +1,146 @@
using StellaOps.Microservice.AspNetCore;
using StellaOps.Router.Common.Enums;
namespace StellaOps.Router.AspNet;
/// <summary>
/// Options for configuring the Stella Router integration with ASP.NET Core.
/// </summary>
public sealed class StellaRouterOptions
{
/// <summary>
/// Service name for Router registration. Required.
/// </summary>
public required string ServiceName { get; set; }
/// <summary>
/// Service version (semver). Required.
/// </summary>
public required string Version { get; set; }
/// <summary>
/// Deployment region. Required.
/// </summary>
public required string Region { get; set; }
/// <summary>
/// Unique instance identifier. Auto-generated if not set.
/// </summary>
public string? InstanceId { get; set; }
/// <summary>
/// Service description for documentation/OpenAPI.
/// </summary>
public string? ServiceDescription { get; set; }
/// <summary>
/// Enable the ASP.NET endpoint bridge. When true, ASP.NET endpoints
/// (MapGet, MapPost, controllers, etc.) are automatically registered with the Router.
/// Default: true.
/// </summary>
public bool EnableAspNetBridge { get; set; } = true;
/// <summary>
/// Enable Stella native endpoint discovery. When true, [StellaEndpoint] handlers
/// are discovered and registered.
/// Default: true.
/// </summary>
public bool EnableStellaEndpoints { get; set; } = true;
/// <summary>
/// Dispatch strategy when both ASP.NET and Stella endpoints are enabled.
/// Default: AspNetFirst.
/// </summary>
public DispatchStrategy DispatchStrategy { get; set; } = DispatchStrategy.AspNetFirst;
/// <summary>
/// Strategy for mapping ASP.NET authorization to Router claims.
/// Default: Hybrid (ASP.NET metadata + YAML overrides).
/// </summary>
public AuthorizationMappingStrategy AuthorizationMapping { get; set; }
= AuthorizationMappingStrategy.Hybrid;
/// <summary>
/// Path to microservice.yaml for endpoint overrides. Optional.
/// </summary>
public string? YamlConfigPath { get; set; }
/// <summary>
/// Behavior when endpoint has no authorization metadata.
/// Default: RequireExplicit (fail if no auth metadata).
/// </summary>
public MissingAuthorizationBehavior OnMissingAuthorization { get; set; }
= MissingAuthorizationBehavior.RequireExplicit;
/// <summary>
/// Default timeout for endpoints.
/// Default: 30 seconds.
/// </summary>
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Heartbeat interval for health reporting.
/// Default: 10 seconds.
/// </summary>
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Initial reconnect backoff delay.
/// Default: 1 second.
/// </summary>
public TimeSpan ReconnectBackoffInitial { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Maximum reconnect backoff delay.
/// Default: 30 seconds.
/// </summary>
public TimeSpan ReconnectBackoffMax { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// List of path prefixes to exclude from Router registration.
/// Default: health, metrics, swagger, openapi.
/// </summary>
public IList<string> ExcludedPathPrefixes { get; set; } = new List<string>
{
"/health",
"/metrics",
"/swagger",
"/openapi"
};
/// <summary>
/// Router gateway endpoints to connect to.
/// </summary>
public IList<RouterGatewayConfig> Gateways { get; set; } = new List<RouterGatewayConfig>();
}
/// <summary>
/// Configuration for a Router gateway connection.
/// </summary>
public sealed class RouterGatewayConfig
{
/// <summary>
/// Gateway host.
/// </summary>
public string Host { get; set; } = "localhost";
/// <summary>
/// Gateway port.
/// </summary>
public int Port { get; set; } = 9100;
/// <summary>
/// Transport type (InMemory, Tcp, Tls, Messaging).
/// </summary>
public TransportType TransportType { get; set; } = TransportType.InMemory;
/// <summary>
/// Enable TLS for TCP transport.
/// </summary>
public bool UseTls { get; set; }
/// <summary>
/// TLS certificate path (if UseTls is true).
/// </summary>
public string? CertificatePath { get; set; }
}

View File

@@ -0,0 +1,84 @@
using StellaOps.Router.Common.Enums;
namespace StellaOps.Router.AspNet;
/// <summary>
/// Base configuration options for Stella Router integration that can be embedded
/// in any WebService's options class. Services should include a property of this type
/// named "Router" to enable Router integration.
/// </summary>
/// <example>
/// <code>
/// public class MyServiceOptions
/// {
/// public StellaRouterOptionsBase? Router { get; set; }
/// }
/// </code>
/// </example>
public class StellaRouterOptionsBase
{
/// <summary>
/// Enable Stella Router integration. When true, ASP.NET endpoints
/// are automatically registered with the Router for discovery and dispatch.
/// Default: false.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Deployment region for this service instance.
/// Default: "default".
/// </summary>
public string Region { get; set; } = "default";
/// <summary>
/// Default request timeout in seconds.
/// Default: 30 seconds.
/// </summary>
public int DefaultTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Heartbeat interval in seconds for health reporting.
/// Default: 10 seconds.
/// </summary>
public int HeartbeatIntervalSeconds { get; set; } = 10;
/// <summary>
/// Gateway endpoints to connect to for endpoint registration.
/// </summary>
public IList<StellaRouterGatewayOptionsBase> Gateways { get; set; } = new List<StellaRouterGatewayOptionsBase>();
}
/// <summary>
/// Base configuration for a Router gateway connection.
/// </summary>
public class StellaRouterGatewayOptionsBase
{
/// <summary>
/// Gateway host.
/// Default: "localhost".
/// </summary>
public string Host { get; set; } = "localhost";
/// <summary>
/// Gateway port.
/// Default: 9100.
/// </summary>
public int Port { get; set; } = 9100;
/// <summary>
/// Transport type (InMemory, Tcp, Tls, Messaging).
/// Default: InMemory.
/// </summary>
public TransportType TransportType { get; set; } = TransportType.InMemory;
/// <summary>
/// Enable TLS for TCP transport.
/// Default: false.
/// </summary>
public bool UseTls { get; set; }
/// <summary>
/// TLS certificate path (if UseTls is true).
/// </summary>
public string? CertificatePath { get; set; }
}

View File

@@ -0,0 +1,191 @@
using FluentAssertions;
using Xunit;
namespace StellaOps.TestKit.Connectors;
/// <summary>
/// Base class for connector live schema drift detection tests.
/// These tests fetch from live upstream sources and compare against stored fixtures.
///
/// IMPORTANT: These tests are opt-in and disabled by default.
/// To enable: set STELLAOPS_LIVE_TESTS=true environment variable.
/// To also auto-update fixtures on drift: set STELLAOPS_UPDATE_FIXTURES=true.
/// </summary>
/// <remarks>
/// Usage:
/// <code>
/// [Trait("Category", TestCategories.Live)]
/// public class NvdLiveSchemaTests : ConnectorLiveSchemaTestBase
/// {
/// protected override string FixturesDirectory => "Fixtures";
/// protected override string ConnectorName => "NVD";
///
/// protected override IEnumerable&lt;LiveSchemaTestCase&gt; GetTestCases()
/// {
/// yield return new("typical-cve.json", "https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-2024-0001");
/// }
///
/// [Fact]
/// public Task DetectSchemaDrift() => RunSchemaDriftTestsAsync();
/// }
/// </code>
/// </remarks>
public abstract class ConnectorLiveSchemaTestBase : IAsyncLifetime
{
private readonly HttpClient _httpClient;
private readonly FixtureUpdater _fixtureUpdater;
private readonly List<FixtureDriftReport> _driftReports = new();
protected ConnectorLiveSchemaTestBase()
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
};
_fixtureUpdater = new FixtureUpdater(FixturesDirectory, _httpClient);
}
/// <summary>
/// Gets the base directory for test fixtures (relative to test assembly).
/// </summary>
protected abstract string FixturesDirectory { get; }
/// <summary>
/// Gets the connector name for logging purposes.
/// </summary>
protected abstract string ConnectorName { get; }
/// <summary>
/// Gets the test cases mapping fixtures to live URLs.
/// </summary>
protected abstract IEnumerable<LiveSchemaTestCase> GetTestCases();
/// <summary>
/// Returns true if live tests are enabled.
/// </summary>
protected static bool IsEnabled =>
Environment.GetEnvironmentVariable("STELLAOPS_LIVE_TESTS") == "true";
/// <summary>
/// Returns true if fixture auto-update is enabled.
/// </summary>
protected static bool IsAutoUpdateEnabled =>
Environment.GetEnvironmentVariable("STELLAOPS_UPDATE_FIXTURES") == "true";
/// <summary>
/// Optional request headers for live requests.
/// </summary>
protected virtual Dictionary<string, string> RequestHeaders => new();
/// <summary>
/// Runs all schema drift tests for this connector.
/// </summary>
protected async Task RunSchemaDriftTestsAsync(CancellationToken ct = default)
{
if (!IsEnabled)
{
// Skip test when not explicitly enabled
return;
}
var testCases = GetTestCases().ToList();
_driftReports.Clear();
foreach (var testCase in testCases)
{
var report = await _fixtureUpdater.CheckDriftAsync(testCase.LiveUrl, testCase.FixtureName, ct);
_driftReports.Add(report);
if (report.HasDrift && IsAutoUpdateEnabled && !string.IsNullOrEmpty(report.LiveContent))
{
await _fixtureUpdater.UpdateJsonFixtureFromUrlAsync(testCase.LiveUrl, testCase.FixtureName, ct);
}
}
// Report all drift findings
var driftCount = _driftReports.Count(r => r.HasDrift);
if (driftCount > 0)
{
var driftMessages = _driftReports
.Where(r => r.HasDrift)
.Select(r => $" - {r.FixtureName}: {r.Message}");
var message = $"Schema drift detected in {driftCount}/{_driftReports.Count} fixtures for {ConnectorName}:\n" +
string.Join("\n", driftMessages);
if (IsAutoUpdateEnabled)
{
// Warn but don't fail when auto-updating
Console.WriteLine($"[WARN] {message}");
Console.WriteLine($"[INFO] Fixtures have been auto-updated. Review changes before committing.");
}
else
{
// Fail test when drift detected without auto-update
Assert.Fail(message + "\n\nSet STELLAOPS_UPDATE_FIXTURES=true to auto-update fixtures.");
}
}
}
/// <summary>
/// Gets the drift reports from the last test run.
/// </summary>
public IReadOnlyList<FixtureDriftReport> DriftReports => _driftReports.AsReadOnly();
public Task InitializeAsync()
{
foreach (var (key, value) in RequestHeaders)
{
_httpClient.DefaultRequestHeaders.TryAddWithoutValidation(key, value);
}
return Task.CompletedTask;
}
public Task DisposeAsync()
{
_httpClient.Dispose();
return Task.CompletedTask;
}
}
/// <summary>
/// Test case for live schema drift detection.
/// </summary>
/// <param name="FixtureName">The fixture file name in the fixtures directory.</param>
/// <param name="LiveUrl">The live URL to fetch for comparison.</param>
/// <param name="Description">Optional description of what this fixture tests.</param>
public sealed record LiveSchemaTestCase(
string FixtureName,
string LiveUrl,
string? Description = null);
/// <summary>
/// Attribute to mark tests that require live external services.
/// These tests are skipped unless STELLAOPS_LIVE_TESTS=true.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)]
public sealed class LiveTestAttribute : FactAttribute
{
public LiveTestAttribute()
{
if (Environment.GetEnvironmentVariable("STELLAOPS_LIVE_TESTS") != "true")
{
Skip = "Live tests are disabled. Set STELLAOPS_LIVE_TESTS=true to enable.";
}
}
}
/// <summary>
/// Theory attribute for live tests that are skipped unless explicitly enabled.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class LiveTheoryAttribute : TheoryAttribute
{
public LiveTheoryAttribute()
{
if (Environment.GetEnvironmentVariable("STELLAOPS_LIVE_TESTS") != "true")
{
Skip = "Live tests are disabled. Set STELLAOPS_LIVE_TESTS=true to enable.";
}
}
}

View File

@@ -0,0 +1,288 @@
// -----------------------------------------------------------------------------
// ConnectorSecurityTestBase.cs
// Sprint: SPRINT_5100_0007_0005_connector_fixtures
// Tasks: CONN-FIX-012, CONN-FIX-013
// Description: Base class for connector security tests including URL allowlist,
// redirect handling, max payload size, and decompression bomb protection.
// -----------------------------------------------------------------------------
using System.IO.Compression;
using System.Net;
using System.Text;
using FluentAssertions;
using Xunit;
namespace StellaOps.TestKit.Connectors;
/// <summary>
/// Base class for connector security tests.
/// Tests URL allowlist, redirect handling, max payload size, and decompression bombs.
/// </summary>
public abstract class ConnectorSecurityTestBase : IDisposable
{
protected readonly ConnectorHttpFixture HttpFixture;
private bool _disposed;
protected ConnectorSecurityTestBase()
{
HttpFixture = new ConnectorHttpFixture();
}
/// <summary>
/// Gets the connector name for logging purposes.
/// </summary>
protected abstract string ConnectorName { get; }
/// <summary>
/// Gets the list of allowed URL patterns/domains for this connector.
/// </summary>
protected abstract IReadOnlyList<string> AllowedUrlPatterns { get; }
/// <summary>
/// Gets the maximum allowed payload size in bytes.
/// Default is 50MB.
/// </summary>
protected virtual long MaxPayloadSizeBytes => 50 * 1024 * 1024;
/// <summary>
/// Gets the maximum allowed decompression ratio.
/// Default is 100:1 (100 bytes uncompressed per 1 byte compressed).
/// </summary>
protected virtual int MaxDecompressionRatio => 100;
/// <summary>
/// Attempts to fetch from URL and returns whether it was allowed.
/// Should implement the connector's actual URL validation logic.
/// </summary>
protected abstract Task<(bool Allowed, string? ErrorMessage)> TryFetchUrlAsync(
string url,
CancellationToken ct = default);
/// <summary>
/// Verifies that the connector rejects URLs not in the allowlist.
/// </summary>
[Fact]
public virtual async Task NonAllowlistedUrl_IsRejected()
{
var disallowedUrls = new[]
{
"https://evil.example.com/api/data",
"http://malicious.test/feed",
"https://attacker.io/redirect",
"ftp://files.example.com/data.json",
"file:///etc/passwd",
"data:text/html,<script>alert(1)</script>",
"javascript:alert(1)"
};
foreach (var url in disallowedUrls)
{
var (allowed, errorMessage) = await TryFetchUrlAsync(url);
allowed.Should().BeFalse(
$"URL '{url}' should be rejected as it's not in the allowlist for {ConnectorName}");
}
}
/// <summary>
/// Verifies that HTTPS downgrade to HTTP is rejected.
/// </summary>
[Fact]
public virtual async Task HttpsToHttpDowngrade_IsRejected()
{
// Setup a redirect from HTTPS to HTTP
var secureUrl = "https://secure.example.com/api";
var insecureUrl = "http://secure.example.com/api";
// The connector should reject HTTP URLs or prevent HTTPS->HTTP redirects
var (allowed, _) = await TryFetchUrlAsync(insecureUrl);
allowed.Should().BeFalse(
$"HTTP URLs should be rejected for {ConnectorName} (HTTPS required)");
}
/// <summary>
/// Verifies that localhost/internal URLs are rejected.
/// </summary>
[Theory]
[InlineData("http://localhost/api")]
[InlineData("http://127.0.0.1/api")]
[InlineData("http://[::1]/api")]
[InlineData("http://169.254.169.254/latest/meta-data")] // AWS metadata
[InlineData("http://metadata.google.internal/computeMetadata/v1/")] // GCP metadata
public virtual async Task InternalUrl_IsRejected(string internalUrl)
{
var (allowed, _) = await TryFetchUrlAsync(internalUrl);
allowed.Should().BeFalse(
$"Internal URL '{internalUrl}' should be rejected for {ConnectorName}");
}
/// <summary>
/// Verifies that redirect chains are limited.
/// </summary>
[Fact]
public virtual void MaxRedirectChain_IsEnforced()
{
// This test documents that redirect chains should be limited
// Implementation varies by connector/HTTP client configuration
// The connector's HTTP client should have MaxAutomaticRedirections set
// to a reasonable value (typically 5-10)
}
/// <summary>
/// Creates a gzip bomb payload (small compressed, huge uncompressed).
/// </summary>
protected static byte[] CreateGzipBomb(int uncompressedSize, int repetitions = 1)
{
// Create a highly compressible payload
var pattern = new byte[1024];
Array.Fill(pattern, (byte)'A');
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.Optimal))
{
for (int r = 0; r < repetitions; r++)
{
for (int i = 0; i < uncompressedSize / pattern.Length; i++)
{
gzip.Write(pattern, 0, pattern.Length);
}
}
}
return output.ToArray();
}
/// <summary>
/// Creates a nested gzip bomb (gzip within gzip).
/// </summary>
protected static byte[] CreateNestedGzipBomb(int depth, int baseSize)
{
var data = Encoding.UTF8.GetBytes(new string('A', baseSize));
for (int i = 0; i < depth; i++)
{
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.Optimal))
{
gzip.Write(data, 0, data.Length);
}
data = output.ToArray();
}
return data;
}
/// <summary>
/// Validates that a URL matches the allowlist patterns.
/// </summary>
protected bool IsUrlInAllowlist(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return false;
}
foreach (var pattern in AllowedUrlPatterns)
{
if (MatchesPattern(url, pattern) || MatchesPattern(uri.Host, pattern))
{
return true;
}
}
return false;
}
private static bool MatchesPattern(string value, string pattern)
{
if (pattern == "*") return true;
if (value == pattern) return true;
if (pattern.StartsWith("*."))
{
var suffix = pattern[1..]; // e.g., ".github.com"
return value.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
}
if (pattern.EndsWith("*"))
{
var prefix = pattern[..^1];
return value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
return false;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
HttpFixture.Dispose();
}
_disposed = true;
}
}
/// <summary>
/// Provides common security test data for connectors.
/// </summary>
public static class ConnectorSecurityTestData
{
/// <summary>
/// Gets a list of URLs that should be rejected by all connectors.
/// </summary>
public static TheoryData<string> GetMaliciousUrls() => new()
{
"file:///etc/passwd",
"file:///C:/Windows/System32/config/SAM",
"data:text/html,<script>alert(1)</script>",
"javascript:alert(1)",
"http://localhost/api",
"http://127.0.0.1/api",
"http://[::1]/api",
"http://169.254.169.254/latest/meta-data",
"http://metadata.google.internal/",
"http://192.168.1.1/admin",
"http://10.0.0.1/internal",
"gopher://evil.com:70/",
"dict://evil.com:2628/"
};
/// <summary>
/// Gets a list of SSRF bypass attempts.
/// </summary>
public static TheoryData<string> GetSsrfBypassAttempts() => new()
{
"http://2130706433/", // 127.0.0.1 as decimal
"http://0x7f000001/", // 127.0.0.1 as hex
"http://017700000001/", // 127.0.0.1 as octal
"http://127.1/",
"http://127.0.1/",
"http://0/",
"http://[0:0:0:0:0:ffff:127.0.0.1]/",
"http://localtest.me/", // DNS rebinding
"http://customer1.app.localhost.my.company.127.0.0.1.nip.io/"
};
/// <summary>
/// Gets oversized payload test data.
/// </summary>
public static byte[] CreateOversizedPayload(long sizeBytes)
{
var payload = new byte[sizeBytes];
Array.Fill(payload, (byte)'{');
return payload;
}
}

View File

@@ -0,0 +1,283 @@
// -----------------------------------------------------------------------------
// FlakyToDeterministicPattern.cs
// Sprint: SPRINT_5100_0007_0001 (Testing Strategy)
// Task: TEST-STRAT-5100-006 - Convert flaky E2E tests to deterministic integration tests
// Description: Template demonstrating how to convert common flaky test patterns into
// deterministic integration tests. Use as reference when refactoring tests.
// -----------------------------------------------------------------------------
using System.Diagnostics;
namespace StellaOps.TestKit.Templates;
/// <summary>
/// Template demonstrating the conversion of common flaky test patterns to deterministic tests.
/// This class documents anti-patterns and their deterministic replacements.
/// </summary>
/// <remarks>
/// Common sources of test flakiness and their solutions:
///
/// 1. **DateTime.Now/UtcNow** → Use injected TimeProvider or DeterministicTime
/// 2. **Random without seed** → Use DeterministicRandom with fixed seed
/// 3. **Task.Delay for timing** → Use polling with configurable timeout or fake timers
/// 4. **External service calls** → Use HttpFixtureServer or mocks
/// 5. **Ordering assumptions** → Ensure explicit ORDER BY or use sorted assertions
/// 6. **Parallel test interference** → Use test isolation (schema-per-test, unique IDs)
/// 7. **Environment dependencies** → Use TestContainers with fixed versions
/// </remarks>
public static class FlakyToDeterministicPattern
{
#region Pattern 1: Replace DateTime.Now with TimeProvider
// FLAKY: Uses system clock - different results on each run
// public void Flaky_DateTimeNow()
// {
// var record = new AuditRecord { CreatedAt = DateTime.UtcNow };
// Assert.True(record.CreatedAt.Hour == 12); // Fails at any other hour
// }
/// <summary>
/// Deterministic version using injected TimeProvider.
/// </summary>
public static void Deterministic_TimeProvider(TimeProvider timeProvider)
{
// Use fixed time: DateTimeOffset.Parse("2025-01-15T12:00:00Z")
var fixedTime = timeProvider.GetUtcNow();
var record = new AuditRecordExample { CreatedAt = fixedTime };
// Assertions now pass regardless of actual system time
if (record.CreatedAt.Hour != 12)
{
throw new InvalidOperationException("Time should be fixed at noon");
}
}
#endregion
#region Pattern 2: Replace Random with Seeded Random
// FLAKY: Different random sequence each run
// public void Flaky_Random()
// {
// var random = new Random();
// var value = random.Next(1, 100);
// Assert.Equal(42, value); // Almost never passes
// }
/// <summary>
/// Deterministic version using seeded random.
/// </summary>
public static int Deterministic_SeededRandom(int seed = 12345)
{
// Same seed always produces same sequence
var random = new Random(seed);
return random.Next(1, 100); // Always returns same value for same seed
}
#endregion
#region Pattern 3: Replace Task.Delay with Polling
// FLAKY: Arbitrary delay may be too short or too long
// public async Task Flaky_TaskDelay()
// {
// await service.StartAsync();
// await Task.Delay(2000); // May be too short in CI
// Assert.True(service.IsReady);
// }
/// <summary>
/// Deterministic version using polling with timeout.
/// </summary>
public static async Task Deterministic_Polling(
Func<bool> condition,
TimeSpan timeout,
TimeSpan? pollInterval = null)
{
var interval = pollInterval ?? TimeSpan.FromMilliseconds(100);
var stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed < timeout)
{
if (condition())
{
return;
}
await Task.Delay(interval);
}
throw new TimeoutException($"Condition not met within {timeout.TotalSeconds}s");
}
/// <summary>
/// Even better: Use fake timers in tests for instant execution.
/// </summary>
public static async Task Deterministic_FakeTimer(
FakeTimeProvider fakeTime,
Func<Task> actionThatWaits)
{
var task = actionThatWaits();
// Advance fake time instantly - no actual waiting
fakeTime.Advance(TimeSpan.FromMinutes(5));
await task;
}
#endregion
#region Pattern 4: Replace External HTTP with Fixture Server
// FLAKY: Depends on external service availability
// public async Task Flaky_ExternalHttp()
// {
// var client = new HttpClient();
// var response = await client.GetAsync("https://api.example.com/data");
// Assert.True(response.IsSuccessStatusCode);
// }
/// <summary>
/// Deterministic version using HttpFixtureServer.
/// </summary>
public static async Task Deterministic_HttpFixture(HttpClient fixturedClient)
{
// HttpFixtureServer configured with:
// server.Given("/data").Respond(HttpStatusCode.OK, fixedPayload);
var response = await fixturedClient.GetAsync("/data");
// Always succeeds because response is mocked
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException("Fixtured response should succeed");
}
}
#endregion
#region Pattern 5: Ensure Deterministic Ordering
// FLAKY: Order not guaranteed without explicit ORDER BY
// public void Flaky_Ordering()
// {
// var items = repository.GetAll();
// Assert.Equal("first", items[0].Name); // May fail due to DB ordering
// }
/// <summary>
/// Deterministic version with explicit ordering.
/// </summary>
public static void Deterministic_ExplicitOrdering<T>(
IEnumerable<T> items,
Func<T, object> orderBy)
{
// Always sort before assertions
var sorted = items.OrderBy(orderBy).ToList();
// Now ordering is deterministic
// Assert.Equal("first", sorted[0].Name);
}
/// <summary>
/// Alternative: Use set-based assertions that ignore order.
/// </summary>
public static void Deterministic_SetAssertion<T>(
IEnumerable<T> actual,
IEnumerable<T> expected)
{
var actualSet = actual.ToHashSet();
var expectedSet = expected.ToHashSet();
if (!actualSet.SetEquals(expectedSet))
{
throw new InvalidOperationException("Sets are not equal");
}
}
#endregion
#region Pattern 6: Test Isolation
// FLAKY: Parallel tests interfere with each other
// public async Task Flaky_SharedState()
// {
// await db.InsertAsync(new Item { Id = 1, Name = "test" });
// var item = await db.GetAsync(1);
// Assert.Equal("test", item.Name); // Fails if another test uses Id=1
// }
/// <summary>
/// Deterministic version with unique identifiers.
/// </summary>
public static string GenerateTestId(string testName)
{
// Each test gets unique ID based on test name + timestamp
return $"{testName}-{Guid.NewGuid():N}";
}
/// <summary>
/// Alternative: Use schema-per-test isolation.
/// </summary>
public static string GetIsolatedSchema(string testName)
{
// PostgresFixture.SchemaPerTest mode creates:
return $"test_{testName.Replace(".", "_").ToLowerInvariant()}";
}
#endregion
#region Pattern 7: Fixed Container Versions
// FLAKY: Different container versions may behave differently
// public class Flaky_ContainerVersion
// {
// private readonly PostgresContainer _container = new PostgresContainer("postgres:latest");
// }
/// <summary>
/// Deterministic version with pinned versions.
/// </summary>
public static class DeterministicContainerVersions
{
// Pin all container versions for reproducibility
public const string PostgresVersion = "postgres:16.1-alpine";
public const string ValkeyVersion = "valkey/valkey:8.0.1";
public const string RabbitMqVersion = "rabbitmq:3.12.10-management-alpine";
}
#endregion
#region Example Types
private class AuditRecordExample
{
public DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Fake TimeProvider for testing time-dependent code.
/// </summary>
public class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _currentTime;
public FakeTimeProvider(DateTimeOffset startTime)
{
_currentTime = startTime;
}
public override DateTimeOffset GetUtcNow() => _currentTime;
public void Advance(TimeSpan duration)
{
_currentTime = _currentTime.Add(duration);
}
public void SetTime(DateTimeOffset newTime)
{
_currentTime = newTime;
}
}
#endregion
}

View File

@@ -0,0 +1,602 @@
// -----------------------------------------------------------------------------
// VersionComparisonPropertyTests.cs
// Sprint: SPRINT_5100_0009_0001 (Scanner Tests)
// Task: SCANNER-5100-001 - Property tests for version/range resolution
// Description: Property-based tests for version comparers verifying
// monotonicity, transitivity, reflexivity, and boundary behavior.
// -----------------------------------------------------------------------------
using FsCheck;
using FsCheck.Xunit;
using StellaOps.VersionComparison.Comparers;
using StellaOps.VersionComparison.Models;
using Xunit;
using FluentAssertions;
namespace StellaOps.VersionComparison.Tests.Properties;
/// <summary>
/// Property-based tests for version comparers.
/// Verifies mathematical properties required for correct sorting:
/// - Reflexivity: Compare(x, x) == 0
/// - Anti-symmetry: Compare(x, y) == -Compare(y, x)
/// - Transitivity: if Compare(x, y) &lt;= 0 and Compare(y, z) &lt;= 0 then Compare(x, z) &lt;= 0
/// </summary>
[Trait("Category", "Property")]
public class VersionComparisonPropertyTests
{
#region RPM Version Property Tests
/// <summary>
/// Reflexivity: Any version equals itself.
/// </summary>
[Property(MaxTest = 100)]
public Property RpmComparer_Reflexivity()
{
return Prop.ForAll(
RpmVersionArb(),
version => RpmVersionComparer.Instance.Compare(version, version) == 0);
}
/// <summary>
/// Anti-symmetry: Compare(x, y) == -Compare(y, x).
/// </summary>
[Property(MaxTest = 100)]
public Property RpmComparer_AntiSymmetry()
{
return Prop.ForAll(
RpmVersionArb(),
RpmVersionArb(),
(x, y) =>
{
var cmpXY = Math.Sign(RpmVersionComparer.Instance.Compare(x, y));
var cmpYX = Math.Sign(RpmVersionComparer.Instance.Compare(y, x));
return cmpXY == -cmpYX;
});
}
/// <summary>
/// Transitivity: if x &lt;= y and y &lt;= z then x &lt;= z.
/// </summary>
[Property(MaxTest = 100)]
public Property RpmComparer_Transitivity()
{
return Prop.ForAll(
RpmVersionArb(),
RpmVersionArb(),
RpmVersionArb(),
(x, y, z) =>
{
var comparer = RpmVersionComparer.Instance;
var cmpXY = comparer.Compare(x, y);
var cmpYZ = comparer.Compare(y, z);
var cmpXZ = comparer.Compare(x, z);
// If x <= y and y <= z, then x <= z
if (cmpXY <= 0 && cmpYZ <= 0)
{
return cmpXZ <= 0;
}
// If x >= y and y >= z, then x >= z
if (cmpXY >= 0 && cmpYZ >= 0)
{
return cmpXZ >= 0;
}
return true; // No constraint for mixed cases
});
}
/// <summary>
/// Monotonicity: Incrementing epoch always results in newer version.
/// </summary>
[Property(MaxTest = 100)]
public Property RpmComparer_EpochMonotonicity()
{
return Prop.ForAll(
Gen.Choose(0, 10).ToArbitrary(),
Gen.Elements("1.0", "2.5", "1.0.1", "10.20.30").ToArbitrary(),
Gen.Elements("1", "2.el8", "3.fc38").ToArbitrary(),
(epoch, version, release) =>
{
var lower = $"{epoch}:{version}-{release}";
var higher = $"{epoch + 1}:{version}-{release}";
return RpmVersionComparer.Instance.Compare(lower, higher) < 0;
});
}
/// <summary>
/// Monotonicity: Major version increments are always newer.
/// </summary>
[Property(MaxTest = 100)]
public Property RpmComparer_MajorVersionMonotonicity()
{
return Prop.ForAll(
Gen.Choose(1, 100).ToArbitrary(),
Gen.Choose(0, 99).ToArbitrary(),
Gen.Choose(0, 99).ToArbitrary(),
(major, minor, patch) =>
{
var lower = $"{major}.{minor}.{patch}-1";
var higher = $"{major + 1}.{minor}.{patch}-1";
return RpmVersionComparer.Instance.Compare(lower, higher) < 0;
});
}
/// <summary>
/// Boundary: Tilde pre-release always sorts before release.
/// </summary>
[Property(MaxTest = 100)]
public Property RpmComparer_TildePreReleaseBehavior()
{
return Prop.ForAll(
Gen.Elements("1.0", "2.0", "3.5.1").ToArbitrary(),
Gen.Elements("alpha", "beta", "rc1", "rc2").ToArbitrary(),
(version, prerelease) =>
{
var preReleaseVersion = $"{version}~{prerelease}-1";
var releaseVersion = $"{version}-1";
return RpmVersionComparer.Instance.Compare(preReleaseVersion, releaseVersion) < 0;
});
}
/// <summary>
/// Consistency: Same input produces same result (determinism).
/// </summary>
[Property(MaxTest = 100)]
public Property RpmComparer_IsDeterministic()
{
return Prop.ForAll(
RpmVersionArb(),
RpmVersionArb(),
(x, y) =>
{
var result1 = RpmVersionComparer.Instance.Compare(x, y);
var result2 = RpmVersionComparer.Instance.Compare(x, y);
return result1 == result2;
});
}
/// <summary>
/// Proof lines are non-empty for valid comparisons.
/// </summary>
[Property(MaxTest = 100)]
public Property RpmComparer_ProofLinesNonEmpty()
{
return Prop.ForAll(
RpmVersionArb(),
RpmVersionArb(),
(x, y) =>
{
var result = RpmVersionComparer.Instance.CompareWithProof(x, y);
return result.ProofLines.Length > 0;
});
}
#endregion
#region Debian Version Property Tests
/// <summary>
/// Reflexivity: Any version equals itself.
/// </summary>
[Property(MaxTest = 100)]
public Property DebianComparer_Reflexivity()
{
return Prop.ForAll(
DebianVersionArb(),
version => DebianVersionComparer.Instance.Compare(version, version) == 0);
}
/// <summary>
/// Anti-symmetry: Compare(x, y) == -Compare(y, x).
/// </summary>
[Property(MaxTest = 100)]
public Property DebianComparer_AntiSymmetry()
{
return Prop.ForAll(
DebianVersionArb(),
DebianVersionArb(),
(x, y) =>
{
var cmpXY = Math.Sign(DebianVersionComparer.Instance.Compare(x, y));
var cmpYX = Math.Sign(DebianVersionComparer.Instance.Compare(y, x));
return cmpXY == -cmpYX;
});
}
/// <summary>
/// Transitivity: if x &lt;= y and y &lt;= z then x &lt;= z.
/// </summary>
[Property(MaxTest = 100)]
public Property DebianComparer_Transitivity()
{
return Prop.ForAll(
DebianVersionArb(),
DebianVersionArb(),
DebianVersionArb(),
(x, y, z) =>
{
var comparer = DebianVersionComparer.Instance;
var cmpXY = comparer.Compare(x, y);
var cmpYZ = comparer.Compare(y, z);
var cmpXZ = comparer.Compare(x, z);
if (cmpXY <= 0 && cmpYZ <= 0)
{
return cmpXZ <= 0;
}
if (cmpXY >= 0 && cmpYZ >= 0)
{
return cmpXZ >= 0;
}
return true;
});
}
/// <summary>
/// Monotonicity: Incrementing epoch always results in newer version.
/// </summary>
[Property(MaxTest = 100)]
public Property DebianComparer_EpochMonotonicity()
{
return Prop.ForAll(
Gen.Choose(0, 10).ToArbitrary(),
Gen.Elements("1.0", "2.5", "1.0.1", "10.20.30").ToArbitrary(),
Gen.Elements("1", "1ubuntu1", "2build1").ToArbitrary(),
(epoch, version, revision) =>
{
var lower = $"{epoch}:{version}-{revision}";
var higher = $"{epoch + 1}:{version}-{revision}";
return DebianVersionComparer.Instance.Compare(lower, higher) < 0;
});
}
/// <summary>
/// Boundary: Tilde pre-release always sorts before release.
/// </summary>
[Property(MaxTest = 100)]
public Property DebianComparer_TildePreReleaseBehavior()
{
return Prop.ForAll(
Gen.Elements("1.0", "2.0", "3.5.1").ToArbitrary(),
Gen.Elements("alpha", "beta", "rc1", "rc2").ToArbitrary(),
(version, prerelease) =>
{
var preReleaseVersion = $"{version}~{prerelease}-1";
var releaseVersion = $"{version}-1";
return DebianVersionComparer.Instance.Compare(preReleaseVersion, releaseVersion) < 0;
});
}
/// <summary>
/// Consistency: Same input produces same result (determinism).
/// </summary>
[Property(MaxTest = 100)]
public Property DebianComparer_IsDeterministic()
{
return Prop.ForAll(
DebianVersionArb(),
DebianVersionArb(),
(x, y) =>
{
var result1 = DebianVersionComparer.Instance.Compare(x, y);
var result2 = DebianVersionComparer.Instance.Compare(x, y);
return result1 == result2;
});
}
/// <summary>
/// Proof lines are non-empty for valid comparisons.
/// </summary>
[Property(MaxTest = 100)]
public Property DebianComparer_ProofLinesNonEmpty()
{
return Prop.ForAll(
DebianVersionArb(),
DebianVersionArb(),
(x, y) =>
{
var result = DebianVersionComparer.Instance.CompareWithProof(x, y);
return result.ProofLines.Length > 0;
});
}
#endregion
#region Boundary Behavior Tests
/// <summary>
/// Null handling: null is less than any valid version.
/// </summary>
[Property(MaxTest = 100)]
public Property RpmComparer_NullIsLessThanAnyVersion()
{
return Prop.ForAll(
RpmVersionArb(),
version => ((IComparer<string>)RpmVersionComparer.Instance).Compare(null, version) < 0);
}
/// <summary>
/// Null handling: any valid version is greater than null.
/// </summary>
[Property(MaxTest = 100)]
public Property RpmComparer_AnyVersionGreaterThanNull()
{
return Prop.ForAll(
RpmVersionArb(),
version => ((IComparer<string>)RpmVersionComparer.Instance).Compare(version, null) > 0);
}
/// <summary>
/// Null handling: null equals null.
/// </summary>
[Fact]
public void RpmComparer_NullEqualsNull()
{
((IComparer<string>)RpmVersionComparer.Instance).Compare(null, null).Should().Be(0);
}
/// <summary>
/// Null handling: null is less than any valid Debian version.
/// </summary>
[Property(MaxTest = 100)]
public Property DebianComparer_NullIsLessThanAnyVersion()
{
return Prop.ForAll(
DebianVersionArb(),
version => ((IComparer<string>)DebianVersionComparer.Instance).Compare(null, version) < 0);
}
/// <summary>
/// Null handling: any valid Debian version is greater than null.
/// </summary>
[Property(MaxTest = 100)]
public Property DebianComparer_AnyVersionGreaterThanNull()
{
return Prop.ForAll(
DebianVersionArb(),
version => ((IComparer<string>)DebianVersionComparer.Instance).Compare(version, null) > 0);
}
/// <summary>
/// Null handling: null equals null for Debian.
/// </summary>
[Fact]
public void DebianComparer_NullEqualsNull()
{
((IComparer<string>)DebianVersionComparer.Instance).Compare(null, null).Should().Be(0);
}
#endregion
#region Version Ordering Tests
/// <summary>
/// Leading zeros in numeric segments are ignored for RPM.
/// </summary>
[Property(MaxTest = 50)]
public Property RpmComparer_LeadingZerosIgnored()
{
return Prop.ForAll(
Gen.Choose(1, 99).ToArbitrary(),
Gen.Choose(1, 99).ToArbitrary(),
(major, minor) =>
{
var withLeading = $"0{major}.0{minor}-1";
var withoutLeading = $"{major}.{minor}-1";
return RpmVersionComparer.Instance.Compare(withLeading, withoutLeading) == 0;
});
}
/// <summary>
/// Leading zeros in numeric segments are ignored for Debian.
/// </summary>
[Property(MaxTest = 50)]
public Property DebianComparer_LeadingZerosIgnored()
{
return Prop.ForAll(
Gen.Choose(1, 99).ToArbitrary(),
Gen.Choose(1, 99).ToArbitrary(),
(major, minor) =>
{
var withLeading = $"0{major}.0{minor}-1";
var withoutLeading = $"{major}.{minor}-1";
return DebianVersionComparer.Instance.Compare(withLeading, withoutLeading) == 0;
});
}
/// <summary>
/// Numeric segments sort numerically, not lexicographically (9 &lt; 10).
/// </summary>
[Property(MaxTest = 50)]
public Property RpmComparer_NumericSegmentsNotLexicographic()
{
return Prop.ForAll(
Gen.Choose(1, 9).ToArbitrary(),
(single) =>
{
var singleDigit = $"1.{single}-1";
var doubleDigit = $"1.{single + 10}-1";
return RpmVersionComparer.Instance.Compare(singleDigit, doubleDigit) < 0;
});
}
/// <summary>
/// Numeric segments sort numerically for Debian (9 &lt; 10).
/// </summary>
[Property(MaxTest = 50)]
public Property DebianComparer_NumericSegmentsNotLexicographic()
{
return Prop.ForAll(
Gen.Choose(1, 9).ToArbitrary(),
(single) =>
{
var singleDigit = $"1.{single}-1";
var doubleDigit = $"1.{single + 10}-1";
return DebianVersionComparer.Instance.Compare(singleDigit, doubleDigit) < 0;
});
}
#endregion
#region Generators
/// <summary>
/// Generator for valid RPM version strings.
/// </summary>
private static Arbitrary<string> RpmVersionArb()
{
var epochGen = Gen.Frequency(
Tuple.Create(3, Gen.Constant("")),
Tuple.Create(1, Gen.Choose(0, 5).Select(e => $"{e}:")));
var versionGen = Gen.Elements(
"1.0", "1.1", "1.2", "2.0", "2.1",
"1.0.0", "1.0.1", "1.1.0", "2.0.0",
"1.9", "1.10", "1.99", "1.100",
"1.0~alpha", "1.0~beta", "1.0~rc1",
"10.20.30", "0.1", "0.0.1");
var releaseGen = Gen.Elements(
"1", "2", "3",
"1.el8", "1.el8_5", "1.el9",
"1.fc38", "1.fc39",
"1ubuntu1", "1build1");
return (from epoch in epochGen
from version in versionGen
from release in releaseGen
select $"{epoch}{version}-{release}").ToArbitrary();
}
/// <summary>
/// Generator for valid Debian version strings.
/// </summary>
private static Arbitrary<string> DebianVersionArb()
{
var epochGen = Gen.Frequency(
Tuple.Create(3, Gen.Constant("")),
Tuple.Create(1, Gen.Choose(0, 5).Select(e => $"{e}:")));
var versionGen = Gen.Elements(
"1.0", "1.1", "1.2", "2.0", "2.1",
"1.0.0", "1.0.1", "1.1.0", "2.0.0",
"1.9", "1.10", "1.99", "1.100",
"1.0~alpha", "1.0~beta", "1.0~rc1",
"10.20.30", "0.1", "0.0.1",
"1.0+dfsg", "1.0+really1.1");
var revisionGen = Gen.Elements(
"1", "2", "3",
"1ubuntu1", "1ubuntu2",
"1build1", "1build2",
"1+deb11u1", "1+deb12u1");
return (from epoch in epochGen
from version in versionGen
from revision in revisionGen
select $"{epoch}{version}-{revision}").ToArbitrary();
}
#endregion
}
/// <summary>
/// Property tests for version range matching and resolution.
/// Verifies range containment, boundary conditions, and semantic consistency.
/// </summary>
[Trait("Category", "Property")]
public class VersionRangePropertyTests
{
/// <summary>
/// For any version v, range "[v,v]" contains exactly v.
/// </summary>
[Property(MaxTest = 50)]
public Property ExactVersionRange_ContainsOnlyExactVersion()
{
return Prop.ForAll(
Gen.Elements("1.0-1", "2.0-1", "1.0.1-1", "3.0~rc1-1").ToArbitrary(),
(string version) =>
{
// Exact range should match
var comparer = RpmVersionComparer.Instance;
return ((IComparer<string>)comparer).Compare(version, version) == 0;
});
}
/// <summary>
/// For versions a &lt; b, range [a, b] contains both endpoints.
/// </summary>
[Property(MaxTest = 50)]
public Property ClosedRange_ContainsBothEndpoints()
{
return Prop.ForAll(
Gen.Elements(
("1.0-1", "2.0-1"),
("1.0-1", "1.1-1"),
("1.0~alpha-1", "1.0-1"),
("0:1.0-1", "1:1.0-1")).ToArbitrary(),
((string lower, string upper) range) =>
{
var (lower, upper) = range;
var comparer = (IComparer<string>)RpmVersionComparer.Instance;
// Lower is contained: lower >= lower && lower <= upper
var lowerContained = comparer.Compare(lower, lower) >= 0 &&
comparer.Compare(lower, upper) <= 0;
// Upper is contained: upper >= lower && upper <= upper
var upperContained = comparer.Compare(upper, lower) >= 0 &&
comparer.Compare(upper, upper) <= 0;
return lowerContained && upperContained;
});
}
/// <summary>
/// For versions a &lt; b, range (a, b) excludes both endpoints.
/// </summary>
[Property(MaxTest = 50)]
public Property OpenRange_ExcludesBothEndpoints()
{
return Prop.ForAll(
Gen.Elements(
("1.0-1", "2.0-1"),
("1.0-1", "1.1-1"),
("0:1.0-1", "1:1.0-1")).ToArbitrary(),
((string lower, string upper) range) =>
{
var (lower, upper) = range;
var comparer = (IComparer<string>)RpmVersionComparer.Instance;
// In open range (a, b): version must be strictly > a and strictly < b
// Since we're testing with just endpoints, they should NOT be in the open range
var lowerInOpen = comparer.Compare(lower, lower) > 0; // false
var upperInOpen = comparer.Compare(upper, upper) < 0; // false
return !lowerInOpen && !upperInOpen;
});
}
/// <summary>
/// Version ordering is consistent with range containment.
/// If a is in range [lower, upper] and b > upper, then a &lt; b.
/// </summary>
[Property(MaxTest = 50)]
public Property RangeContainment_ConsistentWithOrdering()
{
return Prop.ForAll(
Gen.Elements("1.0-1", "1.5-1").ToArbitrary(),
Gen.Elements("2.0-1", "3.0-1").ToArbitrary(),
(string inRange, string outOfRange) =>
{
var comparer = (IComparer<string>)RpmVersionComparer.Instance;
// If inRange < outOfRange, this should hold
return comparer.Compare(inRange, outOfRange) < 0;
});
}
}

View File

@@ -10,6 +10,8 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="FsCheck" Version="2.16.6" />
<PackageReference Include="FsCheck.Xunit" Version="2.16.6" />
</ItemGroup>
<ItemGroup>