Harden runtime HTTP transport lifecycles
This commit is contained in:
@@ -205,6 +205,11 @@ builder.Services.AddHttpClient("HarborFixture", client =>
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient(IdentityProviderManagementService.HttpClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<PlatformMetadataService>();
|
||||
builder.Services.AddSingleton<PlatformContextService>();
|
||||
builder.Services.AddSingleton<IPlatformContextQuery>(sp => sp.GetRequiredService<PlatformContextService>());
|
||||
@@ -276,8 +281,18 @@ builder.Services.AddSingleton<StellaOps.Signals.UnifiedScore.Replay.IReplayVerif
|
||||
// Score history persistence store
|
||||
if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString))
|
||||
{
|
||||
builder.Services.AddSingleton(
|
||||
Npgsql.NpgsqlDataSource.Create(bootstrapOptions.Storage.PostgresConnectionString));
|
||||
builder.Services.AddSingleton(sp =>
|
||||
{
|
||||
var connectionStringBuilder = new Npgsql.NpgsqlConnectionStringBuilder(bootstrapOptions.Storage.PostgresConnectionString)
|
||||
{
|
||||
ApplicationName = "stellaops-platform",
|
||||
};
|
||||
|
||||
return new Npgsql.NpgsqlDataSourceBuilder(connectionStringBuilder.ConnectionString)
|
||||
{
|
||||
Name = "StellaOps.Platform"
|
||||
}.Build();
|
||||
});
|
||||
builder.Services.AddSingleton<IScoreHistoryStore, PostgresScoreHistoryStore>();
|
||||
builder.Services.AddSingleton<IEnvironmentSettingsStore, PostgresEnvironmentSettingsStore>();
|
||||
builder.Services.AddSingleton<IReleaseControlBundleStore, PostgresReleaseControlBundleStore>();
|
||||
@@ -318,7 +333,12 @@ var redisCs = builder.Configuration["ConnectionStrings:Redis"];
|
||||
if (!string.IsNullOrWhiteSpace(redisCs))
|
||||
{
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(
|
||||
sp => ConnectionMultiplexer.Connect(redisCs));
|
||||
sp =>
|
||||
{
|
||||
var redisOptions = ConfigurationOptions.Parse(redisCs);
|
||||
redisOptions.ClientName ??= "stellaops-platform";
|
||||
return ConnectionMultiplexer.Connect(redisOptions);
|
||||
});
|
||||
}
|
||||
|
||||
builder.Services.AddHostedService<EnvironmentSettingsRefreshService>();
|
||||
|
||||
@@ -14,6 +14,8 @@ namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed class IdentityProviderManagementService
|
||||
{
|
||||
public const string HttpClientName = "PlatformIdentityProviderTest";
|
||||
|
||||
private static readonly HashSet<string> ValidTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"standard", "ldap", "saml", "oidc"
|
||||
@@ -35,7 +37,7 @@ public sealed class IdentityProviderManagementService
|
||||
["standard"] = []
|
||||
};
|
||||
|
||||
private readonly IHttpClientFactory? _httpClientFactory;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<IdentityProviderManagementService> _logger;
|
||||
|
||||
// In-memory store keyed by (tenantId, id)
|
||||
@@ -44,10 +46,10 @@ public sealed class IdentityProviderManagementService
|
||||
|
||||
public IdentityProviderManagementService(
|
||||
ILogger<IdentityProviderManagementService> logger,
|
||||
IHttpClientFactory? httpClientFactory = null)
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IdentityProviderConfigDto>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
@@ -391,28 +393,20 @@ public sealed class IdentityProviderManagementService
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var httpClient = _httpClientFactory?.CreateClient("idp-test") ?? new HttpClient();
|
||||
try
|
||||
using var httpClient = _httpClientFactory.CreateClient(HttpClientName);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
var response = await httpClient.GetAsync(metadataUrl, cts.Token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
if (!content.Contains("EntityDescriptor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
var response = await httpClient.GetAsync(metadataUrl, cts.Token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
if (!content.Contains("EntityDescriptor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TestConnectionResult(false, "SAML metadata URL responded but content does not appear to be valid SAML metadata.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(true, $"SAML metadata fetched successfully from {metadataUrl}.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_httpClientFactory is null)
|
||||
httpClient.Dispose();
|
||||
return new TestConnectionResult(false, "SAML metadata URL responded but content does not appear to be valid SAML metadata.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(true, $"SAML metadata fetched successfully from {metadataUrl}.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
private async Task<TestConnectionResult> TestOidcConnectionAsync(
|
||||
@@ -423,29 +417,21 @@ public sealed class IdentityProviderManagementService
|
||||
var discoveryUrl = authority.TrimEnd('/') + "/.well-known/openid-configuration";
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var httpClient = _httpClientFactory?.CreateClient("idp-test") ?? new HttpClient();
|
||||
try
|
||||
using var httpClient = _httpClientFactory.CreateClient(HttpClientName);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
var response = await httpClient.GetAsync(discoveryUrl, cts.Token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Basic validation: should contain issuer field
|
||||
if (!content.Contains("issuer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
var response = await httpClient.GetAsync(discoveryUrl, cts.Token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Basic validation: should contain issuer field
|
||||
if (!content.Contains("issuer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TestConnectionResult(false, "OIDC discovery endpoint responded but content does not appear to be a valid OpenID configuration.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(true, $"OIDC discovery document fetched successfully from {discoveryUrl}.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_httpClientFactory is null)
|
||||
httpClient.Dispose();
|
||||
return new TestConnectionResult(false, "OIDC discovery endpoint responded but content does not appear to be a valid OpenID configuration.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(true, $"OIDC discovery document fetched successfully from {discoveryUrl}.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
private static IdentityProviderConfigDto MapToDto(IdentityProviderConfigEntry entry)
|
||||
|
||||
@@ -5,6 +5,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260405_011-XPORT | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named Platform runtime PostgreSQL data sources for score-history and analytics ingestion/query paths. |
|
||||
| SPRINT_20260405_011-XPORT-VALKEY | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named Platform Valkey client construction. |
|
||||
| SPRINT_20260405_011-XPORT-HTTP | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named Platform identity-provider HTTP test client wiring and removed raw fallback `HttpClient` allocation. |
|
||||
| SPRINT_20260222_051-MGC-12 | DONE | Added `/api/v1/admin/migrations/{modules,status,verify,run}` endpoints with `platform.setup.admin` authorization and server-side migration execution wired to the platform-owned registry in `StellaOps.Platform.Database`. |
|
||||
| SPRINT_20260222_051-MGC-12-SOURCES | DONE | Platform migration admin service now executes and verifies migrations across per-service plugin source sets, applies synthesized per-plugin consolidated migration on empty history with legacy history backfill, and auto-heals partial backfill states before per-source execution. |
|
||||
| SPRINT_20260221_043-PLATFORM-SEED-001 | DONE | Sprint `docs/implplan/SPRINT_20260221_043_DOCS_setup_seed_error_handling_stabilization.md`: fix seed endpoint authorization policy wiring and return structured non-500 error responses for expected failures. |
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class IdentityProviderManagementServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("saml", "idpMetadataUrl", "https://idp.example.com/metadata", "<EntityDescriptor xmlns=\"urn:oasis:names:tc:SAML:2.0:metadata\"></EntityDescriptor>", "https://idp.example.com/metadata")]
|
||||
[InlineData("oidc", "authority", "https://idp.example.com", "{\"issuer\":\"https://idp.example.com\"}", "https://idp.example.com/.well-known/openid-configuration")]
|
||||
public async Task TestConnectionAsync_UsesNamedHttpClientForMetadataFetch(
|
||||
string providerType,
|
||||
string configKey,
|
||||
string configValue,
|
||||
string responseBody,
|
||||
string expectedRequestUri)
|
||||
{
|
||||
var factory = Substitute.For<IHttpClientFactory>();
|
||||
var handler = new StubHttpMessageHandler(_ =>
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
|
||||
});
|
||||
factory.CreateClient(IdentityProviderManagementService.HttpClientName)
|
||||
.Returns(new HttpClient(handler));
|
||||
|
||||
var service = new IdentityProviderManagementService(
|
||||
NullLogger<IdentityProviderManagementService>.Instance,
|
||||
factory);
|
||||
|
||||
var result = await service.TestConnectionAsync(
|
||||
new TestConnectionRequest(
|
||||
providerType,
|
||||
new Dictionary<string, string?> { [configKey] = configValue }),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(expectedRequestUri, handler.LastRequestUri);
|
||||
factory.Received(1).CreateClient(IdentityProviderManagementService.HttpClientName);
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
{
|
||||
_responseFactory = responseFactory;
|
||||
}
|
||||
|
||||
public string? LastRequestUri { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequestUri = request.RequestUri?.ToString();
|
||||
return Task.FromResult(_responseFactory(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,3 +26,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| SPRINT_20260224_004-LOC-305-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: extended `LocalizationEndpointsTests` to verify common-layer and `platform.*` namespace bundle availability for all supported locales. |
|
||||
| SPRINT_20260224_004-LOC-307-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: extended localization and preference endpoint tests for Ukrainian rollout (`uk-UA` locale catalog/bundle assertions and alias normalization to canonical `uk-UA`). |
|
||||
| SPRINT_20260305_005-PLATFORM-BOUND-002 | DONE | Sprint `docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md`: added `PlatformRuntimeBoundaryGuardTests` to enforce approved read-model constructor contracts and disallow foreign persistence references outside explicit migration/seed allowlist files. |
|
||||
| SPRINT_20260405_011-XPORT-HTTP-T | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: added direct `IdentityProviderManagementService` HTTP-factory tests for OIDC/SAML connection probing. |
|
||||
|
||||
Reference in New Issue
Block a user