Add unit tests for RabbitMq and Udp transport servers and clients
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implemented comprehensive unit tests for RabbitMqTransportServer, covering constructor, disposal, connection management, event handlers, and exception handling. - Added configuration tests for RabbitMqTransportServer to validate SSL, durable queues, auto-recovery, and custom virtual host options. - Created unit tests for UdpFrameProtocol, including frame parsing and serialization, header size validation, and round-trip data preservation. - Developed tests for UdpTransportClient, focusing on connection handling, event subscriptions, and exception scenarios. - Established tests for UdpTransportServer, ensuring proper start/stop behavior, connection state management, and event handling. - Included tests for UdpTransportOptions to verify default values and modification capabilities. - Enhanced service registration tests for Udp transport services in the dependency injection container.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://stellaops.io/schemas/verification-policy.v1.json",
|
||||
"title": "VerificationPolicy",
|
||||
"description": "Attestation verification policy configuration for StellaOps",
|
||||
"type": "object",
|
||||
"required": ["policyId", "version", "predicateTypes", "signerRequirements"],
|
||||
"properties": {
|
||||
"policyId": {
|
||||
"type": "string",
|
||||
"description": "Unique policy identifier",
|
||||
"pattern": "^[a-z0-9-]+$",
|
||||
"examples": ["default-verification-policy", "strict-slsa-policy"]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Policy version (SemVer)",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$",
|
||||
"examples": ["1.0.0", "2.1.0"]
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Human-readable policy description"
|
||||
},
|
||||
"tenantScope": {
|
||||
"type": "string",
|
||||
"description": "Tenant ID this policy applies to, or '*' for all tenants",
|
||||
"default": "*"
|
||||
},
|
||||
"predicateTypes": {
|
||||
"type": "array",
|
||||
"description": "Allowed attestation predicate types",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"examples": [
|
||||
["stella.ops/sbom@v1", "stella.ops/vex@v1"]
|
||||
]
|
||||
},
|
||||
"signerRequirements": {
|
||||
"$ref": "#/$defs/SignerRequirements"
|
||||
},
|
||||
"validityWindow": {
|
||||
"$ref": "#/$defs/ValidityWindow"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Free-form metadata",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"SignerRequirements": {
|
||||
"type": "object",
|
||||
"description": "Requirements for attestation signers",
|
||||
"properties": {
|
||||
"minimumSignatures": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 1,
|
||||
"description": "Minimum number of valid signatures required"
|
||||
},
|
||||
"trustedKeyFingerprints": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
},
|
||||
"description": "List of trusted signer key fingerprints (SHA-256)"
|
||||
},
|
||||
"trustedIssuers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"description": "List of trusted issuer identities (OIDC issuers)"
|
||||
},
|
||||
"requireRekor": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Require Sigstore Rekor transparency log entry"
|
||||
},
|
||||
"algorithms": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "EdDSA"]
|
||||
},
|
||||
"description": "Allowed signing algorithms",
|
||||
"default": ["ES256", "RS256", "EdDSA"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ValidityWindow": {
|
||||
"type": "object",
|
||||
"description": "Time-based validity constraints",
|
||||
"properties": {
|
||||
"notBefore": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Policy not valid before this time (ISO-8601)"
|
||||
},
|
||||
"notAfter": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Policy not valid after this time (ISO-8601)"
|
||||
},
|
||||
"maxAttestationAge": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Maximum age of attestation in seconds (0 = no limit)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
{
|
||||
"policyId": "default-verification-policy",
|
||||
"version": "1.0.0",
|
||||
"description": "Default verification policy for StellaOps attestations",
|
||||
"tenantScope": "*",
|
||||
"predicateTypes": [
|
||||
"stella.ops/sbom@v1",
|
||||
"stella.ops/vex@v1",
|
||||
"stella.ops/vexDecision@v1",
|
||||
"stella.ops/policy@v1",
|
||||
"stella.ops/promotion@v1",
|
||||
"stella.ops/evidence@v1",
|
||||
"stella.ops/graph@v1",
|
||||
"stella.ops/replay@v1",
|
||||
"https://slsa.dev/provenance/v1",
|
||||
"https://cyclonedx.org/bom",
|
||||
"https://spdx.dev/Document",
|
||||
"https://openvex.dev/ns"
|
||||
],
|
||||
"signerRequirements": {
|
||||
"minimumSignatures": 1,
|
||||
"trustedKeyFingerprints": [
|
||||
"sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
],
|
||||
"requireRekor": false,
|
||||
"algorithms": ["ES256", "RS256", "EdDSA"]
|
||||
},
|
||||
"validityWindow": {
|
||||
"maxAttestationAge": 86400
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Diagnostics;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
@@ -14,6 +15,7 @@ public sealed class LedgerMerkleAnchorWorker : BackgroundService
|
||||
{
|
||||
private readonly LedgerAnchorQueue _queue;
|
||||
private readonly IMerkleAnchorRepository _repository;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly LedgerServiceOptions.MerkleOptions _options;
|
||||
private readonly ILogger<LedgerMerkleAnchorWorker> _logger;
|
||||
@@ -22,12 +24,14 @@ public sealed class LedgerMerkleAnchorWorker : BackgroundService
|
||||
public LedgerMerkleAnchorWorker(
|
||||
LedgerAnchorQueue queue,
|
||||
IMerkleAnchorRepository repository,
|
||||
ICryptoHash cryptoHash,
|
||||
IOptions<LedgerServiceOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<LedgerMerkleAnchorWorker> logger)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value.Merkle ?? throw new ArgumentNullException(nameof(options));
|
||||
@@ -89,7 +93,7 @@ public sealed class LedgerMerkleAnchorWorker : BackgroundService
|
||||
.ThenBy(e => e.RecordedAt)
|
||||
.ToList();
|
||||
|
||||
var rootHash = MerkleTreeBuilder.ComputeRoot(orderedEvents.Select(e => e.MerkleLeafHash).ToArray());
|
||||
var rootHash = MerkleTreeBuilder.ComputeRoot(_cryptoHash, orderedEvents.Select(e => e.MerkleLeafHash).ToArray());
|
||||
var anchorId = Guid.NewGuid();
|
||||
var windowStart = orderedEvents.First().RecordedAt;
|
||||
var windowEnd = orderedEvents.Last().RecordedAt;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Merkle;
|
||||
|
||||
internal static class MerkleTreeBuilder
|
||||
{
|
||||
public static string ComputeRoot(IReadOnlyList<string> leafHashes)
|
||||
public static string ComputeRoot(ICryptoHash cryptoHash, IReadOnlyList<string> leafHashes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cryptoHash);
|
||||
|
||||
if (leafHashes.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one leaf hash is required to compute a Merkle root.", nameof(leafHashes));
|
||||
@@ -18,13 +20,13 @@ internal static class MerkleTreeBuilder
|
||||
|
||||
while (currentLevel.Length > 1)
|
||||
{
|
||||
currentLevel = ComputeNextLevel(currentLevel);
|
||||
currentLevel = ComputeNextLevel(cryptoHash, currentLevel);
|
||||
}
|
||||
|
||||
return currentLevel[0];
|
||||
}
|
||||
|
||||
private static string[] ComputeNextLevel(IReadOnlyList<string> level)
|
||||
private static string[] ComputeNextLevel(ICryptoHash cryptoHash, IReadOnlyList<string> level)
|
||||
{
|
||||
var next = new string[(level.Count + 1) / 2];
|
||||
var index = 0;
|
||||
@@ -33,16 +35,15 @@ internal static class MerkleTreeBuilder
|
||||
{
|
||||
var left = level[i];
|
||||
var right = i + 1 < level.Count ? level[i + 1] : level[i];
|
||||
next[index++] = HashPair(left, right);
|
||||
next[index++] = HashPair(cryptoHash, left, right);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
private static string HashPair(string left, string right)
|
||||
private static string HashPair(ICryptoHash cryptoHash, string left, string right)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(left + right);
|
||||
var hashBytes = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
return cryptoHash.ComputeHashHexForPurpose(bytes, HashPurpose.Merkle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,4 +30,8 @@
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Gateway.WebService.OpenApi;
|
||||
|
||||
namespace StellaOps.Gateway.WebService;
|
||||
|
||||
@@ -25,4 +26,15 @@ public static class ApplicationBuilderExtensions
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps OpenAPI endpoints to the application.
|
||||
/// Should be called before UseGatewayRouter so OpenAPI requests are handled first.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder.</param>
|
||||
/// <returns>The endpoint route builder for chaining.</returns>
|
||||
public static IEndpointRouteBuilder MapGatewayOpenApi(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
return endpoints.MapGatewayOpenApiEndpoints();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Gateway.WebService.OpenApi;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
@@ -14,17 +15,20 @@ internal sealed class ConnectionManager : IHostedService
|
||||
private readonly InMemoryTransportServer _transportServer;
|
||||
private readonly InMemoryConnectionRegistry _connectionRegistry;
|
||||
private readonly IGlobalRoutingState _routingState;
|
||||
private readonly IGatewayOpenApiDocumentCache? _openApiCache;
|
||||
private readonly ILogger<ConnectionManager> _logger;
|
||||
|
||||
public ConnectionManager(
|
||||
InMemoryTransportServer transportServer,
|
||||
InMemoryConnectionRegistry connectionRegistry,
|
||||
IGlobalRoutingState routingState,
|
||||
ILogger<ConnectionManager> logger)
|
||||
ILogger<ConnectionManager> logger,
|
||||
IGatewayOpenApiDocumentCache? openApiCache = null)
|
||||
{
|
||||
_transportServer = transportServer;
|
||||
_connectionRegistry = connectionRegistry;
|
||||
_routingState = routingState;
|
||||
_openApiCache = openApiCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -55,11 +59,12 @@ internal sealed class ConnectionManager : IHostedService
|
||||
private Task HandleHelloReceivedAsync(ConnectionState connectionState, HelloPayload payload)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Connection registered: {ConnectionId} from {ServiceName}/{Version} with {EndpointCount} endpoints",
|
||||
"Connection registered: {ConnectionId} from {ServiceName}/{Version} with {EndpointCount} endpoints, {SchemaCount} schemas",
|
||||
connectionState.ConnectionId,
|
||||
connectionState.Instance.ServiceName,
|
||||
connectionState.Instance.Version,
|
||||
connectionState.Endpoints.Count);
|
||||
connectionState.Endpoints.Count,
|
||||
connectionState.Schemas.Count);
|
||||
|
||||
// Add the connection to the routing state
|
||||
_routingState.AddConnection(connectionState);
|
||||
@@ -67,6 +72,9 @@ internal sealed class ConnectionManager : IHostedService
|
||||
// Start listening to this connection for frames
|
||||
_transportServer.StartListeningToConnection(connectionState.ConnectionId);
|
||||
|
||||
// Invalidate OpenAPI cache when connections change
|
||||
_openApiCache?.Invalidate();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -94,6 +102,9 @@ internal sealed class ConnectionManager : IHostedService
|
||||
// Remove from routing state
|
||||
_routingState.RemoveConnection(connectionId);
|
||||
|
||||
// Invalidate OpenAPI cache when connections change
|
||||
_openApiCache?.Invalidate();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Maps claim requirements to OpenAPI security schemes.
|
||||
/// </summary>
|
||||
internal static class ClaimSecurityMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates security schemes from claim requirements.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">All endpoint descriptors.</param>
|
||||
/// <param name="tokenUrl">The OAuth2 token URL.</param>
|
||||
/// <returns>Security schemes JSON object.</returns>
|
||||
public static JsonObject GenerateSecuritySchemes(
|
||||
IEnumerable<EndpointDescriptor> endpoints,
|
||||
string tokenUrl)
|
||||
{
|
||||
var schemes = new JsonObject();
|
||||
|
||||
// Always add BearerAuth scheme
|
||||
schemes["BearerAuth"] = new JsonObject
|
||||
{
|
||||
["type"] = "http",
|
||||
["scheme"] = "bearer",
|
||||
["bearerFormat"] = "JWT",
|
||||
["description"] = "JWT Bearer token authentication"
|
||||
};
|
||||
|
||||
// Collect all unique scopes from claims
|
||||
var scopes = new Dictionary<string, string>();
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
foreach (var claim in endpoint.RequiringClaims)
|
||||
{
|
||||
var scope = claim.Type;
|
||||
if (!scopes.ContainsKey(scope))
|
||||
{
|
||||
scopes[scope] = $"Access scope: {scope}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add OAuth2 scheme if there are any scopes
|
||||
if (scopes.Count > 0)
|
||||
{
|
||||
var scopesObject = new JsonObject();
|
||||
foreach (var (scope, description) in scopes)
|
||||
{
|
||||
scopesObject[scope] = description;
|
||||
}
|
||||
|
||||
schemes["OAuth2"] = new JsonObject
|
||||
{
|
||||
["type"] = "oauth2",
|
||||
["flows"] = new JsonObject
|
||||
{
|
||||
["clientCredentials"] = new JsonObject
|
||||
{
|
||||
["tokenUrl"] = tokenUrl,
|
||||
["scopes"] = scopesObject
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return schemes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates security requirement for an endpoint.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">The endpoint descriptor.</param>
|
||||
/// <returns>Security requirement JSON array.</returns>
|
||||
public static JsonArray GenerateSecurityRequirement(EndpointDescriptor endpoint)
|
||||
{
|
||||
var requirements = new JsonArray();
|
||||
|
||||
if (endpoint.RequiringClaims.Count == 0)
|
||||
{
|
||||
return requirements;
|
||||
}
|
||||
|
||||
var requirement = new JsonObject();
|
||||
|
||||
// Always require BearerAuth
|
||||
requirement["BearerAuth"] = new JsonArray();
|
||||
|
||||
// Add OAuth2 scopes
|
||||
var scopes = new JsonArray();
|
||||
foreach (var claim in endpoint.RequiringClaims)
|
||||
{
|
||||
scopes.Add(claim.Type);
|
||||
}
|
||||
|
||||
if (scopes.Count > 0)
|
||||
{
|
||||
requirement["OAuth2"] = scopes;
|
||||
}
|
||||
|
||||
requirements.Add(requirement);
|
||||
return requirements;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Caches the generated OpenAPI document with TTL-based expiration.
|
||||
/// </summary>
|
||||
internal sealed class GatewayOpenApiDocumentCache : IGatewayOpenApiDocumentCache
|
||||
{
|
||||
private readonly IOpenApiDocumentGenerator _generator;
|
||||
private readonly OpenApiAggregationOptions _options;
|
||||
private readonly object _lock = new();
|
||||
|
||||
private string? _cachedDocument;
|
||||
private string? _cachedETag;
|
||||
private DateTime _generatedAt;
|
||||
private bool _invalidated = true;
|
||||
|
||||
public GatewayOpenApiDocumentCache(
|
||||
IOpenApiDocumentGenerator generator,
|
||||
IOptions<OpenApiAggregationOptions> options)
|
||||
{
|
||||
_generator = generator;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public (string DocumentJson, string ETag, DateTime GeneratedAt) GetDocument()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var ttl = TimeSpan.FromSeconds(_options.CacheTtlSeconds);
|
||||
|
||||
// Check if we need to regenerate
|
||||
if (_invalidated || _cachedDocument is null || now - _generatedAt > ttl)
|
||||
{
|
||||
Regenerate();
|
||||
}
|
||||
|
||||
return (_cachedDocument!, _cachedETag!, _generatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Invalidate()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_invalidated = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void Regenerate()
|
||||
{
|
||||
_cachedDocument = _generator.GenerateDocument();
|
||||
_cachedETag = ComputeETag(_cachedDocument);
|
||||
_generatedAt = DateTime.UtcNow;
|
||||
_invalidated = false;
|
||||
}
|
||||
|
||||
private static string ComputeETag(string content)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"\"{Convert.ToHexString(hash)[..16]}\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace StellaOps.Gateway.WebService.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Caches the generated OpenAPI document with TTL-based expiration.
|
||||
/// </summary>
|
||||
public interface IGatewayOpenApiDocumentCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the cached document or regenerates if expired.
|
||||
/// </summary>
|
||||
/// <returns>A tuple containing the document JSON, ETag, and generation timestamp.</returns>
|
||||
(string DocumentJson, string ETag, DateTime GeneratedAt) GetDocument();
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the cache, forcing regeneration on next access.
|
||||
/// </summary>
|
||||
void Invalidate();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.Gateway.WebService.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Generates OpenAPI documents from aggregated microservice schemas.
|
||||
/// </summary>
|
||||
public interface IOpenApiDocumentGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates the OpenAPI 3.1.0 document as JSON.
|
||||
/// </summary>
|
||||
/// <returns>The OpenAPI document as a JSON string.</returns>
|
||||
string GenerateDocument();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace StellaOps.Gateway.WebService.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for OpenAPI document aggregation.
|
||||
/// </summary>
|
||||
public sealed class OpenApiAggregationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "OpenApi";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the API title.
|
||||
/// </summary>
|
||||
public string Title { get; set; } = "StellaOps Gateway API";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the API description.
|
||||
/// </summary>
|
||||
public string Description { get; set; } = "Unified API aggregating all connected microservices.";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the API version.
|
||||
/// </summary>
|
||||
public string Version { get; set; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the server URL.
|
||||
/// </summary>
|
||||
public string ServerUrl { get; set; } = "/";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cache TTL in seconds.
|
||||
/// </summary>
|
||||
public int CacheTtlSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether OpenAPI aggregation is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the license name.
|
||||
/// </summary>
|
||||
public string LicenseName { get; set; } = "AGPL-3.0-or-later";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the contact name.
|
||||
/// </summary>
|
||||
public string? ContactName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the contact email.
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OAuth2 token URL for security schemes.
|
||||
/// </summary>
|
||||
public string TokenUrl { get; set; } = "/auth/token";
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Generates OpenAPI 3.1.0 documents from aggregated microservice schemas.
|
||||
/// </summary>
|
||||
internal sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
|
||||
{
|
||||
private readonly IGlobalRoutingState _routingState;
|
||||
private readonly OpenApiAggregationOptions _options;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public OpenApiDocumentGenerator(
|
||||
IGlobalRoutingState routingState,
|
||||
IOptions<OpenApiAggregationOptions> options)
|
||||
{
|
||||
_routingState = routingState;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateDocument()
|
||||
{
|
||||
var connections = _routingState.GetAllConnections();
|
||||
var doc = new JsonObject
|
||||
{
|
||||
["openapi"] = "3.1.0",
|
||||
["info"] = GenerateInfo(),
|
||||
["servers"] = GenerateServers(),
|
||||
["paths"] = GeneratePaths(connections),
|
||||
["components"] = GenerateComponents(connections),
|
||||
["tags"] = GenerateTags(connections)
|
||||
};
|
||||
|
||||
return doc.ToJsonString(JsonOptions);
|
||||
}
|
||||
|
||||
private JsonObject GenerateInfo()
|
||||
{
|
||||
var info = new JsonObject
|
||||
{
|
||||
["title"] = _options.Title,
|
||||
["version"] = _options.Version,
|
||||
["description"] = _options.Description,
|
||||
["license"] = new JsonObject
|
||||
{
|
||||
["name"] = _options.LicenseName
|
||||
}
|
||||
};
|
||||
|
||||
if (_options.ContactName is not null || _options.ContactEmail is not null)
|
||||
{
|
||||
var contact = new JsonObject();
|
||||
if (_options.ContactName is not null)
|
||||
contact["name"] = _options.ContactName;
|
||||
if (_options.ContactEmail is not null)
|
||||
contact["email"] = _options.ContactEmail;
|
||||
info["contact"] = contact;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private JsonArray GenerateServers()
|
||||
{
|
||||
return new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["url"] = _options.ServerUrl
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private JsonObject GeneratePaths(IReadOnlyList<ConnectionState> connections)
|
||||
{
|
||||
var paths = new JsonObject();
|
||||
|
||||
// Group endpoints by path
|
||||
var pathGroups = new Dictionary<string, List<(ConnectionState Conn, EndpointDescriptor Endpoint)>>();
|
||||
|
||||
foreach (var conn in connections)
|
||||
{
|
||||
foreach (var endpoint in conn.Endpoints.Values)
|
||||
{
|
||||
if (!pathGroups.TryGetValue(endpoint.Path, out var list))
|
||||
{
|
||||
list = [];
|
||||
pathGroups[endpoint.Path] = list;
|
||||
}
|
||||
list.Add((conn, endpoint));
|
||||
}
|
||||
}
|
||||
|
||||
// Generate path items
|
||||
foreach (var (path, endpoints) in pathGroups.OrderBy(p => p.Key))
|
||||
{
|
||||
var pathItem = new JsonObject();
|
||||
|
||||
foreach (var (conn, endpoint) in endpoints)
|
||||
{
|
||||
var operation = GenerateOperation(conn, endpoint);
|
||||
var method = endpoint.Method.ToLowerInvariant();
|
||||
pathItem[method] = operation;
|
||||
}
|
||||
|
||||
paths[path] = pathItem;
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private JsonObject GenerateOperation(ConnectionState conn, EndpointDescriptor endpoint)
|
||||
{
|
||||
var operation = new JsonObject
|
||||
{
|
||||
["operationId"] = $"{conn.Instance.ServiceName}_{endpoint.Path.Replace("/", "_").Trim('_')}_{endpoint.Method}",
|
||||
["tags"] = new JsonArray { conn.Instance.ServiceName }
|
||||
};
|
||||
|
||||
// Add documentation from SchemaInfo
|
||||
if (endpoint.SchemaInfo is not null)
|
||||
{
|
||||
if (endpoint.SchemaInfo.Summary is not null)
|
||||
operation["summary"] = endpoint.SchemaInfo.Summary;
|
||||
if (endpoint.SchemaInfo.Description is not null)
|
||||
operation["description"] = endpoint.SchemaInfo.Description;
|
||||
if (endpoint.SchemaInfo.Deprecated)
|
||||
operation["deprecated"] = true;
|
||||
|
||||
// Override tags if specified
|
||||
if (endpoint.SchemaInfo.Tags.Count > 0)
|
||||
{
|
||||
var tags = new JsonArray();
|
||||
foreach (var tag in endpoint.SchemaInfo.Tags)
|
||||
{
|
||||
tags.Add(tag);
|
||||
}
|
||||
operation["tags"] = tags;
|
||||
}
|
||||
}
|
||||
|
||||
// Add security requirements
|
||||
var security = ClaimSecurityMapper.GenerateSecurityRequirement(endpoint);
|
||||
if (security.Count > 0)
|
||||
{
|
||||
operation["security"] = security;
|
||||
}
|
||||
|
||||
// Add request body if schema exists
|
||||
if (endpoint.SchemaInfo?.RequestSchemaId is not null)
|
||||
{
|
||||
var schemaRef = $"#/components/schemas/{conn.Instance.ServiceName}_{endpoint.SchemaInfo.RequestSchemaId}";
|
||||
operation["requestBody"] = new JsonObject
|
||||
{
|
||||
["required"] = true,
|
||||
["content"] = new JsonObject
|
||||
{
|
||||
["application/json"] = new JsonObject
|
||||
{
|
||||
["schema"] = new JsonObject
|
||||
{
|
||||
["$ref"] = schemaRef
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Add responses
|
||||
var responses = new JsonObject();
|
||||
|
||||
// Success response
|
||||
var successResponse = new JsonObject
|
||||
{
|
||||
["description"] = "Success"
|
||||
};
|
||||
|
||||
if (endpoint.SchemaInfo?.ResponseSchemaId is not null)
|
||||
{
|
||||
var schemaRef = $"#/components/schemas/{conn.Instance.ServiceName}_{endpoint.SchemaInfo.ResponseSchemaId}";
|
||||
successResponse["content"] = new JsonObject
|
||||
{
|
||||
["application/json"] = new JsonObject
|
||||
{
|
||||
["schema"] = new JsonObject
|
||||
{
|
||||
["$ref"] = schemaRef
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
responses["200"] = successResponse;
|
||||
|
||||
// Error responses
|
||||
responses["400"] = new JsonObject { ["description"] = "Bad Request" };
|
||||
responses["401"] = new JsonObject { ["description"] = "Unauthorized" };
|
||||
responses["404"] = new JsonObject { ["description"] = "Not Found" };
|
||||
responses["422"] = new JsonObject { ["description"] = "Validation Error" };
|
||||
responses["500"] = new JsonObject { ["description"] = "Internal Server Error" };
|
||||
|
||||
operation["responses"] = responses;
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
private JsonObject GenerateComponents(IReadOnlyList<ConnectionState> connections)
|
||||
{
|
||||
var components = new JsonObject();
|
||||
|
||||
// Generate schemas with service prefix
|
||||
var schemas = new JsonObject();
|
||||
foreach (var conn in connections)
|
||||
{
|
||||
foreach (var (schemaId, schemaDef) in conn.Schemas)
|
||||
{
|
||||
var prefixedId = $"{conn.Instance.ServiceName}_{schemaId}";
|
||||
try
|
||||
{
|
||||
var schemaNode = JsonNode.Parse(schemaDef.SchemaJson);
|
||||
if (schemaNode is not null)
|
||||
{
|
||||
schemas[prefixedId] = schemaNode;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Skip invalid schemas
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (schemas.Count > 0)
|
||||
{
|
||||
components["schemas"] = schemas;
|
||||
}
|
||||
|
||||
// Generate security schemes
|
||||
var allEndpoints = connections.SelectMany(c => c.Endpoints.Values);
|
||||
var securitySchemes = ClaimSecurityMapper.GenerateSecuritySchemes(allEndpoints, _options.TokenUrl);
|
||||
if (securitySchemes.Count > 0)
|
||||
{
|
||||
components["securitySchemes"] = securitySchemes;
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
private JsonArray GenerateTags(IReadOnlyList<ConnectionState> connections)
|
||||
{
|
||||
var tags = new JsonArray();
|
||||
var seen = new HashSet<string>();
|
||||
|
||||
foreach (var conn in connections)
|
||||
{
|
||||
var serviceName = conn.Instance.ServiceName;
|
||||
if (seen.Add(serviceName))
|
||||
{
|
||||
var tag = new JsonObject
|
||||
{
|
||||
["name"] = serviceName,
|
||||
["description"] = $"{serviceName} microservice (v{conn.Instance.Version})"
|
||||
};
|
||||
|
||||
if (conn.OpenApiInfo?.Description is not null)
|
||||
{
|
||||
tag["description"] = conn.OpenApiInfo.Description;
|
||||
}
|
||||
|
||||
tags.Add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for serving OpenAPI documentation.
|
||||
/// </summary>
|
||||
public static class OpenApiEndpoints
|
||||
{
|
||||
private static readonly ISerializer YamlSerializer = new SerializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Maps OpenAPI endpoints to the application.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapGatewayOpenApiEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet("/.well-known/openapi", GetOpenApiDiscovery)
|
||||
.ExcludeFromDescription();
|
||||
|
||||
endpoints.MapGet("/openapi.json", GetOpenApiJson)
|
||||
.ExcludeFromDescription();
|
||||
|
||||
endpoints.MapGet("/openapi.yaml", GetOpenApiYaml)
|
||||
.ExcludeFromDescription();
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult GetOpenApiDiscovery(
|
||||
[FromServices] IGatewayOpenApiDocumentCache cache,
|
||||
HttpContext context)
|
||||
{
|
||||
var (_, etag, generatedAt) = cache.GetDocument();
|
||||
|
||||
var discovery = new
|
||||
{
|
||||
openapi_json = "/openapi.json",
|
||||
openapi_yaml = "/openapi.yaml",
|
||||
etag,
|
||||
generated_at = generatedAt.ToString("O")
|
||||
};
|
||||
|
||||
context.Response.Headers.CacheControl = "public, max-age=60";
|
||||
return Results.Ok(discovery);
|
||||
}
|
||||
|
||||
private static IResult GetOpenApiJson(
|
||||
[FromServices] IGatewayOpenApiDocumentCache cache,
|
||||
HttpContext context)
|
||||
{
|
||||
var (documentJson, etag, _) = cache.GetDocument();
|
||||
|
||||
// Check If-None-Match header
|
||||
if (context.Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch))
|
||||
{
|
||||
if (ifNoneMatch == etag)
|
||||
{
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=60";
|
||||
return Results.StatusCode(304);
|
||||
}
|
||||
}
|
||||
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=60";
|
||||
return Results.Content(documentJson, "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
private static IResult GetOpenApiYaml(
|
||||
[FromServices] IGatewayOpenApiDocumentCache cache,
|
||||
HttpContext context)
|
||||
{
|
||||
var (documentJson, etag, _) = cache.GetDocument();
|
||||
|
||||
// Check If-None-Match header
|
||||
if (context.Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch))
|
||||
{
|
||||
if (ifNoneMatch == etag)
|
||||
{
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=60";
|
||||
return Results.StatusCode(304);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert JSON to YAML
|
||||
var jsonNode = JsonNode.Parse(documentJson);
|
||||
var yamlContent = ConvertToYaml(jsonNode);
|
||||
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=60";
|
||||
return Results.Content(yamlContent, "application/yaml; charset=utf-8");
|
||||
}
|
||||
|
||||
private static string ConvertToYaml(JsonNode? node)
|
||||
{
|
||||
if (node is null)
|
||||
return string.Empty;
|
||||
|
||||
var obj = ConvertJsonNodeToObject(node);
|
||||
return YamlSerializer.Serialize(obj);
|
||||
}
|
||||
|
||||
private static object? ConvertJsonNodeToObject(JsonNode? node)
|
||||
{
|
||||
return node switch
|
||||
{
|
||||
null => null,
|
||||
JsonObject obj => obj.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => ConvertJsonNodeToObject(kvp.Value)),
|
||||
JsonArray arr => arr.Select(ConvertJsonNodeToObject).ToList(),
|
||||
JsonValue val => val.GetValue<object>(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using StellaOps.Gateway.WebService.OpenApi;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
|
||||
@@ -41,6 +42,12 @@ public static class ServiceCollectionExtensions
|
||||
// Register health monitor as hosted service
|
||||
services.AddHostedService<HealthMonitorService>();
|
||||
|
||||
// Register OpenAPI aggregation services
|
||||
services.Configure<OpenApiAggregationOptions>(
|
||||
configuration.GetSection(OpenApiAggregationOptions.SectionName));
|
||||
services.AddSingleton<IOpenApiDocumentGenerator, OpenApiDocumentGenerator>();
|
||||
services.AddSingleton<IGatewayOpenApiDocumentCache, GatewayOpenApiDocumentCache>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="YamlDotNet" Version="16.2.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Config\StellaOps.Router.Config.csproj" />
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AuthorityClaimsRefreshService"/>.
|
||||
/// </summary>
|
||||
public sealed class AuthorityClaimsRefreshServiceTests
|
||||
{
|
||||
private readonly Mock<IAuthorityClaimsProvider> _claimsProviderMock;
|
||||
private readonly Mock<IEffectiveClaimsStore> _claimsStoreMock;
|
||||
private readonly AuthorityConnectionOptions _options;
|
||||
|
||||
public AuthorityClaimsRefreshServiceTests()
|
||||
{
|
||||
_claimsProviderMock = new Mock<IAuthorityClaimsProvider>();
|
||||
_claimsStoreMock = new Mock<IEffectiveClaimsStore>();
|
||||
_options = new AuthorityConnectionOptions
|
||||
{
|
||||
AuthorityUrl = "http://authority.local",
|
||||
Enabled = true,
|
||||
RefreshInterval = TimeSpan.FromMilliseconds(100),
|
||||
WaitForAuthorityOnStartup = false,
|
||||
StartupTimeout = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
|
||||
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>());
|
||||
}
|
||||
|
||||
private AuthorityClaimsRefreshService CreateService()
|
||||
{
|
||||
return new AuthorityClaimsRefreshService(
|
||||
_claimsProviderMock.Object,
|
||||
_claimsStoreMock.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<AuthorityClaimsRefreshService>.Instance);
|
||||
}
|
||||
|
||||
#region ExecuteAsync Tests - Disabled
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenDisabled_DoesNotFetchClaims()
|
||||
{
|
||||
// Arrange
|
||||
_options.Enabled = false;
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await service.StopAsync(cts.Token);
|
||||
|
||||
// Assert
|
||||
_claimsProviderMock.Verify(
|
||||
p => p.GetOverridesAsync(It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenNoAuthorityUrl_DoesNotFetchClaims()
|
||||
{
|
||||
// Arrange
|
||||
_options.AuthorityUrl = string.Empty;
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await service.StopAsync(cts.Token);
|
||||
|
||||
// Assert
|
||||
_claimsProviderMock.Verify(
|
||||
p => p.GetOverridesAsync(It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync Tests - Enabled
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenEnabled_FetchesClaims()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_claimsProviderMock.Verify(
|
||||
p => p.GetOverridesAsync(It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_UpdatesStoreWithOverrides()
|
||||
{
|
||||
// Arrange
|
||||
var key = EndpointKey.Create("service", "GET", "/api/test");
|
||||
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key] = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
};
|
||||
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(overrides);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_claimsStoreMock.Verify(
|
||||
s => s.UpdateFromAuthority(It.Is<IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>>(
|
||||
d => d.ContainsKey(key))),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync Tests - Wait for Authority
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WaitForAuthority_FetchesOnStartup()
|
||||
{
|
||||
// Arrange
|
||||
_options.WaitForAuthorityOnStartup = true;
|
||||
_options.StartupTimeout = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
// Authority is immediately available
|
||||
_claimsProviderMock.Setup(p => p.IsAvailable).Returns(true);
|
||||
|
||||
var fetchCalled = false;
|
||||
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
|
||||
.Callback(() => fetchCalled = true)
|
||||
.ReturnsAsync(new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>());
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - fetch was called during startup
|
||||
fetchCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WaitForAuthority_StopsAfterTimeout()
|
||||
{
|
||||
// Arrange
|
||||
_options.WaitForAuthorityOnStartup = true;
|
||||
_options.StartupTimeout = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
_claimsProviderMock.Setup(p => p.IsAvailable).Returns(false);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act - should not block forever
|
||||
var startTask = service.StartAsync(cts.Token);
|
||||
await Task.Delay(300);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should complete even if Authority never becomes available
|
||||
startTask.IsCompleted.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Push Notification Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithPushNotifications_SubscribesToEvent()
|
||||
{
|
||||
// Arrange
|
||||
_options.UseAuthorityPushNotifications = true;
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - verify event subscription by checking it doesn't throw
|
||||
_claimsProviderMock.VerifyAdd(
|
||||
p => p.OverridesChanged += It.IsAny<EventHandler<ClaimsOverrideChangedEventArgs>>(),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_WithPushNotifications_UnsubscribesFromEvent()
|
||||
{
|
||||
// Arrange
|
||||
_options.UseAuthorityPushNotifications = true;
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
|
||||
// Act
|
||||
await cts.CancelAsync();
|
||||
service.Dispose();
|
||||
|
||||
// Assert
|
||||
_claimsProviderMock.VerifyRemove(
|
||||
p => p.OverridesChanged -= It.IsAny<EventHandler<ClaimsOverrideChangedEventArgs>>(),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ProviderThrows_ContinuesRefreshLoop()
|
||||
{
|
||||
// Arrange
|
||||
var callCount = 0;
|
||||
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
throw new HttpRequestException("Test error");
|
||||
}
|
||||
return new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>();
|
||||
});
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(250); // Wait for at least 2 refresh cycles
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should have continued after error
|
||||
callCount.Should().BeGreaterThan(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
using System.Security.Claims;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AuthorizationMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class AuthorizationMiddlewareTests
|
||||
{
|
||||
private readonly Mock<IEffectiveClaimsStore> _claimsStoreMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private bool _nextCalled;
|
||||
|
||||
public AuthorizationMiddlewareTests()
|
||||
{
|
||||
_claimsStoreMock = new Mock<IEffectiveClaimsStore>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
}
|
||||
|
||||
private AuthorizationMiddleware CreateMiddleware()
|
||||
{
|
||||
return new AuthorizationMiddleware(
|
||||
_nextMock.Object,
|
||||
_claimsStoreMock.Object,
|
||||
NullLogger<AuthorizationMiddleware>.Instance);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext(
|
||||
EndpointDescriptor? endpoint = null,
|
||||
ClaimsPrincipal? user = null)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
if (endpoint is not null)
|
||||
{
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor] = endpoint;
|
||||
}
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
context.User = user;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(
|
||||
string serviceName = "test-service",
|
||||
string method = "GET",
|
||||
string path = "/api/test",
|
||||
ClaimRequirement[]? claims = null)
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = serviceName,
|
||||
Version = "1.0.0",
|
||||
Method = method,
|
||||
Path = path,
|
||||
RequiringClaims = claims ?? []
|
||||
};
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreateUserWithClaims(params (string Type, string Value)[] claims)
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
claims.Select(c => new Claim(c.Type, c.Value)),
|
||||
"TestAuth");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
#region No Endpoint Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithNoEndpoint_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(endpoint: null);
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Empty Claims Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithEmptyRequiringClaims_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>());
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Matching Claims Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMatchingClaims_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("role", "admin"));
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithClaimTypeOnly_MatchesAnyValue()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("role", "any-value"));
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = null } // Any value matches
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMultipleMatchingClaims_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(
|
||||
("role", "admin"),
|
||||
("department", "engineering"),
|
||||
("level", "senior"));
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" },
|
||||
new() { Type = "department", Value = "engineering" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Missing Claims Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMissingClaim_Returns403()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("role", "user")); // Has role, but wrong value
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMissingClaimType_Returns403()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("department", "engineering"));
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithNoClaims_Returns403()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(); // No claims at all
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithPartialMatchingClaims_Returns403()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("role", "admin")); // Has one, missing another
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" },
|
||||
new() { Type = "department", Value = "engineering" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response Body Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMissingClaim_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims();
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
context.Response.ContentType.Should().StartWith("application/json");
|
||||
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Forbidden");
|
||||
responseBody.Should().Contain("role");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EffectiveClaimsStore"/>.
|
||||
/// </summary>
|
||||
public sealed class EffectiveClaimsStoreTests
|
||||
{
|
||||
private readonly EffectiveClaimsStore _store;
|
||||
|
||||
public EffectiveClaimsStoreTests()
|
||||
{
|
||||
_store = new EffectiveClaimsStore(NullLogger<EffectiveClaimsStore>.Instance);
|
||||
}
|
||||
|
||||
#region GetEffectiveClaims Tests
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_NoClaimsRegistered_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange - fresh store
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("service", "GET", "/api/test");
|
||||
|
||||
// Assert
|
||||
claims.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_MicroserviceClaimsOnly_ReturnsMicroserviceClaims()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
claims.Should().HaveCount(1);
|
||||
claims[0].Type.Should().Be("role");
|
||||
claims[0].Value.Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_AuthorityOverridesTakePrecedence()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "user" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
var key = EndpointKey.Create("test-service", "GET", "/api/users");
|
||||
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key] = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
};
|
||||
_store.UpdateFromAuthority(overrides);
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
claims.Should().HaveCount(1);
|
||||
claims[0].Value.Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_MethodNormalization_MatchesCaseInsensitively()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "get",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
claims.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_PathNormalization_MatchesCaseInsensitively()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/API/USERS",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
claims.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateFromMicroservice Tests
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromMicroservice_MultipleEndpoints_RegistersAll()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "reader" }]
|
||||
},
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "POST",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "writer" }]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users")[0].Value.Should().Be("reader");
|
||||
_store.GetEffectiveClaims("test-service", "POST", "/api/users")[0].Value.Should().Be("writer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromMicroservice_EmptyClaims_RemovesFromStore()
|
||||
{
|
||||
// Arrange - first add some claims
|
||||
var endpoints1 = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints1);
|
||||
|
||||
// Now update with empty claims
|
||||
var endpoints2 = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = []
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromMicroservice("test-service", endpoints2);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromMicroservice_DefaultEmptyClaims_TreatedAsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users"
|
||||
// RequiringClaims defaults to []
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateFromAuthority Tests
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromAuthority_ClearsPreviousOverrides()
|
||||
{
|
||||
// Arrange - add initial override
|
||||
var key1 = EndpointKey.Create("service1", "GET", "/api/test1");
|
||||
var overrides1 = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key1] = [new ClaimRequirement { Type = "role", Value = "old" }]
|
||||
};
|
||||
_store.UpdateFromAuthority(overrides1);
|
||||
|
||||
// Update with new overrides (different key)
|
||||
var key2 = EndpointKey.Create("service2", "POST", "/api/test2");
|
||||
var overrides2 = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key2] = [new ClaimRequirement { Type = "role", Value = "new" }]
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromAuthority(overrides2);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("service1", "GET", "/api/test1").Should().BeEmpty();
|
||||
_store.GetEffectiveClaims("service2", "POST", "/api/test2").Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromAuthority_EmptyClaimsNotStored()
|
||||
{
|
||||
// Arrange
|
||||
var key = EndpointKey.Create("service", "GET", "/api/test");
|
||||
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key] = []
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromAuthority(overrides);
|
||||
|
||||
// Assert - should fall back to microservice (which is empty)
|
||||
_store.GetEffectiveClaims("service", "GET", "/api/test").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromAuthority_MultipleOverrides()
|
||||
{
|
||||
// Arrange
|
||||
var key1 = EndpointKey.Create("service1", "GET", "/api/users");
|
||||
var key2 = EndpointKey.Create("service1", "POST", "/api/users");
|
||||
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key1] = [new ClaimRequirement { Type = "role", Value = "reader" }],
|
||||
[key2] = [new ClaimRequirement { Type = "role", Value = "writer" }]
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromAuthority(overrides);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("service1", "GET", "/api/users")[0].Value.Should().Be("reader");
|
||||
_store.GetEffectiveClaims("service1", "POST", "/api/users")[0].Value.Should().Be("writer");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveService Tests
|
||||
|
||||
[Fact]
|
||||
public void RemoveService_RemovesMicroserviceClaims()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Act
|
||||
_store.RemoveService("test-service");
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveService_CaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "Test-Service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("Test-Service", endpoints);
|
||||
|
||||
// Act - remove with different case
|
||||
_store.RemoveService("TEST-SERVICE");
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveService_OnlyRemovesTargetService()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints1 = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "service-a",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/a",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "a" }]
|
||||
}
|
||||
};
|
||||
var endpoints2 = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "service-b",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/b",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "b" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("service-a", endpoints1);
|
||||
_store.UpdateFromMicroservice("service-b", endpoints2);
|
||||
|
||||
// Act
|
||||
_store.RemoveService("service-a");
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("service-a", "GET", "/api/a").Should().BeEmpty();
|
||||
_store.GetEffectiveClaims("service-b", "GET", "/api/b").Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveService_UnknownService_DoesNotThrow()
|
||||
{
|
||||
// Arrange & Act
|
||||
var action = () => _store.RemoveService("unknown-service");
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EndpointResolutionMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class EndpointResolutionMiddlewareTests
|
||||
{
|
||||
private readonly Mock<IGlobalRoutingState> _routingStateMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private bool _nextCalled;
|
||||
|
||||
public EndpointResolutionMiddlewareTests()
|
||||
{
|
||||
_routingStateMock = new Mock<IGlobalRoutingState>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
}
|
||||
|
||||
private EndpointResolutionMiddleware CreateMiddleware()
|
||||
{
|
||||
return new EndpointResolutionMiddleware(_nextMock.Object);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext(string method = "GET", string path = "/api/test")
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = method;
|
||||
context.Request.Path = path;
|
||||
context.Response.Body = new MemoryStream();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(
|
||||
string serviceName = "test-service",
|
||||
string method = "GET",
|
||||
string path = "/api/test")
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = serviceName,
|
||||
Version = "1.0.0",
|
||||
Method = method,
|
||||
Path = path
|
||||
};
|
||||
}
|
||||
|
||||
#region Matching Endpoint Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithMatchingEndpoint_SetsHttpContextItem()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext();
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/test"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithMatchingEndpoint_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext();
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/test"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unknown Path Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithUnknownPath_Returns404()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(path: "/api/unknown");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/unknown"))
|
||||
.Returns((EndpointDescriptor?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithUnknownPath_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(path: "/api/unknown");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/unknown"))
|
||||
.Returns((EndpointDescriptor?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("not found");
|
||||
responseBody.Should().Contain("/api/unknown");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HTTP Method Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithPostMethod_ResolvesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(method: "POST");
|
||||
var context = CreateHttpContext(method: "POST");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("POST", "/api/test"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithDeleteMethod_ResolvesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(method: "DELETE", path: "/api/users/123");
|
||||
var context = CreateHttpContext(method: "DELETE", path: "/api/users/123");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("DELETE", "/api/users/123"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithWrongMethod_Returns404()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(method: "DELETE", path: "/api/test");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("DELETE", "/api/test"))
|
||||
.Returns((EndpointDescriptor?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Path Variations Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithParameterizedPath_ResolvesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(path: "/api/users/{id}");
|
||||
var context = CreateHttpContext(path: "/api/users/123");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/users/123"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithRootPath_ResolvesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(path: "/");
|
||||
var context = CreateHttpContext(path: "/");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithEmptyPath_PassesEmptyStringToRouting()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(path: "");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", ""))
|
||||
.Returns((EndpointDescriptor?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(r => r.ResolveEndpoint("GET", ""), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multiple Calls Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_MultipleCalls_EachResolvesIndependently()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint1 = CreateEndpoint(path: "/api/users");
|
||||
var endpoint2 = CreateEndpoint(path: "/api/items");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/users"))
|
||||
.Returns(endpoint1);
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/items"))
|
||||
.Returns(endpoint2);
|
||||
|
||||
var context1 = CreateHttpContext(path: "/api/users");
|
||||
var context2 = CreateHttpContext(path: "/api/items");
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context1, _routingStateMock.Object);
|
||||
await middleware.Invoke(context2, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context1.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint1);
|
||||
context2.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="HttpAuthorityClaimsProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class HttpAuthorityClaimsProviderTests
|
||||
{
|
||||
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly AuthorityConnectionOptions _options;
|
||||
|
||||
public HttpAuthorityClaimsProviderTests()
|
||||
{
|
||||
_httpHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClient = new HttpClient(_httpHandlerMock.Object);
|
||||
_options = new AuthorityConnectionOptions
|
||||
{
|
||||
AuthorityUrl = "http://authority.local"
|
||||
};
|
||||
}
|
||||
|
||||
private HttpAuthorityClaimsProvider CreateProvider()
|
||||
{
|
||||
return new HttpAuthorityClaimsProvider(
|
||||
_httpClient,
|
||||
Options.Create(_options),
|
||||
NullLogger<HttpAuthorityClaimsProvider>.Instance);
|
||||
}
|
||||
|
||||
#region GetOverridesAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_NoAuthorityUrl_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
_options.AuthorityUrl = string.Empty;
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_WhitespaceUrl_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
_options.AuthorityUrl = " ";
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_SuccessfulResponse_ParsesOverrides()
|
||||
{
|
||||
// Arrange
|
||||
var responseBody = JsonSerializer.Serialize(new
|
||||
{
|
||||
overrides = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
serviceName = "test-service",
|
||||
method = "GET",
|
||||
path = "/api/users",
|
||||
requiringClaims = new[]
|
||||
{
|
||||
new { type = "role", value = "admin" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
SetupHttpResponse(HttpStatusCode.OK, responseBody);
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
provider.IsAvailable.Should().BeTrue();
|
||||
|
||||
var key = result.Keys.First();
|
||||
key.ServiceName.Should().Be("test-service");
|
||||
key.Method.Should().Be("GET");
|
||||
key.Path.Should().Be("/api/users");
|
||||
|
||||
result[key].Should().HaveCount(1);
|
||||
result[key][0].Type.Should().Be("role");
|
||||
result[key][0].Value.Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_EmptyOverrides_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var responseBody = JsonSerializer.Serialize(new
|
||||
{
|
||||
overrides = Array.Empty<object>()
|
||||
});
|
||||
|
||||
SetupHttpResponse(HttpStatusCode.OK, responseBody);
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_NullOverrides_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var responseBody = "{}";
|
||||
SetupHttpResponse(HttpStatusCode.OK, responseBody);
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_HttpError_ReturnsEmptyAndSetsUnavailable()
|
||||
{
|
||||
// Arrange
|
||||
SetupHttpResponse(HttpStatusCode.InternalServerError, "Error");
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_Timeout_ReturnsEmptyAndSetsUnavailable()
|
||||
{
|
||||
// Arrange
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new TaskCanceledException("Timeout"));
|
||||
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_NetworkError_ReturnsEmptyAndSetsUnavailable()
|
||||
{
|
||||
// Arrange
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Connection refused"));
|
||||
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_TrimsTrailingSlash()
|
||||
{
|
||||
// Arrange
|
||||
_options.AuthorityUrl = "http://authority.local/";
|
||||
var responseBody = JsonSerializer.Serialize(new { overrides = Array.Empty<object>() });
|
||||
|
||||
string? capturedUrl = null;
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync((HttpRequestMessage req, CancellationToken _) =>
|
||||
{
|
||||
capturedUrl = req.RequestUri?.ToString();
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(responseBody)
|
||||
};
|
||||
});
|
||||
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
capturedUrl.Should().Be("http://authority.local/api/v1/claims/overrides");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_MultipleOverrides_ParsesAll()
|
||||
{
|
||||
// Arrange
|
||||
var responseBody = JsonSerializer.Serialize(new
|
||||
{
|
||||
overrides = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
serviceName = "service-a",
|
||||
method = "GET",
|
||||
path = "/api/a",
|
||||
requiringClaims = new[] { new { type = "role", value = "a" } }
|
||||
},
|
||||
new
|
||||
{
|
||||
serviceName = "service-b",
|
||||
method = "POST",
|
||||
path = "/api/b",
|
||||
requiringClaims = new[]
|
||||
{
|
||||
new { type = "role", value = "b1" },
|
||||
new { type = "department", value = "b2" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
SetupHttpResponse(HttpStatusCode.OK, responseBody);
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsAvailable Tests
|
||||
|
||||
[Fact]
|
||||
public void IsAvailable_InitiallyFalse()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Assert
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsAvailable_TrueAfterSuccessfulFetch()
|
||||
{
|
||||
// Arrange
|
||||
SetupHttpResponse(HttpStatusCode.OK, "{}");
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
provider.IsAvailable.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsAvailable_FalseAfterFailedFetch()
|
||||
{
|
||||
// Arrange
|
||||
SetupHttpResponse(HttpStatusCode.ServiceUnavailable, "");
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region OverridesChanged Event Tests
|
||||
|
||||
[Fact]
|
||||
public void OverridesChanged_CanBeSubscribed()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
var eventRaised = false;
|
||||
|
||||
// Act
|
||||
provider.OverridesChanged += (_, _) => eventRaised = true;
|
||||
|
||||
// Assert - no exception during subscription, event not raised yet
|
||||
eventRaised.Should().BeFalse();
|
||||
provider.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupHttpResponse(HttpStatusCode statusCode, string content)
|
||||
{
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(content)
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Gateway.WebService.OpenApi;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.OpenApi;
|
||||
|
||||
public class ClaimSecurityMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_WithNoEndpoints_ReturnsBearerAuthOnly()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = Array.Empty<EndpointDescriptor>();
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
|
||||
|
||||
// Assert
|
||||
schemes.Should().ContainKey("BearerAuth");
|
||||
schemes.Should().NotContainKey("OAuth2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_WithClaimRequirements_ReturnsOAuth2()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/test",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "test:write" }]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
|
||||
|
||||
// Assert
|
||||
schemes.Should().ContainKey("BearerAuth");
|
||||
schemes.Should().ContainKey("OAuth2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_CollectsAllUniqueScopes()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "billing:write" }]
|
||||
},
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "billing:read" }]
|
||||
},
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/payments",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "billing:write" }] // Duplicate
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
|
||||
|
||||
// Assert
|
||||
var oauth2 = schemes["OAuth2"];
|
||||
var scopes = oauth2!["flows"]!["clientCredentials"]!["scopes"]!;
|
||||
|
||||
scopes.AsObject().Count.Should().Be(2); // Only unique scopes
|
||||
scopes["billing:write"].Should().NotBeNull();
|
||||
scopes["billing:read"].Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_SetsCorrectTokenUrl()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/test",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "test:write" }]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/custom/token");
|
||||
|
||||
// Assert
|
||||
var tokenUrl = schemes["OAuth2"]!["flows"]!["clientCredentials"]!["tokenUrl"]!.GetValue<string>();
|
||||
tokenUrl.Should().Be("/custom/token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecurityRequirement_WithNoClaimRequirements_ReturnsEmptyArray()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/public",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = []
|
||||
};
|
||||
|
||||
// Act
|
||||
var requirement = ClaimSecurityMapper.GenerateSecurityRequirement(endpoint);
|
||||
|
||||
// Assert
|
||||
requirement.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecurityRequirement_WithClaimRequirements_ReturnsBearerAndOAuth2()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/secure",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims =
|
||||
[
|
||||
new ClaimRequirement { Type = "billing:write" },
|
||||
new ClaimRequirement { Type = "billing:admin" }
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var requirement = ClaimSecurityMapper.GenerateSecurityRequirement(endpoint);
|
||||
|
||||
// Assert
|
||||
requirement.Count.Should().Be(1);
|
||||
|
||||
var req = requirement[0]!.AsObject();
|
||||
req.Should().ContainKey("BearerAuth");
|
||||
req.Should().ContainKey("OAuth2");
|
||||
|
||||
var scopes = req["OAuth2"]!.AsArray();
|
||||
scopes.Count.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_BearerAuth_HasCorrectStructure()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = Array.Empty<EndpointDescriptor>();
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
|
||||
|
||||
// Assert
|
||||
var bearer = schemes["BearerAuth"]!.AsObject();
|
||||
bearer["type"]!.GetValue<string>().Should().Be("http");
|
||||
bearer["scheme"]!.GetValue<string>().Should().Be("bearer");
|
||||
bearer["bearerFormat"]!.GetValue<string>().Should().Be("JWT");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.OpenApi;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.OpenApi;
|
||||
|
||||
public class GatewayOpenApiDocumentCacheTests
|
||||
{
|
||||
private readonly Mock<IOpenApiDocumentGenerator> _generator = new();
|
||||
private readonly OpenApiAggregationOptions _options = new() { CacheTtlSeconds = 60 };
|
||||
private readonly GatewayOpenApiDocumentCache _sut;
|
||||
|
||||
public GatewayOpenApiDocumentCacheTests()
|
||||
{
|
||||
_sut = new GatewayOpenApiDocumentCache(
|
||||
_generator.Object,
|
||||
Options.Create(_options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_FirstCall_GeneratesDocument()
|
||||
{
|
||||
// Arrange
|
||||
var expectedDoc = """{"openapi":"3.1.0"}""";
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns(expectedDoc);
|
||||
|
||||
// Act
|
||||
var (doc, _, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
doc.Should().Be(expectedDoc);
|
||||
_generator.Verify(x => x.GenerateDocument(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_SubsequentCalls_ReturnsCachedDocument()
|
||||
{
|
||||
// Arrange
|
||||
var expectedDoc = """{"openapi":"3.1.0"}""";
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns(expectedDoc);
|
||||
|
||||
// Act
|
||||
var (doc1, _, _) = _sut.GetDocument();
|
||||
var (doc2, _, _) = _sut.GetDocument();
|
||||
var (doc3, _, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
doc1.Should().Be(expectedDoc);
|
||||
doc2.Should().Be(expectedDoc);
|
||||
doc3.Should().Be(expectedDoc);
|
||||
_generator.Verify(x => x.GenerateDocument(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_AfterInvalidate_RegeneratesDocument()
|
||||
{
|
||||
// Arrange
|
||||
var doc1 = """{"openapi":"3.1.0","version":"1"}""";
|
||||
var doc2 = """{"openapi":"3.1.0","version":"2"}""";
|
||||
|
||||
_generator.SetupSequence(x => x.GenerateDocument())
|
||||
.Returns(doc1)
|
||||
.Returns(doc2);
|
||||
|
||||
// Act
|
||||
var (result1, _, _) = _sut.GetDocument();
|
||||
_sut.Invalidate();
|
||||
var (result2, _, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
result1.Should().Be(doc1);
|
||||
result2.Should().Be(doc2);
|
||||
_generator.Verify(x => x.GenerateDocument(), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_ReturnsConsistentETag()
|
||||
{
|
||||
// Arrange
|
||||
var expectedDoc = """{"openapi":"3.1.0"}""";
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns(expectedDoc);
|
||||
|
||||
// Act
|
||||
var (_, etag1, _) = _sut.GetDocument();
|
||||
var (_, etag2, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
etag1.Should().NotBeNullOrEmpty();
|
||||
etag1.Should().Be(etag2);
|
||||
etag1.Should().StartWith("\"").And.EndWith("\""); // ETag format
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_DifferentContent_DifferentETag()
|
||||
{
|
||||
// Arrange
|
||||
var doc1 = """{"openapi":"3.1.0","version":"1"}""";
|
||||
var doc2 = """{"openapi":"3.1.0","version":"2"}""";
|
||||
|
||||
_generator.SetupSequence(x => x.GenerateDocument())
|
||||
.Returns(doc1)
|
||||
.Returns(doc2);
|
||||
|
||||
// Act
|
||||
var (_, etag1, _) = _sut.GetDocument();
|
||||
_sut.Invalidate();
|
||||
var (_, etag2, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
etag1.Should().NotBe(etag2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_ReturnsGenerationTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns("{}");
|
||||
var beforeGeneration = DateTime.UtcNow;
|
||||
|
||||
// Act
|
||||
var (_, _, generatedAt) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
generatedAt.Should().BeOnOrAfter(beforeGeneration);
|
||||
generatedAt.Should().BeOnOrBefore(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_CanBeCalledMultipleTimes()
|
||||
{
|
||||
// Arrange
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns("{}");
|
||||
_sut.GetDocument();
|
||||
|
||||
// Act & Assert - should not throw
|
||||
_sut.Invalidate();
|
||||
_sut.Invalidate();
|
||||
_sut.Invalidate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_WithZeroTtl_AlwaysRegenerates()
|
||||
{
|
||||
// Arrange
|
||||
var options = new OpenApiAggregationOptions { CacheTtlSeconds = 0 };
|
||||
var sut = new GatewayOpenApiDocumentCache(
|
||||
_generator.Object,
|
||||
Options.Create(options));
|
||||
|
||||
var callCount = 0;
|
||||
_generator.Setup(x => x.GenerateDocument())
|
||||
.Returns(() => $"{{\"call\":{++callCount}}}");
|
||||
|
||||
// Act
|
||||
sut.GetDocument();
|
||||
// Wait a tiny bit to ensure TTL is exceeded
|
||||
Thread.Sleep(10);
|
||||
sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
// With 0 TTL, each call should regenerate
|
||||
_generator.Verify(x => x.GenerateDocument(), Times.Exactly(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.OpenApi;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.OpenApi;
|
||||
|
||||
public class OpenApiDocumentGeneratorTests
|
||||
{
|
||||
private readonly Mock<IGlobalRoutingState> _routingState = new();
|
||||
private readonly OpenApiAggregationOptions _options = new();
|
||||
private readonly OpenApiDocumentGenerator _sut;
|
||||
|
||||
public OpenApiDocumentGeneratorTests()
|
||||
{
|
||||
_sut = new OpenApiDocumentGenerator(
|
||||
_routingState.Object,
|
||||
Options.Create(_options));
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
params EndpointDescriptor[] endpoints)
|
||||
{
|
||||
var connection = new ConnectionState
|
||||
{
|
||||
ConnectionId = $"conn-{serviceName}",
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"inst-{serviceName}",
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = "us-east-1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TransportType = TransportType.InMemory,
|
||||
Schemas = new Dictionary<string, SchemaDefinition>(),
|
||||
OpenApiInfo = new ServiceOpenApiInfo
|
||||
{
|
||||
Title = serviceName,
|
||||
Description = $"Test {serviceName} service"
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
connection.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithNoConnections_ReturnsValidOpenApiDocument()
|
||||
{
|
||||
// Arrange
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
document.Should().NotBeNullOrEmpty();
|
||||
|
||||
var doc = JsonDocument.Parse(document);
|
||||
doc.RootElement.GetProperty("openapi").GetString().Should().Be("3.1.0");
|
||||
doc.RootElement.GetProperty("info").GetProperty("title").GetString().Should().Be(_options.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_SetsCorrectInfoSection()
|
||||
{
|
||||
// Arrange
|
||||
_options.Title = "My Gateway API";
|
||||
_options.Description = "My description";
|
||||
_options.Version = "2.0.0";
|
||||
_options.LicenseName = "MIT";
|
||||
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var info = doc.RootElement.GetProperty("info");
|
||||
|
||||
info.GetProperty("title").GetString().Should().Be("My Gateway API");
|
||||
info.GetProperty("description").GetString().Should().Be("My description");
|
||||
info.GetProperty("version").GetString().Should().Be("2.0.0");
|
||||
info.GetProperty("license").GetProperty("name").GetString().Should().Be("MIT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithConnections_GeneratesPaths()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/items",
|
||||
ServiceName = "inventory",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
var connection = CreateConnection("inventory", "1.0.0", endpoint);
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var paths = doc.RootElement.GetProperty("paths");
|
||||
|
||||
paths.TryGetProperty("/api/items", out var pathItem).Should().BeTrue();
|
||||
pathItem.TryGetProperty("get", out var operation).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithSchemaInfo_IncludesDocumentation()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
SchemaInfo = new EndpointSchemaInfo
|
||||
{
|
||||
Summary = "Create invoice",
|
||||
Description = "Creates a new invoice",
|
||||
Tags = ["billing", "invoices"],
|
||||
Deprecated = false
|
||||
}
|
||||
};
|
||||
|
||||
var connection = CreateConnection("billing", "1.0.0", endpoint);
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var operation = doc.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/invoices")
|
||||
.GetProperty("post");
|
||||
|
||||
operation.GetProperty("summary").GetString().Should().Be("Create invoice");
|
||||
operation.GetProperty("description").GetString().Should().Be("Creates a new invoice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithSchemas_IncludesSchemaReferences()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
SchemaInfo = new EndpointSchemaInfo
|
||||
{
|
||||
RequestSchemaId = "CreateInvoiceRequest"
|
||||
}
|
||||
};
|
||||
|
||||
var connection = CreateConnection("billing", "1.0.0", endpoint);
|
||||
var connectionWithSchemas = new ConnectionState
|
||||
{
|
||||
ConnectionId = connection.ConnectionId,
|
||||
Instance = connection.Instance,
|
||||
Status = connection.Status,
|
||||
TransportType = connection.TransportType,
|
||||
Schemas = new Dictionary<string, SchemaDefinition>
|
||||
{
|
||||
["CreateInvoiceRequest"] = new SchemaDefinition
|
||||
{
|
||||
SchemaId = "CreateInvoiceRequest",
|
||||
SchemaJson = """{"type": "object", "properties": {"amount": {"type": "number"}}}""",
|
||||
ETag = "\"ABC123\""
|
||||
}
|
||||
}
|
||||
};
|
||||
connectionWithSchemas.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
|
||||
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connectionWithSchemas]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
|
||||
// Check request body reference
|
||||
var requestBody = doc.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/invoices")
|
||||
.GetProperty("post")
|
||||
.GetProperty("requestBody")
|
||||
.GetProperty("content")
|
||||
.GetProperty("application/json")
|
||||
.GetProperty("schema")
|
||||
.GetProperty("$ref")
|
||||
.GetString();
|
||||
|
||||
requestBody.Should().Be("#/components/schemas/billing_CreateInvoiceRequest");
|
||||
|
||||
// Check schema exists in components
|
||||
var schemas = doc.RootElement.GetProperty("components").GetProperty("schemas");
|
||||
schemas.TryGetProperty("billing_CreateInvoiceRequest", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithClaimRequirements_IncludesSecurity()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "billing:write" }]
|
||||
};
|
||||
|
||||
var connection = CreateConnection("billing", "1.0.0", endpoint);
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
|
||||
// Check security schemes
|
||||
var securitySchemes = doc.RootElement
|
||||
.GetProperty("components")
|
||||
.GetProperty("securitySchemes");
|
||||
|
||||
securitySchemes.TryGetProperty("BearerAuth", out _).Should().BeTrue();
|
||||
securitySchemes.TryGetProperty("OAuth2", out _).Should().BeTrue();
|
||||
|
||||
// Check operation security
|
||||
var operation = doc.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/invoices")
|
||||
.GetProperty("post");
|
||||
|
||||
operation.TryGetProperty("security", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithMultipleServices_GeneratesTags()
|
||||
{
|
||||
// Arrange
|
||||
var billingEndpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
var inventoryEndpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/items",
|
||||
ServiceName = "inventory",
|
||||
Version = "2.0.0"
|
||||
};
|
||||
|
||||
var billingConn = CreateConnection("billing", "1.0.0", billingEndpoint);
|
||||
var inventoryConn = CreateConnection("inventory", "2.0.0", inventoryEndpoint);
|
||||
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([billingConn, inventoryConn]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var tags = doc.RootElement.GetProperty("tags");
|
||||
|
||||
tags.GetArrayLength().Should().Be(2);
|
||||
|
||||
var tagNames = new List<string>();
|
||||
foreach (var tag in tags.EnumerateArray())
|
||||
{
|
||||
tagNames.Add(tag.GetProperty("name").GetString()!);
|
||||
}
|
||||
|
||||
tagNames.Should().Contain("billing");
|
||||
tagNames.Should().Contain("inventory");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithDeprecatedEndpoint_SetsDeprecatedFlag()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/legacy",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
SchemaInfo = new EndpointSchemaInfo
|
||||
{
|
||||
Deprecated = true
|
||||
}
|
||||
};
|
||||
|
||||
var connection = CreateConnection("test", "1.0.0", endpoint);
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var operation = doc.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/legacy")
|
||||
.GetProperty("get");
|
||||
|
||||
operation.GetProperty("deprecated").GetBoolean().Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="PayloadLimitsMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class PayloadLimitsMiddlewareTests
|
||||
{
|
||||
private readonly Mock<IPayloadTracker> _trackerMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private readonly PayloadLimits _defaultLimits;
|
||||
private bool _nextCalled;
|
||||
|
||||
public PayloadLimitsMiddlewareTests()
|
||||
{
|
||||
_trackerMock = new Mock<IPayloadTracker>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_defaultLimits = new PayloadLimits
|
||||
{
|
||||
MaxRequestBytesPerCall = 10 * 1024 * 1024, // 10MB
|
||||
MaxRequestBytesPerConnection = 100 * 1024 * 1024, // 100MB
|
||||
MaxAggregateInflightBytes = 1024 * 1024 * 1024 // 1GB
|
||||
};
|
||||
}
|
||||
|
||||
private PayloadLimitsMiddleware CreateMiddleware(PayloadLimits? limits = null)
|
||||
{
|
||||
return new PayloadLimitsMiddleware(
|
||||
_nextMock.Object,
|
||||
Options.Create(limits ?? _defaultLimits),
|
||||
NullLogger<PayloadLimitsMiddleware>.Instance);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext(long? contentLength = null, string connectionId = "conn-1")
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.Request.Body = new MemoryStream();
|
||||
context.Connection.Id = connectionId;
|
||||
|
||||
if (contentLength.HasValue)
|
||||
{
|
||||
context.Request.ContentLength = contentLength;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
#region Within Limits Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithinLimits_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoContentLength_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: null);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 0))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithZeroContentLength_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 0);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 0))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Per-Call Limit Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsPerCallLimit_Returns413()
|
||||
{
|
||||
// Arrange
|
||||
var limits = new PayloadLimits { MaxRequestBytesPerCall = 1000 };
|
||||
var middleware = CreateMiddleware(limits);
|
||||
var context = CreateHttpContext(contentLength: 2000);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status413PayloadTooLarge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsPerCallLimit_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var limits = new PayloadLimits { MaxRequestBytesPerCall = 1000 };
|
||||
var middleware = CreateMiddleware(limits);
|
||||
var context = CreateHttpContext(contentLength: 2000);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Payload Too Large");
|
||||
responseBody.Should().Contain("1000");
|
||||
responseBody.Should().Contain("2000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExactlyAtPerCallLimit_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var limits = new PayloadLimits { MaxRequestBytesPerCall = 1000 };
|
||||
var middleware = CreateMiddleware(limits);
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Aggregate Limit Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsAggregateLimit_Returns503()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(false);
|
||||
_trackerMock.Setup(t => t.IsOverloaded)
|
||||
.Returns(true);
|
||||
_trackerMock.Setup(t => t.CurrentInflightBytes)
|
||||
.Returns(1024 * 1024 * 1024); // 1GB
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsAggregateLimit_WritesOverloadedResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(false);
|
||||
_trackerMock.Setup(t => t.IsOverloaded)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Overloaded");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Per-Connection Limit Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsPerConnectionLimit_Returns429()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(false);
|
||||
_trackerMock.Setup(t => t.IsOverloaded)
|
||||
.Returns(false); // Not aggregate limit
|
||||
_trackerMock.Setup(t => t.GetConnectionInflightBytes("conn-1"))
|
||||
.Returns(100 * 1024 * 1024); // 100MB
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status429TooManyRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsPerConnectionLimit_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(false);
|
||||
_trackerMock.Setup(t => t.IsOverloaded)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Too Many Requests");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Release Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_AfterSuccess_ReleasesReservation()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_trackerMock.Verify(t => t.Release("conn-1", It.IsAny<long>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_AfterNextThrows_StillReleasesReservation()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(true);
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Test error"));
|
||||
|
||||
// Act
|
||||
var act = async () => await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
_trackerMock.Verify(t => t.Release("conn-1", It.IsAny<long>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Different Connections Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_DifferentConnections_TrackedSeparately()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context1 = CreateHttpContext(contentLength: 1000, connectionId: "conn-1");
|
||||
var context2 = CreateHttpContext(contentLength: 2000, connectionId: "conn-2");
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve(It.IsAny<string>(), It.IsAny<long>()))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context1, _trackerMock.Object);
|
||||
await middleware.Invoke(context2, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_trackerMock.Verify(t => t.TryReserve("conn-1", 1000), Times.Once);
|
||||
_trackerMock.Verify(t => t.TryReserve("conn-2", 2000), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RoutingDecisionMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class RoutingDecisionMiddlewareTests
|
||||
{
|
||||
private readonly Mock<IRoutingPlugin> _routingPluginMock;
|
||||
private readonly Mock<IGlobalRoutingState> _routingStateMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private readonly GatewayNodeConfig _gatewayConfig;
|
||||
private readonly RoutingOptions _routingOptions;
|
||||
private bool _nextCalled;
|
||||
|
||||
public RoutingDecisionMiddlewareTests()
|
||||
{
|
||||
_routingPluginMock = new Mock<IRoutingPlugin>();
|
||||
_routingStateMock = new Mock<IGlobalRoutingState>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_gatewayConfig = new GatewayNodeConfig
|
||||
{
|
||||
Region = "us-east-1",
|
||||
NodeId = "gw-01",
|
||||
Environment = "test"
|
||||
};
|
||||
|
||||
_routingOptions = new RoutingOptions
|
||||
{
|
||||
DefaultVersion = "1.0.0"
|
||||
};
|
||||
}
|
||||
|
||||
private RoutingDecisionMiddleware CreateMiddleware()
|
||||
{
|
||||
return new RoutingDecisionMiddleware(_nextMock.Object);
|
||||
}
|
||||
|
||||
private HttpContext CreateHttpContext(EndpointDescriptor? endpoint = null)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = "GET";
|
||||
context.Request.Path = "/api/test";
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
if (endpoint is not null)
|
||||
{
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor] = endpoint;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0")
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Method = "GET",
|
||||
Path = "/api/test"
|
||||
};
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId = "conn-1",
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"inst-{connectionId}",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east-1"
|
||||
},
|
||||
Status = status,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
}
|
||||
|
||||
private static RoutingDecision CreateDecision(
|
||||
EndpointDescriptor? endpoint = null,
|
||||
ConnectionState? connection = null)
|
||||
{
|
||||
return new RoutingDecision
|
||||
{
|
||||
Endpoint = endpoint ?? CreateEndpoint(),
|
||||
Connection = connection ?? CreateConnection(),
|
||||
TransportType = TransportType.InMemory,
|
||||
EffectiveTimeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
}
|
||||
|
||||
#region Missing Endpoint Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoEndpoint_Returns500()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(endpoint: null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoEndpoint_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(endpoint: null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("descriptor missing");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Available Instance Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithAvailableInstance_SetsRoutingDecision()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var connection = CreateConnection();
|
||||
var decision = CreateDecision(endpoint, connection);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
endpoint.ServiceName, endpoint.Version, endpoint.Method, endpoint.Path))
|
||||
.Returns([connection]);
|
||||
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Items[RouterHttpContextKeys.RoutingDecision].Should().Be(decision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithAvailableInstance_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([CreateConnection()]);
|
||||
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region No Instances Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoInstances_Returns503()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([]);
|
||||
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RoutingDecision?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoInstances_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([]);
|
||||
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RoutingDecision?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("No instances available");
|
||||
responseBody.Should().Contain("test-service");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Routing Context Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_PassesCorrectRoutingContext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var connection = CreateConnection();
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
endpoint.ServiceName, endpoint.Version, endpoint.Method, endpoint.Path))
|
||||
.Returns([connection]);
|
||||
|
||||
RoutingContext? capturedContext = null;
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.Method.Should().Be("GET");
|
||||
capturedContext.Path.Should().Be("/api/test");
|
||||
capturedContext.GatewayRegion.Should().Be("us-east-1");
|
||||
capturedContext.Endpoint.Should().Be(endpoint);
|
||||
capturedContext.AvailableConnections.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_PassesRequestHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
context.Request.Headers["X-Custom-Header"] = "CustomValue";
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([CreateConnection()]);
|
||||
|
||||
RoutingContext? capturedContext = null;
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
capturedContext!.Headers.Should().ContainKey("X-Custom-Header");
|
||||
capturedContext.Headers["X-Custom-Header"].Should().Be("CustomValue");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Version Extraction Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithXApiVersionHeader_ExtractsVersion()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
context.Request.Headers["X-Api-Version"] = "2.0.0";
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([CreateConnection()]);
|
||||
|
||||
RoutingContext? capturedContext = null;
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
capturedContext!.RequestedVersion.Should().Be("2.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoVersionHeader_UsesDefault()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([CreateConnection()]);
|
||||
|
||||
RoutingContext? capturedContext = null;
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
capturedContext!.RequestedVersion.Should().Be("1.0.0"); // From _routingOptions
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-preview.7.25380.108" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -0,0 +1,786 @@
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="TransportDispatchMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class TransportDispatchMiddlewareTests
|
||||
{
|
||||
private readonly Mock<ITransportClient> _transportClientMock;
|
||||
private readonly Mock<IGlobalRoutingState> _routingStateMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private bool _nextCalled;
|
||||
|
||||
public TransportDispatchMiddlewareTests()
|
||||
{
|
||||
_transportClientMock = new Mock<ITransportClient>();
|
||||
_routingStateMock = new Mock<IGlobalRoutingState>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
}
|
||||
|
||||
private TransportDispatchMiddleware CreateMiddleware()
|
||||
{
|
||||
return new TransportDispatchMiddleware(
|
||||
_nextMock.Object,
|
||||
NullLogger<TransportDispatchMiddleware>.Instance);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext(
|
||||
RoutingDecision? decision = null,
|
||||
string method = "GET",
|
||||
string path = "/api/test",
|
||||
byte[]? body = null)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = method;
|
||||
context.Request.Path = path;
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
context.Request.Body = new MemoryStream(body);
|
||||
context.Request.ContentLength = body.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Request.Body = new MemoryStream();
|
||||
}
|
||||
|
||||
if (decision is not null)
|
||||
{
|
||||
context.Items[RouterHttpContextKeys.RoutingDecision] = decision;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
bool supportsStreaming = false)
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
SupportsStreaming = supportsStreaming
|
||||
};
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId = "conn-1",
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"inst-{connectionId}",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east-1"
|
||||
},
|
||||
Status = status,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
}
|
||||
|
||||
private static RoutingDecision CreateDecision(
|
||||
EndpointDescriptor? endpoint = null,
|
||||
ConnectionState? connection = null,
|
||||
TimeSpan? timeout = null)
|
||||
{
|
||||
return new RoutingDecision
|
||||
{
|
||||
Endpoint = endpoint ?? CreateEndpoint(),
|
||||
Connection = connection ?? CreateConnection(),
|
||||
TransportType = TransportType.InMemory,
|
||||
EffectiveTimeout = timeout ?? TimeSpan.FromSeconds(30)
|
||||
};
|
||||
}
|
||||
|
||||
private static Frame CreateResponseFrame(
|
||||
string requestId = "test-request",
|
||||
int statusCode = 200,
|
||||
Dictionary<string, string>? headers = null,
|
||||
byte[]? payload = null)
|
||||
{
|
||||
var response = new ResponseFrame
|
||||
{
|
||||
RequestId = requestId,
|
||||
StatusCode = statusCode,
|
||||
Headers = headers ?? new Dictionary<string, string>(),
|
||||
Payload = payload ?? []
|
||||
};
|
||||
|
||||
return FrameConverter.ToFrame(response);
|
||||
}
|
||||
|
||||
#region Missing Routing Decision Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoRoutingDecision_Returns500()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(decision: null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoRoutingDecision_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(decision: null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Routing decision missing");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Successful Request/Response Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithSuccessfulResponse_ForwardsStatusCode()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId, statusCode: 201);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(201);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithResponsePayload_WritesToResponseBody()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
var responsePayload = Encoding.UTF8.GetBytes("{\"result\":\"success\"}");
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId, payload: responsePayload);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Be("{\"result\":\"success\"}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithResponseHeaders_ForwardsHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
var responseHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["X-Custom-Header"] = "CustomValue",
|
||||
["Content-Type"] = "application/json"
|
||||
};
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId, headers: responseHeaders);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Headers.Should().ContainKey("X-Custom-Header");
|
||||
context.Response.Headers["X-Custom-Header"].ToString().Should().Be("CustomValue");
|
||||
context.Response.Headers["Content-Type"].ToString().Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithTransferEncodingHeader_DoesNotForward()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
var responseHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["Transfer-Encoding"] = "chunked",
|
||||
["X-Custom-Header"] = "CustomValue"
|
||||
};
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId, headers: responseHeaders);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Headers.Should().NotContainKey("Transfer-Encoding");
|
||||
context.Response.Headers.Should().ContainKey("X-Custom-Header");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithRequestBody_SendsBodyInFrame()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var requestBody = Encoding.UTF8.GetBytes("{\"data\":\"test\"}");
|
||||
var context = CreateHttpContext(decision: decision, body: requestBody);
|
||||
|
||||
byte[]? capturedPayload = null;
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ConnectionState, Frame, TimeSpan, CancellationToken>((conn, req, timeout, ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
capturedPayload = requestFrame?.Payload.ToArray();
|
||||
})
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
capturedPayload.Should().BeEquivalentTo(requestBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithRequestHeaders_ForwardsHeadersInFrame()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
context.Request.Headers["X-Request-Id"] = "req-123";
|
||||
context.Request.Headers["Accept"] = "application/json";
|
||||
|
||||
IReadOnlyDictionary<string, string>? capturedHeaders = null;
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ConnectionState, Frame, TimeSpan, CancellationToken>((conn, req, timeout, ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
capturedHeaders = requestFrame?.Headers;
|
||||
})
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
capturedHeaders.Should().NotBeNull();
|
||||
capturedHeaders.Should().ContainKey("X-Request-Id");
|
||||
capturedHeaders!["X-Request-Id"].Should().Be("req-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Timeout Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithTimeout_Returns504()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision(timeout: TimeSpan.FromMilliseconds(50));
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status504GatewayTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithTimeout_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision(timeout: TimeSpan.FromMilliseconds(50));
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Upstream timeout");
|
||||
responseBody.Should().Contain("test-service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithTimeout_SendsCancelFrame()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision(timeout: TimeSpan.FromMilliseconds(50));
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_transportClientMock.Verify(t => t.SendCancelAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Guid>(),
|
||||
CancelReasons.Timeout), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Upstream Error Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithUpstreamError_Returns502()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Connection failed"));
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status502BadGateway);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithUpstreamError_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Connection failed"));
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Upstream error");
|
||||
responseBody.Should().Contain("Connection failed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Invalid Response Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithInvalidResponseFrame_Returns502()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
// Return a malformed frame that cannot be parsed as ResponseFrame
|
||||
var invalidFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Heartbeat, // Wrong type
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(invalidFrame);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status502BadGateway);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithInvalidResponseFrame_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
var invalidFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Cancel, // Wrong type
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(invalidFrame);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Invalid upstream response");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Connection Ping Update Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithSuccessfulResponse_UpdatesConnectionPing()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(r => r.UpdateConnection(
|
||||
"conn-1",
|
||||
It.IsAny<Action<ConnectionState>>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Streaming Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithStreamingEndpoint_UsesSendStreamingAsync()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(supportsStreaming: true);
|
||||
var decision = CreateDecision(endpoint: endpoint);
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendStreamingAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<Func<Stream, Task>>(),
|
||||
It.IsAny<PayloadLimits>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ConnectionState, Frame, Stream, Func<Stream, Task>, PayloadLimits, CancellationToken>(
|
||||
async (conn, req, requestBody, readResponse, limits, ct) =>
|
||||
{
|
||||
// Simulate streaming response
|
||||
using var responseStream = new MemoryStream(Encoding.UTF8.GetBytes("streamed data"));
|
||||
await readResponse(responseStream);
|
||||
})
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_transportClientMock.Verify(t => t.SendStreamingAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<Func<Stream, Task>>(),
|
||||
It.IsAny<PayloadLimits>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_StreamingWithTimeout_Returns504()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(supportsStreaming: true);
|
||||
var decision = CreateDecision(endpoint: endpoint, timeout: TimeSpan.FromMilliseconds(50));
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendStreamingAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<Func<Stream, Task>>(),
|
||||
It.IsAny<PayloadLimits>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status504GatewayTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_StreamingWithUpstreamError_Returns502()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(supportsStreaming: true);
|
||||
var decision = CreateDecision(endpoint: endpoint);
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendStreamingAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<Func<Stream, Task>>(),
|
||||
It.IsAny<PayloadLimits>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Streaming failed"));
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status502BadGateway);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Query String Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithQueryString_IncludesInRequestPath()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision, path: "/api/test");
|
||||
context.Request.QueryString = new QueryString("?key=value&other=123");
|
||||
|
||||
string? capturedPath = null;
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ConnectionState, Frame, TimeSpan, CancellationToken>((conn, req, timeout, ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
capturedPath = requestFrame?.Path;
|
||||
})
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
capturedPath.Should().Be("/api/test?key=value&other=123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -69,8 +69,10 @@ public sealed record AuditEntry(
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new audit entry with computed hash.
|
||||
/// Uses the platform's compliance-aware crypto abstraction.
|
||||
/// </summary>
|
||||
public static AuditEntry Create(
|
||||
CanonicalJsonHasher hasher,
|
||||
string tenantId,
|
||||
AuditEventType eventType,
|
||||
string resourceType,
|
||||
@@ -89,12 +91,14 @@ public sealed record AuditEntry(
|
||||
long sequenceNumber = 0,
|
||||
string? metadata = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hasher);
|
||||
|
||||
var entryId = Guid.NewGuid();
|
||||
var occurredAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Compute canonical hash from immutable content
|
||||
// Use the same property names and fields as VerifyIntegrity to keep the hash stable.
|
||||
var contentHash = CanonicalJsonHasher.ComputeCanonicalSha256(new
|
||||
var contentHash = hasher.ComputeCanonicalHash(new
|
||||
{
|
||||
EntryId = entryId,
|
||||
TenantId = tenantId,
|
||||
@@ -135,10 +139,13 @@ public sealed record AuditEntry(
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the integrity of this entry's content hash.
|
||||
/// Uses the platform's compliance-aware crypto abstraction.
|
||||
/// </summary>
|
||||
public bool VerifyIntegrity()
|
||||
public bool VerifyIntegrity(CanonicalJsonHasher hasher)
|
||||
{
|
||||
var computed = CanonicalJsonHasher.ComputeCanonicalSha256(new
|
||||
ArgumentNullException.ThrowIfNull(hasher);
|
||||
|
||||
var computed = hasher.ComputeCanonicalHash(new
|
||||
{
|
||||
EntryId,
|
||||
TenantId,
|
||||
@@ -169,12 +176,6 @@ public sealed record AuditEntry(
|
||||
return string.Equals(PreviousEntryHash, previousEntry.ContentHash, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string content)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
namespace StellaOps.Orchestrator.Core.Domain.Events;
|
||||
@@ -178,13 +178,18 @@ public sealed record EventEnvelope(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Computes a digest of the envelope for signing.</summary>
|
||||
public string ComputeDigest()
|
||||
/// <summary>
|
||||
/// Computes a digest of the envelope for signing.
|
||||
/// Uses the platform's compliance-aware crypto abstraction.
|
||||
/// </summary>
|
||||
public string ComputeDigest(ICryptoHash cryptoHash)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cryptoHash);
|
||||
|
||||
var canonicalJson = CanonicalJsonHasher.ToCanonicalJson(new { envelope = this });
|
||||
var bytes = Encoding.UTF8.GetBytes(canonicalJson);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
var hash = cryptoHash.ComputePrefixedHashForPurpose(bytes, HashPurpose.Content);
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
namespace StellaOps.Orchestrator.Core.Domain;
|
||||
@@ -44,8 +44,10 @@ public sealed record PackRunLog(
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new log entry.
|
||||
/// Uses the platform's compliance-aware crypto abstraction.
|
||||
/// </summary>
|
||||
public static PackRunLog Create(
|
||||
ICryptoHash cryptoHash,
|
||||
Guid packRunId,
|
||||
string tenantId,
|
||||
long sequence,
|
||||
@@ -55,7 +57,9 @@ public sealed record PackRunLog(
|
||||
string? data = null,
|
||||
DateTimeOffset? timestamp = null)
|
||||
{
|
||||
var (digest, sizeBytes) = ComputeDigest(message, data, tenantId, packRunId, sequence, level, source);
|
||||
ArgumentNullException.ThrowIfNull(cryptoHash);
|
||||
|
||||
var (digest, sizeBytes) = ComputeDigest(cryptoHash, message, data, tenantId, packRunId, sequence, level, source);
|
||||
|
||||
return new PackRunLog(
|
||||
LogId: Guid.NewGuid(),
|
||||
@@ -75,32 +79,35 @@ public sealed record PackRunLog(
|
||||
/// Creates an info-level stdout log entry.
|
||||
/// </summary>
|
||||
public static PackRunLog Stdout(
|
||||
ICryptoHash cryptoHash,
|
||||
Guid packRunId,
|
||||
string tenantId,
|
||||
long sequence,
|
||||
string message,
|
||||
DateTimeOffset? timestamp = null)
|
||||
{
|
||||
return Create(packRunId, tenantId, sequence, LogLevel.Info, "stdout", message, null, timestamp);
|
||||
return Create(cryptoHash, packRunId, tenantId, sequence, LogLevel.Info, "stdout", message, null, timestamp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a warn-level stderr log entry.
|
||||
/// </summary>
|
||||
public static PackRunLog Stderr(
|
||||
ICryptoHash cryptoHash,
|
||||
Guid packRunId,
|
||||
string tenantId,
|
||||
long sequence,
|
||||
string message,
|
||||
DateTimeOffset? timestamp = null)
|
||||
{
|
||||
return Create(packRunId, tenantId, sequence, LogLevel.Warn, "stderr", message, null, timestamp);
|
||||
return Create(cryptoHash, packRunId, tenantId, sequence, LogLevel.Warn, "stderr", message, null, timestamp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a system-level log entry (lifecycle events).
|
||||
/// </summary>
|
||||
public static PackRunLog System(
|
||||
ICryptoHash cryptoHash,
|
||||
Guid packRunId,
|
||||
string tenantId,
|
||||
long sequence,
|
||||
@@ -109,10 +116,11 @@ public sealed record PackRunLog(
|
||||
string? data = null,
|
||||
DateTimeOffset? timestamp = null)
|
||||
{
|
||||
return Create(packRunId, tenantId, sequence, level, "system", message, data, timestamp);
|
||||
return Create(cryptoHash, packRunId, tenantId, sequence, level, "system", message, data, timestamp);
|
||||
}
|
||||
|
||||
private static (string Digest, long SizeBytes) ComputeDigest(
|
||||
ICryptoHash cryptoHash,
|
||||
string message,
|
||||
string? data,
|
||||
string tenantId,
|
||||
@@ -134,9 +142,9 @@ public sealed record PackRunLog(
|
||||
|
||||
var canonicalJson = CanonicalJsonHasher.ToCanonicalJson(payload);
|
||||
var bytes = Encoding.UTF8.GetBytes(canonicalJson);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
var hash = cryptoHash.ComputeHashHexForPurpose(bytes, HashPurpose.Content);
|
||||
|
||||
return (Convert.ToHexString(hash).ToLowerInvariant(), bytes.LongLength);
|
||||
return (hash, bytes.LongLength);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,15 +18,17 @@ public sealed record ReplayInputsLock(
|
||||
|
||||
public static ReplayInputsLock Create(
|
||||
ReplayManifest manifest,
|
||||
CanonicalJsonHasher hasher,
|
||||
string? notes = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string schemaVersion = DefaultSchemaVersion)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentNullException.ThrowIfNull(hasher);
|
||||
|
||||
return new ReplayInputsLock(
|
||||
SchemaVersion: schemaVersion,
|
||||
ManifestHash: manifest.ComputeHash(),
|
||||
ManifestHash: manifest.ComputeHash(hasher),
|
||||
CreatedAt: createdAt ?? DateTimeOffset.UtcNow,
|
||||
Inputs: manifest.Inputs,
|
||||
Notes: string.IsNullOrWhiteSpace(notes) ? null : notes);
|
||||
@@ -34,6 +36,11 @@ public sealed record ReplayInputsLock(
|
||||
|
||||
/// <summary>
|
||||
/// Canonical hash of the lock content.
|
||||
/// Uses the platform's compliance-aware crypto abstraction.
|
||||
/// </summary>
|
||||
public string ComputeHash() => CanonicalJsonHasher.ComputeCanonicalSha256(this);
|
||||
public string ComputeHash(CanonicalJsonHasher hasher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hasher);
|
||||
return hasher.ComputeCanonicalHash(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,14 @@ public sealed record ReplayManifest(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic SHA-256 over canonical JSON representation of the manifest.
|
||||
/// Deterministic hash over canonical JSON representation of the manifest.
|
||||
/// Uses the platform's compliance-aware crypto abstraction.
|
||||
/// </summary>
|
||||
public string ComputeHash() => CanonicalJsonHasher.ComputeCanonicalSha256(this);
|
||||
public string ComputeHash(CanonicalJsonHasher hasher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hasher);
|
||||
return hasher.ComputeCanonicalHash(this);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ReplayInputs(
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
/// <summary>
|
||||
/// Produces deterministic, canonical JSON and hashes for orchestrator payloads (events, audit, manifests).
|
||||
/// Keys are sorted lexicographically; arrays preserve order; nulls are retained; timestamps remain ISO 8601 with offsets.
|
||||
/// Uses compliance-profile-aware hashing via <see cref="ICryptoHash"/>.
|
||||
/// </summary>
|
||||
public static class CanonicalJsonHasher
|
||||
public sealed class CanonicalJsonHasher
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
@@ -20,6 +23,15 @@ public static class CanonicalJsonHasher
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new CanonicalJsonHasher with the specified crypto hash service.
|
||||
/// </summary>
|
||||
/// <param name="cryptoHash">Crypto hash service for compliance-aware hashing.</param>
|
||||
public CanonicalJsonHasher(ICryptoHash cryptoHash)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize the value to canonical JSON (sorted object keys, stable formatting).
|
||||
/// </summary>
|
||||
@@ -32,14 +44,14 @@ public static class CanonicalJsonHasher
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute SHA-256 over canonical JSON (lowercase hex).
|
||||
/// Compute hash over canonical JSON using the active compliance profile (lowercase hex).
|
||||
/// Uses <see cref="HashPurpose.Content"/> for content hashing.
|
||||
/// </summary>
|
||||
public static string ComputeCanonicalSha256<T>(T value)
|
||||
public string ComputeCanonicalHash<T>(T value)
|
||||
{
|
||||
var canonicalJson = ToCanonicalJson(value);
|
||||
var bytes = Encoding.UTF8.GetBytes(canonicalJson);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
return _cryptoHash.ComputeHashHexForPurpose(bytes, HashPurpose.Content);
|
||||
}
|
||||
|
||||
private static JsonNode OrderNode(JsonNode node)
|
||||
|
||||
@@ -2,11 +2,21 @@ using StellaOps.Orchestrator.Core.Domain.Events;
|
||||
|
||||
namespace StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
public static class EventEnvelopeHasher
|
||||
/// <summary>
|
||||
/// Computes compliance-aware hashes for event envelopes using the platform's crypto abstraction.
|
||||
/// </summary>
|
||||
public sealed class EventEnvelopeHasher
|
||||
{
|
||||
public static string Compute(EventEnvelope envelope)
|
||||
private readonly CanonicalJsonHasher _hasher;
|
||||
|
||||
public EventEnvelopeHasher(CanonicalJsonHasher hasher)
|
||||
{
|
||||
_hasher = hasher ?? throw new ArgumentNullException(nameof(hasher));
|
||||
}
|
||||
|
||||
public string Compute(EventEnvelope envelope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
return CanonicalJsonHasher.ComputeCanonicalSha256(envelope);
|
||||
return _hasher.ComputeCanonicalHash(envelope);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,8 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Orchestrator.Core.Domain;
|
||||
using StellaOps.Orchestrator.Core.Hashing;
|
||||
using StellaOps.Orchestrator.Infrastructure.Repositories;
|
||||
|
||||
namespace StellaOps.Orchestrator.Infrastructure.Postgres;
|
||||
@@ -61,13 +62,16 @@ public sealed class PostgresAuditRepository : IAuditRepository
|
||||
""";
|
||||
|
||||
private readonly OrchestratorDataSource _dataSource;
|
||||
private readonly CanonicalJsonHasher _hasher;
|
||||
private readonly ILogger<PostgresAuditRepository> _logger;
|
||||
|
||||
public PostgresAuditRepository(
|
||||
OrchestratorDataSource dataSource,
|
||||
CanonicalJsonHasher hasher,
|
||||
ILogger<PostgresAuditRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_hasher = hasher ?? throw new ArgumentNullException(nameof(hasher));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -115,6 +119,7 @@ public sealed class PostgresAuditRepository : IAuditRepository
|
||||
|
||||
// Create the entry
|
||||
var entry = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: tenantId,
|
||||
eventType: eventType,
|
||||
resourceType: resourceType,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Orchestrator.Core.Domain;
|
||||
using StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
namespace StellaOps.Orchestrator.Tests.AuditLedger;
|
||||
|
||||
@@ -7,6 +9,13 @@ namespace StellaOps.Orchestrator.Tests.AuditLedger;
|
||||
/// </summary>
|
||||
public sealed class AuditEntryTests
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
private readonly CanonicalJsonHasher _hasher;
|
||||
|
||||
public AuditEntryTests()
|
||||
{
|
||||
_hasher = new CanonicalJsonHasher(_cryptoHash);
|
||||
}
|
||||
[Fact]
|
||||
public void Create_WithValidParameters_SetsAllProperties()
|
||||
{
|
||||
@@ -16,6 +25,7 @@ public sealed class AuditEntryTests
|
||||
|
||||
// Act
|
||||
var entry = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: tenantId,
|
||||
eventType: AuditEventType.JobCreated,
|
||||
resourceType: "job",
|
||||
@@ -62,6 +72,7 @@ public sealed class AuditEntryTests
|
||||
{
|
||||
// Arrange & Act
|
||||
var entry = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: "test-tenant",
|
||||
eventType: AuditEventType.RunCreated,
|
||||
resourceType: "run",
|
||||
@@ -82,6 +93,7 @@ public sealed class AuditEntryTests
|
||||
{
|
||||
// Arrange
|
||||
var entry = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: "test-tenant",
|
||||
eventType: AuditEventType.SourceCreated,
|
||||
resourceType: "source",
|
||||
@@ -92,7 +104,7 @@ public sealed class AuditEntryTests
|
||||
sequenceNumber: 5);
|
||||
|
||||
// Act
|
||||
var isValid = entry.VerifyIntegrity();
|
||||
var isValid = entry.VerifyIntegrity(_hasher);
|
||||
|
||||
// Assert
|
||||
Assert.True(isValid);
|
||||
@@ -103,6 +115,7 @@ public sealed class AuditEntryTests
|
||||
{
|
||||
// Arrange
|
||||
var entry = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: "test-tenant",
|
||||
eventType: AuditEventType.QuotaCreated,
|
||||
resourceType: "quota",
|
||||
@@ -116,7 +129,7 @@ public sealed class AuditEntryTests
|
||||
var tamperedEntry = entry with { Description = "Tampered description" };
|
||||
|
||||
// Act
|
||||
var isValid = tamperedEntry.VerifyIntegrity();
|
||||
var isValid = tamperedEntry.VerifyIntegrity(_hasher);
|
||||
|
||||
// Assert
|
||||
Assert.False(isValid);
|
||||
@@ -127,6 +140,7 @@ public sealed class AuditEntryTests
|
||||
{
|
||||
// Arrange
|
||||
var entry = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: "test-tenant",
|
||||
eventType: AuditEventType.JobScheduled,
|
||||
resourceType: "job",
|
||||
@@ -149,6 +163,7 @@ public sealed class AuditEntryTests
|
||||
{
|
||||
// Arrange
|
||||
var first = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: "test-tenant",
|
||||
eventType: AuditEventType.JobCreated,
|
||||
resourceType: "job",
|
||||
@@ -160,6 +175,7 @@ public sealed class AuditEntryTests
|
||||
sequenceNumber: 1);
|
||||
|
||||
var second = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: "test-tenant",
|
||||
eventType: AuditEventType.JobLeased,
|
||||
resourceType: "job",
|
||||
@@ -182,6 +198,7 @@ public sealed class AuditEntryTests
|
||||
{
|
||||
// Arrange
|
||||
var first = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: "test-tenant",
|
||||
eventType: AuditEventType.JobCreated,
|
||||
resourceType: "job",
|
||||
@@ -193,6 +210,7 @@ public sealed class AuditEntryTests
|
||||
sequenceNumber: 1);
|
||||
|
||||
var second = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: "test-tenant",
|
||||
eventType: AuditEventType.JobCompleted,
|
||||
resourceType: "job",
|
||||
@@ -225,6 +243,7 @@ public sealed class AuditEntryTests
|
||||
{
|
||||
// Act
|
||||
var entry = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: "test-tenant",
|
||||
eventType: eventType,
|
||||
resourceType: resourceType,
|
||||
@@ -237,7 +256,7 @@ public sealed class AuditEntryTests
|
||||
// Assert
|
||||
Assert.Equal(eventType, entry.EventType);
|
||||
Assert.Equal(resourceType, entry.ResourceType);
|
||||
Assert.True(entry.VerifyIntegrity());
|
||||
Assert.True(entry.VerifyIntegrity(_hasher));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -251,6 +270,7 @@ public sealed class AuditEntryTests
|
||||
{
|
||||
// Act
|
||||
var entry = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: "test-tenant",
|
||||
eventType: AuditEventType.JobCreated,
|
||||
resourceType: "job",
|
||||
@@ -262,7 +282,7 @@ public sealed class AuditEntryTests
|
||||
|
||||
// Assert
|
||||
Assert.Equal(actorType, entry.ActorType);
|
||||
Assert.True(entry.VerifyIntegrity());
|
||||
Assert.True(entry.VerifyIntegrity(_hasher));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -274,6 +294,7 @@ public sealed class AuditEntryTests
|
||||
|
||||
// Act
|
||||
var entry = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: "test-tenant",
|
||||
eventType: AuditEventType.JobLeased,
|
||||
resourceType: "job",
|
||||
@@ -295,6 +316,7 @@ public sealed class AuditEntryTests
|
||||
{
|
||||
// Act
|
||||
var entry1 = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: "test-tenant",
|
||||
eventType: AuditEventType.JobCreated,
|
||||
resourceType: "job",
|
||||
@@ -305,6 +327,7 @@ public sealed class AuditEntryTests
|
||||
sequenceNumber: 1);
|
||||
|
||||
var entry2 = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: "test-tenant",
|
||||
eventType: AuditEventType.JobCreated,
|
||||
resourceType: "job",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Orchestrator.Core.Domain;
|
||||
using StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
@@ -6,14 +7,22 @@ namespace StellaOps.Orchestrator.Tests;
|
||||
|
||||
public class CanonicalJsonHasherTests
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
private readonly CanonicalJsonHasher _hasher;
|
||||
|
||||
public CanonicalJsonHasherTests()
|
||||
{
|
||||
_hasher = new CanonicalJsonHasher(_cryptoHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProducesStableHash_WhenObjectPropertyOrderDiffers()
|
||||
{
|
||||
var first = new { b = 1, a = 2 };
|
||||
var second = new { a = 2, b = 1 };
|
||||
|
||||
var firstHash = CanonicalJsonHasher.ComputeCanonicalSha256(first);
|
||||
var secondHash = CanonicalJsonHasher.ComputeCanonicalSha256(second);
|
||||
var firstHash = _hasher.ComputeCanonicalHash(first);
|
||||
var secondHash = _hasher.ComputeCanonicalHash(second);
|
||||
|
||||
Assert.Equal(firstHash, secondHash);
|
||||
}
|
||||
@@ -37,6 +46,7 @@ public class CanonicalJsonHasherTests
|
||||
public void AuditEntry_UsesCanonicalHash()
|
||||
{
|
||||
var entry = AuditEntry.Create(
|
||||
hasher: _hasher,
|
||||
tenantId: "tenant-1",
|
||||
eventType: AuditEventType.JobCreated,
|
||||
resourceType: "job",
|
||||
@@ -45,10 +55,10 @@ public class CanonicalJsonHasherTests
|
||||
actorType: ActorType.User,
|
||||
description: "created job");
|
||||
|
||||
Assert.True(entry.VerifyIntegrity());
|
||||
Assert.True(entry.VerifyIntegrity(_hasher));
|
||||
|
||||
// Changing description should invalidate hash
|
||||
var tampered = entry with { Description = "tampered" };
|
||||
Assert.False(tampered.VerifyIntegrity());
|
||||
Assert.False(tampered.VerifyIntegrity(_hasher));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Orchestrator.Core;
|
||||
using StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
@@ -7,6 +8,15 @@ namespace StellaOps.Orchestrator.Tests;
|
||||
|
||||
public class EventEnvelopeTests
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
private readonly CanonicalJsonHasher _hasher;
|
||||
private readonly EventEnvelopeHasher _envelopeHasher;
|
||||
|
||||
public EventEnvelopeTests()
|
||||
{
|
||||
_hasher = new CanonicalJsonHasher(_cryptoHash);
|
||||
_envelopeHasher = new EventEnvelopeHasher(_hasher);
|
||||
}
|
||||
[Fact]
|
||||
public void ComputeIdempotencyKey_IsDeterministicAndLowercase()
|
||||
{
|
||||
@@ -83,8 +93,8 @@ public class EventEnvelopeTests
|
||||
eventId: "evt-fixed",
|
||||
idempotencyKey: "fixed-key");
|
||||
|
||||
var hash1 = EventEnvelopeHasher.Compute(envelope);
|
||||
var hash2 = EventEnvelopeHasher.Compute(envelope);
|
||||
var hash1 = _envelopeHasher.Compute(envelope);
|
||||
var hash2 = _envelopeHasher.Compute(envelope);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.Equal(64, hash1.Length);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Orchestrator.Core.Domain.Events;
|
||||
using StellaOps.Orchestrator.Infrastructure.Events;
|
||||
|
||||
@@ -11,6 +12,7 @@ namespace StellaOps.Orchestrator.Tests.Events;
|
||||
public class EventPublishingTests
|
||||
{
|
||||
private static readonly CancellationToken CT = CancellationToken.None;
|
||||
private readonly ICryptoHash _cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
|
||||
#region EventEnvelope Tests
|
||||
|
||||
@@ -144,7 +146,7 @@ public class EventPublishingTests
|
||||
tenantId: "tenant-1",
|
||||
actor: actor);
|
||||
|
||||
var digest = envelope.ComputeDigest();
|
||||
var digest = envelope.ComputeDigest(_cryptoHash);
|
||||
|
||||
Assert.StartsWith("sha256:", digest);
|
||||
Assert.Equal(64 + 7, digest.Length); // "sha256:" + 64 hex chars
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Orchestrator.Core.Domain;
|
||||
using StellaOps.Orchestrator.WebService.Contracts;
|
||||
|
||||
@@ -5,6 +6,7 @@ namespace StellaOps.Orchestrator.Tests.PackRun;
|
||||
|
||||
public sealed class PackRunContractTests
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
[Fact]
|
||||
public void PackRunResponse_FromDomain_MapsAllFields()
|
||||
{
|
||||
@@ -88,6 +90,7 @@ public sealed class PackRunContractTests
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var log = PackRunLog.Create(
|
||||
cryptoHash: _cryptoHash,
|
||||
packRunId: packRunId,
|
||||
tenantId: "tenant-1",
|
||||
sequence: 42,
|
||||
@@ -121,6 +124,7 @@ public sealed class PackRunContractTests
|
||||
public void LogEntryResponse_FromDomain_LevelIsLowercase(LogLevel level, string expectedLevelString)
|
||||
{
|
||||
var log = PackRunLog.Create(
|
||||
cryptoHash: _cryptoHash,
|
||||
packRunId: Guid.NewGuid(),
|
||||
tenantId: "t1",
|
||||
sequence: 0,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Orchestrator.Core.Domain;
|
||||
|
||||
namespace StellaOps.Orchestrator.Tests.PackRun;
|
||||
@@ -6,6 +7,7 @@ public sealed class PackRunLogTests
|
||||
{
|
||||
private const string TestTenantId = "tenant-test";
|
||||
private readonly Guid _packRunId = Guid.NewGuid();
|
||||
private readonly ICryptoHash _cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
|
||||
[Fact]
|
||||
public void Create_InitializesAllFields()
|
||||
@@ -13,6 +15,7 @@ public sealed class PackRunLogTests
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var log = PackRunLog.Create(
|
||||
cryptoHash: _cryptoHash,
|
||||
packRunId: _packRunId,
|
||||
tenantId: TestTenantId,
|
||||
sequence: 5,
|
||||
@@ -41,6 +44,7 @@ public sealed class PackRunLogTests
|
||||
var beforeCreate = DateTimeOffset.UtcNow;
|
||||
|
||||
var log = PackRunLog.Create(
|
||||
cryptoHash: _cryptoHash,
|
||||
packRunId: _packRunId,
|
||||
tenantId: TestTenantId,
|
||||
sequence: 0,
|
||||
@@ -59,7 +63,7 @@ public sealed class PackRunLogTests
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var log = PackRunLog.Stdout(_packRunId, TestTenantId, 10, "Hello stdout", now);
|
||||
var log = PackRunLog.Stdout(_cryptoHash, _packRunId, TestTenantId, 10, "Hello stdout", now);
|
||||
|
||||
Assert.Equal(LogLevel.Info, log.Level);
|
||||
Assert.Equal("stdout", log.Source);
|
||||
@@ -73,7 +77,7 @@ public sealed class PackRunLogTests
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var log = PackRunLog.Stderr(_packRunId, TestTenantId, 20, "Warning message", now);
|
||||
var log = PackRunLog.Stderr(_cryptoHash, _packRunId, TestTenantId, 20, "Warning message", now);
|
||||
|
||||
Assert.Equal(LogLevel.Warn, log.Level);
|
||||
Assert.Equal("stderr", log.Source);
|
||||
@@ -86,7 +90,7 @@ public sealed class PackRunLogTests
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var log = PackRunLog.System(_packRunId, TestTenantId, 30, LogLevel.Error, "System error", "{\"code\":500}", now);
|
||||
var log = PackRunLog.System(_cryptoHash, _packRunId, TestTenantId, 30, LogLevel.Error, "System error", "{\"code\":500}", now);
|
||||
|
||||
Assert.Equal(LogLevel.Error, log.Level);
|
||||
Assert.Equal("system", log.Source);
|
||||
@@ -112,6 +116,7 @@ public sealed class PackRunLogBatchTests
|
||||
{
|
||||
private const string TestTenantId = "tenant-test";
|
||||
private readonly Guid _packRunId = Guid.NewGuid();
|
||||
private readonly ICryptoHash _cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
|
||||
[Fact]
|
||||
public void FromLogs_EmptyList_ReturnsEmptyBatch()
|
||||
@@ -130,9 +135,9 @@ public sealed class PackRunLogBatchTests
|
||||
{
|
||||
var logs = new List<PackRunLog>
|
||||
{
|
||||
PackRunLog.Create(_packRunId, TestTenantId, 5, LogLevel.Info, "src", "msg1"),
|
||||
PackRunLog.Create(_packRunId, TestTenantId, 6, LogLevel.Info, "src", "msg2"),
|
||||
PackRunLog.Create(_packRunId, TestTenantId, 7, LogLevel.Info, "src", "msg3")
|
||||
PackRunLog.Create(_cryptoHash, _packRunId, TestTenantId, 5, LogLevel.Info, "src", "msg1"),
|
||||
PackRunLog.Create(_cryptoHash, _packRunId, TestTenantId, 6, LogLevel.Info, "src", "msg2"),
|
||||
PackRunLog.Create(_cryptoHash, _packRunId, TestTenantId, 7, LogLevel.Info, "src", "msg3")
|
||||
};
|
||||
|
||||
var batch = PackRunLogBatch.FromLogs(_packRunId, TestTenantId, logs);
|
||||
@@ -151,8 +156,8 @@ public sealed class PackRunLogBatchTests
|
||||
StartSequence: 100,
|
||||
Logs:
|
||||
[
|
||||
PackRunLog.Create(_packRunId, TestTenantId, 100, LogLevel.Info, "src", "msg1"),
|
||||
PackRunLog.Create(_packRunId, TestTenantId, 101, LogLevel.Info, "src", "msg2")
|
||||
PackRunLog.Create(_cryptoHash, _packRunId, TestTenantId, 100, LogLevel.Info, "src", "msg1"),
|
||||
PackRunLog.Create(_cryptoHash, _packRunId, TestTenantId, 101, LogLevel.Info, "src", "msg2")
|
||||
]);
|
||||
|
||||
Assert.Equal(102, batch.NextSequence);
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Orchestrator.Core.Domain.Replay;
|
||||
using StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
namespace StellaOps.Orchestrator.Tests;
|
||||
|
||||
public class ReplayInputsLockTests
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
private readonly CanonicalJsonHasher _hasher;
|
||||
|
||||
public ReplayInputsLockTests()
|
||||
{
|
||||
_hasher = new CanonicalJsonHasher(_cryptoHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplayInputsLock_ComputesStableHash()
|
||||
{
|
||||
@@ -22,10 +32,10 @@ public class ReplayInputsLockTests
|
||||
artifacts: null,
|
||||
createdAt: new DateTimeOffset(2025, 01, 01, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var lock1 = ReplayInputsLock.Create(manifest, createdAt: new DateTimeOffset(2025, 01, 01, 0, 0, 5, TimeSpan.Zero));
|
||||
var lock2 = ReplayInputsLock.Create(manifest, createdAt: new DateTimeOffset(2025, 01, 01, 0, 0, 5, TimeSpan.Zero));
|
||||
var lock1 = ReplayInputsLock.Create(manifest, _hasher, createdAt: new DateTimeOffset(2025, 01, 01, 0, 0, 5, TimeSpan.Zero));
|
||||
var lock2 = ReplayInputsLock.Create(manifest, _hasher, createdAt: new DateTimeOffset(2025, 01, 01, 0, 0, 5, TimeSpan.Zero));
|
||||
|
||||
Assert.Equal(lock1.ComputeHash(), lock2.ComputeHash());
|
||||
Assert.Equal(lock1.ComputeHash(_hasher), lock2.ComputeHash(_hasher));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -43,8 +53,8 @@ public class ReplayInputsLockTests
|
||||
TimeSource: ReplayTimeSource.wall,
|
||||
Env: ImmutableDictionary<string, string>.Empty));
|
||||
|
||||
var inputsLock = ReplayInputsLock.Create(manifest);
|
||||
var inputsLock = ReplayInputsLock.Create(manifest, _hasher);
|
||||
|
||||
Assert.Equal(manifest.ComputeHash(), inputsLock.ManifestHash);
|
||||
Assert.Equal(manifest.ComputeHash(_hasher), inputsLock.ManifestHash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Orchestrator.Core.Domain.Replay;
|
||||
using StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
namespace StellaOps.Orchestrator.Tests;
|
||||
|
||||
public class ReplayManifestTests
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
private readonly CanonicalJsonHasher _hasher;
|
||||
|
||||
public ReplayManifestTests()
|
||||
{
|
||||
_hasher = new CanonicalJsonHasher(_cryptoHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_IsStableWithCanonicalOrdering()
|
||||
{
|
||||
@@ -31,8 +41,8 @@ public class ReplayManifestTests
|
||||
artifacts: new[] { new ReplayArtifact("ledger.ndjson", "sha256:abc", "application/x-ndjson") },
|
||||
createdAt: new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var hashA = manifestA.ComputeHash();
|
||||
var hashB = manifestB.ComputeHash();
|
||||
var hashA = manifestA.ComputeHash(_hasher);
|
||||
var hashB = manifestB.ComputeHash(_hasher);
|
||||
|
||||
Assert.Equal(hashA, hashB);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Orchestrator.Core;
|
||||
using StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
@@ -7,6 +8,14 @@ namespace StellaOps.Orchestrator.Tests;
|
||||
|
||||
public class SchemaSmokeTests
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
private readonly CanonicalJsonHasher _hasher;
|
||||
|
||||
public SchemaSmokeTests()
|
||||
{
|
||||
_hasher = new CanonicalJsonHasher(_cryptoHash);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("event-envelope.schema.json")]
|
||||
[InlineData("audit-bundle.schema.json")]
|
||||
@@ -47,8 +56,8 @@ public class SchemaSmokeTests
|
||||
eventId: "evt-1",
|
||||
idempotencyKey: "fixed");
|
||||
|
||||
var hash1 = CanonicalJsonHasher.ComputeCanonicalSha256(envelope);
|
||||
var hash2 = CanonicalJsonHasher.ComputeCanonicalSha256(envelope);
|
||||
var hash1 = _hasher.ComputeCanonicalHash(envelope);
|
||||
var hash2 = _hasher.ComputeCanonicalHash(envelope);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.Equal(64, hash1.Length);
|
||||
|
||||
@@ -53,27 +53,27 @@
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="xunit.v3" Version="3.0.0"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="xunit.v3" Version="3.0.0"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -117,12 +117,14 @@
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.Orchestrator.Core\StellaOps.Orchestrator.Core.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.Orchestrator.Infrastructure\StellaOps.Orchestrator.Infrastructure.csproj"/>
|
||||
|
||||
<ProjectReference Include="..\..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Orchestrator.Core.Domain;
|
||||
using StellaOps.Orchestrator.Core.Domain.Events;
|
||||
using StellaOps.Orchestrator.Infrastructure;
|
||||
@@ -102,6 +102,7 @@ public static class PackRunEndpoints
|
||||
[FromServices] IPackRunRepository packRunRepository,
|
||||
[FromServices] IQuotaRepository quotaRepository,
|
||||
[FromServices] IEventPublisher eventPublisher,
|
||||
[FromServices] ICryptoHash cryptoHash,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -127,7 +128,7 @@ public static class PackRunEndpoints
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var parameters = request.Parameters ?? "{}";
|
||||
var parametersDigest = ComputeDigest(parameters);
|
||||
var parametersDigest = ComputeDigest(cryptoHash, parameters);
|
||||
var idempotencyKey = request.IdempotencyKey ?? $"pack-run:{request.PackId}:{parametersDigest}:{now:yyyyMMddHHmm}";
|
||||
|
||||
// Check for existing pack run with same idempotency key
|
||||
@@ -429,6 +430,7 @@ public static class PackRunEndpoints
|
||||
[FromServices] IPackRunRepository packRunRepository,
|
||||
[FromServices] IPackRunLogRepository logRepository,
|
||||
[FromServices] IEventPublisher eventPublisher,
|
||||
[FromServices] ICryptoHash cryptoHash,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -471,7 +473,7 @@ public static class PackRunEndpoints
|
||||
cancellationToken);
|
||||
|
||||
// Append system log entry
|
||||
var log = PackRunLog.System(packRunId, tenantId, 0, PackLogLevel.Info, "Pack run started", null, now);
|
||||
var log = PackRunLog.System(cryptoHash, packRunId, tenantId, 0, PackLogLevel.Info, "Pack run started", null, now);
|
||||
await logRepository.AppendAsync(log, cancellationToken);
|
||||
|
||||
OrchestratorMetrics.PackRunStarted(tenantId, packRun.PackId);
|
||||
@@ -499,6 +501,7 @@ public static class PackRunEndpoints
|
||||
[FromServices] IQuotaRepository quotaRepository,
|
||||
[FromServices] IArtifactRepository artifactRepository,
|
||||
[FromServices] IEventPublisher eventPublisher,
|
||||
[FromServices] ICryptoHash cryptoHash,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -587,7 +590,7 @@ public static class PackRunEndpoints
|
||||
// Append system log entry
|
||||
var (logCount, latestSeq) = await logRepository.GetLogStatsAsync(tenantId, packRunId, cancellationToken);
|
||||
var completionLog = PackRunLog.System(
|
||||
packRunId, tenantId, latestSeq + 1,
|
||||
cryptoHash, packRunId, tenantId, latestSeq + 1,
|
||||
request.Success ? PackLogLevel.Info : PackLogLevel.Error,
|
||||
$"Pack run {(request.Success ? "succeeded" : "failed")} with exit code {request.ExitCode}",
|
||||
null, now);
|
||||
@@ -649,6 +652,7 @@ public static class PackRunEndpoints
|
||||
[FromServices] IPackRunRepository packRunRepository,
|
||||
[FromServices] IPackRunLogRepository logRepository,
|
||||
[FromServices] IEventPublisher eventPublisher,
|
||||
[FromServices] ICryptoHash cryptoHash,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -687,7 +691,7 @@ public static class PackRunEndpoints
|
||||
: PackLogLevel.Info;
|
||||
|
||||
logs.Add(PackRunLog.Create(
|
||||
packRunId, tenantId, seq, level,
|
||||
cryptoHash, packRunId, tenantId, seq, level,
|
||||
entry.Source,
|
||||
entry.Message,
|
||||
entry.Data,
|
||||
@@ -773,6 +777,7 @@ public static class PackRunEndpoints
|
||||
[FromServices] IPackRunLogRepository logRepository,
|
||||
[FromServices] IQuotaRepository quotaRepository,
|
||||
[FromServices] IEventPublisher eventPublisher,
|
||||
[FromServices] ICryptoHash cryptoHash,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -811,7 +816,7 @@ public static class PackRunEndpoints
|
||||
// Append system log entry
|
||||
var (_, latestSeq) = await logRepository.GetLogStatsAsync(tenantId, packRunId, cancellationToken);
|
||||
var cancelLog = PackRunLog.System(
|
||||
packRunId, tenantId, latestSeq + 1,
|
||||
cryptoHash, packRunId, tenantId, latestSeq + 1,
|
||||
PackLogLevel.Warn, $"Pack run canceled: {request.Reason}", null, now);
|
||||
await logRepository.AppendAsync(cancelLog, cancellationToken);
|
||||
|
||||
@@ -839,6 +844,7 @@ public static class PackRunEndpoints
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRunRepository packRunRepository,
|
||||
[FromServices] IEventPublisher eventPublisher,
|
||||
[FromServices] ICryptoHash cryptoHash,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -868,7 +874,7 @@ public static class PackRunEndpoints
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var newPackRunId = Guid.NewGuid();
|
||||
var parameters = request.Parameters ?? packRun.Parameters;
|
||||
var parametersDigest = request.Parameters != null ? ComputeDigest(parameters) : packRun.ParametersDigest;
|
||||
var parametersDigest = request.Parameters != null ? ComputeDigest(cryptoHash, parameters) : packRun.ParametersDigest;
|
||||
var idempotencyKey = request.IdempotencyKey ?? $"retry:{packRunId}:{now:yyyyMMddHHmmss}";
|
||||
|
||||
var newPackRun = PackRun.Create(
|
||||
@@ -1024,11 +1030,10 @@ public static class PackRunEndpoints
|
||||
return quota;
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string content)
|
||||
private static string ComputeDigest(ICryptoHash cryptoHash, string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
return cryptoHash.ComputeHashHexForPurpose(bytes, HashPurpose.Content);
|
||||
}
|
||||
|
||||
private static JsonElement? ToPayload<T>(T value)
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.RiskProfile.Export;
|
||||
|
||||
@@ -145,7 +146,8 @@ internal static class ProfileExportEndpoints
|
||||
HttpContext context,
|
||||
[FromBody] ImportProfilesRequest request,
|
||||
RiskProfileConfigurationService profileService,
|
||||
ProfileExportService exportService)
|
||||
ProfileExportService exportService,
|
||||
ICryptoHash cryptoHash)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
@@ -167,6 +169,7 @@ internal static class ProfileExportEndpoints
|
||||
|
||||
// Create an export service with save capability
|
||||
var importExportService = new ProfileExportService(
|
||||
cryptoHash: cryptoHash,
|
||||
timeProvider: TimeProvider.System,
|
||||
profileLookup: id => profileService.GetProfile(id),
|
||||
lifecycleLookup: null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
@@ -18,6 +18,7 @@ public sealed class RiskScoringTriggerService
|
||||
private readonly RiskProfileConfigurationService _profileService;
|
||||
private readonly IRiskScoringJobStore _jobStore;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _recentTriggers;
|
||||
private readonly TimeSpan _deduplicationWindow;
|
||||
|
||||
@@ -25,13 +26,15 @@ public sealed class RiskScoringTriggerService
|
||||
ILogger<RiskScoringTriggerService> logger,
|
||||
TimeProvider timeProvider,
|
||||
RiskProfileConfigurationService profileService,
|
||||
IRiskScoringJobStore jobStore)
|
||||
IRiskScoringJobStore jobStore,
|
||||
ICryptoHash cryptoHash)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_profileService = profileService ?? throw new ArgumentNullException(nameof(profileService));
|
||||
_jobStore = jobStore ?? throw new ArgumentNullException(nameof(jobStore));
|
||||
_hasher = new RiskProfileHasher();
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_hasher = new RiskProfileHasher(cryptoHash);
|
||||
_recentTriggers = new ConcurrentDictionary<string, DateTimeOffset>();
|
||||
_deduplicationWindow = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
@@ -256,10 +259,10 @@ public sealed class RiskScoringTriggerService
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateJobId(string tenantId, string contextId, DateTimeOffset timestamp)
|
||||
private string GenerateJobId(string tenantId, string contextId, DateTimeOffset timestamp)
|
||||
{
|
||||
var seed = $"{tenantId}|{contextId}|{timestamp:O}|{Guid.NewGuid()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return $"rsj-{Convert.ToHexStringLower(hash)[..16]}";
|
||||
var hash = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(seed), HashPurpose.Content);
|
||||
return $"rsj-{hash[..16]}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
using StellaOps.Policy.RiskProfile.Merge;
|
||||
@@ -27,12 +28,14 @@ public sealed class RiskProfileConfigurationService
|
||||
|
||||
public RiskProfileConfigurationService(
|
||||
ILogger<RiskProfileConfigurationService> logger,
|
||||
IOptions<PolicyEngineOptions> options)
|
||||
IOptions<PolicyEngineOptions> options,
|
||||
ICryptoHash cryptoHash)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value.RiskProfile ?? throw new ArgumentNullException(nameof(options));
|
||||
ArgumentNullException.ThrowIfNull(cryptoHash);
|
||||
_mergeService = new RiskProfileMergeService();
|
||||
_hasher = new RiskProfileHasher();
|
||||
_hasher = new RiskProfileHasher(cryptoHash);
|
||||
_validator = new RiskProfileValidator();
|
||||
_profileCache = new ConcurrentDictionary<string, RiskProfileModel>(StringComparer.OrdinalIgnoreCase);
|
||||
_resolvedCache = new ConcurrentDictionary<string, RiskProfileModel>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
@@ -19,6 +19,7 @@ public sealed class RiskSimulationService
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly RiskProfileConfigurationService _profileService;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
private static readonly double[] PercentileLevels = { 0.25, 0.50, 0.75, 0.90, 0.95, 0.99 };
|
||||
private const int TopMoverCount = 10;
|
||||
@@ -27,12 +28,14 @@ public sealed class RiskSimulationService
|
||||
public RiskSimulationService(
|
||||
ILogger<RiskSimulationService> logger,
|
||||
TimeProvider timeProvider,
|
||||
RiskProfileConfigurationService profileService)
|
||||
RiskProfileConfigurationService profileService,
|
||||
ICryptoHash cryptoHash)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_profileService = profileService ?? throw new ArgumentNullException(nameof(profileService));
|
||||
_hasher = new RiskProfileHasher();
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_hasher = new RiskProfileHasher(cryptoHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -452,10 +455,10 @@ public sealed class RiskSimulationService
|
||||
InformationalCount: scores.Count(s => s.Severity == RiskSeverity.Informational));
|
||||
}
|
||||
|
||||
private static string GenerateSimulationId(RiskSimulationRequest request, string profileHash)
|
||||
private string GenerateSimulationId(RiskSimulationRequest request, string profileHash)
|
||||
{
|
||||
var seed = $"{request.ProfileId}|{profileHash}|{request.Findings.Count}|{Guid.NewGuid()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return $"rsim-{Convert.ToHexStringLower(hash)[..16]}";
|
||||
var hash = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(seed), HashPurpose.Content);
|
||||
return $"rsim-{hash[..16]}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
using StellaOps.Policy.RiskProfile.Lifecycle;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
@@ -17,6 +18,7 @@ public sealed class ProfileExportService
|
||||
private const string DefaultAlgorithm = "HMAC-SHA256";
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly Func<string, RiskProfileModel?>? _profileLookup;
|
||||
private readonly Func<string, RiskProfileVersionInfo?>? _lifecycleLookup;
|
||||
@@ -30,14 +32,16 @@ public sealed class ProfileExportService
|
||||
};
|
||||
|
||||
public ProfileExportService(
|
||||
ICryptoHash cryptoHash,
|
||||
TimeProvider? timeProvider = null,
|
||||
Func<string, RiskProfileModel?>? profileLookup = null,
|
||||
Func<string, RiskProfileVersionInfo?>? lifecycleLookup = null,
|
||||
Action<RiskProfileModel>? profileSave = null,
|
||||
Func<string, string?>? keyLookup = null)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_hasher = new RiskProfileHasher();
|
||||
_hasher = new RiskProfileHasher(cryptoHash);
|
||||
_profileLookup = profileLookup;
|
||||
_lifecycleLookup = lifecycleLookup;
|
||||
_profileSave = profileSave;
|
||||
@@ -331,15 +335,14 @@ public sealed class ProfileExportService
|
||||
.ThenBy(p => p.Profile.Version)
|
||||
.Select(p => p.ContentHash));
|
||||
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(combined));
|
||||
return Convert.ToHexStringLower(hashBytes);
|
||||
return _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(combined), HashPurpose.Content);
|
||||
}
|
||||
|
||||
private static string GenerateBundleId(DateTimeOffset timestamp)
|
||||
private string GenerateBundleId(DateTimeOffset timestamp)
|
||||
{
|
||||
var seed = $"{timestamp:O}|{Guid.NewGuid()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return $"rpb-{Convert.ToHexStringLower(hash)[..16]}";
|
||||
var hash = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(seed), HashPurpose.Content);
|
||||
return $"rpb-{hash[..16]}";
|
||||
}
|
||||
|
||||
private static string GetSourceVersion()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Hashing;
|
||||
@@ -11,6 +11,8 @@ namespace StellaOps.Policy.RiskProfile.Hashing;
|
||||
/// </summary>
|
||||
public sealed class RiskProfileHasher
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
@@ -22,20 +24,24 @@ public sealed class RiskProfileHasher
|
||||
},
|
||||
};
|
||||
|
||||
public RiskProfileHasher(ICryptoHash cryptoHash)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic SHA-256 hash of the risk profile.
|
||||
/// Computes a deterministic hash of the risk profile using the compliance profile's content algorithm.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to hash.</param>
|
||||
/// <returns>Lowercase hex-encoded SHA-256 hash.</returns>
|
||||
/// <returns>Lowercase hex-encoded hash.</returns>
|
||||
public string ComputeHash(RiskProfileModel profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var canonical = CreateCanonicalForm(profile);
|
||||
var json = JsonSerializer.Serialize(canonical, CanonicalJsonOptions);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
return Convert.ToHexStringLower(hash);
|
||||
return _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(json), HashPurpose.Content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -43,16 +49,15 @@ public sealed class RiskProfileHasher
|
||||
/// Useful for detecting semantic changes regardless of versioning.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to hash.</param>
|
||||
/// <returns>Lowercase hex-encoded SHA-256 hash.</returns>
|
||||
/// <returns>Lowercase hex-encoded hash.</returns>
|
||||
public string ComputeContentHash(RiskProfileModel profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var canonical = CreateCanonicalContentForm(profile);
|
||||
var json = JsonSerializer.Serialize(canonical, CanonicalJsonOptions);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
return Convert.ToHexStringLower(hash);
|
||||
return _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(json), HashPurpose.Content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
@@ -16,10 +15,11 @@ public sealed class RiskProfileLifecycleService
|
||||
private readonly ConcurrentDictionary<string, List<RiskProfileVersionInfo>> _versions;
|
||||
private readonly ConcurrentDictionary<string, List<RiskProfileLifecycleEvent>> _events;
|
||||
|
||||
public RiskProfileLifecycleService(TimeProvider? timeProvider = null)
|
||||
public RiskProfileLifecycleService(ICryptoHash cryptoHash, TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cryptoHash);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_hasher = new RiskProfileHasher();
|
||||
_hasher = new RiskProfileHasher(cryptoHash);
|
||||
_versions = new ConcurrentDictionary<string, List<RiskProfileVersionInfo>>(StringComparer.OrdinalIgnoreCase);
|
||||
_events = new ConcurrentDictionary<string, List<RiskProfileLifecycleEvent>>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Schemas\risk-profile-schema@1.json" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.Core.Replay;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
@@ -25,29 +26,33 @@ internal sealed class RecordModeService : IRecordModeService
|
||||
{
|
||||
private readonly RecordModeAssembler _assembler;
|
||||
private readonly ReachabilityReplayWriter _reachability;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly IArtifactObjectStore? _objectStore;
|
||||
private readonly ScannerStorageOptions? _storageOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<RecordModeService>? _logger;
|
||||
|
||||
public RecordModeService(
|
||||
ICryptoHash cryptoHash,
|
||||
IArtifactObjectStore objectStore,
|
||||
IOptions<ScannerStorageOptions> storageOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RecordModeService> logger)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
|
||||
_storageOptions = (storageOptions ?? throw new ArgumentNullException(nameof(storageOptions))).Value;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_assembler = new RecordModeAssembler(timeProvider);
|
||||
_assembler = new RecordModeAssembler(cryptoHash, timeProvider);
|
||||
_reachability = new ReachabilityReplayWriter();
|
||||
}
|
||||
|
||||
// Legacy/testing constructor for unit tests that do not require storage.
|
||||
public RecordModeService(TimeProvider? timeProvider = null)
|
||||
public RecordModeService(ICryptoHash cryptoHash, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_assembler = new RecordModeAssembler(timeProvider);
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_assembler = new RecordModeAssembler(cryptoHash, timeProvider);
|
||||
_reachability = new ReachabilityReplayWriter();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
@@ -241,7 +246,7 @@ internal sealed class RecordModeService : IRecordModeService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
var result = await ReplayBundleWriter.WriteTarZstAsync(entries, buffer, casPrefix: casPrefix, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var result = await ReplayBundleWriter.WriteTarZstAsync(_cryptoHash, entries, buffer, casPrefix: casPrefix, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
buffer.Position = 0;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
@@ -16,12 +17,18 @@ namespace StellaOps.Scanner.Worker.Processing.Replay;
|
||||
internal sealed class ReplayBundleFetcher
|
||||
{
|
||||
private readonly IArtifactObjectStore _objectStore;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly ScannerStorageOptions _storageOptions;
|
||||
private readonly ILogger<ReplayBundleFetcher> _logger;
|
||||
|
||||
public ReplayBundleFetcher(IArtifactObjectStore objectStore, ScannerStorageOptions storageOptions, ILogger<ReplayBundleFetcher> logger)
|
||||
public ReplayBundleFetcher(
|
||||
IArtifactObjectStore objectStore,
|
||||
ICryptoHash cryptoHash,
|
||||
ScannerStorageOptions storageOptions,
|
||||
ILogger<ReplayBundleFetcher> logger)
|
||||
{
|
||||
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_storageOptions = storageOptions ?? throw new ArgumentNullException(nameof(storageOptions));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
@@ -55,7 +62,7 @@ internal sealed class ReplayBundleFetcher
|
||||
// Verify hash
|
||||
await using (var file = File.OpenRead(tempPath))
|
||||
{
|
||||
var actualHex = DeterministicHash.Sha256Hex(file);
|
||||
var actualHex = await DeterministicHash.Sha256HexAsync(_cryptoHash, file, cancellationToken).ConfigureAwait(false);
|
||||
var expected = NormalizeHash(metadata.ManifestHash);
|
||||
if (!string.Equals(actualHex, expected, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Process" Version="1.12.0-beta.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Replay.Core;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Replay;
|
||||
@@ -10,10 +11,12 @@ namespace StellaOps.Scanner.Core.Replay;
|
||||
/// </summary>
|
||||
public sealed class RecordModeAssembler
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public RecordModeAssembler(TimeProvider? timeProvider = null)
|
||||
public RecordModeAssembler(ICryptoHash cryptoHash, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
@@ -31,7 +34,7 @@ public sealed class RecordModeAssembler
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingsDigest);
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var manifestHash = "sha256:" + manifest.ComputeCanonicalSha256();
|
||||
var manifestHash = "sha256:" + manifest.ComputeCanonicalSha256(_cryptoHash);
|
||||
|
||||
return new ReplayRunRecord
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -8,8 +8,15 @@ namespace StellaOps.Scanner.Reachability;
|
||||
/// Builds canonical CodeIDs used by richgraph-v1 to anchor symbols when names are missing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Format: <c>code:<lang>:<base64url-sha256></c> where the hash is computed over a
|
||||
/// <para>
|
||||
/// Format: <c>code:{lang}:{base64url-sha256}</c> where the hash is computed over a
|
||||
/// canonical tuple that is stable across machines and paths.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>INTEROP NOTE:</strong> This static class uses SHA-256 for maximum external tool
|
||||
/// compatibility. For compliance-profile-aware code IDs that respect GOST/SM3/FIPS profiles,
|
||||
/// use <see cref="CodeIdBuilder"/> with an injected <see cref="StellaOps.Cryptography.ICryptoHash"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class CodeId
|
||||
{
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
/// <summary>
|
||||
/// Builds canonical CodeIDs with compliance-profile-aware hashing.
|
||||
/// Uses <see cref="HashPurpose.Symbol"/> which resolves to:
|
||||
/// - SHA-256 for "world" and "fips" profiles
|
||||
/// - GOST3411-2012-256 for "gost" profile
|
||||
/// - SM3 for "sm" profile
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Format: <c>code:{lang}:{base64url-hash}</c> where the hash is computed over a
|
||||
/// canonical tuple that is stable across machines and paths.
|
||||
/// </remarks>
|
||||
public sealed class CodeIdBuilder
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new CodeIdBuilder with the specified crypto hash service.
|
||||
/// </summary>
|
||||
/// <param name="cryptoHash">Crypto hash service for compliance-aware hashing.</param>
|
||||
public CodeIdBuilder(ICryptoHash cryptoHash)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a binary code-id from binary components.
|
||||
/// </summary>
|
||||
public string ForBinary(string buildId, string section, string? relativePath)
|
||||
{
|
||||
var tuple = $"{Norm(buildId)}\0{Norm(section)}\0{Norm(relativePath)}";
|
||||
return Build("binary", tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a .NET code-id from assembly components.
|
||||
/// </summary>
|
||||
public string ForDotNet(string assemblyName, string moduleName, string? mvid)
|
||||
{
|
||||
var tuple = $"{Norm(assemblyName)}\0{Norm(moduleName)}\0{Norm(mvid)}";
|
||||
return Build("dotnet", tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a binary code-id using canonical address + length tuple.
|
||||
/// </summary>
|
||||
public string ForBinarySegment(string format, string fileHash, string address, long? lengthBytes = null, string? section = null, string? codeBlockHash = null)
|
||||
{
|
||||
var tuple = $"{Norm(format)}\0{Norm(fileHash)}\0{NormalizeAddress(address)}\0{NormalizeLength(lengthBytes)}\0{Norm(section)}\0{Norm(codeBlockHash)}";
|
||||
return Build("binary", tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Node code-id from package components.
|
||||
/// </summary>
|
||||
public string ForNode(string packageName, string entryPath)
|
||||
{
|
||||
var tuple = $"{Norm(packageName)}\0{Norm(entryPath)}";
|
||||
return Build("node", tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a code-id from an existing symbol ID.
|
||||
/// </summary>
|
||||
public string FromSymbolId(string symbolId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(symbolId);
|
||||
return Build("sym", symbolId.Trim());
|
||||
}
|
||||
|
||||
private string Build(string lang, string tuple)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(tuple);
|
||||
var hash = _cryptoHash.ComputeHashForPurpose(bytes, HashPurpose.Symbol);
|
||||
var base64 = Convert.ToBase64String(hash)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
return $"code:{lang}:{base64}";
|
||||
}
|
||||
|
||||
private static string NormalizeAddress(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "0x0";
|
||||
}
|
||||
|
||||
var addrText = value.Trim();
|
||||
var isHex = addrText.StartsWith("0x", StringComparison.OrdinalIgnoreCase);
|
||||
if (isHex)
|
||||
{
|
||||
addrText = addrText[2..];
|
||||
}
|
||||
|
||||
if (long.TryParse(addrText, isHex ? System.Globalization.NumberStyles.HexNumber : System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var addrValue))
|
||||
{
|
||||
if (addrValue < 0)
|
||||
{
|
||||
addrValue = 0;
|
||||
}
|
||||
|
||||
return $"0x{addrValue:x}";
|
||||
}
|
||||
|
||||
addrText = addrText.TrimStart('0');
|
||||
if (addrText.Length == 0)
|
||||
{
|
||||
addrText = "0";
|
||||
}
|
||||
|
||||
return $"0x{addrText.ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string NormalizeLength(long? value)
|
||||
{
|
||||
if (value is null or <= 0)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
return value.Value.ToString("D", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string Norm(string? value) => (value ?? string.Empty).Trim();
|
||||
}
|
||||
@@ -1,20 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
/// <summary>
|
||||
/// Writes richgraph-v1 documents to disk with canonical ordering and BLAKE3 hash.
|
||||
/// Writes richgraph-v1 documents to disk with canonical ordering and compliance-profile-aware hashing.
|
||||
/// Uses <see cref="HashPurpose.Graph"/> for content addressing, which resolves to:
|
||||
/// - BLAKE3-256 for "world" profile
|
||||
/// - SHA-256 for "fips" profile
|
||||
/// - GOST3411-2012-256 for "gost" profile
|
||||
/// - SM3 for "sm" profile
|
||||
/// </summary>
|
||||
public sealed class RichGraphWriter
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
private static readonly JsonWriterOptions JsonOptions = new()
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
@@ -22,6 +26,15 @@ public sealed class RichGraphWriter
|
||||
SkipValidation = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new RichGraphWriter with the specified crypto hash service.
|
||||
/// </summary>
|
||||
/// <param name="cryptoHash">Crypto hash service for compliance-aware hashing.</param>
|
||||
public RichGraphWriter(ICryptoHash cryptoHash)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
}
|
||||
|
||||
public async Task<RichGraphWriteResult> WriteAsync(
|
||||
RichGraph graph,
|
||||
string outputRoot,
|
||||
@@ -46,7 +59,7 @@ public sealed class RichGraphWriter
|
||||
}
|
||||
|
||||
var bytes = await File.ReadAllBytesAsync(graphPath, cancellationToken).ConfigureAwait(false);
|
||||
var graphHash = ComputeSha256(bytes);
|
||||
var graphHash = _cryptoHash.ComputePrefixedHashForPurpose(bytes, HashPurpose.Graph);
|
||||
|
||||
var metaPath = Path.Combine(root, "meta.json");
|
||||
await using (var stream = File.Create(metaPath))
|
||||
@@ -169,12 +182,6 @@ public sealed class RichGraphWriter
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static string ComputeSha256(IReadOnlyList<byte> bytes)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(bytes.ToArray());
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RichGraphWriteResult(
|
||||
|
||||
@@ -9,5 +9,6 @@
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -10,8 +10,15 @@ namespace StellaOps.Scanner.Reachability;
|
||||
/// to remain reproducible and cacheable across hosts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Format: <c>sym:{lang}:{stable-fragment}</c>
|
||||
/// where stable-fragment is SHA-256(base64url-no-pad) of the canonical tuple per language.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>INTEROP NOTE:</strong> This static class uses SHA-256 for maximum external tool
|
||||
/// compatibility. For compliance-profile-aware symbol IDs that respect GOST/SM3/FIPS profiles,
|
||||
/// use <see cref="SymbolIdBuilder"/> with an injected <see cref="StellaOps.Cryptography.ICryptoHash"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SymbolId
|
||||
{
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
/// <summary>
|
||||
/// Builds canonical SymbolIDs with compliance-profile-aware hashing.
|
||||
/// Uses <see cref="HashPurpose.Symbol"/> which resolves to:
|
||||
/// - SHA-256 for "world" and "fips" profiles
|
||||
/// - GOST3411-2012-256 for "gost" profile
|
||||
/// - SM3 for "sm" profile
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Format: <c>sym:{lang}:{stable-fragment}</c>
|
||||
/// where stable-fragment is base64url-no-pad of the profile-appropriate hash of the canonical tuple.
|
||||
/// </remarks>
|
||||
public sealed class SymbolIdBuilder
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SymbolIdBuilder with the specified crypto hash service.
|
||||
/// </summary>
|
||||
/// <param name="cryptoHash">Crypto hash service for compliance-aware hashing.</param>
|
||||
public SymbolIdBuilder(ICryptoHash cryptoHash)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Java symbol ID from method signature components.
|
||||
/// </summary>
|
||||
public string ForJava(string package, string className, string method, string descriptor)
|
||||
{
|
||||
var tuple = $"{Lower(package)}\0{Lower(className)}\0{Lower(method)}\0{Lower(descriptor)}";
|
||||
return Build(SymbolId.Lang.Java, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a .NET symbol ID from member signature components.
|
||||
/// </summary>
|
||||
public string ForDotNet(string assemblyName, string ns, string typeName, string memberSignature)
|
||||
{
|
||||
var tuple = $"{Norm(assemblyName)}\0{Norm(ns)}\0{Norm(typeName)}\0{Norm(memberSignature)}";
|
||||
return Build(SymbolId.Lang.DotNet, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Node/Deno symbol ID from module export components.
|
||||
/// </summary>
|
||||
public string ForNode(string pkgNameOrPath, string exportPath, string kind)
|
||||
{
|
||||
var tuple = $"{Norm(pkgNameOrPath)}\0{Norm(exportPath)}\0{Norm(kind)}";
|
||||
return Build(SymbolId.Lang.Node, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Deno symbol ID from module export components.
|
||||
/// </summary>
|
||||
public string ForDeno(string pkgNameOrPath, string exportPath, string kind)
|
||||
{
|
||||
var tuple = $"{Norm(pkgNameOrPath)}\0{Norm(exportPath)}\0{Norm(kind)}";
|
||||
return Build(SymbolId.Lang.Deno, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Go symbol ID from function/method components.
|
||||
/// </summary>
|
||||
public string ForGo(string modulePath, string packagePath, string receiver, string func)
|
||||
{
|
||||
var tuple = $"{Norm(modulePath)}\0{Norm(packagePath)}\0{Norm(receiver)}\0{Norm(func)}";
|
||||
return Build(SymbolId.Lang.Go, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Rust symbol ID from item components.
|
||||
/// </summary>
|
||||
public string ForRust(string crateName, string modulePath, string itemName, string? mangled = null)
|
||||
{
|
||||
var tuple = $"{Norm(crateName)}\0{Norm(modulePath)}\0{Norm(itemName)}\0{Norm(mangled)}";
|
||||
return Build(SymbolId.Lang.Rust, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Swift symbol ID from member components.
|
||||
/// </summary>
|
||||
public string ForSwift(string module, string typeName, string member, string? mangled = null)
|
||||
{
|
||||
var tuple = $"{Norm(module)}\0{Norm(typeName)}\0{Norm(member)}\0{Norm(mangled)}";
|
||||
return Build(SymbolId.Lang.Swift, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a shell symbol ID from script/function components.
|
||||
/// </summary>
|
||||
public string ForShell(string scriptRelPath, string functionOrCmd)
|
||||
{
|
||||
var tuple = $"{Norm(scriptRelPath)}\0{Norm(functionOrCmd)}";
|
||||
return Build(SymbolId.Lang.Shell, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a binary symbol ID from ELF/PE/Mach-O components.
|
||||
/// </summary>
|
||||
public string ForBinary(string buildId, string section, string symbolName)
|
||||
=> ForBinaryAddressed(buildId, section, string.Empty, symbolName, "static", null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a binary symbol ID that includes file hash, section, address, and linkage.
|
||||
/// </summary>
|
||||
public string ForBinaryAddressed(string fileHash, string section, string address, string symbolName, string linkage, string? codeBlockHash = null)
|
||||
{
|
||||
var tuple = $"{Norm(fileHash)}\0{Norm(section)}\0{NormalizeAddress(address)}\0{Norm(symbolName)}\0{Norm(linkage)}\0{Norm(codeBlockHash)}";
|
||||
return Build(SymbolId.Lang.Binary, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Python symbol ID from module/function components.
|
||||
/// </summary>
|
||||
public string ForPython(string packageOrPath, string modulePath, string qualifiedName)
|
||||
{
|
||||
var tuple = $"{Norm(packageOrPath)}\0{Norm(modulePath)}\0{Norm(qualifiedName)}";
|
||||
return Build(SymbolId.Lang.Python, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Ruby symbol ID from module/method components.
|
||||
/// </summary>
|
||||
public string ForRuby(string gemOrPath, string modulePath, string methodName)
|
||||
{
|
||||
var tuple = $"{Norm(gemOrPath)}\0{Norm(modulePath)}\0{Norm(methodName)}";
|
||||
return Build(SymbolId.Lang.Ruby, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PHP symbol ID from namespace/function components.
|
||||
/// </summary>
|
||||
public string ForPhp(string composerPackage, string ns, string qualifiedName)
|
||||
{
|
||||
var tuple = $"{Norm(composerPackage)}\0{Norm(ns)}\0{Norm(qualifiedName)}";
|
||||
return Build(SymbolId.Lang.Php, tuple);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a symbol ID from a pre-computed canonical tuple and language.
|
||||
/// </summary>
|
||||
public string FromTuple(string lang, string canonicalTuple)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(lang);
|
||||
return Build(lang, canonicalTuple);
|
||||
}
|
||||
|
||||
private string Build(string lang, string tuple)
|
||||
{
|
||||
var hash = ComputeFragment(tuple);
|
||||
return $"sym:{lang}:{hash}";
|
||||
}
|
||||
|
||||
private string ComputeFragment(string tuple)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(tuple);
|
||||
var hash = _cryptoHash.ComputeHashForPurpose(bytes, HashPurpose.Symbol);
|
||||
// Base64url without padding per spec
|
||||
return Convert.ToBase64String(hash)
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_')
|
||||
.TrimEnd('=');
|
||||
}
|
||||
|
||||
private static string NormalizeAddress(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "0x0";
|
||||
}
|
||||
|
||||
var addrText = value.Trim();
|
||||
var isHex = addrText.StartsWith("0x", StringComparison.OrdinalIgnoreCase);
|
||||
if (isHex)
|
||||
{
|
||||
addrText = addrText[2..];
|
||||
}
|
||||
|
||||
if (long.TryParse(addrText, isHex ? System.Globalization.NumberStyles.HexNumber : System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var addrValue))
|
||||
{
|
||||
if (addrValue < 0)
|
||||
{
|
||||
addrValue = 0;
|
||||
}
|
||||
|
||||
return $"0x{addrValue:x}";
|
||||
}
|
||||
|
||||
addrText = addrText.TrimStart('0');
|
||||
if (addrText.Length == 0)
|
||||
{
|
||||
addrText = "0";
|
||||
}
|
||||
|
||||
return $"0x{addrText.ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string Lower(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToLowerInvariant();
|
||||
|
||||
private static string Norm(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
|
||||
}
|
||||
@@ -37,6 +37,19 @@ public static class CryptoServiceCollectionExtensions
|
||||
services.Configure(configureRegistry);
|
||||
}
|
||||
|
||||
// Register compliance options with default profile
|
||||
services.TryAddSingleton<IOptionsMonitor<CryptoComplianceOptions>>(sp =>
|
||||
{
|
||||
var configuration = sp.GetService<IConfiguration>();
|
||||
var options = new CryptoComplianceOptions();
|
||||
configuration?.GetSection(CryptoComplianceOptions.SectionKey).Bind(options);
|
||||
options.ApplyEnvironmentOverrides();
|
||||
return new StaticComplianceOptionsMonitor(options);
|
||||
});
|
||||
|
||||
// Register compliance service
|
||||
services.TryAddSingleton<ICryptoComplianceService, CryptoComplianceService>();
|
||||
|
||||
services.TryAddSingleton<DefaultCryptoProvider>(sp =>
|
||||
{
|
||||
var provider = new DefaultCryptoProvider();
|
||||
@@ -64,6 +77,62 @@ public static class CryptoServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers crypto services with compliance profile configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration root.</param>
|
||||
/// <param name="configureCompliance">Optional compliance configuration.</param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection AddStellaOpsCryptoWithCompliance(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
Action<CryptoComplianceOptions>? configureCompliance = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Bind compliance options from configuration
|
||||
services.Configure<CryptoComplianceOptions>(options =>
|
||||
{
|
||||
configuration.GetSection(CryptoComplianceOptions.SectionKey).Bind(options);
|
||||
configureCompliance?.Invoke(options);
|
||||
options.ApplyEnvironmentOverrides();
|
||||
});
|
||||
|
||||
// Register compliance service with options monitor
|
||||
services.TryAddSingleton<ICryptoComplianceService, CryptoComplianceService>();
|
||||
|
||||
// Register base crypto services
|
||||
services.AddStellaOpsCrypto();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for static options monitoring.
|
||||
/// </summary>
|
||||
private sealed class StaticComplianceOptionsMonitor : IOptionsMonitor<CryptoComplianceOptions>
|
||||
{
|
||||
private readonly CryptoComplianceOptions _options;
|
||||
|
||||
public StaticComplianceOptionsMonitor(CryptoComplianceOptions options)
|
||||
=> _options = options;
|
||||
|
||||
public CryptoComplianceOptions CurrentValue => _options;
|
||||
|
||||
public CryptoComplianceOptions Get(string? name) => _options;
|
||||
|
||||
public IDisposable OnChange(Action<CryptoComplianceOptions, string> listener)
|
||||
=> NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
public static IServiceCollection AddStellaOpsCryptoRu(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
|
||||
@@ -37,6 +37,9 @@ public sealed class BouncyCastleEd25519CryptoProvider : ICryptoProvider
|
||||
};
|
||||
}
|
||||
|
||||
public ICryptoHasher GetHasher(string algorithmId)
|
||||
=> throw new NotSupportedException("BouncyCastle Ed25519 provider does not expose hashing capabilities.");
|
||||
|
||||
public IPasswordHasher GetPasswordHasher(string algorithmId)
|
||||
=> throw new NotSupportedException("BouncyCastle provider does not expose password hashing capabilities.");
|
||||
|
||||
|
||||
96
src/__Libraries/StellaOps.Cryptography/ComplianceProfile.cs
Normal file
96
src/__Libraries/StellaOps.Cryptography/ComplianceProfile.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a cryptographic compliance profile that maps hash purposes to algorithms
|
||||
/// according to a specific compliance standard (e.g., FIPS 140-3, GOST, SM).
|
||||
/// </summary>
|
||||
public sealed class ComplianceProfile
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this profile (e.g., "world", "fips", "gost", "sm", "kcmvp", "eidas").
|
||||
/// </summary>
|
||||
public required string ProfileId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name of the compliance standard.
|
||||
/// </summary>
|
||||
public required string StandardName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the compliance standard and its requirements.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mapping of hash purposes to algorithm identifiers.
|
||||
/// Keys are from <see cref="HashPurpose"/>, values are from <see cref="HashAlgorithms"/>.
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<string, string> PurposeAlgorithms { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mapping of hash purposes to hash prefixes (e.g., "blake3:", "sha256:", "gost3411:").
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<string, string> HashPrefixes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the Interop purpose may use SHA-256 even if not the profile default.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool AllowInteropOverride { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the algorithm for a given purpose.
|
||||
/// </summary>
|
||||
/// <param name="purpose">The hash purpose.</param>
|
||||
/// <returns>The algorithm identifier.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the purpose is unknown.</exception>
|
||||
public string GetAlgorithmForPurpose(string purpose)
|
||||
{
|
||||
if (PurposeAlgorithms.TryGetValue(purpose, out var algorithm))
|
||||
{
|
||||
return algorithm;
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Unknown hash purpose '{purpose}' in profile '{ProfileId}'.", nameof(purpose));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hash prefix for a given purpose (e.g., "blake3:", "sha256:").
|
||||
/// </summary>
|
||||
/// <param name="purpose">The hash purpose.</param>
|
||||
/// <returns>The hash prefix string.</returns>
|
||||
public string GetHashPrefix(string purpose)
|
||||
{
|
||||
if (HashPrefixes.TryGetValue(purpose, out var prefix))
|
||||
{
|
||||
return prefix;
|
||||
}
|
||||
|
||||
// Fallback to algorithm-based prefix
|
||||
var algorithm = GetAlgorithmForPurpose(purpose);
|
||||
return algorithm.ToLowerInvariant() + ":";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the given algorithm is compliant for the specified purpose.
|
||||
/// </summary>
|
||||
/// <param name="purpose">The hash purpose.</param>
|
||||
/// <param name="algorithmId">The algorithm to check.</param>
|
||||
/// <returns>True if compliant; otherwise, false.</returns>
|
||||
public bool IsCompliant(string purpose, string algorithmId)
|
||||
{
|
||||
if (!PurposeAlgorithms.TryGetValue(purpose, out var expectedAlgorithm))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Interop always allows SHA-256 if AllowInteropOverride is true
|
||||
if (purpose == HashPurpose.Interop && AllowInteropOverride &&
|
||||
string.Equals(algorithmId, HashAlgorithms.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(expectedAlgorithm, algorithmId, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
252
src/__Libraries/StellaOps.Cryptography/ComplianceProfiles.cs
Normal file
252
src/__Libraries/StellaOps.Cryptography/ComplianceProfiles.cs
Normal file
@@ -0,0 +1,252 @@
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Built-in compliance profiles for different jurisdictional crypto requirements.
|
||||
/// </summary>
|
||||
public static class ComplianceProfiles
|
||||
{
|
||||
/// <summary>
|
||||
/// Default/World profile using BLAKE3 for graph hashing, SHA-256 for everything else.
|
||||
/// Suitable for international deployments without specific compliance requirements.
|
||||
/// </summary>
|
||||
public static readonly ComplianceProfile World = new()
|
||||
{
|
||||
ProfileId = "world",
|
||||
StandardName = "ISO/Default",
|
||||
Description = "Default profile using BLAKE3 for graph content-addressing, SHA-256 for symbol/content hashing.",
|
||||
PurposeAlgorithms = new Dictionary<string, string>
|
||||
{
|
||||
[HashPurpose.Graph] = HashAlgorithms.Blake3_256,
|
||||
[HashPurpose.Symbol] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Content] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Merkle] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Attestation] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Interop] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Secret] = PasswordHashAlgorithms.Argon2id,
|
||||
},
|
||||
HashPrefixes = new Dictionary<string, string>
|
||||
{
|
||||
[HashPurpose.Graph] = "blake3:",
|
||||
[HashPurpose.Symbol] = "sha256:",
|
||||
[HashPurpose.Content] = "sha256:",
|
||||
[HashPurpose.Merkle] = "sha256:",
|
||||
[HashPurpose.Attestation] = "sha256:",
|
||||
[HashPurpose.Interop] = "sha256:",
|
||||
[HashPurpose.Secret] = "argon2id:",
|
||||
},
|
||||
AllowInteropOverride = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// FIPS 140-3 (US Federal) compliance profile.
|
||||
/// Uses only FIPS-approved algorithms: SHA-256, SHA-384, SHA-512, PBKDF2.
|
||||
/// Note: BLAKE3 is not FIPS-approved, so SHA-256 is used for graph hashing.
|
||||
/// </summary>
|
||||
public static readonly ComplianceProfile Fips = new()
|
||||
{
|
||||
ProfileId = "fips",
|
||||
StandardName = "FIPS 140-3",
|
||||
Description = "US Federal Information Processing Standard 140-3. Uses only FIPS-approved algorithms.",
|
||||
PurposeAlgorithms = new Dictionary<string, string>
|
||||
{
|
||||
[HashPurpose.Graph] = HashAlgorithms.Sha256, // BLAKE3 not FIPS-approved
|
||||
[HashPurpose.Symbol] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Content] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Merkle] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Attestation] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Interop] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Secret] = PasswordHashAlgorithms.Pbkdf2Sha256,
|
||||
},
|
||||
HashPrefixes = new Dictionary<string, string>
|
||||
{
|
||||
[HashPurpose.Graph] = "sha256:",
|
||||
[HashPurpose.Symbol] = "sha256:",
|
||||
[HashPurpose.Content] = "sha256:",
|
||||
[HashPurpose.Merkle] = "sha256:",
|
||||
[HashPurpose.Attestation] = "sha256:",
|
||||
[HashPurpose.Interop] = "sha256:",
|
||||
[HashPurpose.Secret] = "pbkdf2:",
|
||||
},
|
||||
AllowInteropOverride = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// GOST R 34.11-2012 (Russian) compliance profile.
|
||||
/// Uses GOST Stribog hash for all purposes except Interop (which remains SHA-256 for external compatibility).
|
||||
/// </summary>
|
||||
public static readonly ComplianceProfile Gost = new()
|
||||
{
|
||||
ProfileId = "gost",
|
||||
StandardName = "GOST R 34.11-2012",
|
||||
Description = "Russian GOST R 34.11-2012 (Stribog) hash standard. Interop uses SHA-256 for external tool compatibility.",
|
||||
PurposeAlgorithms = new Dictionary<string, string>
|
||||
{
|
||||
[HashPurpose.Graph] = HashAlgorithms.Gost3411_2012_256,
|
||||
[HashPurpose.Symbol] = HashAlgorithms.Gost3411_2012_256,
|
||||
[HashPurpose.Content] = HashAlgorithms.Gost3411_2012_256,
|
||||
[HashPurpose.Merkle] = HashAlgorithms.Gost3411_2012_256,
|
||||
[HashPurpose.Attestation] = HashAlgorithms.Gost3411_2012_256,
|
||||
[HashPurpose.Interop] = HashAlgorithms.Sha256, // Override for external compatibility
|
||||
[HashPurpose.Secret] = PasswordHashAlgorithms.Argon2id,
|
||||
},
|
||||
HashPrefixes = new Dictionary<string, string>
|
||||
{
|
||||
[HashPurpose.Graph] = "gost3411:",
|
||||
[HashPurpose.Symbol] = "gost3411:",
|
||||
[HashPurpose.Content] = "gost3411:",
|
||||
[HashPurpose.Merkle] = "gost3411:",
|
||||
[HashPurpose.Attestation] = "gost3411:",
|
||||
[HashPurpose.Interop] = "sha256:",
|
||||
[HashPurpose.Secret] = "argon2id:",
|
||||
},
|
||||
AllowInteropOverride = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// GB/T SM3 (Chinese) compliance profile.
|
||||
/// Uses SM3 hash for all purposes except Interop (which remains SHA-256 for external compatibility).
|
||||
/// </summary>
|
||||
public static readonly ComplianceProfile Sm = new()
|
||||
{
|
||||
ProfileId = "sm",
|
||||
StandardName = "GB/T (SM3)",
|
||||
Description = "Chinese GB/T 32905-2016 SM3 cryptographic hash standard. Interop uses SHA-256 for external tool compatibility.",
|
||||
PurposeAlgorithms = new Dictionary<string, string>
|
||||
{
|
||||
[HashPurpose.Graph] = HashAlgorithms.Sm3,
|
||||
[HashPurpose.Symbol] = HashAlgorithms.Sm3,
|
||||
[HashPurpose.Content] = HashAlgorithms.Sm3,
|
||||
[HashPurpose.Merkle] = HashAlgorithms.Sm3,
|
||||
[HashPurpose.Attestation] = HashAlgorithms.Sm3,
|
||||
[HashPurpose.Interop] = HashAlgorithms.Sha256, // Override for external compatibility
|
||||
[HashPurpose.Secret] = PasswordHashAlgorithms.Argon2id,
|
||||
},
|
||||
HashPrefixes = new Dictionary<string, string>
|
||||
{
|
||||
[HashPurpose.Graph] = "sm3:",
|
||||
[HashPurpose.Symbol] = "sm3:",
|
||||
[HashPurpose.Content] = "sm3:",
|
||||
[HashPurpose.Merkle] = "sm3:",
|
||||
[HashPurpose.Attestation] = "sm3:",
|
||||
[HashPurpose.Interop] = "sha256:",
|
||||
[HashPurpose.Secret] = "argon2id:",
|
||||
},
|
||||
AllowInteropOverride = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// KCMVP (Korea Cryptographic Module Validation Program) compliance profile.
|
||||
/// Uses SHA-256 for hashing. Note: ARIA/SEED/LEA are for encryption, KCDSA for signatures.
|
||||
/// </summary>
|
||||
public static readonly ComplianceProfile Kcmvp = new()
|
||||
{
|
||||
ProfileId = "kcmvp",
|
||||
StandardName = "KCMVP (Korea)",
|
||||
Description = "Korea Cryptographic Module Validation Program. Uses SHA-256 for hashing.",
|
||||
PurposeAlgorithms = new Dictionary<string, string>
|
||||
{
|
||||
[HashPurpose.Graph] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Symbol] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Content] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Merkle] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Attestation] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Interop] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Secret] = PasswordHashAlgorithms.Argon2id,
|
||||
},
|
||||
HashPrefixes = new Dictionary<string, string>
|
||||
{
|
||||
[HashPurpose.Graph] = "sha256:",
|
||||
[HashPurpose.Symbol] = "sha256:",
|
||||
[HashPurpose.Content] = "sha256:",
|
||||
[HashPurpose.Merkle] = "sha256:",
|
||||
[HashPurpose.Attestation] = "sha256:",
|
||||
[HashPurpose.Interop] = "sha256:",
|
||||
[HashPurpose.Secret] = "argon2id:",
|
||||
},
|
||||
AllowInteropOverride = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// eIDAS/ETSI TS 119 312 (European) compliance profile.
|
||||
/// Uses SHA-256 for hashing per ETSI cryptographic suites specification.
|
||||
/// </summary>
|
||||
public static readonly ComplianceProfile Eidas = new()
|
||||
{
|
||||
ProfileId = "eidas",
|
||||
StandardName = "eIDAS/ETSI TS 119 312",
|
||||
Description = "European eIDAS regulation with ETSI TS 119 312 cryptographic suites. Uses SHA-256/384 for hashing.",
|
||||
PurposeAlgorithms = new Dictionary<string, string>
|
||||
{
|
||||
[HashPurpose.Graph] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Symbol] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Content] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Merkle] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Attestation] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Interop] = HashAlgorithms.Sha256,
|
||||
[HashPurpose.Secret] = PasswordHashAlgorithms.Argon2id,
|
||||
},
|
||||
HashPrefixes = new Dictionary<string, string>
|
||||
{
|
||||
[HashPurpose.Graph] = "sha256:",
|
||||
[HashPurpose.Symbol] = "sha256:",
|
||||
[HashPurpose.Content] = "sha256:",
|
||||
[HashPurpose.Merkle] = "sha256:",
|
||||
[HashPurpose.Attestation] = "sha256:",
|
||||
[HashPurpose.Interop] = "sha256:",
|
||||
[HashPurpose.Secret] = "argon2id:",
|
||||
},
|
||||
AllowInteropOverride = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// All built-in profiles indexed by profile ID.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, ComplianceProfile> All =
|
||||
new Dictionary<string, ComplianceProfile>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[World.ProfileId] = World,
|
||||
[Fips.ProfileId] = Fips,
|
||||
[Gost.ProfileId] = Gost,
|
||||
[Sm.ProfileId] = Sm,
|
||||
[Kcmvp.ProfileId] = Kcmvp,
|
||||
[Eidas.ProfileId] = Eidas,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets a profile by ID, returning the World profile if not found.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID to look up.</param>
|
||||
/// <returns>The matching profile, or World if not found.</returns>
|
||||
public static ComplianceProfile GetProfile(string? profileId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(profileId))
|
||||
{
|
||||
return World;
|
||||
}
|
||||
|
||||
return All.TryGetValue(profileId, out var profile) ? profile : World;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a profile by ID, throwing if not found.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID to look up.</param>
|
||||
/// <returns>The matching profile.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the profile ID is not found.</exception>
|
||||
public static ComplianceProfile GetProfileOrThrow(string profileId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(profileId))
|
||||
{
|
||||
throw new ArgumentException("Profile ID cannot be null or empty.", nameof(profileId));
|
||||
}
|
||||
|
||||
if (!All.TryGetValue(profileId, out var profile))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Unknown compliance profile '{profileId}'. Valid profiles: {string.Join(", ", All.Keys)}",
|
||||
nameof(profileId));
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry diagnostics for crypto compliance operations.
|
||||
/// </summary>
|
||||
public sealed class CryptoComplianceDiagnostics : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Activity source name for distributed tracing.
|
||||
/// </summary>
|
||||
public const string ActivitySourceName = "StellaOps.Crypto.Compliance";
|
||||
|
||||
/// <summary>
|
||||
/// Meter name for metrics.
|
||||
/// </summary>
|
||||
public const string MeterName = "StellaOps.Crypto.Compliance";
|
||||
|
||||
private readonly ActivitySource _activitySource;
|
||||
private readonly Meter _meter;
|
||||
|
||||
// Counters
|
||||
private readonly Counter<long> _hashOperations;
|
||||
private readonly Counter<long> _complianceViolations;
|
||||
private readonly Histogram<double> _hashDurationMs;
|
||||
|
||||
public CryptoComplianceDiagnostics()
|
||||
{
|
||||
_activitySource = new ActivitySource(ActivitySourceName, "1.0.0");
|
||||
_meter = new Meter(MeterName, "1.0.0");
|
||||
|
||||
_hashOperations = _meter.CreateCounter<long>(
|
||||
name: "crypto.hash.operations",
|
||||
unit: "{operation}",
|
||||
description: "Total number of hash operations performed.");
|
||||
|
||||
_complianceViolations = _meter.CreateCounter<long>(
|
||||
name: "crypto.compliance.violations",
|
||||
unit: "{violation}",
|
||||
description: "Number of compliance violations detected.");
|
||||
|
||||
_hashDurationMs = _meter.CreateHistogram<double>(
|
||||
name: "crypto.hash.duration",
|
||||
unit: "ms",
|
||||
description: "Duration of hash operations in milliseconds.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for a hash operation.
|
||||
/// </summary>
|
||||
public Activity? StartHashOperation(string purpose, string algorithm, string profile)
|
||||
{
|
||||
var activity = _activitySource.StartActivity("crypto.hash", ActivityKind.Internal);
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag("crypto.purpose", purpose);
|
||||
activity.SetTag("crypto.algorithm", algorithm);
|
||||
activity.SetTag("crypto.profile", profile);
|
||||
}
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a completed hash operation.
|
||||
/// </summary>
|
||||
public void RecordHashOperation(
|
||||
string profile,
|
||||
string purpose,
|
||||
string algorithm,
|
||||
TimeSpan duration,
|
||||
bool success = true)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "profile", profile },
|
||||
{ "purpose", purpose },
|
||||
{ "algorithm", algorithm },
|
||||
{ "success", success.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
_hashOperations.Add(1, tags);
|
||||
_hashDurationMs.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a compliance violation.
|
||||
/// </summary>
|
||||
public void RecordComplianceViolation(
|
||||
string profile,
|
||||
string purpose,
|
||||
string requestedAlgorithm,
|
||||
string expectedAlgorithm,
|
||||
bool wasBlocked)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "profile", profile },
|
||||
{ "purpose", purpose },
|
||||
{ "requested_algorithm", requestedAlgorithm },
|
||||
{ "expected_algorithm", expectedAlgorithm },
|
||||
{ "blocked", wasBlocked.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
_complianceViolations.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes of the diagnostics resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_activitySource.Dispose();
|
||||
_meter.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a cryptographic operation violates compliance requirements.
|
||||
/// </summary>
|
||||
public sealed class CryptoComplianceException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// The compliance profile that was violated.
|
||||
/// </summary>
|
||||
public string ProfileId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The hash purpose that was being processed.
|
||||
/// </summary>
|
||||
public string Purpose { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The algorithm that was requested.
|
||||
/// </summary>
|
||||
public string RequestedAlgorithm { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The algorithm that is expected for the profile and purpose.
|
||||
/// </summary>
|
||||
public string ExpectedAlgorithm { get; }
|
||||
|
||||
public CryptoComplianceException(
|
||||
string message,
|
||||
string profileId,
|
||||
string purpose,
|
||||
string requestedAlgorithm,
|
||||
string expectedAlgorithm)
|
||||
: base(message)
|
||||
{
|
||||
ProfileId = profileId;
|
||||
Purpose = purpose;
|
||||
RequestedAlgorithm = requestedAlgorithm;
|
||||
ExpectedAlgorithm = expectedAlgorithm;
|
||||
}
|
||||
|
||||
public CryptoComplianceException(
|
||||
string message,
|
||||
string profileId,
|
||||
string purpose,
|
||||
string requestedAlgorithm,
|
||||
string expectedAlgorithm,
|
||||
Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
ProfileId = profileId;
|
||||
Purpose = purpose;
|
||||
RequestedAlgorithm = requestedAlgorithm;
|
||||
ExpectedAlgorithm = expectedAlgorithm;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for cryptographic compliance.
|
||||
/// </summary>
|
||||
public sealed class CryptoComplianceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The configuration section key for binding.
|
||||
/// </summary>
|
||||
public const string SectionKey = "Crypto:Compliance";
|
||||
|
||||
/// <summary>
|
||||
/// Active compliance profile ID.
|
||||
/// Valid values: "world", "fips", "gost", "sm", "kcmvp", "eidas".
|
||||
/// Default: "world".
|
||||
/// Can be overridden by STELLAOPS_CRYPTO_COMPLIANCE_PROFILE environment variable.
|
||||
/// </summary>
|
||||
public string ProfileId { get; set; } = "world";
|
||||
|
||||
/// <summary>
|
||||
/// When true, fail on non-compliant algorithm usage.
|
||||
/// Default: true.
|
||||
/// Can be overridden by STELLAOPS_CRYPTO_STRICT_VALIDATION environment variable.
|
||||
/// </summary>
|
||||
public bool StrictValidation { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When StrictValidation=false, emit warning instead of silently proceeding.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool WarnOnNonCompliant { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Allow Interop purpose to override profile algorithm with SHA-256.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool AllowInteropOverride { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enable telemetry for all crypto operations.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool EnableTelemetry { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Custom purpose-to-algorithm overrides that take precedence over profile defaults.
|
||||
/// Keys are from <see cref="HashPurpose"/>, values are from <see cref="HashAlgorithms"/>.
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? PurposeOverrides { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Applies environment variable overrides.
|
||||
/// </summary>
|
||||
public void ApplyEnvironmentOverrides()
|
||||
{
|
||||
var profileEnv = Environment.GetEnvironmentVariable("STELLAOPS_CRYPTO_COMPLIANCE_PROFILE");
|
||||
if (!string.IsNullOrWhiteSpace(profileEnv))
|
||||
{
|
||||
ProfileId = profileEnv.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
var strictEnv = Environment.GetEnvironmentVariable("STELLAOPS_CRYPTO_STRICT_VALIDATION");
|
||||
if (!string.IsNullOrWhiteSpace(strictEnv) &&
|
||||
bool.TryParse(strictEnv, out var strict))
|
||||
{
|
||||
StrictValidation = strict;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Service for resolving cryptographic algorithms based on the active compliance profile.
|
||||
/// </summary>
|
||||
public sealed class CryptoComplianceService : ICryptoComplianceService
|
||||
{
|
||||
private readonly IOptionsMonitor<CryptoComplianceOptions> _options;
|
||||
private readonly ILogger<CryptoComplianceService> _logger;
|
||||
|
||||
public CryptoComplianceService(
|
||||
IOptionsMonitor<CryptoComplianceOptions> options,
|
||||
ILogger<CryptoComplianceService>? logger = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? NullLogger<CryptoComplianceService>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently active compliance profile.
|
||||
/// </summary>
|
||||
public ComplianceProfile ActiveProfile
|
||||
{
|
||||
get
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
opts.ApplyEnvironmentOverrides();
|
||||
return ComplianceProfiles.GetProfile(opts.ProfileId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the algorithm for a given purpose based on the active profile.
|
||||
/// </summary>
|
||||
/// <param name="purpose">The hash purpose.</param>
|
||||
/// <returns>The algorithm identifier.</returns>
|
||||
public string GetAlgorithmForPurpose(string purpose)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purpose))
|
||||
{
|
||||
throw new ArgumentException("Purpose cannot be null or empty.", nameof(purpose));
|
||||
}
|
||||
|
||||
var opts = _options.CurrentValue;
|
||||
opts.ApplyEnvironmentOverrides();
|
||||
|
||||
// Check for purpose overrides first
|
||||
if (opts.PurposeOverrides?.TryGetValue(purpose, out var overrideAlgorithm) == true &&
|
||||
!string.IsNullOrWhiteSpace(overrideAlgorithm))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Using purpose override for {Purpose}: {Algorithm} (profile: {Profile})",
|
||||
purpose, overrideAlgorithm, opts.ProfileId);
|
||||
return overrideAlgorithm;
|
||||
}
|
||||
|
||||
// Get from active profile
|
||||
var profile = ComplianceProfiles.GetProfile(opts.ProfileId);
|
||||
return profile.GetAlgorithmForPurpose(purpose);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hash prefix for a given purpose (e.g., "blake3:", "sha256:").
|
||||
/// </summary>
|
||||
/// <param name="purpose">The hash purpose.</param>
|
||||
/// <returns>The hash prefix string.</returns>
|
||||
public string GetHashPrefix(string purpose)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purpose))
|
||||
{
|
||||
throw new ArgumentException("Purpose cannot be null or empty.", nameof(purpose));
|
||||
}
|
||||
|
||||
var profile = ActiveProfile;
|
||||
return profile.GetHashPrefix(purpose);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an algorithm request against the active compliance profile.
|
||||
/// </summary>
|
||||
/// <param name="purpose">The hash purpose (or null if unknown).</param>
|
||||
/// <param name="requestedAlgorithm">The requested algorithm.</param>
|
||||
/// <exception cref="CryptoComplianceException">
|
||||
/// Thrown when StrictValidation is enabled and the algorithm is not compliant.
|
||||
/// </exception>
|
||||
public void ValidateAlgorithm(string? purpose, string requestedAlgorithm)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
opts.ApplyEnvironmentOverrides();
|
||||
|
||||
var profile = ComplianceProfiles.GetProfile(opts.ProfileId);
|
||||
|
||||
// If purpose is specified, check compliance
|
||||
if (!string.IsNullOrWhiteSpace(purpose))
|
||||
{
|
||||
if (!profile.IsCompliant(purpose, requestedAlgorithm))
|
||||
{
|
||||
var expectedAlgorithm = profile.GetAlgorithmForPurpose(purpose);
|
||||
var message = $"Algorithm '{requestedAlgorithm}' is not compliant for purpose '{purpose}' " +
|
||||
$"in profile '{profile.ProfileId}'. Expected: '{expectedAlgorithm}'.";
|
||||
|
||||
if (opts.StrictValidation)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Compliance violation: {Message}",
|
||||
message);
|
||||
throw new CryptoComplianceException(message, profile.ProfileId, purpose, requestedAlgorithm, expectedAlgorithm);
|
||||
}
|
||||
|
||||
if (opts.WarnOnNonCompliant)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Compliance warning: {Message}",
|
||||
message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the given algorithm is compliant for any purpose in the active profile.
|
||||
/// </summary>
|
||||
/// <param name="algorithmId">The algorithm to check.</param>
|
||||
/// <returns>True if the algorithm is used by any purpose in the profile.</returns>
|
||||
public bool IsAlgorithmCompliant(string algorithmId)
|
||||
{
|
||||
var profile = ActiveProfile;
|
||||
return HashPurpose.All.Any(purpose => profile.IsCompliant(purpose, algorithmId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for compliance-aware cryptographic algorithm resolution.
|
||||
/// </summary>
|
||||
public interface ICryptoComplianceService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the currently active compliance profile.
|
||||
/// </summary>
|
||||
ComplianceProfile ActiveProfile { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the algorithm for a given purpose based on the active profile.
|
||||
/// </summary>
|
||||
string GetAlgorithmForPurpose(string purpose);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hash prefix for a given purpose.
|
||||
/// </summary>
|
||||
string GetHashPrefix(string purpose);
|
||||
|
||||
/// <summary>
|
||||
/// Validates an algorithm request against the active compliance profile.
|
||||
/// </summary>
|
||||
void ValidateAlgorithm(string? purpose, string requestedAlgorithm);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the given algorithm is compliant for any purpose in the active profile.
|
||||
/// </summary>
|
||||
bool IsAlgorithmCompliant(string algorithmId);
|
||||
}
|
||||
@@ -4,43 +4,65 @@ using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Blake3;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Digests;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
public sealed class DefaultCryptoHash : ICryptoHash
|
||||
{
|
||||
private readonly IOptionsMonitor<CryptoHashOptions> options;
|
||||
private readonly ILogger<DefaultCryptoHash> logger;
|
||||
private readonly IOptionsMonitor<CryptoHashOptions> _hashOptions;
|
||||
private readonly IOptionsMonitor<CryptoComplianceOptions> _complianceOptions;
|
||||
private readonly ILogger<DefaultCryptoHash> _logger;
|
||||
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public DefaultCryptoHash(
|
||||
IOptionsMonitor<CryptoHashOptions> options,
|
||||
IOptionsMonitor<CryptoHashOptions> hashOptions,
|
||||
IOptionsMonitor<CryptoComplianceOptions>? complianceOptions = null,
|
||||
ILogger<DefaultCryptoHash>? logger = null)
|
||||
{
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? NullLogger<DefaultCryptoHash>.Instance;
|
||||
_hashOptions = hashOptions ?? throw new ArgumentNullException(nameof(hashOptions));
|
||||
_complianceOptions = complianceOptions ?? new StaticComplianceOptionsMonitor(new CryptoComplianceOptions());
|
||||
_logger = logger ?? NullLogger<DefaultCryptoHash>.Instance;
|
||||
}
|
||||
|
||||
internal DefaultCryptoHash(CryptoHashOptions? options = null)
|
||||
: this(new StaticOptionsMonitor(options ?? new CryptoHashOptions()), NullLogger<DefaultCryptoHash>.Instance)
|
||||
internal DefaultCryptoHash(CryptoHashOptions? hashOptions = null, CryptoComplianceOptions? complianceOptions = null)
|
||||
: this(
|
||||
new StaticOptionsMonitor(hashOptions ?? new CryptoHashOptions()),
|
||||
new StaticComplianceOptionsMonitor(complianceOptions ?? new CryptoComplianceOptions()),
|
||||
NullLogger<DefaultCryptoHash>.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="DefaultCryptoHash"/> instance for use in tests.
|
||||
/// Uses default options with no compliance profile.
|
||||
/// </summary>
|
||||
public static DefaultCryptoHash CreateForTests()
|
||||
=> new(new CryptoHashOptions(), new CryptoComplianceOptions());
|
||||
|
||||
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
{
|
||||
var algorithm = NormalizeAlgorithm(algorithmId);
|
||||
return algorithm switch
|
||||
return ComputeHashWithAlgorithm(data, algorithm);
|
||||
}
|
||||
|
||||
private static byte[] ComputeHashWithAlgorithm(ReadOnlySpan<byte> data, string algorithm)
|
||||
{
|
||||
return algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
HashAlgorithms.Sha256 => ComputeSha256(data),
|
||||
HashAlgorithms.Sha512 => ComputeSha512(data),
|
||||
HashAlgorithms.Gost3411_2012_256 => GostDigestUtilities.ComputeDigest(data, use256: true),
|
||||
HashAlgorithms.Gost3411_2012_512 => GostDigestUtilities.ComputeDigest(data, use256: false),
|
||||
_ => throw new InvalidOperationException($"Unsupported hash algorithm {algorithm}.")
|
||||
"SHA256" => ComputeSha256(data),
|
||||
"SHA384" => ComputeSha384(data),
|
||||
"SHA512" => ComputeSha512(data),
|
||||
"GOST3411-2012-256" => GostDigestUtilities.ComputeDigest(data, use256: true),
|
||||
"GOST3411-2012-512" => GostDigestUtilities.ComputeDigest(data, use256: false),
|
||||
"BLAKE3-256" => ComputeBlake3(data),
|
||||
"SM3" => ComputeSm3(data),
|
||||
_ => throw new InvalidOperationException($"Unsupported hash algorithm '{algorithm}'.")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,13 +78,21 @@ public sealed class DefaultCryptoHash : ICryptoHash
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var algorithm = NormalizeAlgorithm(algorithmId);
|
||||
return algorithm switch
|
||||
return await ComputeHashWithAlgorithmAsync(stream, algorithm, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async ValueTask<byte[]> ComputeHashWithAlgorithmAsync(Stream stream, string algorithm, CancellationToken cancellationToken)
|
||||
{
|
||||
return algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
HashAlgorithms.Sha256 => await ComputeShaStreamAsync(HashAlgorithmName.SHA256, stream, cancellationToken).ConfigureAwait(false),
|
||||
HashAlgorithms.Sha512 => await ComputeShaStreamAsync(HashAlgorithmName.SHA512, stream, cancellationToken).ConfigureAwait(false),
|
||||
HashAlgorithms.Gost3411_2012_256 => await ComputeGostStreamAsync(use256: true, stream, cancellationToken).ConfigureAwait(false),
|
||||
HashAlgorithms.Gost3411_2012_512 => await ComputeGostStreamAsync(use256: false, stream, cancellationToken).ConfigureAwait(false),
|
||||
_ => throw new InvalidOperationException($"Unsupported hash algorithm {algorithm}.")
|
||||
"SHA256" => await ComputeShaStreamAsync(HashAlgorithmName.SHA256, stream, cancellationToken).ConfigureAwait(false),
|
||||
"SHA384" => await ComputeShaStreamAsync(HashAlgorithmName.SHA384, stream, cancellationToken).ConfigureAwait(false),
|
||||
"SHA512" => await ComputeShaStreamAsync(HashAlgorithmName.SHA512, stream, cancellationToken).ConfigureAwait(false),
|
||||
"GOST3411-2012-256" => await ComputeGostStreamAsync(use256: true, stream, cancellationToken).ConfigureAwait(false),
|
||||
"GOST3411-2012-512" => await ComputeGostStreamAsync(use256: false, stream, cancellationToken).ConfigureAwait(false),
|
||||
"BLAKE3-256" => await ComputeBlake3StreamAsync(stream, cancellationToken).ConfigureAwait(false),
|
||||
"SM3" => await ComputeSm3StreamAsync(stream, cancellationToken).ConfigureAwait(false),
|
||||
_ => throw new InvalidOperationException($"Unsupported hash algorithm '{algorithm}'.")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,7 +160,7 @@ public sealed class DefaultCryptoHash : ICryptoHash
|
||||
|
||||
private string NormalizeAlgorithm(string? algorithmId)
|
||||
{
|
||||
var defaultAlgorithm = options.CurrentValue?.DefaultAlgorithm;
|
||||
var defaultAlgorithm = _hashOptions.CurrentValue?.DefaultAlgorithm;
|
||||
if (!string.IsNullOrWhiteSpace(algorithmId))
|
||||
{
|
||||
return algorithmId.Trim().ToUpperInvariant();
|
||||
@@ -144,26 +174,194 @@ public sealed class DefaultCryptoHash : ICryptoHash
|
||||
return HashAlgorithms.Sha256;
|
||||
}
|
||||
|
||||
#region Purpose-based methods
|
||||
|
||||
private ComplianceProfile GetActiveProfile()
|
||||
{
|
||||
var opts = _complianceOptions.CurrentValue;
|
||||
opts.ApplyEnvironmentOverrides();
|
||||
return ComplianceProfiles.GetProfile(opts.ProfileId);
|
||||
}
|
||||
|
||||
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
{
|
||||
var algorithm = GetAlgorithmForPurpose(purpose);
|
||||
return ComputeHashWithAlgorithm(data, algorithm);
|
||||
}
|
||||
|
||||
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> Convert.ToHexString(ComputeHashForPurpose(data, purpose)).ToLowerInvariant();
|
||||
|
||||
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> Convert.ToBase64String(ComputeHashForPurpose(data, purpose));
|
||||
|
||||
public async ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var algorithm = GetAlgorithmForPurpose(purpose);
|
||||
return await ComputeHashWithAlgorithmAsync(stream, algorithm, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bytes = await ComputeHashForPurposeAsync(stream, purpose, cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public string GetAlgorithmForPurpose(string purpose)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purpose))
|
||||
{
|
||||
throw new ArgumentException("Purpose cannot be null or empty.", nameof(purpose));
|
||||
}
|
||||
|
||||
var opts = _complianceOptions.CurrentValue;
|
||||
opts.ApplyEnvironmentOverrides();
|
||||
|
||||
// Check for purpose overrides first
|
||||
if (opts.PurposeOverrides?.TryGetValue(purpose, out var overrideAlgorithm) == true &&
|
||||
!string.IsNullOrWhiteSpace(overrideAlgorithm))
|
||||
{
|
||||
return overrideAlgorithm;
|
||||
}
|
||||
|
||||
// Get from active profile
|
||||
var profile = ComplianceProfiles.GetProfile(opts.ProfileId);
|
||||
return profile.GetAlgorithmForPurpose(purpose);
|
||||
}
|
||||
|
||||
public string GetHashPrefix(string purpose)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purpose))
|
||||
{
|
||||
throw new ArgumentException("Purpose cannot be null or empty.", nameof(purpose));
|
||||
}
|
||||
|
||||
var profile = GetActiveProfile();
|
||||
return profile.GetHashPrefix(purpose);
|
||||
}
|
||||
|
||||
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
{
|
||||
var prefix = GetHashPrefix(purpose);
|
||||
var hash = ComputeHashHexForPurpose(data, purpose);
|
||||
return prefix + hash;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Algorithm implementations
|
||||
|
||||
private static byte[] ComputeSha384(ReadOnlySpan<byte> data)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[48];
|
||||
SHA384.HashData(data, buffer);
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] ComputeBlake3(ReadOnlySpan<byte> data)
|
||||
{
|
||||
using var hasher = Hasher.New();
|
||||
hasher.Update(data);
|
||||
var hash = hasher.Finalize();
|
||||
return hash.AsSpan().ToArray();
|
||||
}
|
||||
|
||||
private static byte[] ComputeSm3(ReadOnlySpan<byte> data)
|
||||
{
|
||||
var digest = new SM3Digest();
|
||||
digest.BlockUpdate(data);
|
||||
var output = new byte[digest.GetDigestSize()];
|
||||
digest.DoFinal(output, 0);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static async ValueTask<byte[]> ComputeBlake3StreamAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
using var hasher = Hasher.New();
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(128 * 1024);
|
||||
try
|
||||
{
|
||||
int bytesRead;
|
||||
while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
hasher.Update(buffer.AsSpan(0, bytesRead));
|
||||
}
|
||||
|
||||
var hash = hasher.Finalize();
|
||||
return hash.AsSpan().ToArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask<byte[]> ComputeSm3StreamAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
var digest = new SM3Digest();
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(128 * 1024);
|
||||
try
|
||||
{
|
||||
int bytesRead;
|
||||
while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
digest.BlockUpdate(buffer, 0, bytesRead);
|
||||
}
|
||||
|
||||
var output = new byte[digest.GetDigestSize()];
|
||||
digest.DoFinal(output, 0);
|
||||
return output;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Static options monitors
|
||||
|
||||
private sealed class StaticOptionsMonitor : IOptionsMonitor<CryptoHashOptions>
|
||||
{
|
||||
private readonly CryptoHashOptions options;
|
||||
private readonly CryptoHashOptions _options;
|
||||
|
||||
public StaticOptionsMonitor(CryptoHashOptions options)
|
||||
=> this.options = options;
|
||||
=> _options = options;
|
||||
|
||||
public CryptoHashOptions CurrentValue => options;
|
||||
public CryptoHashOptions CurrentValue => _options;
|
||||
|
||||
public CryptoHashOptions Get(string? name) => options;
|
||||
public CryptoHashOptions Get(string? name) => _options;
|
||||
|
||||
public IDisposable OnChange(Action<CryptoHashOptions, string> listener)
|
||||
=> NullDisposable.Instance;
|
||||
}
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
private sealed class StaticComplianceOptionsMonitor : IOptionsMonitor<CryptoComplianceOptions>
|
||||
{
|
||||
private readonly CryptoComplianceOptions _options;
|
||||
|
||||
public StaticComplianceOptionsMonitor(CryptoComplianceOptions options)
|
||||
=> _options = options;
|
||||
|
||||
public CryptoComplianceOptions CurrentValue => _options;
|
||||
|
||||
public CryptoComplianceOptions Get(string? name) => _options;
|
||||
|
||||
public IDisposable OnChange(Action<CryptoComplianceOptions, string> listener)
|
||||
=> NullDisposable.Instance;
|
||||
}
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -5,9 +5,24 @@ namespace StellaOps.Cryptography;
|
||||
/// </summary>
|
||||
public static class HashAlgorithms
|
||||
{
|
||||
/// <summary>SHA-256 (256-bit). FIPS/eIDAS/KCMVP compliant.</summary>
|
||||
public const string Sha256 = "SHA256";
|
||||
|
||||
/// <summary>SHA-384 (384-bit). FIPS/eIDAS compliant.</summary>
|
||||
public const string Sha384 = "SHA384";
|
||||
|
||||
/// <summary>SHA-512 (512-bit). FIPS/eIDAS compliant.</summary>
|
||||
public const string Sha512 = "SHA512";
|
||||
|
||||
/// <summary>GOST R 34.11-2012 Stribog (256-bit). Russian compliance.</summary>
|
||||
public const string Gost3411_2012_256 = "GOST3411-2012-256";
|
||||
|
||||
/// <summary>GOST R 34.11-2012 Stribog (512-bit). Russian compliance.</summary>
|
||||
public const string Gost3411_2012_512 = "GOST3411-2012-512";
|
||||
|
||||
/// <summary>BLAKE3-256 (256-bit). Fast, parallelizable, used for graph content-addressing.</summary>
|
||||
public const string Blake3_256 = "BLAKE3-256";
|
||||
|
||||
/// <summary>SM3 (256-bit). Chinese GB/T 32905-2016 compliance.</summary>
|
||||
public const string Sm3 = "SM3";
|
||||
}
|
||||
|
||||
74
src/__Libraries/StellaOps.Cryptography/HashPurpose.cs
Normal file
74
src/__Libraries/StellaOps.Cryptography/HashPurpose.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known hash purpose identifiers for compliance-aware cryptographic operations.
|
||||
/// Components should request hashing by PURPOSE, not by algorithm.
|
||||
/// The platform resolves the correct algorithm based on the active compliance profile.
|
||||
/// </summary>
|
||||
public static class HashPurpose
|
||||
{
|
||||
/// <summary>
|
||||
/// Graph content-addressing (richgraph-v1).
|
||||
/// Default: BLAKE3-256 (world), SHA-256 (fips), GOST3411-2012-256 (gost), SM3 (sm).
|
||||
/// </summary>
|
||||
public const string Graph = "graph";
|
||||
|
||||
/// <summary>
|
||||
/// Symbol identification (SymbolID, CodeID).
|
||||
/// Default: SHA-256 (world/fips/kcmvp/eidas), GOST3411-2012-256 (gost), SM3 (sm).
|
||||
/// </summary>
|
||||
public const string Symbol = "symbol";
|
||||
|
||||
/// <summary>
|
||||
/// Content/file hashing for integrity verification.
|
||||
/// Default: SHA-256 (world/fips/kcmvp/eidas), GOST3411-2012-256 (gost), SM3 (sm).
|
||||
/// </summary>
|
||||
public const string Content = "content";
|
||||
|
||||
/// <summary>
|
||||
/// Merkle tree node hashing.
|
||||
/// Default: SHA-256 (world/fips/kcmvp/eidas), GOST3411-2012-256 (gost), SM3 (sm).
|
||||
/// </summary>
|
||||
public const string Merkle = "merkle";
|
||||
|
||||
/// <summary>
|
||||
/// DSSE payload digest for attestations.
|
||||
/// Default: SHA-256 (world/fips/kcmvp/eidas), GOST3411-2012-256 (gost), SM3 (sm).
|
||||
/// </summary>
|
||||
public const string Attestation = "attestation";
|
||||
|
||||
/// <summary>
|
||||
/// External interoperability (third-party tools like cosign, rekor).
|
||||
/// Always SHA-256, regardless of compliance profile.
|
||||
/// Every use of this purpose MUST be documented with justification.
|
||||
/// </summary>
|
||||
public const string Interop = "interop";
|
||||
|
||||
/// <summary>
|
||||
/// Password/secret derivation.
|
||||
/// Default: Argon2id (world/gost/sm/kcmvp/eidas), PBKDF2-SHA256 (fips).
|
||||
/// </summary>
|
||||
public const string Secret = "secret";
|
||||
|
||||
/// <summary>
|
||||
/// All known hash purposes for validation.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> All = new[]
|
||||
{
|
||||
Graph,
|
||||
Symbol,
|
||||
Content,
|
||||
Merkle,
|
||||
Attestation,
|
||||
Interop,
|
||||
Secret
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Validates whether the given purpose is known.
|
||||
/// </summary>
|
||||
/// <param name="purpose">The purpose to validate.</param>
|
||||
/// <returns>True if the purpose is known; otherwise, false.</returns>
|
||||
public static bool IsKnown(string? purpose)
|
||||
=> !string.IsNullOrWhiteSpace(purpose) && All.Contains(purpose);
|
||||
}
|
||||
@@ -5,15 +5,112 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for cryptographic hashing operations with compliance profile support.
|
||||
/// </summary>
|
||||
public interface ICryptoHash
|
||||
{
|
||||
#region Algorithm-based methods (backward compatible)
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash using the specified or default algorithm.
|
||||
/// </summary>
|
||||
/// <param name="data">The data to hash.</param>
|
||||
/// <param name="algorithmId">Optional algorithm identifier. If null, uses the default algorithm.</param>
|
||||
/// <returns>The hash bytes.</returns>
|
||||
byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash and returns it as a lowercase hex string.
|
||||
/// </summary>
|
||||
string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash and returns it as a Base64 string.
|
||||
/// </summary>
|
||||
string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash from a stream asynchronously.
|
||||
/// </summary>
|
||||
ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash from a stream and returns it as a lowercase hex string.
|
||||
/// </summary>
|
||||
ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Purpose-based methods (preferred for compliance)
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash for the specified purpose using the active compliance profile's algorithm.
|
||||
/// </summary>
|
||||
/// <param name="data">The data to hash.</param>
|
||||
/// <param name="purpose">The hash purpose from <see cref="HashPurpose"/>.</param>
|
||||
/// <returns>The hash bytes.</returns>
|
||||
byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash for the specified purpose and returns it as a lowercase hex string.
|
||||
/// </summary>
|
||||
/// <param name="data">The data to hash.</param>
|
||||
/// <param name="purpose">The hash purpose from <see cref="HashPurpose"/>.</param>
|
||||
/// <returns>The hash as a lowercase hex string.</returns>
|
||||
string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash for the specified purpose and returns it as a Base64 string.
|
||||
/// </summary>
|
||||
/// <param name="data">The data to hash.</param>
|
||||
/// <param name="purpose">The hash purpose from <see cref="HashPurpose"/>.</param>
|
||||
/// <returns>The hash as a Base64 string.</returns>
|
||||
string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash for the specified purpose from a stream asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="stream">The stream to hash.</param>
|
||||
/// <param name="purpose">The hash purpose from <see cref="HashPurpose"/>.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The hash bytes.</returns>
|
||||
ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash for the specified purpose from a stream and returns it as a lowercase hex string.
|
||||
/// </summary>
|
||||
/// <param name="stream">The stream to hash.</param>
|
||||
/// <param name="purpose">The hash purpose from <see cref="HashPurpose"/>.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The hash as a lowercase hex string.</returns>
|
||||
ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Metadata methods
|
||||
|
||||
/// <summary>
|
||||
/// Gets the algorithm that will be used for the specified purpose based on the active compliance profile.
|
||||
/// </summary>
|
||||
/// <param name="purpose">The hash purpose from <see cref="HashPurpose"/>.</param>
|
||||
/// <returns>The algorithm identifier.</returns>
|
||||
string GetAlgorithmForPurpose(string purpose);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hash prefix for the specified purpose (e.g., "blake3:", "sha256:", "gost3411:").
|
||||
/// </summary>
|
||||
/// <param name="purpose">The hash purpose from <see cref="HashPurpose"/>.</param>
|
||||
/// <returns>The hash prefix string.</returns>
|
||||
string GetHashPrefix(string purpose);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a hash for the specified purpose and returns it with the appropriate prefix.
|
||||
/// </summary>
|
||||
/// <param name="data">The data to hash.</param>
|
||||
/// <param name="purpose">The hash purpose from <see cref="HashPurpose"/>.</param>
|
||||
/// <returns>The prefixed hash string (e.g., "blake3:abc123...").</returns>
|
||||
string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
<PropertyGroup Condition="'$(StellaOpsCryptoSodium)' == 'true'">
|
||||
<DefineConstants>$(DefineConstants);STELLAOPS_CRYPTO_SODIUM</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blake3" Version="1.1.0" />
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -52,4 +52,37 @@ internal static class DiagnosticDescriptors
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Info,
|
||||
isEnabledByDefault: false);
|
||||
|
||||
/// <summary>
|
||||
/// Schema generation failed for a type.
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor SchemaGenerationFailed = new(
|
||||
id: "STELLA005",
|
||||
title: "Schema generation failed",
|
||||
messageFormat: "Failed to generate JSON Schema for type '{0}' in endpoint '{1}'",
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true);
|
||||
|
||||
/// <summary>
|
||||
/// [ValidateSchema] applied to non-typed endpoint.
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor ValidateSchemaOnRawEndpoint = new(
|
||||
id: "STELLA006",
|
||||
title: "ValidateSchema on raw endpoint",
|
||||
messageFormat: "[ValidateSchema] on class '{0}' is ignored because it implements IRawStellaEndpoint",
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true);
|
||||
|
||||
/// <summary>
|
||||
/// External schema resource not found.
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor SchemaResourceNotFound = new(
|
||||
id: "STELLA007",
|
||||
title: "Schema resource not found",
|
||||
messageFormat: "External schema resource '{0}' not found for endpoint '{1}'",
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true);
|
||||
}
|
||||
|
||||
@@ -14,4 +14,16 @@ internal sealed record EndpointInfo(
|
||||
string[] RequiredClaims,
|
||||
string? RequestTypeName,
|
||||
string? ResponseTypeName,
|
||||
bool IsRaw);
|
||||
bool IsRaw,
|
||||
bool ValidateRequest = false,
|
||||
bool ValidateResponse = false,
|
||||
string? RequestSchemaJson = null,
|
||||
string? ResponseSchemaJson = null,
|
||||
string? RequestSchemaResource = null,
|
||||
string? ResponseSchemaResource = null,
|
||||
string? Summary = null,
|
||||
string? Description = null,
|
||||
string[]? Tags = null,
|
||||
bool Deprecated = false,
|
||||
string? RequestSchemaId = null,
|
||||
string? ResponseSchemaId = null);
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
using System.Text;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace StellaOps.Microservice.SourceGen;
|
||||
|
||||
/// <summary>
|
||||
/// Generates JSON Schema (draft 2020-12) from C# types at compile time.
|
||||
/// </summary>
|
||||
internal static class SchemaGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a JSON Schema string from a type symbol.
|
||||
/// </summary>
|
||||
/// <param name="typeSymbol">The type to generate schema for.</param>
|
||||
/// <param name="compilation">The compilation context.</param>
|
||||
/// <returns>A JSON Schema string, or null if generation fails.</returns>
|
||||
public static string? GenerateSchema(ITypeSymbol typeSymbol, Compilation compilation)
|
||||
{
|
||||
if (typeSymbol is null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var context = new SchemaContext(compilation);
|
||||
var schema = GenerateTypeSchema(typeSymbol, context, isRoot: true);
|
||||
return schema;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateTypeSchema(ITypeSymbol typeSymbol, SchemaContext context, bool isRoot)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("{");
|
||||
|
||||
if (isRoot)
|
||||
{
|
||||
sb.AppendLine(" \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",");
|
||||
}
|
||||
|
||||
// Check for simple types first
|
||||
var simpleType = GetSimpleTypeSchema(typeSymbol);
|
||||
if (simpleType is not null)
|
||||
{
|
||||
sb.Append(simpleType);
|
||||
sb.AppendLine();
|
||||
sb.Append("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// Check for nullable
|
||||
if (typeSymbol.NullableAnnotation == NullableAnnotation.Annotated ||
|
||||
(typeSymbol is INamedTypeSymbol namedType && namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T))
|
||||
{
|
||||
var underlyingType = GetUnderlyingType(typeSymbol);
|
||||
if (underlyingType is not null)
|
||||
{
|
||||
var underlyingSimple = GetSimpleTypeSchema(underlyingType);
|
||||
if (underlyingSimple is not null)
|
||||
{
|
||||
// Nullable simple type
|
||||
var nullableSchema = underlyingSimple.Replace("\"type\":", "\"type\": [") + ", \"null\"]";
|
||||
sb.Append(nullableSchema);
|
||||
sb.AppendLine();
|
||||
sb.Append("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for arrays/lists
|
||||
if (IsCollectionType(typeSymbol, out var elementType))
|
||||
{
|
||||
sb.AppendLine(" \"type\": \"array\",");
|
||||
if (elementType is not null)
|
||||
{
|
||||
var elementSchema = GetSimpleTypeSchema(elementType);
|
||||
if (elementSchema is not null)
|
||||
{
|
||||
sb.AppendLine(" \"items\": {");
|
||||
sb.Append(" ");
|
||||
sb.AppendLine(elementSchema);
|
||||
sb.AppendLine(" }");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(" \"items\": { \"type\": \"object\" }");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(" \"items\": {}");
|
||||
}
|
||||
sb.Append("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// Object type
|
||||
sb.AppendLine(" \"type\": \"object\",");
|
||||
|
||||
// Get properties
|
||||
var properties = GetPublicProperties(typeSymbol);
|
||||
var requiredProps = new List<string>();
|
||||
|
||||
if (properties.Count > 0)
|
||||
{
|
||||
sb.AppendLine(" \"properties\": {");
|
||||
|
||||
var propIndex = 0;
|
||||
foreach (var prop in properties.OrderBy(p => p.Name))
|
||||
{
|
||||
var propSchema = GeneratePropertySchema(prop, context);
|
||||
var isRequired = IsPropertyRequired(prop);
|
||||
|
||||
if (isRequired)
|
||||
{
|
||||
requiredProps.Add(ToCamelCase(prop.Name));
|
||||
}
|
||||
|
||||
sb.Append($" \"{ToCamelCase(prop.Name)}\": ");
|
||||
sb.Append(propSchema);
|
||||
|
||||
if (propIndex < properties.Count - 1)
|
||||
{
|
||||
sb.AppendLine(",");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine();
|
||||
}
|
||||
propIndex++;
|
||||
}
|
||||
|
||||
sb.AppendLine(" },");
|
||||
}
|
||||
|
||||
// Required array
|
||||
if (requiredProps.Count > 0)
|
||||
{
|
||||
sb.Append(" \"required\": [");
|
||||
sb.Append(string.Join(", ", requiredProps.Select(p => $"\"{p}\"")));
|
||||
sb.AppendLine("],");
|
||||
}
|
||||
|
||||
sb.AppendLine(" \"additionalProperties\": false");
|
||||
sb.Append("}");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GeneratePropertySchema(IPropertySymbol prop, SchemaContext context)
|
||||
{
|
||||
var type = prop.Type;
|
||||
|
||||
// Handle nullable types
|
||||
var isNullable = type.NullableAnnotation == NullableAnnotation.Annotated;
|
||||
if (isNullable)
|
||||
{
|
||||
type = GetUnderlyingType(type) ?? type;
|
||||
}
|
||||
|
||||
var simpleSchema = GetSimpleTypeSchema(type);
|
||||
if (simpleSchema is not null)
|
||||
{
|
||||
if (isNullable)
|
||||
{
|
||||
// Convert "type": "X" to "type": ["X", "null"]
|
||||
return "{ " + MakeTypeNullable(simpleSchema) + " }";
|
||||
}
|
||||
return "{ " + simpleSchema + " }";
|
||||
}
|
||||
|
||||
// Collections
|
||||
if (IsCollectionType(type, out var elementType))
|
||||
{
|
||||
var itemSchema = elementType is not null
|
||||
? GetSimpleTypeSchema(elementType) ?? "\"type\": \"object\""
|
||||
: "\"type\": \"object\"";
|
||||
|
||||
return $"{{ \"type\": \"array\", \"items\": {{ {itemSchema} }} }}";
|
||||
}
|
||||
|
||||
// Complex object - just use object type for now
|
||||
return "{ \"type\": \"object\" }";
|
||||
}
|
||||
|
||||
private static string? GetSimpleTypeSchema(ITypeSymbol type)
|
||||
{
|
||||
var fullName = type.ToDisplayString();
|
||||
|
||||
return fullName switch
|
||||
{
|
||||
"string" or "System.String" => "\"type\": \"string\"",
|
||||
"int" or "System.Int32" => "\"type\": \"integer\"",
|
||||
"long" or "System.Int64" => "\"type\": \"integer\"",
|
||||
"short" or "System.Int16" => "\"type\": \"integer\"",
|
||||
"byte" or "System.Byte" => "\"type\": \"integer\"",
|
||||
"uint" or "System.UInt32" => "\"type\": \"integer\"",
|
||||
"ulong" or "System.UInt64" => "\"type\": \"integer\"",
|
||||
"ushort" or "System.UInt16" => "\"type\": \"integer\"",
|
||||
"sbyte" or "System.SByte" => "\"type\": \"integer\"",
|
||||
"float" or "System.Single" => "\"type\": \"number\"",
|
||||
"double" or "System.Double" => "\"type\": \"number\"",
|
||||
"decimal" or "System.Decimal" => "\"type\": \"number\"",
|
||||
"bool" or "System.Boolean" => "\"type\": \"boolean\"",
|
||||
"System.DateTime" or "System.DateTimeOffset" => "\"type\": \"string\", \"format\": \"date-time\"",
|
||||
"System.DateOnly" => "\"type\": \"string\", \"format\": \"date\"",
|
||||
"System.TimeOnly" or "System.TimeSpan" => "\"type\": \"string\", \"format\": \"time\"",
|
||||
"System.Guid" => "\"type\": \"string\", \"format\": \"uuid\"",
|
||||
"System.Uri" => "\"type\": \"string\", \"format\": \"uri\"",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static ITypeSymbol? GetUnderlyingType(ITypeSymbol type)
|
||||
{
|
||||
if (type is INamedTypeSymbol namedType)
|
||||
{
|
||||
if (namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
|
||||
namedType.TypeArguments.Length > 0)
|
||||
{
|
||||
return namedType.TypeArguments[0];
|
||||
}
|
||||
}
|
||||
|
||||
// For reference types with nullable annotation, just return the type itself
|
||||
return type;
|
||||
}
|
||||
|
||||
private static bool IsCollectionType(ITypeSymbol type, out ITypeSymbol? elementType)
|
||||
{
|
||||
elementType = null;
|
||||
|
||||
if (type is IArrayTypeSymbol arrayType)
|
||||
{
|
||||
elementType = arrayType.ElementType;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type is INamedTypeSymbol namedType)
|
||||
{
|
||||
// Check for List<T>, IList<T>, IEnumerable<T>, ICollection<T>, etc.
|
||||
var fullName = namedType.OriginalDefinition.ToDisplayString();
|
||||
if ((fullName.StartsWith("System.Collections.Generic.List") ||
|
||||
fullName.StartsWith("System.Collections.Generic.IList") ||
|
||||
fullName.StartsWith("System.Collections.Generic.IEnumerable") ||
|
||||
fullName.StartsWith("System.Collections.Generic.ICollection") ||
|
||||
fullName.StartsWith("System.Collections.Generic.IReadOnlyList") ||
|
||||
fullName.StartsWith("System.Collections.Generic.IReadOnlyCollection")) &&
|
||||
namedType.TypeArguments.Length > 0)
|
||||
{
|
||||
elementType = namedType.TypeArguments[0];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<IPropertySymbol> GetPublicProperties(ITypeSymbol type)
|
||||
{
|
||||
var properties = new List<IPropertySymbol>();
|
||||
|
||||
foreach (var member in type.GetMembers())
|
||||
{
|
||||
if (member is IPropertySymbol prop &&
|
||||
prop.DeclaredAccessibility == Accessibility.Public &&
|
||||
!prop.IsStatic &&
|
||||
!prop.IsIndexer &&
|
||||
prop.GetMethod is not null)
|
||||
{
|
||||
properties.Add(prop);
|
||||
}
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static bool IsPropertyRequired(IPropertySymbol prop)
|
||||
{
|
||||
// Check for [Required] attribute
|
||||
foreach (var attr in prop.GetAttributes())
|
||||
{
|
||||
var attrName = attr.AttributeClass?.ToDisplayString();
|
||||
if (attrName == "System.ComponentModel.DataAnnotations.RequiredAttribute")
|
||||
return true;
|
||||
}
|
||||
|
||||
// Non-nullable reference types are required
|
||||
if (prop.Type.IsReferenceType &&
|
||||
prop.NullableAnnotation != NullableAnnotation.Annotated &&
|
||||
prop.Type.NullableAnnotation != NullableAnnotation.Annotated)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string MakeTypeNullable(string simpleSchema)
|
||||
{
|
||||
// Convert "type": "X" to "type": ["X", "null"]
|
||||
// Input: "type": "string"
|
||||
// Output: "type": ["string", "null"]
|
||||
const string typePrefix = "\"type\": \"";
|
||||
var idx = simpleSchema.IndexOf(typePrefix, StringComparison.Ordinal);
|
||||
if (idx < 0)
|
||||
return simpleSchema;
|
||||
|
||||
var startOfType = idx + typePrefix.Length;
|
||||
var endOfType = simpleSchema.IndexOf('"', startOfType);
|
||||
if (endOfType < 0)
|
||||
return simpleSchema;
|
||||
|
||||
var typeName = simpleSchema.Substring(startOfType, endOfType - startOfType);
|
||||
var rest = simpleSchema.Substring(endOfType + 1);
|
||||
return $"\"type\": [\"{typeName}\", \"null\"]{rest}";
|
||||
}
|
||||
|
||||
private static string ToCamelCase(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return name;
|
||||
|
||||
if (name.Length == 1)
|
||||
return name.ToLowerInvariant();
|
||||
|
||||
return char.ToLowerInvariant(name[0]) + name.Substring(1);
|
||||
}
|
||||
|
||||
private sealed class SchemaContext
|
||||
{
|
||||
public Compilation Compilation { get; }
|
||||
public Dictionary<string, string> Definitions { get; } = new();
|
||||
|
||||
public SchemaContext(Compilation compilation)
|
||||
{
|
||||
Compilation = compilation;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,13 @@ namespace StellaOps.Microservice.SourceGen;
|
||||
|
||||
/// <summary>
|
||||
/// Incremental source generator for [StellaEndpoint] decorated classes.
|
||||
/// Generates endpoint descriptors and DI registration at compile time.
|
||||
/// Generates endpoint descriptors, DI registration, and JSON Schemas at compile time.
|
||||
/// </summary>
|
||||
[Generator]
|
||||
public sealed class StellaEndpointGenerator : IIncrementalGenerator
|
||||
{
|
||||
private const string StellaEndpointAttributeName = "StellaOps.Microservice.StellaEndpointAttribute";
|
||||
private const string ValidateSchemaAttributeName = "StellaOps.Microservice.ValidateSchemaAttribute";
|
||||
private const string IStellaEndpointName = "StellaOps.Microservice.IStellaEndpoint";
|
||||
private const string IRawStellaEndpointName = "StellaOps.Microservice.IRawStellaEndpoint";
|
||||
|
||||
@@ -88,7 +89,7 @@ public sealed class StellaEndpointGenerator : IIncrementalGenerator
|
||||
if (classSymbol is null)
|
||||
continue;
|
||||
|
||||
var endpoint = ExtractEndpointInfo(classSymbol, context);
|
||||
var endpoint = ExtractEndpointInfo(classSymbol, compilation, context);
|
||||
if (endpoint is not null)
|
||||
{
|
||||
endpoints.Add(endpoint);
|
||||
@@ -124,20 +125,37 @@ public sealed class StellaEndpointGenerator : IIncrementalGenerator
|
||||
context.AddSource("StellaEndpoints.g.cs", SourceText.From(source, Encoding.UTF8));
|
||||
|
||||
// Generate the provider class
|
||||
var providerSource = GenerateProviderClass();
|
||||
var providerSource = GenerateProviderClass(endpoints);
|
||||
context.AddSource("GeneratedEndpointProvider.g.cs", SourceText.From(providerSource, Encoding.UTF8));
|
||||
|
||||
// Generate schema provider if any endpoints have validation enabled
|
||||
var endpointsWithSchemas = endpoints.Where(e => e.ValidateRequest || e.ValidateResponse).ToList();
|
||||
if (endpointsWithSchemas.Count > 0)
|
||||
{
|
||||
var schemaProviderSource = GenerateSchemaProviderClass(endpointsWithSchemas);
|
||||
context.AddSource("GeneratedSchemaProvider.g.cs", SourceText.From(schemaProviderSource, Encoding.UTF8));
|
||||
}
|
||||
}
|
||||
|
||||
private static EndpointInfo? ExtractEndpointInfo(INamedTypeSymbol classSymbol, SourceProductionContext context)
|
||||
private static EndpointInfo? ExtractEndpointInfo(
|
||||
INamedTypeSymbol classSymbol,
|
||||
Compilation compilation,
|
||||
SourceProductionContext context)
|
||||
{
|
||||
// Find StellaEndpoint attribute
|
||||
AttributeData? stellaAttribute = null;
|
||||
AttributeData? validateSchemaAttribute = null;
|
||||
|
||||
foreach (var attr in classSymbol.GetAttributes())
|
||||
{
|
||||
if (attr.AttributeClass?.ToDisplayString() == StellaEndpointAttributeName)
|
||||
var attrName = attr.AttributeClass?.ToDisplayString();
|
||||
if (attrName == StellaEndpointAttributeName)
|
||||
{
|
||||
stellaAttribute = attr;
|
||||
break;
|
||||
}
|
||||
else if (attrName == ValidateSchemaAttributeName)
|
||||
{
|
||||
validateSchemaAttribute = attr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +210,8 @@ public sealed class StellaEndpointGenerator : IIncrementalGenerator
|
||||
// Find handler interface implementation
|
||||
string? requestTypeName = null;
|
||||
string? responseTypeName = null;
|
||||
ITypeSymbol? requestTypeSymbol = null;
|
||||
ITypeSymbol? responseTypeSymbol = null;
|
||||
bool isRaw = false;
|
||||
|
||||
foreach (var iface in classSymbol.AllInterfaces)
|
||||
@@ -200,8 +220,10 @@ public sealed class StellaEndpointGenerator : IIncrementalGenerator
|
||||
|
||||
if (fullName.StartsWith(IStellaEndpointName) && iface.TypeArguments.Length == 2)
|
||||
{
|
||||
requestTypeName = iface.TypeArguments[0].ToDisplayString();
|
||||
responseTypeName = iface.TypeArguments[1].ToDisplayString();
|
||||
requestTypeSymbol = iface.TypeArguments[0];
|
||||
responseTypeSymbol = iface.TypeArguments[1];
|
||||
requestTypeName = requestTypeSymbol.ToDisplayString();
|
||||
responseTypeName = responseTypeSymbol.ToDisplayString();
|
||||
isRaw = false;
|
||||
break;
|
||||
}
|
||||
@@ -223,6 +245,113 @@ public sealed class StellaEndpointGenerator : IIncrementalGenerator
|
||||
return null;
|
||||
}
|
||||
|
||||
// Process ValidateSchema attribute
|
||||
bool validateRequest = false;
|
||||
bool validateResponse = false;
|
||||
string? requestSchemaJson = null;
|
||||
string? responseSchemaJson = null;
|
||||
string? requestSchemaResource = null;
|
||||
string? responseSchemaResource = null;
|
||||
string? summary = null;
|
||||
string? description = null;
|
||||
string[]? tags = null;
|
||||
bool deprecated = false;
|
||||
string? requestSchemaId = null;
|
||||
string? responseSchemaId = null;
|
||||
|
||||
if (validateSchemaAttribute is not null)
|
||||
{
|
||||
// Warn if applied to raw endpoint
|
||||
if (isRaw)
|
||||
{
|
||||
context.ReportDiagnostic(Diagnostic.Create(
|
||||
DiagnosticDescriptors.ValidateSchemaOnRawEndpoint,
|
||||
Location.None,
|
||||
classSymbol.Name));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Extract ValidateSchema named arguments
|
||||
validateRequest = true; // Default is true
|
||||
validateResponse = false; // Default is false
|
||||
|
||||
foreach (var namedArg in validateSchemaAttribute.NamedArguments)
|
||||
{
|
||||
switch (namedArg.Key)
|
||||
{
|
||||
case "ValidateRequest":
|
||||
validateRequest = (bool)(namedArg.Value.Value ?? true);
|
||||
break;
|
||||
case "ValidateResponse":
|
||||
validateResponse = (bool)(namedArg.Value.Value ?? false);
|
||||
break;
|
||||
case "RequestSchemaResource":
|
||||
requestSchemaResource = namedArg.Value.Value as string;
|
||||
break;
|
||||
case "ResponseSchemaResource":
|
||||
responseSchemaResource = namedArg.Value.Value as string;
|
||||
break;
|
||||
case "Summary":
|
||||
summary = namedArg.Value.Value as string;
|
||||
break;
|
||||
case "Description":
|
||||
description = namedArg.Value.Value as string;
|
||||
break;
|
||||
case "Tags":
|
||||
if (!namedArg.Value.IsNull && namedArg.Value.Values.Length > 0)
|
||||
{
|
||||
tags = namedArg.Value.Values
|
||||
.Select(v => v.Value as string)
|
||||
.Where(s => s is not null)
|
||||
.Cast<string>()
|
||||
.ToArray();
|
||||
}
|
||||
break;
|
||||
case "Deprecated":
|
||||
deprecated = (bool)(namedArg.Value.Value ?? false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate schemas if no external resource specified
|
||||
if (validateRequest && requestSchemaResource is null && requestTypeSymbol is not null)
|
||||
{
|
||||
requestSchemaJson = SchemaGenerator.GenerateSchema(requestTypeSymbol, compilation);
|
||||
if (requestSchemaJson is null)
|
||||
{
|
||||
context.ReportDiagnostic(Diagnostic.Create(
|
||||
DiagnosticDescriptors.SchemaGenerationFailed,
|
||||
Location.None,
|
||||
requestTypeName,
|
||||
classSymbol.Name));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Generate schema ID from type name
|
||||
requestSchemaId = GetSchemaId(requestTypeSymbol);
|
||||
}
|
||||
}
|
||||
|
||||
if (validateResponse && responseSchemaResource is null && responseTypeSymbol is not null)
|
||||
{
|
||||
responseSchemaJson = SchemaGenerator.GenerateSchema(responseTypeSymbol, compilation);
|
||||
if (responseSchemaJson is null)
|
||||
{
|
||||
context.ReportDiagnostic(Diagnostic.Create(
|
||||
DiagnosticDescriptors.SchemaGenerationFailed,
|
||||
Location.None,
|
||||
responseTypeName,
|
||||
classSymbol.Name));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Generate schema ID from type name
|
||||
responseSchemaId = GetSchemaId(responseTypeSymbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ns = classSymbol.ContainingNamespace.IsGlobalNamespace
|
||||
? string.Empty
|
||||
: classSymbol.ContainingNamespace.ToDisplayString();
|
||||
@@ -238,7 +367,26 @@ public sealed class StellaEndpointGenerator : IIncrementalGenerator
|
||||
RequiredClaims: requiredClaims,
|
||||
RequestTypeName: requestTypeName,
|
||||
ResponseTypeName: responseTypeName,
|
||||
IsRaw: isRaw);
|
||||
IsRaw: isRaw,
|
||||
ValidateRequest: validateRequest,
|
||||
ValidateResponse: validateResponse,
|
||||
RequestSchemaJson: requestSchemaJson,
|
||||
ResponseSchemaJson: responseSchemaJson,
|
||||
RequestSchemaResource: requestSchemaResource,
|
||||
ResponseSchemaResource: responseSchemaResource,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: tags,
|
||||
Deprecated: deprecated,
|
||||
RequestSchemaId: requestSchemaId,
|
||||
ResponseSchemaId: responseSchemaId);
|
||||
}
|
||||
|
||||
private static string GetSchemaId(ITypeSymbol typeSymbol)
|
||||
{
|
||||
// Use simple name for schema ID, stripping namespace
|
||||
var name = typeSymbol.Name;
|
||||
return name;
|
||||
}
|
||||
|
||||
private static string GenerateEndpointsClass(List<EndpointInfo> endpoints)
|
||||
@@ -292,7 +440,42 @@ public sealed class StellaEndpointGenerator : IIncrementalGenerator
|
||||
}
|
||||
sb.AppendLine(" },");
|
||||
}
|
||||
sb.AppendLine($" HandlerType = typeof(global::{ep.FullyQualifiedName})");
|
||||
sb.AppendLine($" HandlerType = typeof(global::{ep.FullyQualifiedName}),");
|
||||
|
||||
// Add SchemaInfo if endpoint has validation or documentation
|
||||
if (ep.ValidateRequest || ep.ValidateResponse || ep.Summary is not null || ep.Description is not null || ep.Tags is not null || ep.Deprecated)
|
||||
{
|
||||
sb.AppendLine(" SchemaInfo = new global::StellaOps.Router.Common.Models.EndpointSchemaInfo");
|
||||
sb.AppendLine(" {");
|
||||
if (ep.RequestSchemaId is not null)
|
||||
{
|
||||
sb.AppendLine($" RequestSchemaId = \"{EscapeString(ep.RequestSchemaId)}\",");
|
||||
}
|
||||
if (ep.ResponseSchemaId is not null)
|
||||
{
|
||||
sb.AppendLine($" ResponseSchemaId = \"{EscapeString(ep.ResponseSchemaId)}\",");
|
||||
}
|
||||
if (ep.Summary is not null)
|
||||
{
|
||||
sb.AppendLine($" Summary = \"{EscapeString(ep.Summary)}\",");
|
||||
}
|
||||
if (ep.Description is not null)
|
||||
{
|
||||
sb.AppendLine($" Description = \"{EscapeString(ep.Description)}\",");
|
||||
}
|
||||
if (ep.Tags is not null && ep.Tags.Length > 0)
|
||||
{
|
||||
sb.Append(" Tags = new string[] { ");
|
||||
sb.Append(string.Join(", ", ep.Tags.Select(t => $"\"{EscapeString(t)}\"")));
|
||||
sb.AppendLine(" },");
|
||||
}
|
||||
sb.AppendLine($" Deprecated = {(ep.Deprecated ? "true" : "false")}");
|
||||
sb.AppendLine(" }");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(" SchemaInfo = null");
|
||||
}
|
||||
sb.Append(" }");
|
||||
if (i < endpoints.Count - 1)
|
||||
{
|
||||
@@ -355,7 +538,7 @@ public sealed class StellaEndpointGenerator : IIncrementalGenerator
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GenerateProviderClass()
|
||||
private static string GenerateProviderClass(List<EndpointInfo> endpoints)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
@@ -381,6 +564,121 @@ public sealed class StellaEndpointGenerator : IIncrementalGenerator
|
||||
sb.AppendLine(" /// <inheritdoc />");
|
||||
sb.AppendLine(" public global::System.Collections.Generic.IReadOnlyList<global::System.Type> GetHandlerTypes()");
|
||||
sb.AppendLine(" => StellaEndpoints.GetHandlerTypes();");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" /// <inheritdoc />");
|
||||
sb.AppendLine(" public global::System.Collections.Generic.IReadOnlyDictionary<string, global::StellaOps.Router.Common.Models.SchemaDefinition> GetSchemaDefinitions()");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" var schemas = new global::System.Collections.Generic.Dictionary<string, global::StellaOps.Router.Common.Models.SchemaDefinition>();");
|
||||
|
||||
// Collect unique schemas
|
||||
var schemas = new Dictionary<string, EndpointInfo>();
|
||||
foreach (var ep in endpoints)
|
||||
{
|
||||
if (ep.RequestSchemaId is not null && ep.RequestSchemaJson is not null && !schemas.ContainsKey(ep.RequestSchemaId))
|
||||
{
|
||||
schemas[ep.RequestSchemaId] = ep;
|
||||
}
|
||||
if (ep.ResponseSchemaId is not null && ep.ResponseSchemaJson is not null && !schemas.ContainsKey(ep.ResponseSchemaId))
|
||||
{
|
||||
schemas[ep.ResponseSchemaId] = ep;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kvp in schemas)
|
||||
{
|
||||
var schemaId = kvp.Key;
|
||||
var ep = kvp.Value;
|
||||
var schemaJson = schemaId == ep.RequestSchemaId ? ep.RequestSchemaJson : ep.ResponseSchemaJson;
|
||||
if (schemaJson is not null)
|
||||
{
|
||||
sb.AppendLine($" schemas[\"{EscapeString(schemaId)}\"] = new global::StellaOps.Router.Common.Models.SchemaDefinition");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine($" SchemaId = \"{EscapeString(schemaId)}\",");
|
||||
sb.AppendLine($" SchemaJson = @\"{EscapeVerbatimString(schemaJson)}\",");
|
||||
sb.AppendLine($" ETag = ComputeETag(@\"{EscapeVerbatimString(schemaJson)}\")");
|
||||
sb.AppendLine(" };");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine(" return schemas;");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" private static string ComputeETag(string content)");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" using var sha256 = global::System.Security.Cryptography.SHA256.Create();");
|
||||
sb.AppendLine(" var hash = sha256.ComputeHash(global::System.Text.Encoding.UTF8.GetBytes(content));");
|
||||
sb.AppendLine(" return $\"\\\"{global::System.Convert.ToHexString(hash)[..16]}\\\"\";");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine("}");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GenerateSchemaProviderClass(List<EndpointInfo> endpoints)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("// <auto-generated/>");
|
||||
sb.AppendLine("#nullable enable");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("namespace StellaOps.Microservice.Generated");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine(" /// <summary>");
|
||||
sb.AppendLine(" /// Generated implementation of IGeneratedSchemaProvider.");
|
||||
sb.AppendLine(" /// Provides JSON Schemas for endpoints with [ValidateSchema] attribute.");
|
||||
sb.AppendLine(" /// </summary>");
|
||||
sb.AppendLine(" [global::System.CodeDom.Compiler.GeneratedCode(\"StellaOps.Microservice.SourceGen\", \"1.0.0\")]");
|
||||
sb.AppendLine(" internal sealed class GeneratedSchemaProvider : global::StellaOps.Microservice.Validation.IGeneratedSchemaProvider");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" /// <inheritdoc />");
|
||||
sb.AppendLine(" public global::System.Collections.Generic.IReadOnlyList<global::StellaOps.Microservice.Validation.EndpointSchemaDefinition> GetSchemaDefinitions()");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" return new global::StellaOps.Microservice.Validation.EndpointSchemaDefinition[]");
|
||||
sb.AppendLine(" {");
|
||||
|
||||
for (int i = 0; i < endpoints.Count; i++)
|
||||
{
|
||||
var ep = endpoints[i];
|
||||
sb.AppendLine(" new global::StellaOps.Microservice.Validation.EndpointSchemaDefinition(");
|
||||
sb.AppendLine($" Method: \"{EscapeString(ep.Method)}\",");
|
||||
sb.AppendLine($" Path: \"{EscapeString(ep.Path)}\",");
|
||||
|
||||
// Request schema
|
||||
if (ep.RequestSchemaJson is not null)
|
||||
{
|
||||
sb.AppendLine($" RequestSchemaJson: @\"{EscapeVerbatimString(ep.RequestSchemaJson)}\",");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(" RequestSchemaJson: null,");
|
||||
}
|
||||
|
||||
// Response schema
|
||||
if (ep.ResponseSchemaJson is not null)
|
||||
{
|
||||
sb.AppendLine($" ResponseSchemaJson: @\"{EscapeVerbatimString(ep.ResponseSchemaJson)}\",");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(" ResponseSchemaJson: null,");
|
||||
}
|
||||
|
||||
sb.AppendLine($" ValidateRequest: {(ep.ValidateRequest ? "true" : "false")},");
|
||||
sb.Append($" ValidateResponse: {(ep.ValidateResponse ? "true" : "false")})");
|
||||
|
||||
if (i < endpoints.Count - 1)
|
||||
{
|
||||
sb.AppendLine(",");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine(" };");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine("}");
|
||||
|
||||
@@ -396,4 +694,10 @@ public sealed class StellaEndpointGenerator : IIncrementalGenerator
|
||||
.Replace("\r", "\\r")
|
||||
.Replace("\t", "\\t");
|
||||
}
|
||||
|
||||
private static string EscapeVerbatimString(string value)
|
||||
{
|
||||
// In verbatim strings, only " needs escaping (as "")
|
||||
return value.Replace("\"", "\"\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Microservice.Validation;
|
||||
|
||||
namespace StellaOps.Microservice.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Response model for the schema index endpoint.
|
||||
/// </summary>
|
||||
public sealed record SchemaIndexResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// List of endpoints with schema information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("endpoints")]
|
||||
public required IReadOnlyList<EndpointSchemaInfo> Endpoints { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an endpoint's schema availability.
|
||||
/// </summary>
|
||||
public sealed record EndpointSchemaInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// The HTTP method.
|
||||
/// </summary>
|
||||
[JsonPropertyName("method")]
|
||||
public required string Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The endpoint path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a request schema is available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hasRequestSchema")]
|
||||
public bool HasRequestSchema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a response schema is available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hasResponseSchema")]
|
||||
public bool HasResponseSchema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to fetch the request schema.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requestSchemaUrl")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RequestSchemaUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to fetch the response schema.
|
||||
/// </summary>
|
||||
[JsonPropertyName("responseSchemaUrl")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ResponseSchemaUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint that returns an index of all available schemas.
|
||||
/// </summary>
|
||||
[StellaEndpoint("GET", "/.well-known/schemas")]
|
||||
public sealed class SchemaIndexEndpoint : IStellaEndpoint<SchemaIndexResponse>
|
||||
{
|
||||
private readonly ISchemaRegistry _schemaRegistry;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SchemaIndexEndpoint"/> class.
|
||||
/// </summary>
|
||||
/// <param name="schemaRegistry">The schema registry.</param>
|
||||
public SchemaIndexEndpoint(ISchemaRegistry schemaRegistry)
|
||||
{
|
||||
_schemaRegistry = schemaRegistry;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SchemaIndexResponse> HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var definitions = _schemaRegistry.GetAllSchemas();
|
||||
var endpoints = definitions.Select(d => new EndpointSchemaInfo
|
||||
{
|
||||
Method = d.Method,
|
||||
Path = d.Path,
|
||||
HasRequestSchema = d.ValidateRequest && d.RequestSchemaJson is not null,
|
||||
HasResponseSchema = d.ValidateResponse && d.ResponseSchemaJson is not null,
|
||||
RequestSchemaUrl = d.ValidateRequest && d.RequestSchemaJson is not null
|
||||
? $"/.well-known/schemas/{d.Method.ToLowerInvariant()}{d.Path}?direction=request"
|
||||
: null,
|
||||
ResponseSchemaUrl = d.ValidateResponse && d.ResponseSchemaJson is not null
|
||||
? $"/.well-known/schemas/{d.Method.ToLowerInvariant()}{d.Path}?direction=response"
|
||||
: null
|
||||
}).ToList();
|
||||
|
||||
return Task.FromResult(new SchemaIndexResponse { Endpoints = endpoints });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint that returns a specific schema as application/schema+json.
|
||||
/// Supports ETag and If-None-Match for caching.
|
||||
/// </summary>
|
||||
[StellaEndpoint("GET", "/.well-known/schemas/{method}/{*path}")]
|
||||
public sealed class SchemaDetailEndpoint : IRawStellaEndpoint
|
||||
{
|
||||
private readonly ISchemaRegistry _schemaRegistry;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SchemaDetailEndpoint"/> class.
|
||||
/// </summary>
|
||||
/// <param name="schemaRegistry">The schema registry.</param>
|
||||
public SchemaDetailEndpoint(ISchemaRegistry schemaRegistry)
|
||||
{
|
||||
_schemaRegistry = schemaRegistry;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
// Extract method and path from path parameters
|
||||
if (!context.PathParameters.TryGetValue("method", out var method) ||
|
||||
!context.PathParameters.TryGetValue("path", out var path))
|
||||
{
|
||||
return Task.FromResult(RawResponse.NotFound("Schema not found"));
|
||||
}
|
||||
|
||||
// Normalize method to uppercase
|
||||
method = method.ToUpperInvariant();
|
||||
|
||||
// Ensure path starts with /
|
||||
if (!path.StartsWith('/'))
|
||||
{
|
||||
path = "/" + path;
|
||||
}
|
||||
|
||||
// Get direction from query string (default to request)
|
||||
var direction = SchemaDirection.Request;
|
||||
if (context.Headers.TryGetValue("X-Schema-Direction", out var directionHeader) &&
|
||||
directionHeader is not null)
|
||||
{
|
||||
if (directionHeader.Equals("response", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
direction = SchemaDirection.Response;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for query parameter (parsed from path or context)
|
||||
// For simplicity, check if path contains ?direction=response
|
||||
if (path.Contains("?direction=response", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
direction = SchemaDirection.Response;
|
||||
path = path.Split('?')[0];
|
||||
}
|
||||
else if (path.Contains("?direction=request", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
path = path.Split('?')[0];
|
||||
}
|
||||
|
||||
// Check if schema exists
|
||||
if (!_schemaRegistry.HasSchema(method, path, direction))
|
||||
{
|
||||
return Task.FromResult(RawResponse.NotFound($"No {direction.ToString().ToLowerInvariant()} schema found for {method} {path}"));
|
||||
}
|
||||
|
||||
// Get ETag for conditional requests
|
||||
var etag = _schemaRegistry.GetSchemaETag(method, path, direction);
|
||||
|
||||
// Check If-None-Match header
|
||||
if (etag is not null && context.Headers.TryGetValue("If-None-Match", out var ifNoneMatch))
|
||||
{
|
||||
if (ifNoneMatch == etag)
|
||||
{
|
||||
var notModifiedHeaders = new HeaderCollection();
|
||||
notModifiedHeaders.Set("ETag", etag);
|
||||
notModifiedHeaders.Set("Cache-Control", "public, max-age=3600");
|
||||
return Task.FromResult(new RawResponse
|
||||
{
|
||||
StatusCode = 304,
|
||||
Headers = notModifiedHeaders,
|
||||
Body = Stream.Null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get schema text
|
||||
var schemaText = _schemaRegistry.GetSchemaText(method, path, direction);
|
||||
if (schemaText is null)
|
||||
{
|
||||
return Task.FromResult(RawResponse.NotFound("Schema not found"));
|
||||
}
|
||||
|
||||
// Build response
|
||||
var headers = new HeaderCollection();
|
||||
headers.Set("Content-Type", "application/schema+json; charset=utf-8");
|
||||
headers.Set("Cache-Control", "public, max-age=3600");
|
||||
if (etag is not null)
|
||||
{
|
||||
headers.Set("ETag", etag);
|
||||
}
|
||||
|
||||
return Task.FromResult(new RawResponse
|
||||
{
|
||||
StatusCode = 200,
|
||||
Headers = headers,
|
||||
Body = new MemoryStream(Encoding.UTF8.GetBytes(schemaText))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,10 @@ public interface IGeneratedEndpointProvider
|
||||
/// Gets all handler types for endpoint discovery.
|
||||
/// </summary>
|
||||
IReadOnlyList<Type> GetHandlerTypes();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema definitions for OpenAPI and validation.
|
||||
/// Keys are schema IDs, values are JSON Schema definitions.
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<string, SchemaDefinition> GetSchemaDefinitions();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Microservice.Validation;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
@@ -15,6 +16,8 @@ public sealed class RequestDispatcher
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<RequestDispatcher> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly ISchemaRegistry? _schemaRegistry;
|
||||
private readonly IRequestSchemaValidator? _schemaValidator;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RequestDispatcher"/> class.
|
||||
@@ -22,16 +25,22 @@ public sealed class RequestDispatcher
|
||||
/// <param name="registry">The endpoint registry.</param>
|
||||
/// <param name="serviceProvider">The service provider for resolving handlers.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="schemaRegistry">Optional schema registry for validation.</param>
|
||||
/// <param name="schemaValidator">Optional schema validator.</param>
|
||||
/// <param name="jsonOptions">Optional JSON serialization options.</param>
|
||||
public RequestDispatcher(
|
||||
IEndpointRegistry registry,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<RequestDispatcher> logger,
|
||||
ISchemaRegistry? schemaRegistry = null,
|
||||
IRequestSchemaValidator? schemaValidator = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_registry = registry;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
_schemaRegistry = schemaRegistry;
|
||||
_schemaValidator = schemaValidator;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
@@ -180,6 +189,60 @@ public sealed class RequestDispatcher
|
||||
{
|
||||
try
|
||||
{
|
||||
// Schema validation (before deserialization)
|
||||
if (_schemaRegistry is not null && _schemaValidator is not null &&
|
||||
_schemaRegistry.HasSchema(context.Method, context.Path, SchemaDirection.Request))
|
||||
{
|
||||
if (context.Body == Stream.Null || context.Body.Length == 0)
|
||||
{
|
||||
return ValidationProblemDetails.Create(
|
||||
context.Method,
|
||||
context.Path,
|
||||
SchemaDirection.Request,
|
||||
[new SchemaValidationError("/", "#", "Request body is required", "required")],
|
||||
context.CorrelationId
|
||||
).ToRawResponse();
|
||||
}
|
||||
|
||||
context.Body.Position = 0;
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = await JsonDocument.ParseAsync(context.Body, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ValidationProblemDetails.Create(
|
||||
context.Method,
|
||||
context.Path,
|
||||
SchemaDirection.Request,
|
||||
[new SchemaValidationError("/", "#", $"Invalid JSON: {ex.Message}", "json")],
|
||||
context.CorrelationId
|
||||
).ToRawResponse();
|
||||
}
|
||||
|
||||
var schema = _schemaRegistry.GetRequestSchema(context.Method, context.Path);
|
||||
if (schema is not null && !_schemaValidator.TryValidate(doc, schema, out var errors))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Schema validation failed for {Method} {Path}: {ErrorCount} errors",
|
||||
context.Method,
|
||||
context.Path,
|
||||
errors.Count);
|
||||
|
||||
return ValidationProblemDetails.Create(
|
||||
context.Method,
|
||||
context.Path,
|
||||
SchemaDirection.Request,
|
||||
errors,
|
||||
context.CorrelationId
|
||||
).ToRawResponse();
|
||||
}
|
||||
|
||||
// Reset stream for deserialization
|
||||
context.Body.Position = 0;
|
||||
}
|
||||
|
||||
// Deserialize request
|
||||
object? request;
|
||||
if (context.Body == Stream.Null || context.Body.Length == 0)
|
||||
|
||||
@@ -15,10 +15,13 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
|
||||
private readonly StellaMicroserviceOptions _options;
|
||||
private readonly IEndpointDiscoveryProvider _endpointDiscovery;
|
||||
private readonly IMicroserviceTransport? _microserviceTransport;
|
||||
private readonly IGeneratedEndpointProvider? _generatedProvider;
|
||||
private readonly ILogger<RouterConnectionManager> _logger;
|
||||
private readonly ConcurrentDictionary<string, ConnectionState> _connections = new();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private IReadOnlyList<EndpointDescriptor>? _endpoints;
|
||||
private IReadOnlyDictionary<string, SchemaDefinition>? _schemas;
|
||||
private ServiceOpenApiInfo? _openApiInfo;
|
||||
private Task? _heartbeatTask;
|
||||
private bool _disposed;
|
||||
private volatile InstanceHealthStatus _currentStatus = InstanceHealthStatus.Healthy;
|
||||
@@ -35,11 +38,13 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
|
||||
IOptions<StellaMicroserviceOptions> options,
|
||||
IEndpointDiscoveryProvider endpointDiscovery,
|
||||
IMicroserviceTransport? microserviceTransport,
|
||||
IGeneratedEndpointProvider? generatedProvider,
|
||||
ILogger<RouterConnectionManager> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_endpointDiscovery = endpointDiscovery;
|
||||
_microserviceTransport = microserviceTransport;
|
||||
_generatedProvider = generatedProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -86,6 +91,19 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
|
||||
_endpoints = _endpointDiscovery.DiscoverEndpoints();
|
||||
_logger.LogInformation("Discovered {EndpointCount} endpoints", _endpoints.Count);
|
||||
|
||||
// Get schema definitions from generated provider
|
||||
_schemas = _generatedProvider?.GetSchemaDefinitions()
|
||||
?? new Dictionary<string, SchemaDefinition>();
|
||||
_logger.LogInformation("Discovered {SchemaCount} schemas", _schemas.Count);
|
||||
|
||||
// Build OpenAPI info from options
|
||||
_openApiInfo = new ServiceOpenApiInfo
|
||||
{
|
||||
Title = _options.ServiceName,
|
||||
Description = _options.ServiceDescription,
|
||||
Contact = _options.ContactInfo
|
||||
};
|
||||
|
||||
// Connect to each router
|
||||
foreach (var router in _options.Routers)
|
||||
{
|
||||
@@ -148,7 +166,9 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
|
||||
Instance = instance,
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
LastHeartbeatUtc = DateTime.UtcNow,
|
||||
TransportType = router.TransportType
|
||||
TransportType = router.TransportType,
|
||||
Schemas = _schemas ?? new Dictionary<string, SchemaDefinition>(),
|
||||
OpenApiInfo = _openApiInfo
|
||||
};
|
||||
|
||||
// Register endpoints
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Microservice.Validation;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
@@ -50,6 +51,9 @@ public static class ServiceCollectionExtensions
|
||||
// Register request dispatcher
|
||||
services.TryAddSingleton<RequestDispatcher>();
|
||||
|
||||
// Register schema validation services
|
||||
RegisterSchemaValidationServices(services);
|
||||
|
||||
// Register connection manager
|
||||
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
|
||||
|
||||
@@ -102,6 +106,9 @@ public static class ServiceCollectionExtensions
|
||||
// Register request dispatcher
|
||||
services.TryAddSingleton<RequestDispatcher>();
|
||||
|
||||
// Register schema validation services
|
||||
RegisterSchemaValidationServices(services);
|
||||
|
||||
// Register connection manager
|
||||
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
|
||||
|
||||
@@ -123,4 +130,43 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<THandler>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a generated schema provider for schema validation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TProvider">The generated schema provider type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddStellaSchemaProvider<TProvider>(this IServiceCollection services)
|
||||
where TProvider : class, IGeneratedSchemaProvider
|
||||
{
|
||||
services.TryAddSingleton<IGeneratedSchemaProvider, TProvider>();
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void RegisterSchemaValidationServices(IServiceCollection services)
|
||||
{
|
||||
// Try to find and register generated schema provider (if source gen was used)
|
||||
// The generated provider will be named "GeneratedSchemaProvider" in the namespace
|
||||
// "StellaOps.Microservice.Generated"
|
||||
var generatedType = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(a =>
|
||||
{
|
||||
try { return a.GetTypes(); }
|
||||
catch { return []; }
|
||||
})
|
||||
.FirstOrDefault(t =>
|
||||
t.Name == "GeneratedSchemaProvider" &&
|
||||
typeof(IGeneratedSchemaProvider).IsAssignableFrom(t) &&
|
||||
!t.IsAbstract);
|
||||
|
||||
if (generatedType is not null)
|
||||
{
|
||||
services.TryAddSingleton(typeof(IGeneratedSchemaProvider), generatedType);
|
||||
}
|
||||
|
||||
// Register validator and registry
|
||||
services.TryAddSingleton<IRequestSchemaValidator, RequestSchemaValidator>();
|
||||
services.TryAddSingleton<ISchemaRegistry, SchemaRegistry>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,16 @@ public sealed partial class StellaMicroserviceOptions
|
||||
/// </summary>
|
||||
public string? ConfigFilePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the service description for OpenAPI documentation.
|
||||
/// </summary>
|
||||
public string? ServiceDescription { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the contact information for OpenAPI documentation.
|
||||
/// </summary>
|
||||
public string? ContactInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the heartbeat interval.
|
||||
/// Default: 10 seconds.
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.7.1" />
|
||||
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Enables JSON Schema validation for this endpoint.
|
||||
/// Schemas are generated from TRequest/TResponse types at compile time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When applied to an endpoint class that implements <see cref="IStellaEndpoint{TRequest, TResponse}"/>,
|
||||
/// the source generator will generate a JSON Schema from the request type and validate all incoming
|
||||
/// requests against it. Invalid requests receive a 422 Unprocessable Entity response with detailed errors.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Response validation is opt-in via <see cref="ValidateResponse"/>. This is useful for catching
|
||||
/// bugs in handlers but adds overhead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// // Basic usage - validates request only
|
||||
/// [StellaEndpoint("POST", "/invoices")]
|
||||
/// [ValidateSchema]
|
||||
/// public sealed class CreateInvoiceEndpoint : IStellaEndpoint<CreateInvoiceRequest, CreateInvoiceResponse>
|
||||
///
|
||||
/// // With response validation
|
||||
/// [StellaEndpoint("GET", "/invoices/{id}")]
|
||||
/// [ValidateSchema(ValidateResponse = true)]
|
||||
/// public sealed class GetInvoiceEndpoint : IStellaEndpoint<GetInvoiceRequest, GetInvoiceResponse>
|
||||
///
|
||||
/// // With external schema file
|
||||
/// [StellaEndpoint("POST", "/orders")]
|
||||
/// [ValidateSchema(RequestSchemaResource = "Schemas.create-order.json")]
|
||||
/// public sealed class CreateOrderEndpoint : IStellaEndpoint<CreateOrderRequest, CreateOrderResponse>
|
||||
/// </code>
|
||||
/// </example>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class ValidateSchemaAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether to validate request bodies.
|
||||
/// Default is true.
|
||||
/// </summary>
|
||||
public bool ValidateRequest { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to validate response bodies.
|
||||
/// Default is false. Enable for debugging or strict contract enforcement.
|
||||
/// </summary>
|
||||
public bool ValidateResponse { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the embedded resource path to an external request schema file.
|
||||
/// If null, the schema is auto-generated from the TRequest type.
|
||||
/// </summary>
|
||||
/// <example>"Schemas.create-order.json"</example>
|
||||
public string? RequestSchemaResource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the embedded resource path to an external response schema file.
|
||||
/// If null, the schema is auto-generated from the TResponse type.
|
||||
/// </summary>
|
||||
public string? ResponseSchemaResource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OpenAPI operation summary.
|
||||
/// A brief description of what the endpoint does.
|
||||
/// </summary>
|
||||
public string? Summary { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OpenAPI operation description.
|
||||
/// A longer description of the endpoint's behavior.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OpenAPI tags for this endpoint.
|
||||
/// Tags are used to group endpoints in documentation.
|
||||
/// </summary>
|
||||
public string[]? Tags { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this endpoint is deprecated.
|
||||
/// Deprecated endpoints are marked as such in OpenAPI documentation.
|
||||
/// </summary>
|
||||
public bool Deprecated { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Microservice.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the schema information for an endpoint.
|
||||
/// Generated at compile time by the source generator.
|
||||
/// </summary>
|
||||
/// <param name="Method">The HTTP method (GET, POST, etc.).</param>
|
||||
/// <param name="Path">The endpoint path template.</param>
|
||||
/// <param name="RequestSchemaJson">The JSON Schema for the request body, or null if not validated.</param>
|
||||
/// <param name="ResponseSchemaJson">The JSON Schema for the response body, or null if not validated.</param>
|
||||
/// <param name="ValidateRequest">Whether request validation is enabled.</param>
|
||||
/// <param name="ValidateResponse">Whether response validation is enabled.</param>
|
||||
public sealed record EndpointSchemaDefinition(
|
||||
string Method,
|
||||
string Path,
|
||||
string? RequestSchemaJson,
|
||||
string? ResponseSchemaJson,
|
||||
bool ValidateRequest,
|
||||
bool ValidateResponse);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Microservice.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Interface implemented by the source-generated schema provider.
|
||||
/// Provides access to schemas generated at compile time.
|
||||
/// </summary>
|
||||
public interface IGeneratedSchemaProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all endpoint schema definitions generated at compile time.
|
||||
/// </summary>
|
||||
/// <returns>A list of all endpoint schema definitions.</returns>
|
||||
IReadOnlyList<EndpointSchemaDefinition> GetSchemaDefinitions();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json;
|
||||
using Json.Schema;
|
||||
|
||||
namespace StellaOps.Microservice.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Validates JSON documents against JSON schemas.
|
||||
/// </summary>
|
||||
public interface IRequestSchemaValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates a JSON document against a schema.
|
||||
/// </summary>
|
||||
/// <param name="document">The JSON document to validate.</param>
|
||||
/// <param name="schema">The JSON schema to validate against.</param>
|
||||
/// <param name="errors">When validation fails, contains the list of errors.</param>
|
||||
/// <returns>True if the document is valid, false otherwise.</returns>
|
||||
bool TryValidate(
|
||||
JsonDocument document,
|
||||
JsonSchema schema,
|
||||
out IReadOnlyList<SchemaValidationError> errors);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Json.Schema;
|
||||
|
||||
namespace StellaOps.Microservice.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Registry for JSON schemas associated with endpoints.
|
||||
/// Provides compiled schemas for validation and raw text for documentation.
|
||||
/// </summary>
|
||||
public interface ISchemaRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the compiled JSON schema for an endpoint's request.
|
||||
/// </summary>
|
||||
/// <param name="method">The HTTP method.</param>
|
||||
/// <param name="path">The endpoint path template.</param>
|
||||
/// <returns>The compiled schema, or null if no schema is registered.</returns>
|
||||
JsonSchema? GetRequestSchema(string method, string path);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the compiled JSON schema for an endpoint's response.
|
||||
/// </summary>
|
||||
/// <param name="method">The HTTP method.</param>
|
||||
/// <param name="path">The endpoint path template.</param>
|
||||
/// <returns>The compiled schema, or null if no schema is registered.</returns>
|
||||
JsonSchema? GetResponseSchema(string method, string path);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw schema text for documentation/publication.
|
||||
/// </summary>
|
||||
/// <param name="method">The HTTP method.</param>
|
||||
/// <param name="path">The endpoint path template.</param>
|
||||
/// <param name="direction">Whether to get request or response schema.</param>
|
||||
/// <returns>The raw JSON schema text, or null if no schema is registered.</returns>
|
||||
string? GetSchemaText(string method, string path, SchemaDirection direction);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ETag for the schema (for HTTP caching).
|
||||
/// </summary>
|
||||
/// <param name="method">The HTTP method.</param>
|
||||
/// <param name="path">The endpoint path template.</param>
|
||||
/// <param name="direction">Whether to get request or response schema.</param>
|
||||
/// <returns>The ETag value, or null if no schema is registered.</returns>
|
||||
string? GetSchemaETag(string method, string path, SchemaDirection direction);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an endpoint has a schema registered.
|
||||
/// </summary>
|
||||
/// <param name="method">The HTTP method.</param>
|
||||
/// <param name="path">The endpoint path template.</param>
|
||||
/// <param name="direction">Whether to check request or response schema.</param>
|
||||
/// <returns>True if a schema is registered.</returns>
|
||||
bool HasSchema(string method, string path, SchemaDirection direction);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered schema definitions.
|
||||
/// </summary>
|
||||
/// <returns>All endpoint schema definitions.</returns>
|
||||
IReadOnlyList<EndpointSchemaDefinition> GetAllSchemas();
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Text.Json;
|
||||
using Json.Schema;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Microservice.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Validates JSON documents against JSON schemas using JsonSchema.Net.
|
||||
/// Follows the same pattern as Concelier's JsonSchemaValidator.
|
||||
/// </summary>
|
||||
public sealed class RequestSchemaValidator : IRequestSchemaValidator
|
||||
{
|
||||
private readonly ILogger<RequestSchemaValidator> _logger;
|
||||
private const int MaxLoggedErrors = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new request schema validator.
|
||||
/// </summary>
|
||||
public RequestSchemaValidator(ILogger<RequestSchemaValidator> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryValidate(
|
||||
JsonDocument document,
|
||||
JsonSchema schema,
|
||||
out IReadOnlyList<SchemaValidationError> errors)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
ArgumentNullException.ThrowIfNull(schema);
|
||||
|
||||
var result = schema.Evaluate(document.RootElement, new EvaluationOptions
|
||||
{
|
||||
OutputFormat = OutputFormat.List,
|
||||
RequireFormatValidation = true
|
||||
});
|
||||
|
||||
if (result.IsValid)
|
||||
{
|
||||
errors = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
errors = CollectErrors(result);
|
||||
|
||||
if (errors.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Schema validation failed with unknown errors");
|
||||
errors = [new SchemaValidationError("#", "#", "Unknown validation error", "unknown")];
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var violation in errors.Take(MaxLoggedErrors))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Schema violation at {InstanceLocation} (keyword: {Keyword}): {Message}",
|
||||
string.IsNullOrEmpty(violation.InstanceLocation) ? "#" : violation.InstanceLocation,
|
||||
violation.Keyword,
|
||||
violation.Message);
|
||||
}
|
||||
|
||||
if (errors.Count > MaxLoggedErrors)
|
||||
{
|
||||
_logger.LogDebug("{Count} additional schema violations suppressed", errors.Count - MaxLoggedErrors);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SchemaValidationError> CollectErrors(EvaluationResults result)
|
||||
{
|
||||
var errors = new List<SchemaValidationError>();
|
||||
Aggregate(result, errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static void Aggregate(EvaluationResults node, List<SchemaValidationError> errors)
|
||||
{
|
||||
if (node.Errors is { Count: > 0 })
|
||||
{
|
||||
foreach (var kvp in node.Errors)
|
||||
{
|
||||
errors.Add(new SchemaValidationError(
|
||||
node.InstanceLocation?.ToString() ?? string.Empty,
|
||||
node.SchemaLocation?.ToString() ?? string.Empty,
|
||||
kvp.Value,
|
||||
kvp.Key));
|
||||
}
|
||||
}
|
||||
|
||||
if (node.Details is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var child in node.Details)
|
||||
{
|
||||
Aggregate(child, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Microservice.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the direction of schema validation.
|
||||
/// </summary>
|
||||
public enum SchemaDirection
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates incoming request bodies.
|
||||
/// </summary>
|
||||
Request,
|
||||
|
||||
/// <summary>
|
||||
/// Validates outgoing response bodies.
|
||||
/// </summary>
|
||||
Response
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Json.Schema;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Microservice.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Registry for JSON schemas with caching and ETag support.
|
||||
/// Follows the RiskProfileSchemaProvider pattern.
|
||||
/// </summary>
|
||||
public sealed class SchemaRegistry : ISchemaRegistry
|
||||
{
|
||||
private readonly ILogger<SchemaRegistry> _logger;
|
||||
private readonly IReadOnlyList<EndpointSchemaDefinition> _definitions;
|
||||
private readonly ConcurrentDictionary<(string Method, string Path, SchemaDirection Direction), SchemaEntry> _cache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new schema registry.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="generatedProvider">The source-generated schema provider, if available.</param>
|
||||
public SchemaRegistry(
|
||||
ILogger<SchemaRegistry> logger,
|
||||
IGeneratedSchemaProvider? generatedProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_definitions = generatedProvider?.GetSchemaDefinitions() ?? [];
|
||||
|
||||
if (_definitions.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Schema registry initialized with {Count} endpoint schemas", _definitions.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public JsonSchema? GetRequestSchema(string method, string path)
|
||||
{
|
||||
return GetEntry(method, path, SchemaDirection.Request)?.CompiledSchema;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public JsonSchema? GetResponseSchema(string method, string path)
|
||||
{
|
||||
return GetEntry(method, path, SchemaDirection.Response)?.CompiledSchema;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetSchemaText(string method, string path, SchemaDirection direction)
|
||||
{
|
||||
return GetEntry(method, path, direction)?.SchemaText;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetSchemaETag(string method, string path, SchemaDirection direction)
|
||||
{
|
||||
return GetEntry(method, path, direction)?.ETag;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasSchema(string method, string path, SchemaDirection direction)
|
||||
{
|
||||
var def = FindDefinition(method, path);
|
||||
if (def is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return direction == SchemaDirection.Request
|
||||
? def.ValidateRequest && def.RequestSchemaJson is not null
|
||||
: def.ValidateResponse && def.ResponseSchemaJson is not null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<EndpointSchemaDefinition> GetAllSchemas()
|
||||
{
|
||||
return _definitions;
|
||||
}
|
||||
|
||||
private SchemaEntry? GetEntry(string method, string path, SchemaDirection direction)
|
||||
{
|
||||
var key = (method.ToUpperInvariant(), path, direction);
|
||||
|
||||
return _cache.GetOrAdd(key, k =>
|
||||
{
|
||||
var def = FindDefinition(k.Method, k.Path);
|
||||
if (def is null)
|
||||
{
|
||||
return null!;
|
||||
}
|
||||
|
||||
var schemaJson = k.Direction == SchemaDirection.Request
|
||||
? def.RequestSchemaJson
|
||||
: def.ResponseSchemaJson;
|
||||
|
||||
if (schemaJson is null)
|
||||
{
|
||||
return null!;
|
||||
}
|
||||
|
||||
var shouldValidate = k.Direction == SchemaDirection.Request
|
||||
? def.ValidateRequest
|
||||
: def.ValidateResponse;
|
||||
|
||||
if (!shouldValidate)
|
||||
{
|
||||
return null!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var compiled = JsonSchema.FromText(schemaJson);
|
||||
var etag = ComputeETag(schemaJson);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Compiled {Direction} schema for {Method} {Path}",
|
||||
k.Direction,
|
||||
k.Method,
|
||||
k.Path);
|
||||
|
||||
return new SchemaEntry(compiled, schemaJson, etag);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to compile {Direction} schema for {Method} {Path}",
|
||||
k.Direction,
|
||||
k.Method,
|
||||
k.Path);
|
||||
return null!;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private EndpointSchemaDefinition? FindDefinition(string method, string path)
|
||||
{
|
||||
var normalizedMethod = method.ToUpperInvariant();
|
||||
return _definitions.FirstOrDefault(d =>
|
||||
d.Method.Equals(normalizedMethod, StringComparison.OrdinalIgnoreCase) &&
|
||||
d.Path.Equals(path, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string ComputeETag(string schemaText)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(schemaText));
|
||||
return $"\"{Convert.ToHexStringLower(hash)[..16]}\"";
|
||||
}
|
||||
|
||||
private sealed record SchemaEntry(
|
||||
JsonSchema CompiledSchema,
|
||||
string SchemaText,
|
||||
string ETag);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Microservice.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single schema validation error.
|
||||
/// </summary>
|
||||
/// <param name="InstanceLocation">JSON pointer to the invalid value (e.g., "/amount").</param>
|
||||
/// <param name="SchemaLocation">JSON pointer to the schema constraint (e.g., "#/properties/amount/minimum").</param>
|
||||
/// <param name="Message">Human-readable error message.</param>
|
||||
/// <param name="Keyword">The JSON Schema keyword that failed (e.g., "required", "minimum", "type").</param>
|
||||
public sealed record SchemaValidationError(
|
||||
string InstanceLocation,
|
||||
string SchemaLocation,
|
||||
string Message,
|
||||
string Keyword);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user