wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -184,6 +184,12 @@ public sealed class GatewayAuthOptions
|
||||
/// </summary>
|
||||
public bool AllowScopeHeader { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Enables per-request tenant override when explicitly configured.
|
||||
/// Default: false.
|
||||
/// </summary>
|
||||
public bool EnableTenantOverride { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Emit signed identity envelope headers for router-dispatched requests.
|
||||
/// </summary>
|
||||
|
||||
@@ -22,6 +22,8 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<IdentityHeaderPolicyMiddleware> _logger;
|
||||
private readonly IdentityHeaderPolicyOptions _options;
|
||||
private static readonly char[] TenantClaimDelimiters = [' ', ',', ';', '\t', '\r', '\n'];
|
||||
private static readonly string[] TenantRequestHeaders = ["X-StellaOps-Tenant", "X-Stella-Tenant", "X-Tenant-Id"];
|
||||
|
||||
/// <summary>
|
||||
/// Reserved identity headers that must never be trusted from external clients.
|
||||
@@ -79,12 +81,41 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
return;
|
||||
}
|
||||
|
||||
var requestedTenant = CaptureRequestedTenant(context.Request.Headers);
|
||||
var clientSuppliedTenantHeader = HasClientSuppliedTenantHeader(context.Request.Headers);
|
||||
|
||||
// Step 1: Strip all reserved identity headers from incoming request
|
||||
StripReservedHeaders(context);
|
||||
StripReservedHeaders(context, ShouldPreserveAuthHeaders(context.Request.Path));
|
||||
|
||||
// Step 2: Extract identity from validated principal
|
||||
var identity = ExtractIdentity(context);
|
||||
|
||||
if (clientSuppliedTenantHeader)
|
||||
{
|
||||
LogTenantHeaderTelemetry(
|
||||
context,
|
||||
identity,
|
||||
requestedTenant);
|
||||
}
|
||||
|
||||
if (!identity.IsAnonymous &&
|
||||
!string.IsNullOrWhiteSpace(requestedTenant) &&
|
||||
!string.IsNullOrWhiteSpace(identity.Tenant) &&
|
||||
!string.Equals(requestedTenant, identity.Tenant, StringComparison.Ordinal))
|
||||
{
|
||||
if (!TryApplyTenantOverride(context, identity, requestedTenant))
|
||||
{
|
||||
await context.Response.WriteAsJsonAsync(
|
||||
new
|
||||
{
|
||||
error = "tenant_override_forbidden",
|
||||
message = "Requested tenant override is not permitted for this principal."
|
||||
},
|
||||
cancellationToken: context.RequestAborted).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Store normalized identity in HttpContext.Items
|
||||
StoreIdentityContext(context, identity);
|
||||
|
||||
@@ -94,12 +125,8 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private void StripReservedHeaders(HttpContext context)
|
||||
private void StripReservedHeaders(HttpContext context, bool preserveAuthHeaders)
|
||||
{
|
||||
var preserveAuthHeaders = _options.JwtPassthroughPrefixes.Count > 0
|
||||
&& _options.JwtPassthroughPrefixes.Any(prefix =>
|
||||
context.Request.Path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
foreach (var header in ReservedHeaders)
|
||||
{
|
||||
// Preserve Authorization/DPoP for routes that need JWT pass-through
|
||||
@@ -119,6 +146,172 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldPreserveAuthHeaders(PathString path)
|
||||
{
|
||||
if (_options.JwtPassthroughPrefixes.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var configuredMatch = _options.JwtPassthroughPrefixes.Any(prefix =>
|
||||
path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
if (!configuredMatch)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_options.ApprovedAuthPassthroughPrefixes.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var approvedMatch = _options.ApprovedAuthPassthroughPrefixes.Any(prefix =>
|
||||
path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
if (approvedMatch)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Gateway route {Path} requested Authorization/DPoP passthrough but prefix is not in approved allow-list. Headers will be stripped.",
|
||||
path.Value);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasClientSuppliedTenantHeader(IHeaderDictionary headers)
|
||||
=> TenantRequestHeaders.Any(headers.ContainsKey);
|
||||
|
||||
private static string? CaptureRequestedTenant(IHeaderDictionary headers)
|
||||
{
|
||||
foreach (var header in TenantRequestHeaders)
|
||||
{
|
||||
if (!headers.TryGetValue(header, out var value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = NormalizeTenant(value.ToString());
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void LogTenantHeaderTelemetry(HttpContext context, IdentityContext identity, string? requestedTenant)
|
||||
{
|
||||
var resolvedTenant = identity.Tenant;
|
||||
var actor = identity.Actor ?? "unknown";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestedTenant))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Gateway stripped client-supplied tenant headers with empty value. Route={Route} Actor={Actor} ResolvedTenant={ResolvedTenant}",
|
||||
context.Request.Path.Value,
|
||||
actor,
|
||||
resolvedTenant);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolvedTenant))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Gateway stripped tenant override attempt but authenticated principal has no resolved tenant. Route={Route} Actor={Actor} RequestedTenant={RequestedTenant}",
|
||||
context.Request.Path.Value,
|
||||
actor,
|
||||
requestedTenant);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(requestedTenant, resolvedTenant, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Gateway stripped tenant override attempt. Route={Route} Actor={Actor} RequestedTenant={RequestedTenant} ResolvedTenant={ResolvedTenant}",
|
||||
context.Request.Path.Value,
|
||||
actor,
|
||||
requestedTenant,
|
||||
resolvedTenant);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Gateway stripped client-supplied tenant header that matched resolved tenant. Route={Route} Actor={Actor} Tenant={Tenant}",
|
||||
context.Request.Path.Value,
|
||||
actor,
|
||||
resolvedTenant);
|
||||
}
|
||||
|
||||
private bool TryApplyTenantOverride(HttpContext context, IdentityContext identity, string requestedTenant)
|
||||
{
|
||||
if (!_options.EnableTenantOverride)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tenant override rejected because feature is disabled. Route={Route} Actor={Actor} RequestedTenant={RequestedTenant} ResolvedTenant={ResolvedTenant}",
|
||||
context.Request.Path.Value,
|
||||
identity.Actor ?? "unknown",
|
||||
requestedTenant,
|
||||
identity.Tenant);
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return false;
|
||||
}
|
||||
|
||||
var allowedTenants = ResolveAllowedTenants(context.User);
|
||||
if (!allowedTenants.Contains(requestedTenant))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tenant override rejected because requested tenant is not in allow-list. Route={Route} Actor={Actor} RequestedTenant={RequestedTenant} AllowedTenants={AllowedTenants}",
|
||||
context.Request.Path.Value,
|
||||
identity.Actor ?? "unknown",
|
||||
requestedTenant,
|
||||
string.Join(",", allowedTenants.OrderBy(static tenant => tenant, StringComparer.Ordinal)));
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return false;
|
||||
}
|
||||
|
||||
identity.Tenant = requestedTenant;
|
||||
_logger.LogInformation(
|
||||
"Tenant override accepted. Route={Route} Actor={Actor} SelectedTenant={SelectedTenant}",
|
||||
context.Request.Path.Value,
|
||||
identity.Actor ?? "unknown",
|
||||
identity.Tenant);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static HashSet<string> ResolveAllowedTenants(ClaimsPrincipal principal)
|
||||
{
|
||||
var tenants = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.AllowedTenants))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var raw in claim.Value.Split(TenantClaimDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var normalized = NormalizeTenant(raw);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
tenants.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var selectedTenant = NormalizeTenant(principal.FindFirstValue(StellaOpsClaimTypes.Tenant) ?? principal.FindFirstValue("tid"));
|
||||
if (!string.IsNullOrWhiteSpace(selectedTenant))
|
||||
{
|
||||
tenants.Add(selectedTenant);
|
||||
}
|
||||
|
||||
return tenants;
|
||||
}
|
||||
|
||||
private static string? NormalizeTenant(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
|
||||
private IdentityContext ExtractIdentity(HttpContext context)
|
||||
{
|
||||
var principal = context.User;
|
||||
@@ -137,11 +330,15 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
// Extract subject (actor)
|
||||
var actor = principal.FindFirstValue(StellaOpsClaimTypes.Subject);
|
||||
|
||||
// Extract tenant - try canonical claim first, then legacy 'tid',
|
||||
// then fall back to "default".
|
||||
var tenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
|
||||
?? principal.FindFirstValue("tid")
|
||||
?? "default";
|
||||
// Extract tenant from validated claims. Legacy 'tid' remains compatibility-only.
|
||||
var tenant = NormalizeTenant(principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
|
||||
?? principal.FindFirstValue("tid"));
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Authenticated request {TraceId} missing tenant claim; downstream tenant headers will be omitted.",
|
||||
context.TraceIdentifier);
|
||||
}
|
||||
|
||||
// Extract project (optional)
|
||||
var project = principal.FindFirstValue(StellaOpsClaimTypes.Project);
|
||||
@@ -386,7 +583,7 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
{
|
||||
public bool IsAnonymous { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
public string? Tenant { get; init; }
|
||||
public string? Tenant { get; set; }
|
||||
public string? Project { get; init; }
|
||||
public HashSet<string> Scopes { get; init; } = [];
|
||||
public IReadOnlyList<string> Roles { get; init; } = [];
|
||||
@@ -446,4 +643,20 @@ public sealed class IdentityHeaderPolicyOptions
|
||||
/// Default: empty (strip auth headers for all routes).
|
||||
/// </summary>
|
||||
public List<string> JwtPassthroughPrefixes { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Approved route prefixes where auth passthrough is allowed when configured.
|
||||
/// </summary>
|
||||
public List<string> ApprovedAuthPassthroughPrefixes { get; set; } =
|
||||
[
|
||||
"/connect",
|
||||
"/console",
|
||||
"/api/admin"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Enables per-request tenant override using tenant headers and allow-list claims.
|
||||
/// Default: false.
|
||||
/// </summary>
|
||||
public bool EnableTenantOverride { get; set; } = false;
|
||||
}
|
||||
|
||||
@@ -125,7 +125,8 @@ builder.Services.AddSingleton(new IdentityHeaderPolicyOptions
|
||||
JwtPassthroughPrefixes = bootstrapOptions.Routes
|
||||
.Where(r => r.PreserveAuthHeaders)
|
||||
.Select(r => r.Path)
|
||||
.ToList()
|
||||
.ToList(),
|
||||
EnableTenantOverride = bootstrapOptions.Auth.EnableTenantOverride
|
||||
});
|
||||
|
||||
// Route table: resolver + error routes + HTTP client for reverse proxy
|
||||
|
||||
@@ -191,13 +191,6 @@ public sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
|
||||
operation["summary"] = $"{endpoint.Method} {gatewayPath}";
|
||||
}
|
||||
|
||||
if (operation["description"] is null &&
|
||||
operation["summary"] is JsonValue summaryValue &&
|
||||
summaryValue.TryGetValue<string>(out var summaryText))
|
||||
{
|
||||
operation["description"] = summaryText;
|
||||
}
|
||||
|
||||
// Add security requirements
|
||||
var security = ClaimSecurityMapper.GenerateSecurityRequirement(
|
||||
endpoint.AllowAnonymous,
|
||||
|
||||
@@ -124,7 +124,7 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
|
||||
#region Header Overwriting (Not Set-If-Missing)
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_OverwritesSpoofedTenantWithClaimValue()
|
||||
public async Task InvokeAsync_RejectsSpoofedTenantHeaderWhenOverrideDisabled()
|
||||
{
|
||||
var middleware = CreateMiddleware();
|
||||
var claims = new[]
|
||||
@@ -133,15 +133,15 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
|
||||
new Claim(StellaOpsClaimTypes.Subject, "real-subject")
|
||||
};
|
||||
var context = CreateHttpContext("/api/scan", claims);
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
// Client attempts to spoof tenant
|
||||
context.Request.Headers["X-StellaOps-Tenant"] = "spoofed-tenant";
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
// Header should contain claim value, not spoofed value
|
||||
Assert.Equal("real-tenant", context.Request.Headers["X-StellaOps-Tenant"].ToString());
|
||||
Assert.False(_nextCalled);
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -225,6 +225,7 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.Equal("tenant-abc", context.Request.Headers["X-StellaOps-Tenant"].ToString());
|
||||
Assert.Equal("tenant-abc", context.Request.Headers["X-Tenant-Id"].ToString());
|
||||
Assert.Equal("tenant-abc", context.Items[GatewayContextKeys.TenantId]);
|
||||
}
|
||||
|
||||
@@ -243,6 +244,25 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.Equal("legacy-tenant-456", context.Request.Headers["X-StellaOps-Tenant"].ToString());
|
||||
Assert.Equal("legacy-tenant-456", context.Request.Headers["X-Tenant-Id"].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_AuthenticatedRequestWithoutTenantClaim_DoesNotWriteTenantHeaders()
|
||||
{
|
||||
var middleware = CreateMiddleware();
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(StellaOpsClaimTypes.Subject, "user")
|
||||
};
|
||||
var context = CreateHttpContext("/api/scan", claims);
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.DoesNotContain("X-StellaOps-Tenant", context.Request.Headers.Keys);
|
||||
Assert.DoesNotContain("X-Stella-Tenant", context.Request.Headers.Keys);
|
||||
Assert.DoesNotContain("X-Tenant-Id", context.Request.Headers.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -308,6 +328,109 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tenant Override
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_OverrideEnabledAndAllowed_UsesRequestedTenant()
|
||||
{
|
||||
_options.EnableTenantOverride = true;
|
||||
var middleware = CreateMiddleware();
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(StellaOpsClaimTypes.Subject, "user"),
|
||||
new Claim(StellaOpsClaimTypes.Tenant, "tenant-a"),
|
||||
new Claim(StellaOpsClaimTypes.AllowedTenants, "tenant-a tenant-b")
|
||||
};
|
||||
var context = CreateHttpContext("/api/platform", claims);
|
||||
|
||||
context.Request.Headers["X-StellaOps-Tenant"] = "TENANT-B";
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.Equal("tenant-b", context.Request.Headers["X-StellaOps-Tenant"].ToString());
|
||||
Assert.Equal("tenant-b", context.Request.Headers["X-Tenant-Id"].ToString());
|
||||
Assert.Equal("tenant-b", context.Items[GatewayContextKeys.TenantId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_OverrideEnabledButNotAllowed_ReturnsForbidden()
|
||||
{
|
||||
_options.EnableTenantOverride = true;
|
||||
var middleware = CreateMiddleware();
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(StellaOpsClaimTypes.Subject, "user"),
|
||||
new Claim(StellaOpsClaimTypes.Tenant, "tenant-a"),
|
||||
new Claim(StellaOpsClaimTypes.AllowedTenants, "tenant-a tenant-c")
|
||||
};
|
||||
var context = CreateHttpContext("/api/platform", claims);
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.Request.Headers["X-StellaOps-Tenant"] = "tenant-b";
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.False(_nextCalled);
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Auth Header Passthrough
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_PreservesAuthorizationHeadersForApprovedConfiguredPrefix()
|
||||
{
|
||||
_options.JwtPassthroughPrefixes = ["/connect"];
|
||||
_options.ApprovedAuthPassthroughPrefixes = ["/connect", "/console"];
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext("/connect/token");
|
||||
context.Request.Headers.Authorization = "Bearer token-value";
|
||||
context.Request.Headers["DPoP"] = "proof-value";
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.Equal("Bearer token-value", context.Request.Headers.Authorization.ToString());
|
||||
Assert.Equal("proof-value", context.Request.Headers["DPoP"].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_StripsAuthorizationHeadersWhenConfiguredPrefixIsNotApproved()
|
||||
{
|
||||
_options.JwtPassthroughPrefixes = ["/api/v1/authority"];
|
||||
_options.ApprovedAuthPassthroughPrefixes = ["/connect"];
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext("/api/v1/authority/clients");
|
||||
context.Request.Headers.Authorization = "Bearer token-value";
|
||||
context.Request.Headers["DPoP"] = "proof-value";
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.False(context.Request.Headers.ContainsKey("Authorization"));
|
||||
Assert.False(context.Request.Headers.ContainsKey("DPoP"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_StripsAuthorizationHeadersWhenPrefixIsNotConfigured()
|
||||
{
|
||||
_options.JwtPassthroughPrefixes = [];
|
||||
_options.ApprovedAuthPassthroughPrefixes = ["/connect"];
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext("/connect/token");
|
||||
context.Request.Headers.Authorization = "Bearer token-value";
|
||||
context.Request.Headers["DPoP"] = "proof-value";
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.False(context.Request.Headers.ContainsKey("Authorization"));
|
||||
Assert.False(context.Request.Headers.ContainsKey("DPoP"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Legacy Header Compatibility
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -464,7 +464,7 @@ public sealed class OpenApiDocumentGeneratorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithoutExplicitDescription_UsesSummaryAsDescriptionFallback()
|
||||
public void GenerateDocument_WithoutExplicitDescription_LeavesDescriptionUnset()
|
||||
{
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
@@ -485,7 +485,37 @@ public sealed class OpenApiDocumentGeneratorTests
|
||||
var operation = document["paths"]!["/api/v1/timeline"]!["get"]!.AsObject();
|
||||
|
||||
operation["summary"]!.GetValue<string>().Should().Be("GET /api/v1/timeline");
|
||||
operation["description"]!.GetValue<string>().Should().Be("GET /api/v1/timeline");
|
||||
operation["description"].Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithSchemaDescription_PreservesEndpointDescription()
|
||||
{
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/v1/timeline",
|
||||
SchemaInfo = new EndpointSchemaInfo
|
||||
{
|
||||
Summary = "Get timeline",
|
||||
Description = "Return the timeline entries in reverse chronological order."
|
||||
}
|
||||
};
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState.Setup(state => state.GetAllConnections()).Returns([CreateConnection("timelineindexer", endpoint)]);
|
||||
|
||||
var generator = new OpenApiDocumentGenerator(
|
||||
routingState.Object,
|
||||
Options.Create(new OpenApiAggregationOptions()));
|
||||
|
||||
var document = JsonNode.Parse(generator.GenerateDocument())!.AsObject();
|
||||
var operation = document["paths"]!["/api/v1/timeline"]!["get"]!.AsObject();
|
||||
|
||||
operation["summary"]!.GetValue<string>().Should().Be("Get timeline");
|
||||
operation["description"]!.GetValue<string>().Should().Be("Return the timeline entries in reverse chronological order.");
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
|
||||
Reference in New Issue
Block a user