Compare commits

..

No commits in common. "1b0933c2159dbcb204bbab8c2a4efd53053096df" and "f3bb039d2f57cd280db8ed4d6de45d511f1ba37e" have entirely different histories.

7 changed files with 64 additions and 116 deletions

View file

@ -48,8 +48,6 @@ namespace Yavsc.Blogs.Tests;
/// </summary> /// </summary>
public sealed class BlogsWebServerFixture : WebHostFixture public sealed class BlogsWebServerFixture : WebHostFixture
{ {
protected override int HttpsPort => 5103;
private InMemoryDatabaseRoot? _inMemoryRoot; private InMemoryDatabaseRoot? _inMemoryRoot;
protected override WebApplication BuildApp(WebApplicationBuilder builder) protected override WebApplication BuildApp(WebApplicationBuilder builder)

View file

@ -44,22 +44,15 @@ internal class Program
} }
foreach (var audience in builder.Configuration.GetValue<string[]>("Site:Audience")) // AuthenticationBuilder
{ services.AddAuthentication("Bearer")
if (string.IsNullOrEmpty(audience)) .AddYavscJwtBearer(builder.Configuration,
{ options =>
throw new Exception("Site:Audience is not configured in appsettings.json"); {
} options.Authority = authority;
// AuthenticationBuilder options.Audience = builder.Configuration.GetValue<string>
services.AddAuthentication("Bearer") ("Site:Audience");
.AddYavscJwtBearer(builder.Configuration, });
options =>
{
options.Authority = authority;
options.Audience = audience;
});
}
// DbContextBuilder // DbContextBuilder
services.AddDbContext<ApplicationDbContext>(options => services.AddDbContext<ApplicationDbContext>(options =>

View file

@ -8,32 +8,32 @@ namespace Yavsc.Org.Tests
[Trait("regression", "oui")] [Trait("regression", "oui")]
public class Remoting : BaseTestContext, IClassFixture<WebServerFixture> public class Remoting : BaseTestContext, IClassFixture<WebServerFixture>
{ {
private readonly ITestOutputHelper _output;
public Remoting(WebServerFixture serverFixture, ITestOutputHelper output) public Remoting(WebServerFixture serverFixture, ITestOutputHelper output)
: base(output, serverFixture) : base(output, serverFixture)
{ {
_output = output;
} }
[Fact] [Fact]
public async Task ObtainServiceToken() public async Task ObtainServiceToken()
{ {
var serverUrl = GetServerUrl(); var serverUrl = _serverFixture.Addresses.FirstOrDefault(u => u.StartsWith("https:"));
var cancellationToken = TestContext.Current.CancellationToken; if (string.IsNullOrEmpty(serverUrl))
throw new InvalidOperationException("No HTTPS server address found");
HttpClient client = NewHttpClient(); 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 var response = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{ {
Address = tokenEndpoint, Address = disco.TokenEndpoint,
ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)), ClientId = _serverFixture.TestClientId,
ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)), ClientSecret = _serverFixture.TestClientSecret,
Scope = "test", Scope = "test",
GrantType = "client_credentials" GrantType = "client_credentials"
}, cancellationToken); });
if (response.IsError) throw new Exception(response.Error); if (response.IsError) throw new Exception(response.Error);
} }
@ -45,25 +45,27 @@ namespace Yavsc.Org.Tests
[Fact] [Fact]
public async Task ObtainResourceOwnerPasswordToken() public async Task ObtainResourceOwnerPasswordToken()
{ {
var serverUrl = GetServerUrl(); var serverUrl = _serverFixture.Addresses.FirstOrDefault(u => u.StartsWith("https:"));
var cancellationToken = TestContext.Current.CancellationToken; if (string.IsNullOrEmpty(serverUrl))
throw new InvalidOperationException("No HTTPS server address found");
var client = NewHttpClient(); 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 var response = await client.RequestPasswordTokenAsync(new PasswordTokenRequest
{ {
Address = tokenEndpoint, Address = disco.TokenEndpoint,
ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)), ClientId = _serverFixture.TestClientId,
ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)), ClientSecret = _serverFixture.TestClientSecret,
UserName = RequireNonEmpty(_serverFixture.TestingUserName, nameof(_serverFixture.TestingUserName)), UserName = _serverFixture.TestingUserName,
Password = RequireNonEmpty(_serverFixture.TestingUserPassword, nameof(_serverFixture.TestingUserPassword)), Password = _serverFixture.TestingUserPassword,
Scope = "test", Scope = "test",
Parameters = Parameters =
{ {
{ "acr_values", "tenant:custom_account_store1 foo bar quux" } { "acr_values", "tenant:custom_account_store1 foo bar quux" }
} }
}, cancellationToken); });
if (response.IsError) throw new Exception(response.Error); if (response.IsError) throw new Exception(response.Error);
@ -74,36 +76,6 @@ namespace Yavsc.Org.Tests
return new object[][] { new object[] { "testuser", "test" } }; return new object[][] { new object[] { "testuser", "test" } };
} }
private async Task<string> 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 internal class BypassSslValidationHandler : HttpClientHandler

View file

@ -2,14 +2,14 @@ using IdentityServer8.EntityFramework.Entities;
using IdentityServer8.Models; using IdentityServer8.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System.Net;
using System.Net.Sockets;
using Yavsc; using Yavsc;
using Yavsc.Extensions; using Yavsc.Extensions;
using Yavsc.Interfaces; using Yavsc.Interfaces;
@ -41,10 +41,6 @@ namespace Yavsc.Org.Tests;
[CollectionDefinition("Yavsc Server")] [CollectionDefinition("Yavsc Server")]
public sealed class WebServerFixture : WebHostFixture public sealed class WebServerFixture : WebHostFixture
{ {
private static readonly int _httpsPort = GetAvailableLoopbackPort();
protected override int HttpsPort => _httpsPort;
private static IConfiguration? _sharedConfiguration; private static IConfiguration? _sharedConfiguration;
private static SiteSettings? _sharedSiteSettings; private static SiteSettings? _sharedSiteSettings;
private static ILogger? _sharedLogger; private static ILogger? _sharedLogger;
@ -70,8 +66,6 @@ public sealed class WebServerFixture : WebHostFixture
protected override WebApplication BuildApp(WebApplicationBuilder builder) protected override WebApplication BuildApp(WebApplicationBuilder builder)
{ {
var authority = $"https://localhost:{_httpsPort}";
// WebApplication.CreateBuilder defaults WebRootPath to // WebApplication.CreateBuilder defaults WebRootPath to
// {ContentRoot}/wwwroot. The test assembly runs from // {ContentRoot}/wwwroot. The test assembly runs from
// src/Yavsc.Org.Tests/bin/.../, which has no wwwroot of // src/Yavsc.Org.Tests/bin/.../, which has no wwwroot of
@ -89,7 +83,6 @@ public sealed class WebServerFixture : WebHostFixture
["Smtp:Port"] = "465", ["Smtp:Port"] = "465",
["Smtp:UserName"] = "test-user", ["Smtp:UserName"] = "test-user",
["Smtp:Password"] = "test-pass", ["Smtp:Password"] = "test-pass",
["Site:Authority"] = authority
}); });
Configuration = builder.Configuration; Configuration = builder.Configuration;
@ -285,19 +278,4 @@ public sealed class WebServerFixture : WebHostFixture
TestingUser = dbContext.Users.FirstOrDefault(u => u.UserName == testingUserName); 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();
}
}
} }

View file

@ -1,6 +1,6 @@
{ {
"Site": { "Site": {
"Authority": "https://localhost:5101", "Authority": "https://mercure.pschneider.fr",
"Title": "Yavsc dev", "Title": "Yavsc dev",
"Slogan": "Yavsc : WIP.", "Slogan": "Yavsc : WIP.",
"Banner": "/images/yavsc.png", "Banner": "/images/yavsc.png",
@ -62,16 +62,6 @@
"UserName": "fakeuser", "UserName": "fakeuser",
"Password": "f/\\kePassw0rd" "Password": "f/\\kePassw0rd"
} }
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:5100"
},
"Https": {
"Url": "https://localhost:5101"
}
}
} }
} }

View file

@ -10,6 +10,29 @@ namespace Yavsc.Migrations
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) 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( migrationBuilder.DropForeignKey(
name: "FK_BlogSpot_AspNetUsers_AuthorId", name: "FK_BlogSpot_AspNetUsers_AuthorId",

View file

@ -16,8 +16,8 @@ namespace Yavsc.Tests.Shared;
/// ///
/// <list type="bullet"> /// <list type="bullet">
/// <item><description>Kestrel with a self-signed HTTPS certificate /// <item><description>Kestrel with a self-signed HTTPS certificate
/// on a fixture-defined fixed port for deterministic integration /// on a dynamically-allocated port (no port collisions between
/// test endpoints.</description></item> /// parallel xUnit test classes).</description></item>
/// <item><description>A per-process single-instance host initialised /// <item><description>A per-process single-instance host initialised
/// on first construction and torn down when the last fixture is /// on first construction and torn down when the last fixture is
/// disposed — same lazy + lock + count pattern as the original Org /// disposed — same lazy + lock + count pattern as the original Org
@ -86,12 +86,11 @@ public abstract class WebHostFixture : IDisposable
protected virtual void CopySpecialisedSharedState() { } protected virtual void CopySpecialisedSharedState() { }
/// <summary>Specialisations register their services and middleware /// <summary>Specialisations register their services and middleware
/// here. The base class has already configured Kestrel HTTPS on /// here. The base class has already configured Kestrel HTTPS on a
/// the fixture-defined test port — do not bind additional /// dynamic port — do not bind additional listeners.</summary>
/// listeners.</summary>
/// <param name="builder">The <see cref="WebApplicationBuilder"/> /// <param name="builder">The <see cref="WebApplicationBuilder"/>
/// configured with Kestrel HTTPS on the fixture-defined test port /// configured with Kestrel HTTPS on a dynamic port and the shared
/// and the shared self-signed certificate.</param> /// self-signed certificate.</param>
/// <returns>The fully built <see cref="WebApplication"/>, ready /// <returns>The fully built <see cref="WebApplication"/>, ready
/// for <c>ConfigurePipeline</c> + <c>StartAsync</c>.</returns> /// for <c>ConfigurePipeline</c> + <c>StartAsync</c>.</returns>
protected abstract WebApplication BuildApp(WebApplicationBuilder builder); protected abstract WebApplication BuildApp(WebApplicationBuilder builder);
@ -105,18 +104,13 @@ public abstract class WebHostFixture : IDisposable
return app; return app;
} }
/// <summary>HTTPS port used by this fixture's Kestrel host.
/// Override in derived fixtures when they must not share the same
/// listen port.</summary>
protected virtual int HttpsPort => 5101;
private async Task InitializeAsync() private async Task InitializeAsync()
{ {
var builder = WebApplication.CreateBuilder(); var builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(options => builder.WebHost.ConfigureKestrel(options =>
{ {
options.Listen(IPAddress.Loopback, HttpsPort, listenOptions => options.Listen(IPAddress.Loopback, 0, listenOptions =>
{ {
listenOptions.UseHttps(_selfSignedCertificate.Value); listenOptions.UseHttps(_selfSignedCertificate.Value);
}); });