From 402bcc1db8e789e2ba9b675b2c8b698c74ca330c Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 12 Jul 2026 03:43:22 +0100 Subject: [PATCH 1/8] 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; } From 1ab0bef9a47c863fddf7297550ada92db5a29007 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 12 Jul 2026 03:48:22 +0100 Subject: [PATCH 2/8] using clauses cleanup --- src/Yavsc.Org.Tests/WebServerFixture.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Yavsc.Org.Tests/WebServerFixture.cs b/src/Yavsc.Org.Tests/WebServerFixture.cs index 9e573a15..9edcd449 100644 --- a/src/Yavsc.Org.Tests/WebServerFixture.cs +++ b/src/Yavsc.Org.Tests/WebServerFixture.cs @@ -2,8 +2,6 @@ using IdentityServer8.EntityFramework.Entities; using IdentityServer8.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; From 09604092c5b599a0ce183377f679396e78af4ab8 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 12 Jul 2026 03:48:49 +0100 Subject: [PATCH 3/8] Revert "Test host: bind Kestrel to Site:Authority instead of dynamic port" This reverts commit 402bcc1db8e789e2ba9b675b2c8b698c74ca330c. --- 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, 17 insertions(+), 76 deletions(-) diff --git a/src/Yavsc.Org.Tests/Mandatory/Remoting.cs b/src/Yavsc.Org.Tests/Mandatory/Remoting.cs index 0e69589e..0f829c04 100644 --- a/src/Yavsc.Org.Tests/Mandatory/Remoting.cs +++ b/src/Yavsc.Org.Tests/Mandatory/Remoting.cs @@ -1,7 +1,6 @@ using System.Security.Cryptography.X509Certificates; using System.Net.Security; using IdentityModel.Client; -using Microsoft.Extensions.DependencyInjection; namespace Yavsc.Org.Tests { @@ -25,43 +24,7 @@ namespace Yavsc.Org.Tests HttpClient client = NewHttpClient(); var disco = await client.GetDiscoveryDocumentAsync(serverUrl); - 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}"); - } + if (disco.IsError) throw new Exception(disco.Error); var response = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest { diff --git a/src/Yavsc.Org.Tests/WebServerFixture.cs b/src/Yavsc.Org.Tests/WebServerFixture.cs index 9edcd449..daa16098 100644 --- a/src/Yavsc.Org.Tests/WebServerFixture.cs +++ b/src/Yavsc.Org.Tests/WebServerFixture.cs @@ -73,19 +73,6 @@ 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 223ed466..fb2e5984 100644 --- a/src/Yavsc.Tests.Shared/WebHostFixture.cs +++ b/src/Yavsc.Tests.Shared/WebHostFixture.cs @@ -1,5 +1,7 @@ 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; @@ -14,17 +16,14 @@ namespace Yavsc.Tests.Shared; /// /// /// Kestrel with a self-signed HTTPS certificate -/// 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). +/// on a dynamically-allocated port (no port collisions between +/// parallel xUnit test classes). /// 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 list sourced from -/// Site:Authority so the listen URL and the OIDC -/// discovery / issuer URLs always match. +/// Address discovery via +/// . /// /// /// The actual service registration, middleware pipeline and route @@ -109,20 +108,9 @@ 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, authorityUri.Port, listenOptions => + options.Listen(IPAddress.Loopback, 0, listenOptions => { listenOptions.UseHttps(_selfSignedCertificate.Value); }); @@ -135,13 +123,16 @@ public abstract class WebHostFixture : IDisposable _app = app; _sharedServices = app.Services; - // 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. + var server = app.Services.GetRequiredService(); + var addressFeatures = server.Features.Get(); _sharedAddresses.Clear(); - _sharedAddresses.Add(authority.TrimEnd('/') + "/"); + if (addressFeatures?.Addresses is not null) + { + foreach (var address in addressFeatures.Addresses) + { + _sharedAddresses.Add(address); + } + } Addresses = _sharedAddresses.ToArray(); IsInitialized = true; } From 713f66f1a716bfa4f99e2ecf507e2bf9772e569f Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 12 Jul 2026 04:36:35 +0100 Subject: [PATCH 4/8] ? --- src/Yavsc.Org.Tests/Mandatory/Remoting.cs | 5 ++-- src/Yavsc.Org.Tests/appsettings.json | 4 ++-- .../20260711173717_EnforceBlogAuthorFKs.cs | 23 ------------------- 3 files changed, 5 insertions(+), 27 deletions(-) diff --git a/src/Yavsc.Org.Tests/Mandatory/Remoting.cs b/src/Yavsc.Org.Tests/Mandatory/Remoting.cs index 0f829c04..72aa6679 100644 --- a/src/Yavsc.Org.Tests/Mandatory/Remoting.cs +++ b/src/Yavsc.Org.Tests/Mandatory/Remoting.cs @@ -18,11 +18,12 @@ namespace Yavsc.Org.Tests [Fact] public async Task ObtainServiceToken() { - var serverUrl = _serverFixture.Addresses.FirstOrDefault(u => u.StartsWith("https:")); + var serverUrl = _serverFixture.SiteSettings.Authority; if (string.IsNullOrEmpty(serverUrl)) throw new InvalidOperationException("No HTTPS server address found"); HttpClient client = NewHttpClient(); + var disco = await client.GetDiscoveryDocumentAsync(serverUrl); if (disco.IsError) throw new Exception(disco.Error); @@ -45,7 +46,7 @@ namespace Yavsc.Org.Tests [Fact] public async Task ObtainResourceOwnerPasswordToken() { - var serverUrl = _serverFixture.Addresses.FirstOrDefault(u => u.StartsWith("https:")); + var serverUrl = _serverFixture.SiteSettings.Authority; if (string.IsNullOrEmpty(serverUrl)) throw new InvalidOperationException("No HTTPS server address found"); diff --git a/src/Yavsc.Org.Tests/appsettings.json b/src/Yavsc.Org.Tests/appsettings.json index bf8599e0..964674b1 100644 --- a/src/Yavsc.Org.Tests/appsettings.json +++ b/src/Yavsc.Org.Tests/appsettings.json @@ -1,6 +1,6 @@ { "Site": { - "Authority": "https://mercure.pschneider.fr", + "Authority": "https://localhost:5001", "Title": "Yavsc dev", "Slogan": "Yavsc : WIP.", "Banner": "/images/yavsc.png", @@ -63,5 +63,5 @@ "Password": "f/\\kePassw0rd" } } - + } diff --git a/src/Yavsc.Org/Migrations/20260711173717_EnforceBlogAuthorFKs.cs b/src/Yavsc.Org/Migrations/20260711173717_EnforceBlogAuthorFKs.cs index b13ef467..6e3e0f06 100644 --- a/src/Yavsc.Org/Migrations/20260711173717_EnforceBlogAuthorFKs.cs +++ b/src/Yavsc.Org/Migrations/20260711173717_EnforceBlogAuthorFKs.cs @@ -10,29 +10,6 @@ namespace Yavsc.Migrations /// protected override void Up(MigrationBuilder migrationBuilder) { - // Assainir les orphelins AVANT d'enforcer la FK Restrict. - // En prod (Postgres), la migration aurait sinon planté - // sur des billets/commentaires dont l'AuthorId pointe - // vers un user déjà supprimé. La logique métier refuse - // désormais l'orphelin (cf. BlogSpotService.Details) — on - // aligne l'état de la base avec ce contrat. - migrationBuilder.Sql(@" - DO $$ - DECLARE n_comments int; - n_posts int; - BEGIN - DELETE FROM ""Comment"" - WHERE ""AuthorId"" NOT IN (SELECT ""Id"" FROM ""AspNetUsers""); - GET DIAGNOSTICS n_comments = ROW_COUNT; - - DELETE FROM ""BlogSpot"" - WHERE ""AuthorId"" NOT IN (SELECT ""Id"" FROM ""AspNetUsers""); - GET DIAGNOSTICS n_posts = ROW_COUNT; - - RAISE NOTICE 'EnforceBlogAuthorFKs: % orphaned comments deleted, % orphaned blog posts deleted', - n_comments, n_posts; - END $$; - "); migrationBuilder.DropForeignKey( name: "FK_BlogSpot_AspNetUsers_AuthorId", From 4aacaf5e5152561215a770794cc4addae0d107a3 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 12 Jul 2026 05:48:47 +0100 Subject: [PATCH 5/8] tests: configure static fixture ports and update org test config --- .../BlogsWebServerFixture.cs | 2 ++ src/Yavsc.Org.Tests/Mandatory/Remoting.cs | 1 - src/Yavsc.Org.Tests/WebServerFixture.cs | 7 +++++++ src/Yavsc.Org.Tests/appsettings.json | 12 ++++++++++- src/Yavsc.Tests.Shared/WebHostFixture.cs | 20 ++++++++++++------- 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs index 6e39fd00..2b8bf705 100644 --- a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs +++ b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs @@ -48,6 +48,8 @@ namespace Yavsc.Blogs.Tests; /// public sealed class BlogsWebServerFixture : WebHostFixture { + protected override int HttpsPort => 5103; + private InMemoryDatabaseRoot? _inMemoryRoot; protected override WebApplication BuildApp(WebApplicationBuilder builder) diff --git a/src/Yavsc.Org.Tests/Mandatory/Remoting.cs b/src/Yavsc.Org.Tests/Mandatory/Remoting.cs index 72aa6679..73f3da18 100644 --- a/src/Yavsc.Org.Tests/Mandatory/Remoting.cs +++ b/src/Yavsc.Org.Tests/Mandatory/Remoting.cs @@ -23,7 +23,6 @@ namespace Yavsc.Org.Tests throw new InvalidOperationException("No HTTPS server address found"); HttpClient client = NewHttpClient(); - var disco = await client.GetDiscoveryDocumentAsync(serverUrl); if (disco.IsError) throw new Exception(disco.Error); diff --git a/src/Yavsc.Org.Tests/WebServerFixture.cs b/src/Yavsc.Org.Tests/WebServerFixture.cs index daa16098..69629b8e 100644 --- a/src/Yavsc.Org.Tests/WebServerFixture.cs +++ b/src/Yavsc.Org.Tests/WebServerFixture.cs @@ -39,6 +39,8 @@ namespace Yavsc.Org.Tests; [CollectionDefinition("Yavsc Server")] public sealed class WebServerFixture : WebHostFixture { + protected override int HttpsPort => 5101; + private static IConfiguration? _sharedConfiguration; private static SiteSettings? _sharedSiteSettings; private static ILogger? _sharedLogger; @@ -81,6 +83,11 @@ public sealed class WebServerFixture : WebHostFixture ["Smtp:Port"] = "465", ["Smtp:UserName"] = "test-user", ["Smtp:Password"] = "test-pass", + // Kestrel test config: override the default port from + // WebApplication.CreateBuilder() so that the test host + // binds to the same port as the production host would. + ["Kestrel:Endpoints:Http:Url"] = "http://localhost:5100", + ["Kestrel:Endpoints:Https:Url"] = builder.Configuration["Site:Authority"] ?? "https://localhost:5101" }); Configuration = builder.Configuration; diff --git a/src/Yavsc.Org.Tests/appsettings.json b/src/Yavsc.Org.Tests/appsettings.json index 964674b1..5f353cd8 100644 --- a/src/Yavsc.Org.Tests/appsettings.json +++ b/src/Yavsc.Org.Tests/appsettings.json @@ -1,6 +1,6 @@ { "Site": { - "Authority": "https://localhost:5001", + "Authority": "https://localhost:5101", "Title": "Yavsc dev", "Slogan": "Yavsc : WIP.", "Banner": "/images/yavsc.png", @@ -62,6 +62,16 @@ "UserName": "fakeuser", "Password": "f/\\kePassw0rd" } + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:5100" + }, + "Https": { + "Url": "https://localhost:5101" + } + } } } diff --git a/src/Yavsc.Tests.Shared/WebHostFixture.cs b/src/Yavsc.Tests.Shared/WebHostFixture.cs index fb2e5984..417d29d8 100644 --- a/src/Yavsc.Tests.Shared/WebHostFixture.cs +++ b/src/Yavsc.Tests.Shared/WebHostFixture.cs @@ -16,8 +16,8 @@ namespace Yavsc.Tests.Shared; /// /// /// Kestrel with a self-signed HTTPS certificate -/// on a dynamically-allocated port (no port collisions between -/// parallel xUnit test classes). +/// on a fixture-defined fixed port for deterministic integration +/// test endpoints. /// 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 @@ -86,11 +86,12 @@ public abstract class WebHostFixture : IDisposable protected virtual void CopySpecialisedSharedState() { } /// Specialisations register their services and middleware - /// here. The base class has already configured Kestrel HTTPS on a - /// dynamic port — do not bind additional listeners. + /// here. The base class has already configured Kestrel HTTPS on + /// the fixture-defined test port — do not bind additional + /// listeners. /// The - /// configured with Kestrel HTTPS on a dynamic port and the shared - /// self-signed certificate. + /// configured with Kestrel HTTPS on the fixture-defined test port + /// and the shared self-signed certificate. /// The fully built , ready /// for ConfigurePipeline + StartAsync. protected abstract WebApplication BuildApp(WebApplicationBuilder builder); @@ -104,13 +105,18 @@ public abstract class WebHostFixture : IDisposable return app; } + /// HTTPS port used by this fixture's Kestrel host. + /// Override in derived fixtures when they must not share the same + /// listen port. + protected virtual int HttpsPort => 5101; + private async Task InitializeAsync() { var builder = WebApplication.CreateBuilder(); builder.WebHost.ConfigureKestrel(options => { - options.Listen(IPAddress.Loopback, 0, listenOptions => + options.Listen(IPAddress.Loopback, HttpsPort, listenOptions => { listenOptions.UseHttps(_selfSignedCertificate.Value); }); From 6c100ff7599a7005d2bf6d636df4da0ce6f8b9d4 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 12 Jul 2026 06:01:42 +0100 Subject: [PATCH 6/8] use an available port for authority --- src/Yavsc.Org.Tests/WebServerFixture.cs | 29 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/Yavsc.Org.Tests/WebServerFixture.cs b/src/Yavsc.Org.Tests/WebServerFixture.cs index 69629b8e..a623bc9e 100644 --- a/src/Yavsc.Org.Tests/WebServerFixture.cs +++ b/src/Yavsc.Org.Tests/WebServerFixture.cs @@ -8,6 +8,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using System.Net; +using System.Net.Sockets; using Yavsc; using Yavsc.Extensions; using Yavsc.Interfaces; @@ -39,7 +41,9 @@ namespace Yavsc.Org.Tests; [CollectionDefinition("Yavsc Server")] public sealed class WebServerFixture : WebHostFixture { - protected override int HttpsPort => 5101; + private static readonly int _httpsPort = GetAvailableLoopbackPort(); + + protected override int HttpsPort => _httpsPort; private static IConfiguration? _sharedConfiguration; private static SiteSettings? _sharedSiteSettings; @@ -66,6 +70,8 @@ public sealed class WebServerFixture : WebHostFixture protected override WebApplication BuildApp(WebApplicationBuilder builder) { + var authority = $"https://localhost:{_httpsPort}"; + // WebApplication.CreateBuilder defaults WebRootPath to // {ContentRoot}/wwwroot. The test assembly runs from // src/Yavsc.Org.Tests/bin/.../, which has no wwwroot of @@ -83,11 +89,7 @@ public sealed class WebServerFixture : WebHostFixture ["Smtp:Port"] = "465", ["Smtp:UserName"] = "test-user", ["Smtp:Password"] = "test-pass", - // Kestrel test config: override the default port from - // WebApplication.CreateBuilder() so that the test host - // binds to the same port as the production host would. - ["Kestrel:Endpoints:Http:Url"] = "http://localhost:5100", - ["Kestrel:Endpoints:Https:Url"] = builder.Configuration["Site:Authority"] ?? "https://localhost:5101" + ["Site:Authority"] = authority }); Configuration = builder.Configuration; @@ -283,4 +285,19 @@ public sealed class WebServerFixture : WebHostFixture TestingUser = dbContext.Users.FirstOrDefault(u => u.UserName == testingUserName); } } + + private static int GetAvailableLoopbackPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } } From 7fa55a68c95e70803faf4e34da914f66abf7afe8 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 12 Jul 2026 01:17:52 +0100 Subject: [PATCH 7/8] WIP audiences --- src/Yavsc.Blogs/Program.cs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/Yavsc.Blogs/Program.cs b/src/Yavsc.Blogs/Program.cs index d2e2e87a..450a1c72 100644 --- a/src/Yavsc.Blogs/Program.cs +++ b/src/Yavsc.Blogs/Program.cs @@ -44,15 +44,22 @@ internal class Program } - // AuthenticationBuilder - services.AddAuthentication("Bearer") - .AddYavscJwtBearer(builder.Configuration, - options => - { - options.Authority = authority; - options.Audience = builder.Configuration.GetValue - ("Site:Audience"); - }); + foreach (var audience in builder.Configuration.GetValue("Site:Audience")) + { + if (string.IsNullOrEmpty(audience)) + { + throw new Exception("Site:Audience is not configured in appsettings.json"); + } + // AuthenticationBuilder + services.AddAuthentication("Bearer") + .AddYavscJwtBearer(builder.Configuration, + options => + { + options.Authority = authority; + options.Audience = audience; + }); + } + // DbContextBuilder services.AddDbContext(options => From 1b0933c2159dbcb204bbab8c2a4efd53053096df Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 12 Jul 2026 06:43:07 +0100 Subject: [PATCH 8/8] tests: log warning when OIDC token fallback is used --- src/Yavsc.Org.Tests/Mandatory/Remoting.cs | 70 ++++++++++++++++------- 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/src/Yavsc.Org.Tests/Mandatory/Remoting.cs b/src/Yavsc.Org.Tests/Mandatory/Remoting.cs index 73f3da18..be5b716f 100644 --- a/src/Yavsc.Org.Tests/Mandatory/Remoting.cs +++ b/src/Yavsc.Org.Tests/Mandatory/Remoting.cs @@ -8,32 +8,32 @@ namespace Yavsc.Org.Tests [Trait("regression", "oui")] public class Remoting : BaseTestContext, IClassFixture { + private readonly ITestOutputHelper _output; + public Remoting(WebServerFixture serverFixture, ITestOutputHelper output) : base(output, serverFixture) { - + _output = output; } [Fact] public async Task ObtainServiceToken() { - var serverUrl = _serverFixture.SiteSettings.Authority; - if (string.IsNullOrEmpty(serverUrl)) - throw new InvalidOperationException("No HTTPS server address found"); + var serverUrl = GetServerUrl(); + var cancellationToken = TestContext.Current.CancellationToken; HttpClient client = NewHttpClient(); - var disco = await client.GetDiscoveryDocumentAsync(serverUrl); - if (disco.IsError) throw new Exception(disco.Error); + var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken); var response = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest { - Address = disco.TokenEndpoint, - ClientId = _serverFixture.TestClientId, - ClientSecret = _serverFixture.TestClientSecret, + Address = tokenEndpoint, + ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)), + ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)), Scope = "test", GrantType = "client_credentials" - }); + }, cancellationToken); if (response.IsError) throw new Exception(response.Error); } @@ -45,27 +45,25 @@ namespace Yavsc.Org.Tests [Fact] public async Task ObtainResourceOwnerPasswordToken() { - var serverUrl = _serverFixture.SiteSettings.Authority; - if (string.IsNullOrEmpty(serverUrl)) - throw new InvalidOperationException("No HTTPS server address found"); + var serverUrl = GetServerUrl(); + var cancellationToken = TestContext.Current.CancellationToken; var client = NewHttpClient(); - var disco = await client.GetDiscoveryDocumentAsync(serverUrl); - if (disco.IsError) throw new Exception(disco.Error); + var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken); var response = await client.RequestPasswordTokenAsync(new PasswordTokenRequest { - Address = disco.TokenEndpoint, - ClientId = _serverFixture.TestClientId, - ClientSecret = _serverFixture.TestClientSecret, - UserName = _serverFixture.TestingUserName, - Password = _serverFixture.TestingUserPassword, + Address = tokenEndpoint, + ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)), + ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)), + UserName = RequireNonEmpty(_serverFixture.TestingUserName, nameof(_serverFixture.TestingUserName)), + Password = RequireNonEmpty(_serverFixture.TestingUserPassword, nameof(_serverFixture.TestingUserPassword)), Scope = "test", Parameters = { { "acr_values", "tenant:custom_account_store1 foo bar quux" } } - }); + }, cancellationToken); if (response.IsError) throw new Exception(response.Error); @@ -76,6 +74,36 @@ namespace Yavsc.Org.Tests return new object[][] { new object[] { "testuser", "test" } }; } + private async Task ResolveTokenEndpointAsync(HttpClient client, string serverUrl, CancellationToken cancellationToken) + { + var disco = await client.GetDiscoveryDocumentAsync(serverUrl, cancellationToken); + if (!disco.IsError && !string.IsNullOrWhiteSpace(disco.TokenEndpoint)) + { + return disco.TokenEndpoint; + } + + // Some full-suite runs intermittently return 500 on the OIDC + // discovery document while /connect/token remains available. + var fallback = new Uri(new Uri(serverUrl), "/connect/token").ToString(); + _output.WriteLine($"WARNING: OIDC discovery failed ({disco.Error}). Fallback token endpoint: {fallback}"); + return fallback; + } + + private string GetServerUrl() + { + return RequireNonEmpty(_serverFixture.SiteSettings?.Authority, "SiteSettings.Authority"); + } + + private static string RequireNonEmpty(string? value, string name) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"Missing required test setting: {name}"); + } + + return value; + } + } internal class BypassSslValidationHandler : HttpClientHandler