diff --git a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs index ed9416e..9a3d228 100644 --- a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -3,6 +3,7 @@ using Bifrost.Web.Domain; using Bifrost.Web.Features.Oidc; using Bifrost.Web.Tests.Support; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; @@ -157,6 +158,7 @@ public sealed class OidcLogoutTests [Theory] [InlineData(FakeMasHandler.NetworkFault)] [InlineData(FakeMasHandler.Timeout)] + [InlineData(300)] [InlineData(302)] [InlineData(401)] public async Task A_network_fault_timeout_or_unexpected_status_keeps_the_authorizations_and_retries(int fault) @@ -222,19 +224,7 @@ public sealed class OidcLogoutTests { using var factory = new OidcLogoutFactory(); var (active, _) = await factory.GrantAsync(MembershipStatus.Active); - using (var scope = factory.Services.CreateScope()) - { - var applications = scope.ServiceProvider.GetRequiredService(); - var authorizations = scope.ServiceProvider.GetRequiredService(); - var application = (await applications.FindByClientIdAsync(OidcTestKit.ClientId))!; - await authorizations.CreateAsync(new OpenIddictAuthorizationDescriptor - { - ApplicationId = await applications.GetIdAsync(application), - Subject = "not-a-guid", - Status = OpenIddictConstants.Statuses.Valid, - Type = OpenIddictConstants.AuthorizationTypes.AdHoc - }); - } + await factory.AddAuthorizationAsync("not-a-guid"); await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); @@ -258,6 +248,140 @@ public sealed class OidcLogoutTests Assert.Equal(0, factory.ValidAuthorizations(subject)); } + [Theory] + [InlineData(201)] + [InlineData(204)] + [InlineData(299)] + public async Task Any_2xx_is_delivery(int status) + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + factory.Mas.Script(status); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(identity).ToString("D"))); + } + + [Fact] + public async Task A_failure_is_logged_by_status_and_delay_only_and_a_success_logs_no_failure() + { + using var factory = new OidcLogoutFactory(); + await factory.GrantAsync(MembershipStatus.Suspended); + await factory.GrantAsync(MembershipStatus.Suspended); + var log = new ListLogger(); + factory.Mas.Script(503, FakeMasHandler.NetworkFault); + + await factory.NewReconciler(logger: log).ReconcileOnceAsync(CancellationToken.None); + + // The first subject failed with 503, the second with no response; both were delivered nothing. + var warnings = log.Entries.Where(x => x.Level == LogLevel.Warning).Select(x => x.Message).ToArray(); + Assert.Equal(2, warnings.Length); + Assert.Contains(warnings, x => x.Contains("503", StringComparison.Ordinal) && x.Contains("00:00:30", StringComparison.Ordinal)); + Assert.Contains(warnings, x => x.Contains("no response", StringComparison.Ordinal)); + Assert.All(log.Entries, x => + { + Assert.DoesNotContain(FakeMasHandler.BodyCanary, x.Message, StringComparison.Ordinal); + Assert.DoesNotContain("mas.example.test", x.Message, StringComparison.Ordinal); + Assert.DoesNotContain("logout_token", x.Message, StringComparison.Ordinal); + }); + + var clean = new ListLogger(); + await factory.NewReconciler(logger: clean).ReconcileOnceAsync(CancellationToken.None); + Assert.Empty(clean.Entries.Where(x => x.Level >= LogLevel.Warning)); + } + + [Fact] + public async Task A_subject_who_recovers_or_whose_grants_vanish_carries_no_stale_backoff() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + factory.Mas.Script(500, 500); + var reconciler = factory.Reconciler; + + // Recovered: Active again clears the backoff, so the next demotion is delivered at once. + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Single(factory.Mas.Bodies); + await factory.SetMembershipAsync(identity, MembershipStatus.Active); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + await factory.SetMembershipAsync(identity, MembershipStatus.Suspended); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(2, factory.Mas.Bodies.Count); + + // Vanished: grants revoked elsewhere drop the subject from the list, which drops its backoff. + await factory.RevokeAllAsync(subject); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + await factory.AddAuthorizationAsync(subject); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(3, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Fact] + public async Task An_unexpected_exception_is_not_a_network_fault_and_a_cancelled_post_is_not_a_failed_delivery() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + var reconciler = factory.Reconciler; + + factory.Mas.Script(FakeMasHandler.Unexpected); + await Assert.ThrowsAsync(() => reconciler.ReconcileOnceAsync(CancellationToken.None)); + + // Stopping while the post is in flight surfaces as cancellation and records no backoff: the next pass posts at once. + factory.Mas.Script(FakeMasHandler.Hang); + using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + await Assert.ThrowsAnyAsync(() => reconciler.ReconcileOnceAsync(stop.Token)); + Assert.Equal(2, factory.Mas.Bodies.Count); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(3, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Fact] + public async Task The_hosted_loop_ticks_again_after_each_interval_and_survives_a_failed_tick() + { + using var factory = new OidcLogoutFactory(); + var clock = new ControlledClock(); + var reconciler = factory.NewReconciler(clock); + var (first, _) = await factory.GrantAsync(MembershipStatus.Suspended); + await reconciler.StartAsync(CancellationToken.None); + try + { + await WaitForAsync(() => factory.Mas.Bodies.Count == 1); + Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(first).ToString("D"))); + + // A failing tick is logged and retried only after the interval, never in a tight loop. + var (second, _) = await factory.GrantAsync(MembershipStatus.Suspended); + factory.Mas.Script(FakeMasHandler.Unexpected); + await WaitForAsync(() => clock.Waiting > 0); + clock.FireTimers(); + await WaitForAsync(() => factory.Mas.Bodies.Count == 2); + await Task.Delay(300); + Assert.Equal(2, factory.Mas.Bodies.Count); + + await WaitForAsync(() => clock.Waiting > 0); + clock.FireTimers(); + await WaitForAsync(() => factory.Mas.Bodies.Count == 3); + await WaitForAsync(() => factory.ValidAuthorizations(factory.AccountId(second).ToString("D")) == 0); + } + finally + { + await reconciler.StopAsync(CancellationToken.None); + } + } + + private static async Task WaitForAsync(Func condition) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (!condition()) + { + Assert.True(DateTime.UtcNow < deadline, "timed out waiting for the reconciler"); + await Task.Delay(20); + } + } + [Fact] public void Without_a_back_channel_logout_uri_the_reconciler_is_not_hosted_and_nothing_is_revoked() { diff --git a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs index 82872cc..abb5a06 100644 --- a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs +++ b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs @@ -15,9 +15,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; using OpenIddict.EntityFrameworkCore.Models; using OpenIddict.Server; @@ -36,6 +38,100 @@ public sealed class ManualClock : TimeProvider base.CreateTimer(callback, state, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); } +/// A clock whose timers fire only when the test says so, to drive the reconciler's own loop. +public sealed class ControlledClock : TimeProvider +{ + private readonly List _timers = []; + private DateTimeOffset _now = DateTimeOffset.UtcNow; + + public override DateTimeOffset GetUtcNow() => _now; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = new PendingTimer(() => callback(state)); + lock (_timers) + { + _timers.Add(timer); + } + + return timer; + } + + /// How many delays are currently waiting to be fired. + public int Waiting + { + get + { + lock (_timers) + { + return _timers.Count(x => !x.Disposed); + } + } + } + + /// Fires every timer that is waiting (each delay the loop is sitting in). + public void FireTimers() + { + PendingTimer[] due; + lock (_timers) + { + due = _timers.Where(x => !x.Disposed).ToArray(); + _timers.Clear(); + } + + foreach (var timer in due) + { + timer.Fire(); + } + } + + private sealed class PendingTimer(Action fire) : ITimer + { + public bool Disposed { get; private set; } + + public void Fire() => fire(); + + public bool Change(TimeSpan dueTime, TimeSpan period) => true; + + public void Dispose() => Disposed = true; + + public ValueTask DisposeAsync() + { + Disposed = true; + return ValueTask.CompletedTask; + } + } +} + +/// Collects what a component logs, formatted, so a test can say what must and must not appear. +public sealed class ListLogger : ILogger +{ + private readonly List<(LogLevel Level, string Message)> _entries = []; + + public IReadOnlyList<(LogLevel Level, string Message)> Entries + { + get + { + lock (_entries) + { + return _entries.ToArray(); + } + } + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (_entries) + { + _entries.Add((logLevel, formatter(state, exception))); + } + } +} + /// /// A stand-in for MAS's back-channel logout route: answers a scripted status per POST (then 200), records every form /// body, and can fail like the network does. @@ -44,6 +140,8 @@ public sealed class FakeMasHandler : HttpMessageHandler { public const int NetworkFault = -1; public const int Timeout = -2; + public const int Unexpected = -3; + public const int Hang = -4; private readonly Queue _script = new(); private readonly List _bodies = []; @@ -98,9 +196,19 @@ public sealed class FakeMasHandler : HttpMessageHandler { NetworkFault => throw new HttpRequestException("network down"), Timeout => throw new TaskCanceledException("timed out"), - _ => new HttpResponseMessage((HttpStatusCode)status) { Content = new StringContent("mas-body-canary") } + Unexpected => throw new InvalidOperationException("not a network fault"), + Hang => await HangAsync(cancellationToken), + _ => new HttpResponseMessage((HttpStatusCode)status) { Content = new StringContent(BodyCanary) } }; } + + public const string BodyCanary = "mas-body-canary"; + + private static async Task HangAsync(CancellationToken cancellationToken) + { + await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("unreachable"); + } } /// An OIDC host with MAS's back-channel logout URI set, a fake MAS behind it and a clock the test drives. @@ -128,14 +236,14 @@ 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() => new( + public OidcLogoutReconciler NewReconciler(TimeProvider? time = null, ILogger? logger = null) => new( Services.GetRequiredService(), Services.GetRequiredService(), Services.GetRequiredService(), Services.GetRequiredService>(), Services.GetRequiredService(), - Time, - NullLogger.Instance); + time ?? Time, + logger ?? NullLogger.Instance); public WebApplicationFactoryClientOptions HostClientOptions() => new() { @@ -205,6 +313,33 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory response.Headers.Location is { } location && (location.IsAbsoluteUri ? location.AbsolutePath : location.OriginalString).StartsWith("/connect/authorize", StringComparison.Ordinal); + /// Gives one more valid ad hoc authorization of the MAS client. + public async Task AddAuthorizationAsync(string subject) + { + using var scope = Services.CreateScope(); + var applications = scope.ServiceProvider.GetRequiredService(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + var application = (await applications.FindByClientIdAsync(OidcTestKit.ClientId))!; + await authorizations.CreateAsync(new OpenIddictAuthorizationDescriptor + { + ApplicationId = await applications.GetIdAsync(application), + Subject = subject, + Status = OpenIddictConstants.Statuses.Valid, + Type = OpenIddictConstants.AuthorizationTypes.AdHoc + }); + } + + /// Revokes every authorization of the subject, as an operator would outside the reconciler. + public async Task RevokeAllAsync(string subject) + { + using var scope = Services.CreateScope(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + await foreach (var authorization in authorizations.FindBySubjectAsync(subject)) + { + await authorizations.TryRevokeAsync(authorization); + } + } + /// How many of the subject's authorizations are still valid. public int ValidAuthorizations(string subject) {