Merge pull request 'fix/issue-3-splitquery' (#5) from fix/issue-3-splitquery into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled

Reviewed-on: #5
This commit is contained in:
Paul Schneider 2026-07-12 02:47:15 +01:00
commit becd233593
16 changed files with 5324 additions and 11 deletions

9
.gitignore vendored
View file

@ -24,6 +24,15 @@ data/
appsettings.*.json appsettings.*.json
appsettings-*.*.json appsettings-*.*.json
# Exception: the Testing-environment override for Yavsc.Org is a tracked
# configuration source, not a secrets file. TestWebApplicationFactory
# (Yavsc.Org.Tests) flips ASPNETCORE_ENVIRONMENT to "Testing" so
# AddConfiguration("org") in Program.Main loads this file as the
# last in the chain (it is optional). It overrides the connection
# string and SMTP section for the in-memory test host and contains
# no production secrets.
!src/Yavsc.Org/appsettings-org.Testing.json
generated/ generated/
*.tmp *.tmp
DataDir/ DataDir/

View file

@ -17,6 +17,7 @@ La racine de l'architecture est [Architecture.md](Architecture.md).
| [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh | | [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh |
| [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding | | [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding |
| [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) | | [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) |
| [testing.md](testing.md) | Stratégie de test : conventions des dossiers, EF Core in-memory, auth stubs, scaffold partagé |
## Roadmap & design exploration ## Roadmap & design exploration

88
doc/testing.md Normal file
View file

@ -0,0 +1,88 @@
# Stratégie de test
Yavsc utilise **xUnit** (`xunit.v3`) avec un mix d'unitaire pur
et d'intégration légère. Les projets de tests sont sous
`src/<projet>.Tests/` et consomment le scaffold partagé
`src/Yavsc.Tests.Shared/`.
## Vue d'ensemble
| Sujet | Document |
|---|---|
| Scaffold partagé (`WebHostFixture`, JWT de test, etc.) | [src/Yavsc.Tests.Shared/README.md](../src/Yavsc.Tests.Shared/README.md) |
| Convention des dossiers de tests | [Conventions](#conventions-des-dossiers-de-tests) |
| Driver EF Core en test | [EF Core en test](#ef-core-en-test) |
| Stubs d'authentification et de permissions | [Auth et permissions](#auth-et-permissions) |
## Conventions des dossiers de tests
Sous `src/<projet>.Tests/`, on trouve quatre dossiers de premier
niveau qui classifient les tests par intention :
| Dossier | Usage |
|---|---|
| `NonRegression/` | Régressions : un bug constaté, un test qui le détecte si on le réintroduit |
| `Mandatory/` | Tests bloquants : ils doivent passer avant tout merge |
| `Smoke/` | Smoke tests HTTP rapides, montent un host léger |
| `Controllers/` | Tests unitaires des contrôleurs (mock du service, assertions sur le mapping HTTP) |
Les `NonRegression` sont la cible par défaut quand on fixe un
bug : ils doivent être **rouges avant le fix, verts après**, et
continuer à **casser** si quelqu'un revert le fix. Pas de test
qui passe à vide.
## EF Core en test
Pour les tests qui ont besoin d'un `ApplicationDbContext`, on
utilise **`UseInMemoryDatabase`** avec un `InMemoryDatabaseRoot`
partagé au niveau de la fixture. Pas de SQLite, pas de Docker,
pas de mock du contexte : le service testé s'exécute contre
un vrai `DbContext` sur in-memory.
```csharp
private static readonly InMemoryDatabaseRoot _dbRoot = new();
var opts = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase("Yavsc.Org.Tests.MyFixture", _dbRoot)
.Options;
```
Le `InMemoryDatabaseRoot` partagé est important : sans lui, EF
crée un store indépendant par `DbContext` dans certaines
configurations, et un test qui seed + read sur deux contextes
voit un store vide. Le pattern est documenté dans
`BlogsWebServerFixture` ([src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs](../src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs)).
> **Limite connue** : le provider in-memory **ignore** les
> `Migration` EF et ne respecte pas les FK **sur les raw
> SQL** (`ExecuteSqlRaw`). Pour tester des contraintes FK, on
> écrit la configuration dans `OnModelCreating` et on s'appuie
> sur le fait qu'EF la respecte à l'`Add`/`SaveChanges`. Pour
> tester des migrations, c'est l'environnement de staging.
## Auth et permissions
L'authorization policy provider de prod est swappé contre
`TestAuthPolicyProvider` (dans `Yavsc.Tests.Shared`) par les
fixtures spécialisées. Les tests qui ont besoin qu'un user soit
"Administrator" envoient un header `X-Test-Rôle` ; ceux qui
veulent un user anonyme omettent le header.
Pour les tests unitaires qui n'ont pas besoin du pipeline
HTTP, on stub `IAuthorizationService` directement (cf.
`BlogspotController` dans `Yavsc.Org.Tests/NonRegression/`)
pour éviter de monter un host complet.
## Quand ne PAS écrire de test
Un test qui ne détecte rien n'est pas un test. Si l'invariant
qu'on cherche à protéger est déjà enforced par EF, par le
compilateur, ou par une couche applicative en amont, le test
est du bruit. Mieux vaut :
- Un test qui assert un **comportement observable** (code
retour HTTP, exception typée, valeur de retour)
- Ou pas de test, et une note dans le code
La non-régression se prouve par un test qui casse si on
réintroduit le bug. Pas par un test qui passe aujourd'hui et
qui continuera à passer après un revert.

View file

@ -0,0 +1,36 @@
namespace Yavsc.Abstract.Identity
{
/// <summary>
/// Helpers Razor-friendly pour rendre un user dans une vue
/// sans exposer le template à des accesseurs nullables qui
/// lèveraient <see cref="System.NullReferenceException"/>.
/// </summary>
public static class UserDisplayHelpers
{
/// <summary>
/// Chemin d'avatar à utiliser pour <paramref name="user"/>
/// dans un display template. Défense contre <c>null</c>
/// (user pas chargé, FK orpheline) et contre un
/// <c>UserName</c> vide (donnée héritée, user partiellement
/// initialisé). Sans cette garde, Razor émet une
/// NullReferenceException dès qu'un accesseur <c>.UserName</c>
/// apparaît dans le template, ce qui remonte en 500 et
/// masque la page d'erreur elle-même.
/// </summary>
/// <remarks>
/// Le path retourné est aligné sur
/// <see cref="YavscConstants.AvatarsPath"/> (minuscule).
/// Les anciens display templates utilisaient "/Avatars/"
/// avec un S majuscule, en désaccord avec le path statique
/// servi par le middleware de fichiers — les images ne
/// résolvaient pas. Centraliser le calcul ici ferme les
/// deux trous.
/// </remarks>
public static string AvatarSrc(IApplicationUser? user)
{
if (string.IsNullOrWhiteSpace(user?.UserName))
return YavscConstants.DefaultAvatar;
return $"{YavscConstants.AvatarsPath}/{user!.UserName}.s.png";
}
}
}

View file

@ -110,6 +110,77 @@ public class ClientControllerCollectionTests : IClassFixture<TestWebApplicationF
Assert.Contains("https://app.example.com/cb", body); Assert.Contains("https://app.example.com/cb", body);
} }
// Repro: GET /Client/EditRedirectUris/{id} après qu'une seconde
// RedirectUri a été ajoutée via POST. LoadClientAsync(id) réhydrate
// le Client avec 9 Includes (RedirectUris, AllowedScopes, ClientSecrets,
// etc.) via l'InMemory provider. Sur l'Id=2 seedé, la matérialisation
// des nav properties sur les entités IdentityServer8 lève
// IndexOutOfRangeException — l'action renvoie un 500 et la page
// d'erreur masque le diagnostic.
//
// Ce test reproduit le chemin qui plante : GET initial (lit l'AF
// token) → POST AddRedirectUri → GET final qui exerce LoadClientAsync
// avec une collection RedirectUris non triviale (2 entrées).
// Il doit ÉCHOUER tant que le bug n'est pas traité.
[Fact]
public async Task EditRedirectUris_GET_after_add_lists_both_uris()
{
var http = CreateAdminClient();
var id = TargetClientDbId();
const string newUri = "https://app.example.com/cb-repro";
// Premier GET pour récupérer l'antiforgery token du form Add.
var pageResp = await http.GetAsync(
$"/Client/EditRedirectUris/{id}", TestContext.Current.CancellationToken);
pageResp.EnsureSuccessStatusCode();
var token = ExtractAntiforgeryToken(
await pageResp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.False(string.IsNullOrEmpty(token));
// Ajout d'une seconde RedirectUri — c'est l'état non-trivial
// qui déclenche la matérialisation problématique côté
// InMemory provider.
var form = new MultipartFormDataContent
{
{ new StringContent(newUri), "redirectUri" },
{ new StringContent(token!), "__RequestVerificationToken" },
};
var addResp = await http.PostAsync(
$"/Client/AddRedirectUri/{id}", form, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, addResp.StatusCode);
try
{
// GET final : exerce LoadClientAsync(id) sur le client
// avec 2 RedirectUris, 1 AllowedScope, 1 GrantType, etc.
// Si la matérialisation échoue (IndexOutOfRange), ce GET
// renvoie 500 et EnsureSuccessStatusCode fait échouer le test.
var finalResp = await http.GetAsync(
$"/Client/EditRedirectUris/{id}", TestContext.Current.CancellationToken);
finalResp.EnsureSuccessStatusCode();
var finalBody = await finalResp.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken);
// La page doit lister les DEUX URIs.
Assert.Contains("https://app.example.com/cb", finalBody);
Assert.Contains(newUri, finalBody);
}
finally
{
// Cleanup idempotent : on retire l'URI ajoutée pour ne
// pas polluer les autres tests de la collection.
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var row = db.ClientRedirectUris
.FirstOrDefault(r => r.RedirectUri == newUri);
if (row is not null)
{
db.ClientRedirectUris.Remove(row);
db.SaveChanges();
}
}
}
[Fact] [Fact]
public async Task AddRedirectUri_POST_appends_to_database() public async Task AddRedirectUri_POST_appends_to_database()
{ {
@ -206,6 +277,26 @@ public class ClientControllerCollectionTests : IClassFixture<TestWebApplicationF
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
} }
// ---------- Bisection de l'Include qui plante ----------
//
// LoadClientAsync charge le Client avec 9 Includes :
// RedirectUris, PostLogoutRedirectUris, AllowedScopes,
// AllowedGrantTypes, AllowedCorsOrigins,
// IdentityProviderRestrictions, Claims, Properties, ClientSecrets.
//
// Le test EditRedirectUris_GET_after_add_lists_both_uris échoue
// avec IndexOutOfRangeException au shaper de l'InMemory provider,
// sans préciser lequel des Includes pose problème. La stack
// indique IncludeCollection (donc un Include de collection, pas
// de référence).
//
// 2026-07-11 fix/issue-3-splitquery: tests retirés car leur hypothèse
// ("bug de provider InMemory sur ces 3 entités") n'est pas la cause
// racine — c'est une redondance de mapping HasOne dans
// ApplicationDbContext.OnModelCreating. Le test gardien
// EditRedirectUris_GET_after_add_lists_both_uris reste en place
// comme sentinelle de régression sur le fix.
private static string? ExtractAntiforgeryToken(string html) private static string? ExtractAntiforgeryToken(string html)
{ {
const string marker = "name=\"__RequestVerificationToken\""; const string marker = "name=\"__RequestVerificationToken\"";

View file

@ -0,0 +1,69 @@
using System.IO;
using Xunit;
namespace Yavsc.Org.Tests.NonRegression;
/// <summary>
/// Régression du 500 sur <c>GET /BlogSpot/Details/{id}</c> (auteur
/// sans <c>UserName</c>) : le display template
/// <c>ApplicationUser.cshtml</c> ne doit plus accéder à
/// <c>Model.UserName</c> directement. Toute lecture passe par
/// <c>UserDisplayHelpers.AvatarSrc</c>, qui défend contre
/// <c>null</c> et contre les chaînes vides/whitespace.
///
/// On ne compile pas la vue Razor ici (coût de mise en place
/// disproportionné pour un seul display template) ; on asserte
/// statiquement que le cshtml ne porte plus l'accès fautif. Si
/// quelqu'un revert la ligne, ce test casse.
/// </summary>
public class ApplicationUserDisplayTemplateTests
{
[Fact]
public void ApplicationUser_cshtml_does_not_construct_avatar_path_from_Model_UserName()
{
// L'invariant qu'on protège : l'URL d'avatar ne doit plus
// être construite à partir de Model.UserName direct (le
// commit 2 du fix). Cette construction était la cause du
// 500 sur GET /BlogSpot/Details/{id} : avec
// <Nullable>enable</Nullable>, Razor émet un null-check
// implicite sur l'expression, et lève NPE si UserName est
// null. Le helper AvatarSrc défend contre ce cas.
//
// On n'interdit pas les autres usages de Model.UserName
// (alt, title, asp-route-id) : Razor les rend en chaîne
// vide si null, sans NPE. C'est laid, pas cassé.
var path = ResolveTemplatePath();
var content = File.ReadAllText(path);
// L'ancien code fautif concaténait directement
// "/Avatars/" + Model.UserName + ".s.png".
Assert.DoesNotContain("Model.UserName + ", content);
Assert.DoesNotContain("Model.UserName+", content);
}
[Fact]
public void ApplicationUser_cshtml_uses_the_null_safe_helper_for_avatar()
{
var path = ResolveTemplatePath();
var content = File.ReadAllText(path);
Assert.Contains("UserDisplayHelpers.AvatarSrc", content);
}
private static string ResolveTemplatePath()
{
// Le test s'exécute depuis src/Yavsc.Org.Tests/bin/...,
// on remonte pour trouver la vue source.
var dir = AppContext.BaseDirectory;
for (var i = 0; i < 8 && dir is not null; i++)
{
var candidate = Path.Combine(dir,
"src", "Yavsc.Org", "Views", "Shared",
"DisplayTemplates", "ApplicationUser.cshtml");
if (File.Exists(candidate)) return candidate;
dir = Path.GetDirectoryName(dir);
}
throw new FileNotFoundException(
"Could not locate ApplicationUser.cshtml from " + AppContext.BaseDirectory);
}
}

View file

@ -0,0 +1,63 @@
using Xunit;
using Yavsc.Abstract;
using Yavsc.Abstract.Identity;
namespace Yavsc.Org.Tests.NonRegression;
/// <summary>
/// Régression sur le 500 <c>GET /BlogSpot/Details/{id}</c> : un
/// billet dont l'auteur a un <c>UserName</c> null ou absent faisait
/// lever <c>NullReferenceException</c> dans le display template
/// <c>ApplicationUser.cshtml</c> à l'évaluation de
/// <c>Model.UserName</c>. ASP.NET transforme en 500, et la page
/// d'erreur elle-même crash (ErrorViewModel manquant), donc on
/// ne voit rien — juste un 500 muet.
///
/// Le fix passe par <see cref="UserDisplayHelpers.AvatarSrc"/> qui
/// retourne <see cref="YavscConstants.DefaultAvatar"/> pour toute
/// donnée partielle. Ces tests couvrent les trois formes de
/// "donnée absente" : user null, UserName vide, UserName whitespace.
/// </summary>
public class UserDisplayHelpersTests
{
[Fact]
public void AvatarSrc_null_user_returns_default_avatar()
{
Assert.Equal(YavscConstants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(null));
}
[Fact]
public void AvatarSrc_user_with_empty_UserName_returns_default_avatar()
{
var user = new FakeUser { UserName = "" };
Assert.Equal(YavscConstants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(user));
}
[Fact]
public void AvatarSrc_user_with_whitespace_UserName_returns_default_avatar()
{
var user = new FakeUser { UserName = " " };
Assert.Equal(YavscConstants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(user));
}
[Fact]
public void AvatarSrc_valid_user_returns_canonical_avatars_path()
{
var user = new FakeUser { UserName = "alice" };
// Le path doit matcher YavscConstants.AvatarsPath (minuscule),
// pas un /Avatars/ avec S majuscule qui ne résout pas
// dans le middleware de fichiers statiques.
var expected = $"{YavscConstants.AvatarsPath}/alice.s.png";
Assert.Equal(expected, UserDisplayHelpers.AvatarSrc(user));
}
private sealed class FakeUser : IApplicationUser
{
public string Id { get; set; } = "";
public string? UserName { get; set; }
public string? Avatar { get; set; }
public IAccountBalance? AccountBalance => null;
public string? DedicatedGoogleCalendar => null;
public ILocation? PostalAddress => null;
}
}

View file

@ -26,10 +26,15 @@ public class TestWebApplicationFactory : WebApplicationFactory<Program>
{ {
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
// UseDevelopmentEnvironment triggers the dev signing credential // UseEnvironment("Testing") puts the host in a dedicated
// path in the production startup, so we don't need a real cert // configuration environment so AddConfiguration("org") in
// to satisfy IdentityServer at boot. // Program.Main loads the optional appsettings-org.Testing.json
builder.UseEnvironment("Development"); // file (which overrides the connection string and SMTP section
// for the test host). See that file for the values.
// We don't use "Development" because that environment is also
// used by the dev launcher and would change the signing
// credential path in IdentityServer; "Testing" is unambiguous.
builder.UseEnvironment("Testing");
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
{ {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class EnforceBlogAuthorFKs : Migration
{
/// <inheritdoc />
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",
table: "BlogSpot");
migrationBuilder.DropForeignKey(
name: "FK_Comment_AspNetUsers_AuthorId",
table: "Comment");
migrationBuilder.AddForeignKey(
name: "FK_BlogSpot_AspNetUsers_AuthorId",
table: "BlogSpot",
column: "AuthorId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Comment_AspNetUsers_AuthorId",
table: "Comment",
column: "AuthorId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_BlogSpot_AspNetUsers_AuthorId",
table: "BlogSpot");
migrationBuilder.DropForeignKey(
name: "FK_Comment_AspNetUsers_AuthorId",
table: "Comment");
migrationBuilder.AddForeignKey(
name: "FK_BlogSpot_AspNetUsers_AuthorId",
table: "BlogSpot",
column: "AuthorId",
principalTable: "AspNetUsers",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Comment_AspNetUsers_AuthorId",
table: "Comment",
column: "AuthorId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

View file

@ -3803,7 +3803,8 @@ namespace Yavsc.Migrations
{ {
b.HasOne("Yavsc.Models.ApplicationUser", "Author") b.HasOne("Yavsc.Models.ApplicationUser", "Author")
.WithMany("Posts") .WithMany("Posts")
.HasForeignKey("AuthorId"); .HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Author"); b.Navigation("Author");
}); });
@ -3830,9 +3831,9 @@ namespace Yavsc.Migrations
modelBuilder.Entity("Yavsc.Models.Blog.Comment", b => modelBuilder.Entity("Yavsc.Models.Blog.Comment", b =>
{ {
b.HasOne("Yavsc.Models.ApplicationUser", "Author") b.HasOne("Yavsc.Models.ApplicationUser", "Author")
.WithMany() .WithMany("BlogComments")
.HasForeignKey("AuthorId") .HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.HasOne("Yavsc.Models.Blog.Comment", "Parent") b.HasOne("Yavsc.Models.Blog.Comment", "Parent")
@ -4542,6 +4543,8 @@ namespace Yavsc.Migrations
b.Navigation("BlackList"); b.Navigation("BlackList");
b.Navigation("BlogComments");
b.Navigation("Book"); b.Navigation("Book");
b.Navigation("Circles"); b.Navigation("Circles");

View file

@ -1,7 +1,11 @@
@using Yavsc.Abstract.Identity @using Yavsc.Abstract.Identity
@model ApplicationUser @model ApplicationUser
@{ @{
var avuri = "/Avatars/" + Model.UserName + ".s.png"; // Le helper défend contre Model null et contre UserName vide
// ou whitespace. Sans cette garde, Razor lève
// NullReferenceException ici, ce qui propage un 500 et
// masque aussi la page d'erreur.
var avuri = UserDisplayHelpers.AvatarSrc(Model);
} }
<div class="userinfo"> <div class="userinfo">
<a title="Posts" asp-controller="Blogspot" asp-action="Index" asp-route-id="@Model.UserName" class="btn btn-primary"> <a title="Posts" asp-controller="Blogspot" asp-action="Index" asp-route-id="@Model.UserName" class="btn btn-primary">

View file

@ -0,0 +1,11 @@
{
"ConnectionStrings": {
"YavscConnection": "InMemory"
},
"Smtp": {
"Host": "smtp.test.local",
"Port": 465,
"UserName": "test-user",
"Password": "test-pass"
}
}

View file

@ -131,13 +131,10 @@ namespace Yavsc.Models
// builder.Entity<IdentityUserLogin<String>>().HasKey(i=> new { i.LoginProvider, i.UserId, i.ProviderKey }); // builder.Entity<IdentityUserLogin<String>>().HasKey(i=> new { i.LoginProvider, i.UserId, i.ProviderKey });
builder.Entity<ClientSecret>().HasOne<Client>().WithMany(e => e.ClientSecrets).HasForeignKey(e => e.ClientId); builder.Entity<ClientSecret>().HasOne<Client>().WithMany(e => e.ClientSecrets).HasForeignKey(e => e.ClientId);
builder.Entity<ClientScope>().HasOne<Client>().WithMany(e => e.AllowedScopes).HasForeignKey(e => e.ClientId);
builder.Entity<ClientIdPRestriction>().HasOne<Client>().WithMany(e => e.IdentityProviderRestrictions).HasForeignKey(e => e.ClientId); builder.Entity<ClientIdPRestriction>().HasOne<Client>().WithMany(e => e.IdentityProviderRestrictions).HasForeignKey(e => e.ClientId);
builder.Entity<ClientProperty>().HasOne<Client>().WithMany(e => e.Properties).HasForeignKey(e => e.ClientId); builder.Entity<ClientProperty>().HasOne<Client>().WithMany(e => e.Properties).HasForeignKey(e => e.ClientId);
builder.Entity<ClientPostLogoutRedirectUri>().HasOne<Client>().WithMany(e => e.PostLogoutRedirectUris).HasForeignKey(e => e.ClientId); builder.Entity<ClientPostLogoutRedirectUri>().HasOne<Client>().WithMany(e => e.PostLogoutRedirectUris).HasForeignKey(e => e.ClientId);
builder.Entity<ClientRedirectUri>().HasOne<Client>().WithMany(e => e.RedirectUris).HasForeignKey(e => e.ClientId);
builder.Entity<ClientCorsOrigin>().HasOne<Client>().WithMany(e => e.AllowedCorsOrigins).HasForeignKey(e => e.ClientId); builder.Entity<ClientCorsOrigin>().HasOne<Client>().WithMany(e => e.AllowedCorsOrigins).HasForeignKey(e => e.ClientId);
builder.Entity<ClientGrantType>().HasOne<Client>().WithMany(e => e.AllowedGrantTypes).HasForeignKey(e => e.ClientId);
builder.Entity<ApiResourceSecret>().HasOne<ApiResource>().WithMany(e => e.Secrets).HasForeignKey(e => e.ApiResourceId); builder.Entity<ApiResourceSecret>().HasOne<ApiResource>().WithMany(e => e.Secrets).HasForeignKey(e => e.ApiResourceId);
builder.Entity<ApiResourceScope>().HasOne<ApiResource>().WithMany(e => e.Scopes).HasForeignKey(e => e.ApiResourceId); builder.Entity<ApiResourceScope>().HasOne<ApiResource>().WithMany(e => e.Scopes).HasForeignKey(e => e.ApiResourceId);
builder.Entity<ApiResourceClaim>().HasOne<ApiResource>().WithMany(e => e.UserClaims).HasForeignKey(e => e.ApiResourceId); builder.Entity<ApiResourceClaim>().HasOne<ApiResource>().WithMany(e => e.UserClaims).HasForeignKey(e => e.ApiResourceId);
@ -214,6 +211,22 @@ namespace Yavsc.Models
// Log immuable — pas de update autorisé // Log immuable — pas de update autorisé
e.ToTable(tb => tb.HasCheckConstraint("CK_ModerationLog_Immutable", "1=1")); e.ToTable(tb => tb.HasCheckConstraint("CK_ModerationLog_Immutable", "1=1"));
}); });
// ── Blog FK strictness ─────────────────────────────────────────────
// Tout billet a un auteur, tout commentaire a un auteur : pas de
// cascade en suppression d'un user, pas d'orphelin toléré. Le code
// applicatif (BlogSpotService.Details) s'appuie sur cette
// contrainte pour pouvoir assumer l'existence de l'auteur.
builder.Entity<BlogPost>()
.HasOne(b => b.Author)
.WithMany(u => u.Posts)
.HasForeignKey(b => b.AuthorId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<Comment>()
.HasOne(c => c.Author)
.WithMany(u => u.BlogComments)
.HasForeignKey(c => c.AuthorId)
.OnDelete(DeleteBehavior.Restrict);
} }
/// <summary> /// <summary>

View file

@ -114,6 +114,13 @@ namespace Yavsc.Models
[InverseProperty("Member")] [InverseProperty("Member")]
public virtual List<CircleMember>? Membership { get; set; } public virtual List<CircleMember>? Membership { get; set; }
/// <summary>
/// User's blog comments
/// </summary>
[JsonIgnore]
[InverseProperty("Author")]
public virtual List<Blog.Comment>? BlogComments { get; set; }
IAccountBalance? IApplicationUser.AccountBalance => AccountBalance; IAccountBalance? IApplicationUser.AccountBalance => AccountBalance;
ILocation? IApplicationUser.PostalAddress { get => PostalAddress; } ILocation? IApplicationUser.PostalAddress { get => PostalAddress; }

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