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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-28 01:57:31 +01:00
commit 37c8fe1a72
2 changed files with 53 additions and 2 deletions

View file

@ -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<OidcLogoutReconciler>();
var flaky = new FlakyScopeFactory(factory.Services.GetRequiredService<IServiceScopeFactory>(), 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<bool> condition)
{
var deadline = DateTime.UtcNow.AddSeconds(10);

View file

@ -317,6 +317,24 @@ public sealed class RedirectingMas : IDisposable
}
}
/// <summary>A scope factory that fails the first scopes asked of it, as a database outage would.</summary>
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();
}
}
/// <summary>An OIDC host with MAS's back-channel logout URI set, a fake MAS behind it and a clock the test drives.</summary>
public sealed class OidcLogoutFactory : OidcWebApplicationFactory
{
@ -361,8 +379,11 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory
}
/// <summary>A reconciler with no memory, built over this host's database, as a restarted process would have.</summary>
public OidcLogoutReconciler NewReconciler(TimeProvider? time = null, ILogger<OidcLogoutReconciler>? logger = null) => new(
Services.GetRequiredService<IServiceScopeFactory>(),
public OidcLogoutReconciler NewReconciler(
TimeProvider? time = null,
ILogger<OidcLogoutReconciler>? logger = null,
IServiceScopeFactory? scopes = null) => new(
scopes ?? Services.GetRequiredService<IServiceScopeFactory>(),
Services.GetRequiredService<OidcIssuerOptions>(),
Services.GetRequiredService<OidcKeyMaterial>(),
Services.GetRequiredService<IOptions<OpenIddictServerOptions>>(),