From b12c272df7fa91bee2ab27dc6df4438ac64660d1 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 12 Jul 2026 01:15:02 +0100 Subject: [PATCH] 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). --- .../ClientControllerCollectionTests.cs | 229 ++++++++++++++++++ .../ClientController.Collections.cs | 1 + 2 files changed, 230 insertions(+) diff --git a/src/Yavsc.Org.Tests/Controllers/ClientControllerCollectionTests.cs b/src/Yavsc.Org.Tests/Controllers/ClientControllerCollectionTests.cs index fe6bc2fc..cf18970e 100644 --- a/src/Yavsc.Org.Tests/Controllers/ClientControllerCollectionTests.cs +++ b/src/Yavsc.Org.Tests/Controllers/ClientControllerCollectionTests.cs @@ -110,6 +110,77 @@ public class ClientControllerCollectionTests : IClassFixture(); + 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,164 @@ public class ClientControllerCollectionTests : IClassFixture BisectClientDbId() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return db.Clients.Single(c => c.ClientId == TargetClientId).Id; + } + + [Fact] + public async Task Bisect_RedirectUris_alone() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = await BisectClientDbId(); + var client = await db.Clients + .Include(c => c.RedirectUris) + .SingleOrDefaultAsync(c => c.Id == id, TestContext.Current.CancellationToken); + Assert.NotNull(client); + Assert.NotNull(client!.RedirectUris); + } + + [Fact] + public async Task Bisect_PostLogoutRedirectUris_alone() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = await BisectClientDbId(); + var client = await db.Clients + .Include(c => c.PostLogoutRedirectUris) + .SingleOrDefaultAsync(c => c.Id == id, TestContext.Current.CancellationToken); + Assert.NotNull(client); + Assert.NotNull(client!.PostLogoutRedirectUris); + } + + [Fact] + public async Task Bisect_AllowedScopes_alone() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = await BisectClientDbId(); + var client = await db.Clients + .Include(c => c.AllowedScopes) + .SingleOrDefaultAsync(c => c.Id == id, TestContext.Current.CancellationToken); + Assert.NotNull(client); + Assert.NotNull(client!.AllowedScopes); + } + + [Fact] + public async Task Bisect_AllowedGrantTypes_alone() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = await BisectClientDbId(); + var client = await db.Clients + .Include(c => c.AllowedGrantTypes) + .SingleOrDefaultAsync(c => c.Id == id, TestContext.Current.CancellationToken); + Assert.NotNull(client); + Assert.NotNull(client!.AllowedGrantTypes); + } + + [Fact] + public async Task Bisect_AllowedCorsOrigins_alone() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = await BisectClientDbId(); + var client = await db.Clients + .Include(c => c.AllowedCorsOrigins) + .SingleOrDefaultAsync(c => c.Id == id, TestContext.Current.CancellationToken); + Assert.NotNull(client); + Assert.NotNull(client!.AllowedCorsOrigins); + } + + [Fact] + public async Task Bisect_IdentityProviderRestrictions_alone() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = await BisectClientDbId(); + var client = await db.Clients + .Include(c => c.IdentityProviderRestrictions) + .SingleOrDefaultAsync(c => c.Id == id, TestContext.Current.CancellationToken); + Assert.NotNull(client); + Assert.NotNull(client!.IdentityProviderRestrictions); + } + + [Fact] + public async Task Bisect_Claims_alone() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = await BisectClientDbId(); + var client = await db.Clients + .Include(c => c.Claims) + .SingleOrDefaultAsync(c => c.Id == id, TestContext.Current.CancellationToken); + Assert.NotNull(client); + Assert.NotNull(client!.Claims); + } + + [Fact] + public async Task Bisect_Properties_alone() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = await BisectClientDbId(); + var client = await db.Clients + .Include(c => c.Properties) + .SingleOrDefaultAsync(c => c.Id == id, TestContext.Current.CancellationToken); + Assert.NotNull(client); + Assert.NotNull(client!.Properties); + } + + [Fact] + public async Task Bisect_ClientSecrets_alone() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = await BisectClientDbId(); + var client = await db.Clients + .Include(c => c.ClientSecrets) + .SingleOrDefaultAsync(c => c.Id == id, TestContext.Current.CancellationToken); + Assert.NotNull(client); + Assert.NotNull(client!.ClientSecrets); + } + + [Fact] + public async Task Bisect_baseline_no_include() + { + // Sanity check : la query de base (sans Include) doit passer. + // Si celle-ci échoue, le problème n'est pas un Include. + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = await BisectClientDbId(); + var client = await db.Clients + .SingleOrDefaultAsync(c => c.Id == id, TestContext.Current.CancellationToken); + Assert.NotNull(client); + } + private static string? ExtractAntiforgeryToken(string html) { const string marker = "name=\"__RequestVerificationToken\""; diff --git a/src/Yavsc.Org/Controllers/Administration/ClientController.Collections.cs b/src/Yavsc.Org/Controllers/Administration/ClientController.Collections.cs index b31ec0c3..5569bf77 100644 --- a/src/Yavsc.Org/Controllers/Administration/ClientController.Collections.cs +++ b/src/Yavsc.Org/Controllers/Administration/ClientController.Collections.cs @@ -287,6 +287,7 @@ public partial class ClientController private async Task LoadClientAsync(int id) => await dbContext.Clients + .AsSplitQuery() .Include(c => c.RedirectUris) .Include(c => c.PostLogoutRedirectUris) .Include(c => c.AllowedScopes)