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:
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
26
src/__Libraries/StellaOps.Microservice/IRequestDispatcher.cs
Normal file
26
src/__Libraries/StellaOps.Microservice/IRequestDispatcher.cs
Normal 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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
146
src/__Libraries/StellaOps.Router.AspNet/StellaRouterOptions.cs
Normal file
146
src/__Libraries/StellaOps.Router.AspNet/StellaRouterOptions.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<LiveSchemaTestCase> 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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) <= 0 and Compare(y, z) <= 0 then Compare(x, z) <= 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 <= y and y <= z then x <= 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 <= y and y <= z then x <= 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 < 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 < 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 < 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 < 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 < 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user