REORG
This commit is contained in:
parent
f0792a844f
commit
8c8ab70d38
82 changed files with 113 additions and 94 deletions
31
src/test/Yavsc.Tests.Shared/BlogHelpers.cs
Normal file
31
src/test/Yavsc.Tests.Shared/BlogHelpers.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
|
||||
namespace Yavsc.Blogs.Tests.Fixtures;
|
||||
using static Yavsc.Constants;
|
||||
|
||||
public static class BlogHelpers
|
||||
{
|
||||
public static string ApiUrl(this IBackendFixture fixture, string apiSubPath)
|
||||
{
|
||||
var secured = fixture.Addresses.FirstOrDefault(a => a.StartsWith("https://"));
|
||||
if (secured is null)
|
||||
{
|
||||
var unsecured = fixture.Addresses.FirstOrDefault(a => a.StartsWith("http://"));
|
||||
if (unsecured is null)
|
||||
{
|
||||
throw new InvalidOperationException("No backend address found");
|
||||
}
|
||||
return $"{unsecured}/{APIPrefix}/{apiSubPath}";
|
||||
}
|
||||
return $"{secured}/{APIPrefix}/{apiSubPath}";
|
||||
}
|
||||
|
||||
public static string BlogAclUrl(this IBackendFixture fixture)
|
||||
=> fixture.ApiUrl(BlogAclPath);
|
||||
|
||||
public static string BlogSpotUrl(this IBackendFixture fixture)
|
||||
=> fixture.ApiUrl(BlogSpotPath);
|
||||
|
||||
public static string PublishUrl(this IBackendFixture fixture, long id)
|
||||
=> fixture.ApiUrl(BlogSpotPath) +"/" + id + "/publish";
|
||||
|
||||
}
|
||||
13
src/test/Yavsc.Tests.Shared/IBackendFixture.cs
Normal file
13
src/test/Yavsc.Tests.Shared/IBackendFixture.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
public interface IBackendFixture : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The addresses the fixture bound to.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> Addresses { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The service provider for the fixture host.
|
||||
/// </summary>
|
||||
IServiceProvider Services { get; }
|
||||
|
||||
}
|
||||
30
src/test/Yavsc.Tests.Shared/InMemoryDatabaseName.cs
Normal file
30
src/test/Yavsc.Tests.Shared/InMemoryDatabaseName.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
namespace Yavsc.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for the in-memory connection string used by test fixtures.
|
||||
///
|
||||
/// EF Core's <c>UseInMemoryDatabase(name)</c> returns the same backing
|
||||
/// store to every <c>DbContext</c> that asks for it under the same
|
||||
/// <paramref name="name"/>, in the same process. That means every
|
||||
/// fixture that uses the bare <c>"InMemory"</c> connection string
|
||||
/// shares the same in-memory database — which leaks state between
|
||||
/// fixtures that are supposed to be independent, and silently makes
|
||||
/// tests order-dependent.
|
||||
///
|
||||
/// The fix is to give each fixture its own suffix. <see cref="For"/>
|
||||
/// returns a stable, fixture-scoped connection string. The fixture
|
||||
/// stores the suffix in an instance field so successive calls within
|
||||
/// the same fixture always resolve to the same database.
|
||||
/// </summary>
|
||||
public static class InMemoryDatabaseName
|
||||
{
|
||||
/// <summary>Base connection string for the in-memory provider,
|
||||
/// as it appears in <c>appsettings-org.Testing.json</c>.</summary>
|
||||
public const string Base = "InMemory";
|
||||
|
||||
/// <summary>Builds a per-fixture connection string. Two calls
|
||||
/// with the same <paramref name="fixtureId"/> return the same
|
||||
/// string; two calls with different ids return different
|
||||
/// strings, isolating the underlying in-memory stores.</summary>
|
||||
public static string For(string fixtureId) => $"{Base}-{fixtureId}";
|
||||
}
|
||||
149
src/test/Yavsc.Tests.Shared/README.md
Normal file
149
src/test/Yavsc.Tests.Shared/README.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# Yavsc.Tests.Shared
|
||||
|
||||
Scaffold partagé pour les tests d'intégration ASP.NET Core de
|
||||
Yavsc. Ce projet **n'est pas lui-même un projet de tests** — il
|
||||
n'a pas xUnit ni de test runner. Il expose des fixtures
|
||||
réutilisables que les projets de tests consommateurs
|
||||
(`Yavsc.Org.Tests`, `Yavsc.Blogs.Tests`, etc.) héritent ou
|
||||
instancient.
|
||||
|
||||
## Contenu
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `WebHostFixture.cs` | Base abstraite : Kestrel HTTPS, certificat auto-signé, port dynamique, host partagé inter-fixtures |
|
||||
| `TestAuthPolicyProvider.cs` | `IAuthorizationPolicyProvider` de test, lit `X-Test-Role` au lieu d'interroger la DB |
|
||||
| `TestTokenIssuer.cs` | Émet des JWT HS256 signés avec une clé statique, pour les tests d'API qui montent un `AddJwtBearer` réel |
|
||||
|
||||
## WebHostFixture
|
||||
|
||||
`WebHostFixture` est la base de toute fixture d'intégration.
|
||||
Une seule instance de `WebApplication` tourne par process ; les
|
||||
fixtures qui héritent partagent le host. Kestrel est bindé sur
|
||||
`127.0.0.1:0` (port dynamique) avec un certificat auto-signé
|
||||
généré lazily.
|
||||
|
||||
### Cycle de vie
|
||||
|
||||
- **Premier ctor** d'une fixture concrète → `InitializeAsync()`
|
||||
lance `BuildApp(builder)` puis `ConfigurePipelineAsync(app)`
|
||||
puis `app.StartAsync()`. L'`IServerAddressesFeature` est lu
|
||||
pour peupler `Addresses`.
|
||||
- **Ctors suivants** (xUnit instancie une fixture par
|
||||
`IClassFixture<T>`) → reprise de l'état partagé via
|
||||
`CopySpecialisedSharedState()` (vide par défaut, surchargeable).
|
||||
- **Dernier `Dispose`** → `app.StopAsync()`, reset des slots
|
||||
statiques.
|
||||
|
||||
### Hooks à surcharger
|
||||
|
||||
| Hook | Quand | Quoi y mettre |
|
||||
|---|---|---|
|
||||
| `BuildApp(builder)` | Toujours | Enregistrement des services, configuration in-memory, seeding éventuel |
|
||||
| `ConfigurePipelineAsync(app)` | Optionnel | Pipeline middleware spécifique (sinon : pas de pipeline custom) |
|
||||
| `CopySpecialisedSharedState()` | Optionnel | Recopie des slots statiques de la spécialisation sur les propriétés d'instance |
|
||||
|
||||
### Exemple : fixture de portée minimale
|
||||
|
||||
```csharp
|
||||
public sealed class MyFixture : WebHostFixture
|
||||
{
|
||||
protected override WebApplication BuildApp(WebApplicationBuilder builder)
|
||||
{
|
||||
// In-memory config, services, etc.
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`MyFixture` n'a pas de test runner propre ; c'est l'assembly
|
||||
consommateur (par exemple `Yavsc.MyModule.Tests`) qui déclare
|
||||
les `[Fact]` et utilise `IClassFixture<MyFixture>`.
|
||||
|
||||
## Spécifications : fixtures concrètes
|
||||
|
||||
Deux fixtures héritent de `WebHostFixture` dans le repo :
|
||||
|
||||
### Yavsc.Org.Tests.WebServerFixture
|
||||
|
||||
Pour le host principal de Yavsc.Org. Caractéristiques :
|
||||
|
||||
- Configure `InMemory` pour la `ConnectionStrings` Yavsc
|
||||
- Remplace `IAuthorizationPolicyProvider` par
|
||||
`TestAuthPolicyProvider` **avant** `ConfigureWebAppServices`
|
||||
(qui freeze la collection de services)
|
||||
- Stub `ISmtpClientFactory` par `RecordingSmtpClientFactory`
|
||||
pour capturer les envois sans SMTP réel
|
||||
- Seed IdentityServer8 : un `Client` + une `ApiScope` "test" +
|
||||
un `ApplicationUser` "Tester"
|
||||
- Configure le pipeline via `app.ConfigurePipeline(...)` avec
|
||||
un manifeste de static assets explicite (le MSBuild target
|
||||
`CopyYavscOrgStaticAssets` du csproj miroir les manifests
|
||||
Yavsc.Org sous le nom Yavsc.Org.Tests.* dans le bin de test)
|
||||
|
||||
Cf. `src/Yavsc.Org.Tests/WebServerFixture.cs`.
|
||||
|
||||
### Yavsc.Blogs.Tests.BlogsWebServerFixture
|
||||
|
||||
Pour le host API de Yavsc.Blogs. Caractéristiques :
|
||||
|
||||
- `UseInMemoryDatabase("Yavsc.Blogs.Tests", _inMemoryRoot)` —
|
||||
un `InMemoryDatabaseRoot` partagé pour que POST + GET voient
|
||||
le même store
|
||||
- `BlogSpotService` réel (pas de mock)
|
||||
- `PermissionHandler` réel (le handler d'authorization qui
|
||||
résout `IsOwner(user, blog)`)
|
||||
- `AddJwtBearer` réel avec HS256, validation contre
|
||||
`TestTokenIssuer.SigningKey` — pas d'OIDC discovery, pas
|
||||
d'IdP
|
||||
- Politique `BlogScope` verbatim (`RequireAuthenticatedUser` +
|
||||
`RequireClaim("scope", "blogs")`)
|
||||
|
||||
Cf. `src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs`.
|
||||
|
||||
## TestAuthPolicyProvider
|
||||
|
||||
`IAuthorizationPolicyProvider` de test qui lit le rôle dans
|
||||
l'en-tête HTTP `X-Test-Role` au lieu d'interroger la
|
||||
`UserManager`. Permet aux smoke tests d'exercer `[Authorize
|
||||
("AdministratorOnly")]` sans seed de rôle réel.
|
||||
|
||||
Activation : enregistré par les fixtures spécialisées **avant**
|
||||
`ConfigureWebAppServices` (qui call `builder.Build()` et
|
||||
fige la collection). La sémantique last-write-wins du
|
||||
`AddSingleton` fait que le test provider prend le pas.
|
||||
|
||||
## TestTokenIssuer
|
||||
|
||||
Émet un JWT HS256 avec une `SigningKey` statique, exposé en
|
||||
`TestTokenIssuer.SigningKey` (et `Issuer`). Les fixtures qui
|
||||
montent un `AddJwtBearer` réutilisent cette clé pour valider
|
||||
les tokens localement, sans OIDC discovery.
|
||||
|
||||
Helpers :
|
||||
|
||||
- `TestTokenIssuer.Issue(subject, scope, lifetime)` →
|
||||
chaîne `"Bearer <jwt>"` prête pour un header HTTP
|
||||
- `TestTokenIssuer.SigningKey` — `SymmetricSecurityKey` à
|
||||
passer au `TokenValidationParameters` du `AddJwtBearer`
|
||||
|
||||
## Tests statiques sur du code compilé
|
||||
|
||||
Pour tester un display template Razor sans monter un host
|
||||
ASP.NET, on peut s'appuyer sur la lecture du fichier source
|
||||
et asserter des invariants syntaxiques. Cf.
|
||||
`Yavsc.Org.Tests/NonRegression/ApplicationUserDisplayTemplateTests`
|
||||
pour un exemple : on asserte que le cshtml ne porte plus
|
||||
`Model.UserName` directement, ce qui aurait rouvert la
|
||||
non-régression du 500 sur `/BlogSpot/Details/{id}`.
|
||||
|
||||
C'est pragmatique : la mise en place d'un `RazorProjectEngine`
|
||||
pour compiler et rendre une vue hors host coûte plus cher que
|
||||
ce qu'elle protège pour un seul template.
|
||||
|
||||
## Pour aller plus loin
|
||||
|
||||
- `doc/testing.md` à la racine : vue d'ensemble de la
|
||||
stratégie de test
|
||||
- `src/Yavsc.Org.Tests/NonRegression/` et
|
||||
`src/Yavsc.Blogs.Tests/` : exemples d'utilisation
|
||||
71
src/test/Yavsc.Tests.Shared/TestAuthPolicyProvider.cs
Normal file
71
src/test/Yavsc.Tests.Shared/TestAuthPolicyProvider.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Yavsc.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization policy provider used by integration tests. Replaces the
|
||||
/// production provider in the test host so that any policy-protected
|
||||
/// controller can be exercised by sending a <c>X-Test-Role: …</c>
|
||||
/// header — no login roundtrip, no cookie, no database user.
|
||||
///
|
||||
/// Any policy that requires a role short-circuits to success when the
|
||||
/// matching header is present; otherwise the production policy is
|
||||
/// preserved. The test does not perform a real login, so we attach an
|
||||
/// in-memory <see cref="ClaimsPrincipal"/> carrying the role claim to
|
||||
/// the request <see cref="HttpContext.User"/> before the assertion
|
||||
/// fires, so claim-based requirements (e.g. <c>RequireRole("Admin")</c>)
|
||||
/// also pass.
|
||||
/// </summary>
|
||||
public sealed class TestAuthPolicyProvider : IAuthorizationPolicyProvider
|
||||
{
|
||||
/// <summary>HTTP header read by the test bypass to learn the role.</summary>
|
||||
public const string HeaderName = "X-Test-Role";
|
||||
|
||||
/// <summary>Conventional admin role name; the production default.</summary>
|
||||
public const string AdminRole = "Administrator";
|
||||
|
||||
private readonly DefaultAuthorizationPolicyProvider _fallback;
|
||||
|
||||
public TestAuthPolicyProvider(IOptions<AuthorizationOptions> options)
|
||||
{
|
||||
_fallback = new DefaultAuthorizationPolicyProvider(options);
|
||||
}
|
||||
|
||||
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
|
||||
|
||||
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
|
||||
|
||||
public async Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
||||
{
|
||||
var policy = await _fallback.GetPolicyAsync(policyName);
|
||||
if (policy is null) return null;
|
||||
return new AuthorizationPolicyBuilder()
|
||||
.RequireAssertion(ctx =>
|
||||
{
|
||||
// ASP.NET Core sets ctx.Resource to the HttpContext when
|
||||
// the authorization middleware invokes the policy. Use
|
||||
// the request headers directly to honour X-Test-Role.
|
||||
var http = ctx.Resource as HttpContext;
|
||||
if (http is null) return false;
|
||||
var role = http.Request.Headers[HeaderName].ToString();
|
||||
if (string.IsNullOrEmpty(role)) return false;
|
||||
if (http.User.Identity is null || !http.User.Identity.IsAuthenticated)
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
new[]
|
||||
{
|
||||
new Claim(
|
||||
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
|
||||
role),
|
||||
},
|
||||
authenticationType: "TestAuth");
|
||||
http.User = new ClaimsPrincipal(identity);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
122
src/test/Yavsc.Tests.Shared/TestTokenIssuer.cs
Normal file
122
src/test/Yavsc.Tests.Shared/TestTokenIssuer.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Yavsc.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Mints HS256-signed JWTs for integration tests. The signing key is
|
||||
/// held in a static field shared with the test host's
|
||||
/// <c>AddJwtBearer</c> registration: whatever the host validates
|
||||
/// against, this issuer signs with.
|
||||
///
|
||||
/// <para>
|
||||
/// HS256 (symmetric) is the right choice for a unit-test issuer:
|
||||
/// no key generation ceremony, no PEM round-trip, no asymmetric
|
||||
/// crypto on the hot path. The key never leaves the test process.
|
||||
/// Production continues to validate against the OIDC authority via
|
||||
/// <c>AddYavscJwtBearer</c> — this issuer is *only* for the
|
||||
/// in-process test host.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class TestTokenIssuer
|
||||
{
|
||||
/// <summary>
|
||||
/// Symmetric signing key shared with the test host's
|
||||
/// <c>TokenValidationParameters.IssuerSigningKey</c>.
|
||||
/// 32 bytes of zeros is enough entropy for HS256 *within the test
|
||||
/// process*; the assertion we care about is "does the policy
|
||||
/// evaluate a properly-signed token", not "is the key unguessable
|
||||
/// by an attacker" (there is no attacker here).
|
||||
/// </summary>
|
||||
public static readonly SymmetricSecurityKey SigningKey =
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('k', 32)));
|
||||
|
||||
/// <summary>
|
||||
/// Issuer stamped into the <c>iss</c> claim and checked by the
|
||||
/// test host. Must match
|
||||
/// <c>TokenValidationParameters.ValidIssuer</c>.
|
||||
/// </summary>
|
||||
public const string Issuer = "yavsc-test-issuer";
|
||||
|
||||
/// <summary>
|
||||
/// Audience stamped into the <c>aud</c> claim. The test host
|
||||
/// does not validate audience (production may), so this is here
|
||||
/// for shape only.
|
||||
/// </summary>
|
||||
public const string Audience = "yavsc-test";
|
||||
|
||||
private static bool _inboundClaimTypeMapCleared;
|
||||
|
||||
/// <summary>
|
||||
/// Mint a JWT carrying the given <paramref name="subject"/> as
|
||||
/// the <c>sub</c> claim, a single <c>scope</c> claim with value
|
||||
/// <paramref name="scope"/>, and any additional
|
||||
/// <paramref name="extraClaims"/>. Token is valid for one hour
|
||||
/// from now.
|
||||
/// </summary>
|
||||
/// <param name="subject">Value of the <c>sub</c> claim. Read
|
||||
/// back by <c>UserHelpers.GetUserId</c>, which is how
|
||||
/// <c>PermissionHandler.IsOwner</c> identifies the author of a
|
||||
/// <c>BlogPost</c> on PUT.</param>
|
||||
/// <param name="scope">Value of the <c>scope</c> claim. The
|
||||
/// production <c>BlogScope</c> policy requires
|
||||
/// <c>RequireClaim("scope", "blogs")</c>.</param>
|
||||
/// <param name="name">Value of the <c>name</c> claim. Defaults to
|
||||
/// <paramref name="subject"/> (the test convention where the login
|
||||
/// equals the subject). Pass a distinct login to mirror production,
|
||||
/// where <c>sub</c> is a GUID and <c>name</c> is the user's
|
||||
/// <c>UserName</c> — the file-system list/upload paths key the
|
||||
/// personal-storage tree off the login, not the subject.</param>
|
||||
/// <param name="extraClaims">Optional additional claims
|
||||
/// (e.g. a role for an admin-bypass test).</param>
|
||||
public static string Issue(
|
||||
string subject,
|
||||
string scope = "blogs",
|
||||
string? name = null,
|
||||
IEnumerable<Claim>? extraClaims = null)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new("sub", subject),
|
||||
// Mirror production: IdentityServer's ProfileService emits the
|
||||
// "name" claim as the user's login (UserName) when the profile
|
||||
// scope is granted, and the file-system helpers resolve the
|
||||
// per-user storage root via GetUserName() (FindFirstValue("name"))
|
||||
// — not via User.Identity.Name. A test token without "name" would
|
||||
// make ReceiveUserFile / EnsureDestinationDirectory compute a
|
||||
// null owner root and 500 on upload, so stamp it here.
|
||||
new("name", name ?? subject),
|
||||
new("scope", scope),
|
||||
};
|
||||
if (extraClaims is not null) claims.AddRange(extraClaims);
|
||||
|
||||
// JwtSecurityTokenHandler ships with a static
|
||||
// DefaultInboundClaimTypeMap that rewrites short JWT claim
|
||||
// names to their long Microsoft URIs at deserialisation
|
||||
// time. The most relevant rewrite for us is
|
||||
// "sub" → ClaimTypes.NameIdentifier. Without clearing the
|
||||
// map, UserHelpers.GetUserId() — which reads the literal
|
||||
// "sub" claim — would not find the value, PermissionHandler
|
||||
// .IsOwner would compare against null, and the controller
|
||||
// would return 401 on every PUT. Clearing is the standard
|
||||
// way to opt out of the legacy mapping.
|
||||
if (!_inboundClaimTypeMapCleared)
|
||||
{
|
||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
|
||||
_inboundClaimTypeMapCleared = true;
|
||||
}
|
||||
|
||||
var creds = new SigningCredentials(SigningKey, SecurityAlgorithms.HmacSha256);
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: claims,
|
||||
notBefore: now,
|
||||
expires: now.AddHours(1),
|
||||
signingCredentials: creds);
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
226
src/test/Yavsc.Tests.Shared/WebHostFixture.cs
Normal file
226
src/test/Yavsc.Tests.Shared/WebHostFixture.cs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
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.Runtime.Loader;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace Yavsc.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for ASP.NET Core integration test hosts. Provides the
|
||||
/// cross-cutting plumbing shared by every test fixture in the
|
||||
/// repository:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Kestrel with a self-signed HTTPS certificate
|
||||
/// on a fixture-defined fixed port for deterministic integration
|
||||
/// test endpoints.</description></item>
|
||||
/// <item><description>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.</description></item>
|
||||
/// <item><description>Address discovery via
|
||||
/// <see cref="IServerAddressesFeature"/>.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// The actual service registration, middleware pipeline and route
|
||||
/// mapping are the responsibility of the subclass, through
|
||||
/// <see cref="BuildApp"/>.
|
||||
/// </summary>
|
||||
public abstract class WebHostFixture : IBackendFixture
|
||||
{
|
||||
private static readonly Lazy<X509Certificate2> _selfSignedCertificate =
|
||||
new Lazy<X509Certificate2>(CreateSelfSignedCertificate);
|
||||
private static readonly object _sync = new object();
|
||||
private static WebApplication? _app;
|
||||
private static bool _isInitialized;
|
||||
private static bool _shutdownHooksRegistered;
|
||||
private static int _instanceCount;
|
||||
private static readonly List<string> _sharedAddresses = new();
|
||||
private static IServiceProvider? _sharedServices;
|
||||
|
||||
/// <summary>HTTPS listen URLs the host bound to.</summary>
|
||||
public IReadOnlyList<string> Addresses { get; private set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>The DI service provider of the running host. Read from
|
||||
/// the shared static slot so every fixture instance (xUnit creates
|
||||
/// one per <c>IClassFixture</c>) sees the same provider after the
|
||||
/// first initialisation. Throws if <see cref="IsInitialized"/> is
|
||||
/// false.</summary>
|
||||
public IServiceProvider Services => _sharedServices
|
||||
?? throw new InvalidOperationException(
|
||||
"WebHostFixture has not been initialised. Call InitializeAsync first.");
|
||||
|
||||
/// <summary>True once <see cref="InitializeAsync"/> has completed
|
||||
/// successfully and the host is running.</summary>
|
||||
public bool IsInitialized { get; private set; }
|
||||
|
||||
#pragma warning disable CS8618 // Un champ non-nullable doit contenir une valeur autre que Null lors de la fermeture du constructeur. Envisagez d’ajouter le modificateur « required » ou de déclarer le champ comme pouvant accepter la valeur Null.
|
||||
protected WebHostFixture()
|
||||
#pragma warning restore CS8618 // Un champ non-nullable doit contenir une valeur autre que Null lors de la fermeture du constructeur. Envisagez d’ajouter le modificateur « required » ou de déclarer le champ comme pouvant accepter la valeur Null.
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
RegisterShutdownHooks();
|
||||
|
||||
if (!_isInitialized)
|
||||
{
|
||||
InitializeAsync().GetAwaiter().GetResult();
|
||||
_isInitialized = true;
|
||||
}
|
||||
_instanceCount++;
|
||||
CopySharedState();
|
||||
CopySpecialisedSharedState();
|
||||
}
|
||||
}
|
||||
|
||||
private void CopySharedState()
|
||||
{
|
||||
Addresses = _sharedAddresses.ToArray();
|
||||
IsInitialized = _isInitialized;
|
||||
App = _app ?? App;
|
||||
}
|
||||
|
||||
/// <summary>Hook for specialisations to copy any other shared
|
||||
/// state (test client credentials, user names, factories, etc.)
|
||||
/// from the static slots exposed by the base class onto instance
|
||||
/// properties. Called once per fixture construction, after
|
||||
/// <see cref="InitializeAsync"/> has populated the shared state
|
||||
/// the first time.</summary>
|
||||
protected virtual void CopySpecialisedSharedState() { }
|
||||
|
||||
/// <summary>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.</summary>
|
||||
/// <param name="builder">The <see cref="WebApplicationBuilder"/>
|
||||
/// configured with Kestrel HTTPS on the fixture-defined test port
|
||||
/// and the shared self-signed certificate.</param>
|
||||
/// <returns>The fully built <see cref="WebApplication"/>, ready
|
||||
/// for <c>ConfigurePipeline</c> + <c>StartAsync</c>.</returns>
|
||||
protected abstract WebApplication BuildApp(WebApplicationBuilder builder);
|
||||
|
||||
/// <summary>Apply the production pipeline to <paramref name="app"/>.
|
||||
/// Defaults to identity + routing + auth + MapStaticAssets; override
|
||||
/// only if your host needs a different shape.</summary>
|
||||
protected virtual async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
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;
|
||||
|
||||
/// <summary>Options used to create the WebApplicationBuilder.
|
||||
/// Derived fixtures can override (for example, to set
|
||||
/// ApplicationName for MVC controller discovery).</summary>
|
||||
protected virtual WebApplicationOptions CreateBuilderOptions()
|
||||
{
|
||||
return new WebApplicationOptions();
|
||||
}
|
||||
|
||||
public WebApplication App { get; private set; }
|
||||
|
||||
private async Task InitializeAsync()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(CreateBuilderOptions());
|
||||
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.Listen(IPAddress.Loopback, HttpsPort, listenOptions =>
|
||||
{
|
||||
listenOptions.UseHttps(_selfSignedCertificate.Value);
|
||||
});
|
||||
});
|
||||
|
||||
this.App = BuildApp(builder);
|
||||
this.App = await ConfigurePipelineAsync(this.App);
|
||||
await this.App.StartAsync();
|
||||
|
||||
_app = this.App;
|
||||
_sharedServices = this.App.Services;
|
||||
|
||||
var server = this.App.Services.GetRequiredService<IServer>();
|
||||
var addressFeatures = server.Features.Get<IServerAddressesFeature>();
|
||||
_sharedAddresses.Clear();
|
||||
if (addressFeatures?.Addresses is not null)
|
||||
{
|
||||
foreach (var address in addressFeatures.Addresses)
|
||||
{
|
||||
_sharedAddresses.Add(address);
|
||||
}
|
||||
}
|
||||
Addresses = _sharedAddresses.ToArray();
|
||||
IsInitialized = true;
|
||||
}
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_instanceCount > 0)
|
||||
{
|
||||
_instanceCount--;
|
||||
}
|
||||
|
||||
IsInitialized = _isInitialized;
|
||||
|
||||
// Keep the shared host alive for the whole test process.
|
||||
// Disposing per class/collection can race with other test
|
||||
// classes and intermittently drop the listener mid-run.
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterShutdownHooks()
|
||||
{
|
||||
if (_shutdownHooksRegistered)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, __) => ShutdownSharedHost();
|
||||
AssemblyLoadContext.Default.Unloading += _ => ShutdownSharedHost();
|
||||
_shutdownHooksRegistered = true;
|
||||
}
|
||||
|
||||
private static void ShutdownSharedHost()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_isInitialized || _app is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_app.StopAsync().GetAwaiter().GetResult();
|
||||
_app = null;
|
||||
_isInitialized = false;
|
||||
_sharedServices = null;
|
||||
_sharedAddresses.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateSelfSignedCertificate()
|
||||
{
|
||||
var rsa = RSA.Create(2048);
|
||||
var certRequest = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
|
||||
certRequest.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(X509KeyUsageFlags.DataEncipherment | X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DigitalSignature, false));
|
||||
|
||||
certRequest.CertificateExtensions.Add(
|
||||
new X509EnhancedKeyUsageExtension(
|
||||
new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
|
||||
|
||||
return certRequest.CreateSelfSigned(
|
||||
new DateTimeOffset(DateTime.UtcNow.AddDays(-1)),
|
||||
new DateTimeOffset(DateTime.UtcNow.AddDays(3650)));
|
||||
}
|
||||
}
|
||||
29
src/test/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj
Normal file
29
src/test/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Yavsc.Tests.Shared</RootNamespace>
|
||||
<!--
|
||||
Shared library of test fixtures (WebHostFixture, auth bypass,
|
||||
etc.) consumed by both Yavsc.Org.Tests and Yavsc.Blogs.Tests.
|
||||
Not a test project itself: no xUnit, no test runner, no
|
||||
TestSdk. The consumer projects own the test execution and
|
||||
inherit from the shared base classes.
|
||||
-->
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Hosting" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Loading…
Reference in a new issue