From 37c8fe1a72e0c10ba46fbfbc5f517ce489fad474 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 01:57:31 +0100 Subject: [PATCH] Logout tests: pin the loop's own tick-failure catch now that subject faults are isolated A pass that cannot read its subjects (a scope factory failing like a database outage) is logged by exception type only and retried after the interval, never in a tight loop. The earlier loop test reached this catch through a per-subject exception, which the isolation now absorbs. Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/OidcLogoutTests.cs | 30 +++++++++++++++++++ .../Support/OidcLogoutKit.cs | 25 ++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs index c357507..8aa5d15 100644 --- a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -439,6 +439,36 @@ public sealed class OidcLogoutTests } } + [Fact] + public async Task A_pass_that_cannot_read_its_subjects_is_logged_by_type_and_retried_after_the_interval() + { + using var factory = new OidcLogoutFactory(); + var clock = new ControlledClock(); + var log = new ListLogger(); + var flaky = new FlakyScopeFactory(factory.Services.GetRequiredService(), failures: 1); + var reconciler = factory.NewReconciler(clock, log, flaky); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + await reconciler.StartAsync(CancellationToken.None); + try + { + await WaitForAsync(() => log.Entries.Any(x => x.Level == LogLevel.Error) && clock.Waiting > 0); + var failure = Assert.Single(log.Entries, x => x.Level == LogLevel.Error); + Assert.Contains(nameof(InvalidOperationException), failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain(FlakyScopeFactory.Secret, failure.Message, StringComparison.Ordinal); + + // Never a tight loop: nothing posts until the interval passes, then the pass succeeds. + await Task.Delay(300); + Assert.Empty(factory.Mas.Bodies); + clock.FireTimers(); + await WaitForAsync(() => factory.Mas.Bodies.Count == 1); + await WaitForAsync(() => factory.ValidAuthorizations(factory.AccountId(identity).ToString("D")) == 0); + } + finally + { + await reconciler.StopAsync(CancellationToken.None); + } + } + private static async Task WaitForAsync(Func condition) { var deadline = DateTime.UtcNow.AddSeconds(10); diff --git a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs index 9b771fd..b7425f6 100644 --- a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs +++ b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs @@ -317,6 +317,24 @@ public sealed class RedirectingMas : IDisposable } } +/// A scope factory that fails the first scopes asked of it, as a database outage would. +public sealed class FlakyScopeFactory(IServiceScopeFactory inner, int failures) : IServiceScopeFactory +{ + public const string Secret = "scope-factory-secret-detail"; + + private int _failures = failures; + + public IServiceScope CreateScope() + { + if (Interlocked.Decrement(ref _failures) >= 0) + { + throw new InvalidOperationException(Secret); + } + + return inner.CreateScope(); + } +} + /// An OIDC host with MAS's back-channel logout URI set, a fake MAS behind it and a clock the test drives. public sealed class OidcLogoutFactory : OidcWebApplicationFactory { @@ -361,8 +379,11 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory } /// A reconciler with no memory, built over this host's database, as a restarted process would have. - public OidcLogoutReconciler NewReconciler(TimeProvider? time = null, ILogger? logger = null) => new( - Services.GetRequiredService(), + public OidcLogoutReconciler NewReconciler( + TimeProvider? time = null, + ILogger? logger = null, + IServiceScopeFactory? scopes = null) => new( + scopes ?? Services.GetRequiredService(), Services.GetRequiredService(), Services.GetRequiredService(), Services.GetRequiredService>(),