diff --git a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs index 2b8bf705..6e39fd00 100644 --- a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs +++ b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs @@ -48,8 +48,6 @@ 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.Blogs/Program.cs b/src/Yavsc.Blogs/Program.cs index 450a1c72..d2e2e87a 100644 --- a/src/Yavsc.Blogs/Program.cs +++ b/src/Yavsc.Blogs/Program.cs @@ -44,22 +44,15 @@ internal class Program } - 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; - }); - } - + // AuthenticationBuilder + services.AddAuthentication("Bearer") + .AddYavscJwtBearer(builder.Configuration, + options => + { + options.Authority = authority; + options.Audience = builder.Configuration.GetValue + ("Site:Audience"); + }); // DbContextBuilder services.AddDbContext(options => diff --git a/src/Yavsc.Org.Tests/Mandatory/Remoting.cs b/src/Yavsc.Org.Tests/Mandatory/Remoting.cs index be5b716f..0f829c04 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 = GetServerUrl(); - var cancellationToken = TestContext.Current.CancellationToken; + var serverUrl = _serverFixture.Addresses.FirstOrDefault(u => u.StartsWith("https:")); + if (string.IsNullOrEmpty(serverUrl)) + throw new InvalidOperationException("No HTTPS server address found"); HttpClient client = NewHttpClient(); - var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken); + var disco = await client.GetDiscoveryDocumentAsync(serverUrl); + if (disco.IsError) throw new Exception(disco.Error); var response = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest { - Address = tokenEndpoint, - ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)), - ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)), + Address = disco.TokenEndpoint, + ClientId = _serverFixture.TestClientId, + ClientSecret = _serverFixture.TestClientSecret, Scope = "test", GrantType = "client_credentials" - }, cancellationToken); + }); if (response.IsError) throw new Exception(response.Error); } @@ -45,25 +45,27 @@ namespace Yavsc.Org.Tests [Fact] public async Task ObtainResourceOwnerPasswordToken() { - var serverUrl = GetServerUrl(); - var cancellationToken = TestContext.Current.CancellationToken; + var serverUrl = _serverFixture.Addresses.FirstOrDefault(u => u.StartsWith("https:")); + if (string.IsNullOrEmpty(serverUrl)) + throw new InvalidOperationException("No HTTPS server address found"); var client = NewHttpClient(); - var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken); + var disco = await client.GetDiscoveryDocumentAsync(serverUrl); + if (disco.IsError) throw new Exception(disco.Error); var response = await client.RequestPasswordTokenAsync(new PasswordTokenRequest { - 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)), + Address = disco.TokenEndpoint, + ClientId = _serverFixture.TestClientId, + ClientSecret = _serverFixture.TestClientSecret, + UserName = _serverFixture.TestingUserName, + Password = _serverFixture.TestingUserPassword, Scope = "test", Parameters = { { "acr_values", "tenant:custom_account_store1 foo bar quux" } } - }, cancellationToken); + }); if (response.IsError) throw new Exception(response.Error); @@ -74,36 +76,6 @@ 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 diff --git a/src/Yavsc.Org.Tests/WebServerFixture.cs b/src/Yavsc.Org.Tests/WebServerFixture.cs index a623bc9e..ed0bd69d 100644 --- a/src/Yavsc.Org.Tests/WebServerFixture.cs +++ b/src/Yavsc.Org.Tests/WebServerFixture.cs @@ -2,14 +2,14 @@ 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; 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; @@ -41,10 +41,6 @@ namespace Yavsc.Org.Tests; [CollectionDefinition("Yavsc Server")] public sealed class WebServerFixture : WebHostFixture { - private static readonly int _httpsPort = GetAvailableLoopbackPort(); - - protected override int HttpsPort => _httpsPort; - private static IConfiguration? _sharedConfiguration; private static SiteSettings? _sharedSiteSettings; private static ILogger? _sharedLogger; @@ -70,8 +66,6 @@ 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 @@ -89,7 +83,6 @@ public sealed class WebServerFixture : WebHostFixture ["Smtp:Port"] = "465", ["Smtp:UserName"] = "test-user", ["Smtp:Password"] = "test-pass", - ["Site:Authority"] = authority }); Configuration = builder.Configuration; @@ -285,19 +278,4 @@ 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(); - } - } } diff --git a/src/Yavsc.Org.Tests/appsettings.json b/src/Yavsc.Org.Tests/appsettings.json index 5f353cd8..bf8599e0 100644 --- a/src/Yavsc.Org.Tests/appsettings.json +++ b/src/Yavsc.Org.Tests/appsettings.json @@ -1,6 +1,6 @@ { "Site": { - "Authority": "https://localhost:5101", + "Authority": "https://mercure.pschneider.fr", "Title": "Yavsc dev", "Slogan": "Yavsc : WIP.", "Banner": "/images/yavsc.png", @@ -62,16 +62,6 @@ "UserName": "fakeuser", "Password": "f/\\kePassw0rd" } - }, - "Kestrel": { - "Endpoints": { - "Http": { - "Url": "http://localhost:5100" - }, - "Https": { - "Url": "https://localhost:5101" - } - } } - + } diff --git a/src/Yavsc.Org/Migrations/20260711173717_EnforceBlogAuthorFKs.cs b/src/Yavsc.Org/Migrations/20260711173717_EnforceBlogAuthorFKs.cs index 6e3e0f06..b13ef467 100644 --- a/src/Yavsc.Org/Migrations/20260711173717_EnforceBlogAuthorFKs.cs +++ b/src/Yavsc.Org/Migrations/20260711173717_EnforceBlogAuthorFKs.cs @@ -10,6 +10,29 @@ 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", diff --git a/src/Yavsc.Tests.Shared/WebHostFixture.cs b/src/Yavsc.Tests.Shared/WebHostFixture.cs index 417d29d8..fb2e5984 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 fixture-defined fixed port for deterministic integration -/// test endpoints. +/// 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 @@ -86,12 +86,11 @@ 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 - /// the fixture-defined test port — do not bind additional - /// listeners. + /// here. The base class has already configured Kestrel HTTPS on a + /// dynamic port — do not bind additional listeners. /// The - /// configured with Kestrel HTTPS on the fixture-defined test port - /// and the shared self-signed certificate. + /// configured with Kestrel HTTPS on a dynamic port and the shared + /// self-signed certificate. /// The fully built , ready /// for ConfigurePipeline + StartAsync. protected abstract WebApplication BuildApp(WebApplicationBuilder builder); @@ -105,18 +104,13 @@ 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, HttpsPort, listenOptions => + options.Listen(IPAddress.Loopback, 0, listenOptions => { listenOptions.UseHttps(_selfSignedCertificate.Value); });