Compare commits
No commits in common. "becd23359305324480cf87997a9c9b2b3461f390" and "7f56afd7e09e8686ea07e519bd5ca1759f7ca509" have entirely different histories.
becd233593
...
7f56afd7e0
16 changed files with 11 additions and 5324 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -24,15 +24,6 @@ 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/
|
||||||
|
|
|
||||||
|
|
@ -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-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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/<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.
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -110,77 +110,6 @@ 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()
|
||||||
{
|
{
|
||||||
|
|
@ -277,26 +206,6 @@ 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\"";
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -26,15 +26,10 @@ public class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||||
{
|
{
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
{
|
{
|
||||||
// UseEnvironment("Testing") puts the host in a dedicated
|
// UseDevelopmentEnvironment triggers the dev signing credential
|
||||||
// configuration environment so AddConfiguration("org") in
|
// path in the production startup, so we don't need a real cert
|
||||||
// Program.Main loads the optional appsettings-org.Testing.json
|
// to satisfy IdentityServer at boot.
|
||||||
// file (which overrides the connection string and SMTP section
|
builder.UseEnvironment("Development");
|
||||||
// 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
|
|
@ -1,89 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3803,8 +3803,7 @@ 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");
|
||||||
});
|
});
|
||||||
|
|
@ -3831,9 +3830,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("BlogComments")
|
.WithMany()
|
||||||
.HasForeignKey("AuthorId")
|
.HasForeignKey("AuthorId")
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("Yavsc.Models.Blog.Comment", "Parent")
|
b.HasOne("Yavsc.Models.Blog.Comment", "Parent")
|
||||||
|
|
@ -4543,8 +4542,6 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
b.Navigation("BlackList");
|
b.Navigation("BlackList");
|
||||||
|
|
||||||
b.Navigation("BlogComments");
|
|
||||||
|
|
||||||
b.Navigation("Book");
|
b.Navigation("Book");
|
||||||
|
|
||||||
b.Navigation("Circles");
|
b.Navigation("Circles");
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
@using Yavsc.Abstract.Identity
|
@using Yavsc.Abstract.Identity
|
||||||
@model ApplicationUser
|
@model ApplicationUser
|
||||||
@{
|
@{
|
||||||
// Le helper défend contre Model null et contre UserName vide
|
var avuri = "/Avatars/" + Model.UserName + ".s.png";
|
||||||
// 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">
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"YavscConnection": "InMemory"
|
|
||||||
},
|
|
||||||
"Smtp": {
|
|
||||||
"Host": "smtp.test.local",
|
|
||||||
"Port": 465,
|
|
||||||
"UserName": "test-user",
|
|
||||||
"Password": "test-pass"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -131,10 +131,13 @@ 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);
|
||||||
|
|
@ -211,22 +214,6 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -114,13 +114,6 @@ 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; }
|
||||||
|
|
|
||||||
|
|
@ -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<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
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue