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