Compare commits

...

9 commits

Author SHA1 Message Date
becd233593 Merge pull request 'fix/issue-3-splitquery' (#5) from fix/issue-3-splitquery into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #5
2026-07-12 02:47:15 +01:00
9846210fd6 ApplicationDbContext: drop redundant HasOne on 3 Client navs
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Has been cancelled
Dotnet build and test / build (pull_request) Has been cancelled
LoadClientAsync(id) on ClientController used to trip an
IndexOutOfRangeException at the InMemory shaper for any
.Include() of one of three Client navs: RedirectUris,
AllowedScopes, AllowedGrantTypes. Five other Client navs (with
the same EF shape and the same application-level config) worked
fine.

Bisection pointed at the InMemory provider; that hypothesis was
wrong. The real cause is in ApplicationDbContext.OnModelCreating:
yavsc was redeclaring the HasOne<Client>().WithMany(...).
HasForeignKey(e => e.ClientId) for all eight Client* navs. The
same relation is already declared (more completely, with
.IsRequired().OnDelete(DeleteBehavior.Cascade)) by
IdentityServer8's ConfigureClientStore via ModelBuilderExtensions.

The redundant mapping on three specific entities — ClientScope,
ClientRedirectUri, ClientGrantType — interacts with the InMemory
provider's shaper in a way that throws IndexOutOfRange. Removing
the redundancy fixes it.

This commit also walks back b12c272d:
- Drops .AsSplitQuery() from LoadClientAsync (no longer needed
  for the InMemory shaper, and the Postgres path it was a hedge
  against was a false alarm — there is no Postgres production
  bug here, only an InMemory shaper quirk surfaced by the
  redundant mapping).
- Removes the 9 Bisect_*_alone tests that were the artefact of
  the provider-hypothesis phase. They pointed at the right
  entities but for the wrong reason.
- Keeps EditRedirectUris_GET_after_add_lists_both_uris as the
  end-to-end regression sentinel: with the fix in place, it
  loads a Client with two RedirectUris and asserts both are
  rendered. Without the fix, it fails with IndexOutOfRange.
2026-07-12 02:39:13 +01:00
b12c272df7 ClientController: split LoadClientAsync into per-collection subqueries
LoadClientAsync chains 9 .Include() calls on dbContext.Clients.
On Postgres (and InMemory for some IdentityServer8 nav types), the
resulting cartesian product trips the query shaper with
IndexOutOfRangeException at IncludeCollection materialisation time.
Bug reproduces in production on the Blog admin pages that load a
Client by id.

AsSplitQuery() rewrites the load as 9 separate SELECTs joined by
client id, which sidesteps the cartesian explosion and any shaper
ambiguity between Claims/Properties/ClientSecrets (which share
Type/Value column names across some IdentityServer8 versions).

Tests:
- EditRedirectUris_GET_after_add_lists_both_uris: end-to-end
  reproducer that adds a second RedirectUri via POST then re-GETs
  the editor. Guards the fix on the integration path.
- Bisect_*_alone: nine unit tests that exercise the same
  .SingleOrDefaultAsync(c => c.Id == id).Include(nav) on the
  InMemory provider, one nav at a time. Pinpointed three
  problematic navs (RedirectUris, AllowedScopes, AllowedGrantTypes)
  on InMemory; kept as a regression net for any future shaper
  regressions on the InMemory provider (not the Postgres path).
2026-07-12 01:15:02 +01:00
cb20b8a2d5 Revert "repoduces the bug"
This reverts commit fa7794b7a0.
2026-07-11 22:17:58 +01:00
Lum
fa7794b7a0 repoduces the bug 2026-07-11 21:56:25 +01:00
7a066707b3 Tests: route Yavsc.Org test host through Testing environment
TestWebApplicationFactory used ASPNETCORE_ENVIRONMENT=Development, which
caused Program.Main's AddConfiguration("org") to load the tracked
appsettings-org.json (the reference file with the
'*** via dotnet user-secrets ou variable d'environnement ***'
placeholder connection string). Npgsql then failed to parse that
placeholder during host startup, failing six integration tests
(observed 2026-07-11: System.ArgumentException on
NpgsqlConnectionStringBuilder.set_Item).

Switching the test host to a dedicated Testing environment makes
AddConfiguration("org") pick up the new optional
appsettings-org.Testing.json file as the last source in the chain
(JSON → env vars), which overrides YavscConnection with the
InMemory marker and the Smtp section with the test stub values.

The .gitignore exception whitelists this file explicitly: it is a
configuration source for the test host, not a secrets file.

The WebServerFixture path is unchanged — it owns its
WebApplicationBuilder and adds the same in-memory override via its
BuildApp hook.
2026-07-11 20:50:39 +01:00
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
Lum
98613e7070 Blog: enforce Restrict FK on BlogPost.Author and Comment.Author
Tout billet a un auteur, tout commentaire a un auteur. On aligne la
base sur ce contrat (Postgres) en droppant les orphelins existants
puis en remplaçant les FK en cascade par des FK Restrict.

- ApplicationDbContext: fluent pour BlogPost.Author et Comment.Author
  en DeleteBehavior.Restrict.
- ApplicationUser: ajoute la nav inverse BlogComments (manquait,
  EF aurait sinon créé une shadow FK).
- Migration 20260711173717_EnforceBlogAuthorFKs: Up purge les
  Comment/BlogSpot dont l'AuthorId n'existe plus, log le volume,
  puis drop+add des FK. Down laisse la cascade (état pré-migration).

Le code applicatif (BlogSpotService.Details) s'appuiera sur cette
contrainte dans un commit séparé.
2026-07-11 18:45:27 +01:00
16 changed files with 5324 additions and 11 deletions

9
.gitignore vendored
View file

@ -24,6 +24,15 @@ data/
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/
*.tmp
DataDir/

View file

@ -17,6 +17,7 @@ La racine de l'architecture est [Architecture.md](Architecture.md).
| [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh |
| [architecture/postit.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

@ -110,6 +110,77 @@ public class ClientControllerCollectionTests : IClassFixture<TestWebApplicationF
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]
public async Task AddRedirectUri_POST_appends_to_database()
{
@ -206,6 +277,26 @@ public class ClientControllerCollectionTests : IClassFixture<TestWebApplicationF
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)
{
const string marker = "name=\"__RequestVerificationToken\"";

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class EnforceBlogAuthorFKs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Assainir les orphelins AVANT d'enforcer la FK Restrict.
// En prod (Postgres), la migration aurait sinon planté
// sur des billets/commentaires dont l'AuthorId pointe
// vers un user déjà supprimé. La logique métier refuse
// désormais l'orphelin (cf. BlogSpotService.Details) — on
// aligne l'état de la base avec ce contrat.
migrationBuilder.Sql(@"
DO $$
DECLARE n_comments int;
n_posts int;
BEGIN
DELETE FROM ""Comment""
WHERE ""AuthorId"" NOT IN (SELECT ""Id"" FROM ""AspNetUsers"");
GET DIAGNOSTICS n_comments = ROW_COUNT;
DELETE FROM ""BlogSpot""
WHERE ""AuthorId"" NOT IN (SELECT ""Id"" FROM ""AspNetUsers"");
GET DIAGNOSTICS n_posts = ROW_COUNT;
RAISE NOTICE 'EnforceBlogAuthorFKs: % orphaned comments deleted, % orphaned blog posts deleted',
n_comments, n_posts;
END $$;
");
migrationBuilder.DropForeignKey(
name: "FK_BlogSpot_AspNetUsers_AuthorId",
table: "BlogSpot");
migrationBuilder.DropForeignKey(
name: "FK_Comment_AspNetUsers_AuthorId",
table: "Comment");
migrationBuilder.AddForeignKey(
name: "FK_BlogSpot_AspNetUsers_AuthorId",
table: "BlogSpot",
column: "AuthorId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Comment_AspNetUsers_AuthorId",
table: "Comment",
column: "AuthorId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_BlogSpot_AspNetUsers_AuthorId",
table: "BlogSpot");
migrationBuilder.DropForeignKey(
name: "FK_Comment_AspNetUsers_AuthorId",
table: "Comment");
migrationBuilder.AddForeignKey(
name: "FK_BlogSpot_AspNetUsers_AuthorId",
table: "BlogSpot",
column: "AuthorId",
principalTable: "AspNetUsers",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Comment_AspNetUsers_AuthorId",
table: "Comment",
column: "AuthorId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

View file

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

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,11 @@
{
"ConnectionStrings": {
"YavscConnection": "InMemory"
},
"Smtp": {
"Host": "smtp.test.local",
"Port": 465,
"UserName": "test-user",
"Password": "test-pass"
}
}

View file

@ -131,13 +131,10 @@ namespace Yavsc.Models
// builder.Entity<IdentityUserLogin<String>>().HasKey(i=> new { i.LoginProvider, i.UserId, i.ProviderKey });
builder.Entity<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<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<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<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<ApiResourceScope>().HasOne<ApiResource>().WithMany(e => e.Scopes).HasForeignKey(e => e.ApiResourceId);
builder.Entity<ApiResourceClaim>().HasOne<ApiResource>().WithMany(e => e.UserClaims).HasForeignKey(e => e.ApiResourceId);
@ -214,6 +211,22 @@ namespace Yavsc.Models
// Log immuable — pas de update autorisé
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>

View file

@ -114,6 +114,13 @@ namespace Yavsc.Models
[InverseProperty("Member")]
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;
ILocation? IApplicationUser.PostalAddress { get => PostalAddress; }

View file

@ -0,0 +1,149 @@
# Yavsc.Tests.Shared
Scaffold partagé pour les tests d'intégration ASP.NET Core de
Yavsc. Ce projet **n'est pas lui-même un projet de tests** — il
n'a pas xUnit ni de test runner. Il expose des fixtures
réutilisables que les projets de tests consommateurs
(`Yavsc.Org.Tests`, `Yavsc.Blogs.Tests`, etc.) héritent ou
instancient.
## Contenu
| Fichier | Rôle |
|---|---|
| `WebHostFixture.cs` | Base abstraite : Kestrel HTTPS, certificat auto-signé, port dynamique, host partagé inter-fixtures |
| `TestAuthPolicyProvider.cs` | `IAuthorizationPolicyProvider` de test, lit `X-Test-Role` au lieu d'interroger la DB |
| `TestTokenIssuer.cs` | Émet des JWT HS256 signés avec une clé statique, pour les tests d'API qui montent un `AddJwtBearer` réel |
## WebHostFixture
`WebHostFixture` est la base de toute fixture d'intégration.
Une seule instance de `WebApplication` tourne par process ; les
fixtures qui héritent partagent le host. Kestrel est bindé sur
`127.0.0.1:0` (port dynamique) avec un certificat auto-signé
généré lazily.
### Cycle de vie
- **Premier ctor** d'une fixture concrète → `InitializeAsync()`
lance `BuildApp(builder)` puis `ConfigurePipelineAsync(app)`
puis `app.StartAsync()`. L'`IServerAddressesFeature` est lu
pour peupler `Addresses`.
- **Ctors suivants** (xUnit instancie une fixture par
`IClassFixture<T>`) → reprise de l'état partagé via
`CopySpecialisedSharedState()` (vide par défaut, surchargeable).
- **Dernier `Dispose`**`app.StopAsync()`, reset des slots
statiques.
### Hooks à surcharger
| Hook | Quand | Quoi y mettre |
|---|---|---|
| `BuildApp(builder)` | Toujours | Enregistrement des services, configuration in-memory, seeding éventuel |
| `ConfigurePipelineAsync(app)` | Optionnel | Pipeline middleware spécifique (sinon : pas de pipeline custom) |
| `CopySpecialisedSharedState()` | Optionnel | Recopie des slots statiques de la spécialisation sur les propriétés d'instance |
### Exemple : fixture de portée minimale
```csharp
public sealed class MyFixture : WebHostFixture
{
protected override WebApplication BuildApp(WebApplicationBuilder builder)
{
// In-memory config, services, etc.
return builder.Build();
}
}
```
`MyFixture` n'a pas de test runner propre ; c'est l'assembly
consommateur (par exemple `Yavsc.MyModule.Tests`) qui déclare
les `[Fact]` et utilise `IClassFixture<MyFixture>`.
## Spécifications : fixtures concrètes
Deux fixtures héritent de `WebHostFixture` dans le repo :
### Yavsc.Org.Tests.WebServerFixture
Pour le host principal de Yavsc.Org. Caractéristiques :
- Configure `InMemory` pour la `ConnectionStrings` Yavsc
- Remplace `IAuthorizationPolicyProvider` par
`TestAuthPolicyProvider` **avant** `ConfigureWebAppServices`
(qui freeze la collection de services)
- Stub `ISmtpClientFactory` par `RecordingSmtpClientFactory`
pour capturer les envois sans SMTP réel
- Seed IdentityServer8 : un `Client` + une `ApiScope` "test" +
un `ApplicationUser` "Tester"
- Configure le pipeline via `app.ConfigurePipeline(...)` avec
un manifeste de static assets explicite (le MSBuild target
`CopyYavscOrgStaticAssets` du csproj miroir les manifests
Yavsc.Org sous le nom Yavsc.Org.Tests.* dans le bin de test)
Cf. `src/Yavsc.Org.Tests/WebServerFixture.cs`.
### Yavsc.Blogs.Tests.BlogsWebServerFixture
Pour le host API de Yavsc.Blogs. Caractéristiques :
- `UseInMemoryDatabase("Yavsc.Blogs.Tests", _inMemoryRoot)`
un `InMemoryDatabaseRoot` partagé pour que POST + GET voient
le même store
- `BlogSpotService` réel (pas de mock)
- `PermissionHandler` réel (le handler d'authorization qui
résout `IsOwner(user, blog)`)
- `AddJwtBearer` réel avec HS256, validation contre
`TestTokenIssuer.SigningKey` — pas d'OIDC discovery, pas
d'IdP
- Politique `BlogScope` verbatim (`RequireAuthenticatedUser` +
`RequireClaim("scope", "blogs")`)
Cf. `src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs`.
## TestAuthPolicyProvider
`IAuthorizationPolicyProvider` de test qui lit le rôle dans
l'en-tête HTTP `X-Test-Role` au lieu d'interroger la
`UserManager`. Permet aux smoke tests d'exercer `[Authorize
("AdministratorOnly")]` sans seed de rôle réel.
Activation : enregistré par les fixtures spécialisées **avant**
`ConfigureWebAppServices` (qui call `builder.Build()` et
fige la collection). La sémantique last-write-wins du
`AddSingleton` fait que le test provider prend le pas.
## TestTokenIssuer
Émet un JWT HS256 avec une `SigningKey` statique, exposé en
`TestTokenIssuer.SigningKey` (et `Issuer`). Les fixtures qui
montent un `AddJwtBearer` réutilisent cette clé pour valider
les tokens localement, sans OIDC discovery.
Helpers :
- `TestTokenIssuer.Issue(subject, scope, lifetime)`
chaîne `"Bearer <jwt>"` prête pour un header HTTP
- `TestTokenIssuer.SigningKey``SymmetricSecurityKey` à
passer au `TokenValidationParameters` du `AddJwtBearer`
## Tests statiques sur du code compilé
Pour tester un display template Razor sans monter un host
ASP.NET, on peut s'appuyer sur la lecture du fichier source
et asserter des invariants syntaxiques. Cf.
`Yavsc.Org.Tests/NonRegression/ApplicationUserDisplayTemplateTests`
pour un exemple : on asserte que le cshtml ne porte plus
`Model.UserName` directement, ce qui aurait rouvert la
non-régression du 500 sur `/BlogSpot/Details/{id}`.
C'est pragmatique : la mise en place d'un `RazorProjectEngine`
pour compiler et rendre une vue hors host coûte plus cher que
ce qu'elle protège pour un seul template.
## Pour aller plus loin
- `doc/testing.md` à la racine : vue d'ensemble de la
stratégie de test
- `src/Yavsc.Org.Tests/NonRegression/` et
`src/Yavsc.Blogs.Tests/` : exemples d'utilisation