Harden runtime HTTP transport lifecycles

This commit is contained in:
master
2026-04-05 23:52:14 +03:00
parent 1151c30e3a
commit 751546084e
44 changed files with 1173 additions and 136 deletions

View File

@@ -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>();

View File

@@ -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)

View File

@@ -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. |

View File

@@ -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));
}
}
}

View File

@@ -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. |