This commit is contained in:
Paul Schneider 2026-09-26 00:35:43 +01:00
commit 8c8ab70d38
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
82 changed files with 113 additions and 94 deletions

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

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

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

View 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

View 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();
}
}

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

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

View 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>