Compare commits

..

2 commits

Author SHA1 Message Date
Lum
cb7526de9d Blog: add test guarding the display-template fix + document test architecture
Le commit 2 a fixé la NPE du /BlogSpot/Details/{id} en passant
le display template par UserDisplayHelpers.AvatarSrc, qui
défend contre un UserName null. Ce commit complète le filet
de non-régression et pose la doc d'architecture des tests.

- ApplicationUserDisplayTemplateTests : assert que le cshtml ne
  concatène plus directement Model.UserName (ancien code fautif)
  et qu'il utilise bien le helper. Si quelqu'un revert la ligne
  4 du cshtml, les tests cassent. Les autres usages de
  Model.UserName (alt, title, asp-route-id) sont autorisés : ils
  ne sont pas la cause du 500, juste laids si null.
- doc/testing.md : vue d'ensemble de la stratégie de test
  (conventions NonRegression/Mandatory/Smoke/Controllers, EF
  in-memory via InMemoryDatabaseRoot partagé, auth stubs,
  quand ne pas écrire de test).
- src/Yavsc.Tests.Shared/README.md : détails du scaffold partagé
  (WebHostFixture + son cycle de vie et ses hooks,
  TestAuthPolicyProvider, TestTokenIssuer) et des deux
  spécialisations dans le repo
  (Yavsc.Org.Tests.WebServerFixture et
  Yavsc.Blogs.Tests.BlogsWebServerFixture).
- doc/README.md : entrée vers testing.md dans l'index.
2026-07-11 19:59:03 +01:00
Lum
bbdcc7f2ad Blog: render user avatar through a null-safe helper
GET /BlogSpot/Details/1 retournait 500 (avec un 500-sur-500 sur
la page d'erreur elle-même) parce que DisplayTemplates
/ApplicationUser.cshtml faisait `var avuri = "/Avatars/" +
Model.UserName + ".s.png"` : avec <Nullable>enable</Nullable>,
Razor émet un null-check implicite sur Model.UserName et lève
NullReferenceException quand l'auteur n'a pas de UserName
posé (donnée héritée, user partiellement initialisé).

- UserDisplayHelpers.AvatarSrc : helper statique pur dans
  Yavsc.Abstract.Identity qui retourne
  YavscConstants.DefaultAvatar pour user null / UserName vide
  ou whitespace, et un path /avatars/<name>.s.png sinon.
- ApplicationUser.cshtml : utilise le helper.
- Tests : 4 cas (null, vide, whitespace, valide) dans
  Yavsc.Org.Tests/NonRegression.

Bonus : le path d'avatar passe de "/Avatars/" (S majuscule,
ne résolvait pas dans le middleware de fichiers statiques) à
YavscConstants.AvatarsPath ("/avatars" minuscule), pour fermer
l'autre trou que centraliser le calcul permettait de fixer
proprement.
2026-07-11 19:41:09 +01:00
7 changed files with 411 additions and 1 deletions

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.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) |
| [testing.md](testing.md) | Stratégie de test : conventions des dossiers, EF Core in-memory, auth stubs, scaffold partagé |
## 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

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

@ -1,7 +1,11 @@
@using Yavsc.Abstract.Identity
@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">
<a title="Posts" asp-controller="Blogspot" asp-action="Index" asp-route-id="@Model.UserName" class="btn btn-primary">

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