diff --git a/doc/README.md b/doc/README.md index b189bb27..912a2e56 100644 --- a/doc/README.md +++ b/doc/README.md @@ -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 diff --git a/doc/testing.md b/doc/testing.md new file mode 100644 index 00000000..ef80cda9 --- /dev/null +++ b/doc/testing.md @@ -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/.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/.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() + .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. diff --git a/src/Yavsc.Abstract/Identity/UserDisplayHelpers.cs b/src/Yavsc.Abstract/Identity/UserDisplayHelpers.cs new file mode 100644 index 00000000..c2700e93 --- /dev/null +++ b/src/Yavsc.Abstract/Identity/UserDisplayHelpers.cs @@ -0,0 +1,36 @@ +namespace Yavsc.Abstract.Identity +{ + /// + /// Helpers Razor-friendly pour rendre un user dans une vue + /// sans exposer le template à des accesseurs nullables qui + /// lèveraient . + /// + public static class UserDisplayHelpers + { + /// + /// Chemin d'avatar à utiliser pour + /// dans un display template. Défense contre null + /// (user pas chargé, FK orpheline) et contre un + /// UserName vide (donnée héritée, user partiellement + /// initialisé). Sans cette garde, Razor émet une + /// NullReferenceException dès qu'un accesseur .UserName + /// apparaît dans le template, ce qui remonte en 500 et + /// masque la page d'erreur elle-même. + /// + /// + /// Le path retourné est aligné sur + /// (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. + /// + public static string AvatarSrc(IApplicationUser? user) + { + if (string.IsNullOrWhiteSpace(user?.UserName)) + return YavscConstants.DefaultAvatar; + return $"{YavscConstants.AvatarsPath}/{user!.UserName}.s.png"; + } + } +} diff --git a/src/Yavsc.Org.Tests/NonRegression/ApplicationUserDisplayTemplateTests.cs b/src/Yavsc.Org.Tests/NonRegression/ApplicationUserDisplayTemplateTests.cs new file mode 100644 index 00000000..9fed6a15 --- /dev/null +++ b/src/Yavsc.Org.Tests/NonRegression/ApplicationUserDisplayTemplateTests.cs @@ -0,0 +1,69 @@ +using System.IO; +using Xunit; + +namespace Yavsc.Org.Tests.NonRegression; + +/// +/// Régression du 500 sur GET /BlogSpot/Details/{id} (auteur +/// sans UserName) : le display template +/// ApplicationUser.cshtml ne doit plus accéder à +/// Model.UserName directement. Toute lecture passe par +/// UserDisplayHelpers.AvatarSrc, qui défend contre +/// null 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. +/// +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 + // enable, 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); + } +} diff --git a/src/Yavsc.Org.Tests/NonRegression/UserDisplayHelpersTests.cs b/src/Yavsc.Org.Tests/NonRegression/UserDisplayHelpersTests.cs new file mode 100644 index 00000000..a0249fa4 --- /dev/null +++ b/src/Yavsc.Org.Tests/NonRegression/UserDisplayHelpersTests.cs @@ -0,0 +1,63 @@ +using Xunit; +using Yavsc.Abstract; +using Yavsc.Abstract.Identity; + +namespace Yavsc.Org.Tests.NonRegression; + +/// +/// Régression sur le 500 GET /BlogSpot/Details/{id} : un +/// billet dont l'auteur a un UserName null ou absent faisait +/// lever NullReferenceException dans le display template +/// ApplicationUser.cshtml à l'évaluation de +/// Model.UserName. 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 qui +/// retourne pour toute +/// donnée partielle. Ces tests couvrent les trois formes de +/// "donnée absente" : user null, UserName vide, UserName whitespace. +/// +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; + } +} diff --git a/src/Yavsc.Org/Views/Shared/DisplayTemplates/ApplicationUser.cshtml b/src/Yavsc.Org/Views/Shared/DisplayTemplates/ApplicationUser.cshtml index b7bd532f..2e4ef1bb 100644 --- a/src/Yavsc.Org/Views/Shared/DisplayTemplates/ApplicationUser.cshtml +++ b/src/Yavsc.Org/Views/Shared/DisplayTemplates/ApplicationUser.cshtml @@ -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); }
diff --git a/src/Yavsc.Tests.Shared/README.md b/src/Yavsc.Tests.Shared/README.md new file mode 100644 index 00000000..2bef8fc9 --- /dev/null +++ b/src/Yavsc.Tests.Shared/README.md @@ -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`) → 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`. + +## 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 "` 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