diff --git a/doc/README.md b/doc/README.md index 912a2e56..b189bb27 100644 --- a/doc/README.md +++ b/doc/README.md @@ -17,7 +17,6 @@ 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 deleted file mode 100644 index ef80cda9..00000000 --- a/doc/testing.md +++ /dev/null @@ -1,88 +0,0 @@ -# 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 deleted file mode 100644 index c2700e93..00000000 --- a/src/Yavsc.Abstract/Identity/UserDisplayHelpers.cs +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 9fed6a15..00000000 --- a/src/Yavsc.Org.Tests/NonRegression/ApplicationUserDisplayTemplateTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index a0249fa4..00000000 --- a/src/Yavsc.Org.Tests/NonRegression/UserDisplayHelpersTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -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 2e4ef1bb..b7bd532f 100644 --- a/src/Yavsc.Org/Views/Shared/DisplayTemplates/ApplicationUser.cshtml +++ b/src/Yavsc.Org/Views/Shared/DisplayTemplates/ApplicationUser.cshtml @@ -1,11 +1,7 @@ @using Yavsc.Abstract.Identity @model ApplicationUser @{ - // 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); + var avuri = "/Avatars/" + Model.UserName + ".s.png"; }
diff --git a/src/Yavsc.Tests.Shared/README.md b/src/Yavsc.Tests.Shared/README.md deleted file mode 100644 index 2bef8fc9..00000000 --- a/src/Yavsc.Tests.Shared/README.md +++ /dev/null @@ -1,149 +0,0 @@ -# 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