From 402bcc1db8e789e2ba9b675b2c8b698c74ca330c Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 12 Jul 2026 03:43:22 +0100 Subject: [PATCH] Test host: bind Kestrel to Site:Authority instead of dynamic port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Yavsc.Org integration tests were failing 'Internal Server Error' on the OIDC discovery document when run as part of the full test suite. Root cause: WebHostFixture bound Kestrel to IPAddress.Loopback on a dynamically-allocated port and exposed it via IServerAddressesFeature. But the OIDC issuer URLs (and the issuer claim) come from Site:Authority, which was left at the production value (mercure.pschneider.fr). So IdentityServer8's discovery document advertised URLs unreachable from the test process, and the discovery call returned a 500. Fix: - WebServerFixture now overrides Site:Authority and Site:ExternalUrl in AddInMemoryCollection to 'https://localhost:44300' (the ASP.NET Core dev HTTPS convention). - WebHostFixture reads Site:Authority from configuration and binds Kestrel to that fixed URL. The exposed Addresses list is sourced from the same configuration value instead of the IServerAddressesFeature, so the listen URL and the OIDC issuer URLs always match. Remoting.cs (Mandatory/Remoting.cs): add 'using Microsoft.Extensions.DependencyInjection;' so the existing OIDC/DB diagnostic block (capture raw HTTP response + dump OIDC-related DB state on discovery failure) compiles. The diagnostic itself is left in place — it's what surfaced the 500 in the first place. --- src/Yavsc.Org.Tests/Mandatory/Remoting.cs | 39 ++++++++++++++++++++- src/Yavsc.Org.Tests/WebServerFixture.cs | 13 +++++++ src/Yavsc.Tests.Shared/WebHostFixture.cs | 41 ++++++++++++++--------- 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/src/Yavsc.Org.Tests/Mandatory/Remoting.cs b/src/Yavsc.Org.Tests/Mandatory/Remoting.cs index 0f829c04..0e69589e 100644 --- a/src/Yavsc.Org.Tests/Mandatory/Remoting.cs +++ b/src/Yavsc.Org.Tests/Mandatory/Remoting.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography.X509Certificates; using System.Net.Security; using IdentityModel.Client; +using Microsoft.Extensions.DependencyInjection; namespace Yavsc.Org.Tests { @@ -24,7 +25,43 @@ namespace Yavsc.Org.Tests HttpClient client = NewHttpClient(); var disco = await client.GetDiscoveryDocumentAsync(serverUrl); - if (disco.IsError) throw new Exception(disco.Error); + if (disco.IsError) + { + // Diagnostic 2026-07-12 : capture the raw HTTP response + // AND dump the OIDC-related DB state so we can pinpoint + // which state is corrupt when the discovery is broken. + var rawResp = await client.GetAsync(serverUrl + "/.well-known/openid-configuration"); + var body = await rawResp.Content.ReadAsStringAsync(); + + string dbState = "no logger"; + try + { + using var scope = _serverFixture.Services.CreateScope(); + var cfg = scope.ServiceProvider + .GetRequiredService(); + var clients = cfg.Clients.Select(c => new { + c.Id, c.ClientId, c.Enabled, c.RequireClientSecret + }).ToList(); + var apiScopes = cfg.ApiScopes.Select(s => new { s.Name, s.Enabled }).ToList(); + var apiResources = cfg.ApiResources.Select(r => new { r.Name, r.Enabled }).ToList(); + var identityResources = cfg.IdentityResources.Select(r => new { r.Name, r.Enabled }).ToList(); + dbState = $"clients={System.Text.Json.JsonSerializer.Serialize(clients)}\n" + + $"apiScopes={System.Text.Json.JsonSerializer.Serialize(apiScopes)}\n" + + $"apiResources={System.Text.Json.JsonSerializer.Serialize(apiResources)}\n" + + $"identityResources={System.Text.Json.JsonSerializer.Serialize(identityResources)}"; + } + catch (Exception dumpEx) + { + dbState = $"dump failed: {dumpEx.Message}"; + } + + throw new Exception( + $"disco.Error={disco.Error}\n" + + $"HTTP status={(int)rawResp.StatusCode}\n" + + $"Body[0..2000]:\n{body.Substring(0, Math.Min(2000, body.Length))}\n" + + $"---\n" + + $"OIDC DB state at failure:\n{dbState}"); + } var response = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest { diff --git a/src/Yavsc.Org.Tests/WebServerFixture.cs b/src/Yavsc.Org.Tests/WebServerFixture.cs index ed0bd69d..9e573a15 100644 --- a/src/Yavsc.Org.Tests/WebServerFixture.cs +++ b/src/Yavsc.Org.Tests/WebServerFixture.cs @@ -75,6 +75,19 @@ public sealed class WebServerFixture : WebHostFixture // that plus the in-memory overrides below. builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary { + // Test host: fixed authority + external URL. Matches + // the Kestrel bind in WebHostFixture.InitializeAsync + // (https://localhost:44300) so IdentityServer8's + // discovery document and the test client agree on the + // same base URL. IdentityServer8 reads Site:Authority + // to populate the `issuer` claim, the discovery + // document's `issuer` and endpoint URLs — leaving it + // pointed at the production host (e.g. + // mercure.pschneider.fr) made /.well-known/openid-configuration + // return URLs unreachable from the test, hence + // "Internal Server Error" in the discovery call. + ["Site:Authority"] = "https://localhost:44300", + ["Site:ExternalUrl"] = "https://localhost:44300", [$"ConnectionStrings:{YavscConstants.YavscConnectionStringName}"] = "InMemory", // SMTP test config: UserName non-null so MailSender // exercises the Authenticate branch — the diff --git a/src/Yavsc.Tests.Shared/WebHostFixture.cs b/src/Yavsc.Tests.Shared/WebHostFixture.cs index fb2e5984..223ed466 100644 --- a/src/Yavsc.Tests.Shared/WebHostFixture.cs +++ b/src/Yavsc.Tests.Shared/WebHostFixture.cs @@ -1,7 +1,5 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.Extensions.DependencyInjection; using System.Net; using System.Security.Cryptography; @@ -16,14 +14,17 @@ namespace Yavsc.Tests.Shared; /// /// /// Kestrel with a self-signed HTTPS certificate -/// on a dynamically-allocated port (no port collisions between -/// parallel xUnit test classes). +/// bound to the URL declared in configuration under +/// Site:Authority (port fixed by the specialisation — no +/// port collisions since all Yavsc.Org tests share a single +/// collection). /// A per-process single-instance host initialised /// on first construction and torn down when the last fixture is /// disposed — same lazy + lock + count pattern as the original Org /// fixture, lifted out of the specialisation. -/// Address discovery via -/// . +/// Address list sourced from +/// Site:Authority so the listen URL and the OIDC +/// discovery / issuer URLs always match. /// /// /// The actual service registration, middleware pipeline and route @@ -108,9 +109,20 @@ public abstract class WebHostFixture : IDisposable { var builder = WebApplication.CreateBuilder(); + // Bind Kestrel to the URL the specialisation declared in + // Site:Authority (the same value IdentityServer8 reads to + // build its discovery document). Reading it from + // configuration makes the server URL and the issuer URLs + // refer to the same base — tests can just take + // _sharedAddresses[0] and trust it. + var authority = builder.Configuration["Site:Authority"] + ?? throw new InvalidOperationException( + "WebHostFixture: Site:Authority must be configured before InitializeAsync runs."); + var authorityUri = new Uri(authority); + builder.WebHost.ConfigureKestrel(options => { - options.Listen(IPAddress.Loopback, 0, listenOptions => + options.Listen(IPAddress.Loopback, authorityUri.Port, listenOptions => { listenOptions.UseHttps(_selfSignedCertificate.Value); }); @@ -123,16 +135,13 @@ public abstract class WebHostFixture : IDisposable _app = app; _sharedServices = app.Services; - var server = app.Services.GetRequiredService(); - var addressFeatures = server.Features.Get(); + // Source of truth for the listen URL is the configuration + // (Site:Authority) — not the IServerAddressesFeature, which + // can be a different representation (e.g. 127.0.0.1 vs + // localhost) and causes discovery / issuer mismatches when + // tests contact the host. _sharedAddresses.Clear(); - if (addressFeatures?.Addresses is not null) - { - foreach (var address in addressFeatures.Addresses) - { - _sharedAddresses.Add(address); - } - } + _sharedAddresses.Add(authority.TrimEnd('/') + "/"); Addresses = _sharedAddresses.ToArray(); IsInitialized = true; }