Logout tests: loop, log content, 2xx range, backoff reset, exception and cancellation paths

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-28 00:56:18 +01:00
commit af64868326
2 changed files with 276 additions and 17 deletions

View file

@ -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<IOpenIddictApplicationManager>();
var authorizations = scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
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<OidcLogoutReconciler>();
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<OidcLogoutReconciler>();
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<InvalidOperationException>(() => 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<OperationCanceledException>(() => 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<bool> 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()
{

View file

@ -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);
}
/// <summary>A clock whose timers fire only when the test says so, to drive the reconciler's own loop.</summary>
public sealed class ControlledClock : TimeProvider
{
private readonly List<PendingTimer> _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;
}
/// <summary>How many delays are currently waiting to be fired.</summary>
public int Waiting
{
get
{
lock (_timers)
{
return _timers.Count(x => !x.Disposed);
}
}
}
/// <summary>Fires every timer that is waiting (each delay the loop is sitting in).</summary>
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;
}
}
}
/// <summary>Collects what a component logs, formatted, so a test can say what must and must not appear.</summary>
public sealed class ListLogger<T> : ILogger<T>
{
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>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
lock (_entries)
{
_entries.Add((logLevel, formatter(state, exception)));
}
}
}
/// <summary>
/// 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<int> _script = new();
private readonly List<string> _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<HttpResponseMessage> HangAsync(CancellationToken cancellationToken)
{
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, cancellationToken);
throw new InvalidOperationException("unreachable");
}
}
/// <summary>An OIDC host with MAS's back-channel logout URI set, a fake MAS behind it and a clock the test drives.</summary>
@ -128,14 +236,14 @@ 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() => new(
public OidcLogoutReconciler NewReconciler(TimeProvider? time = null, ILogger<OidcLogoutReconciler>? logger = null) => new(
Services.GetRequiredService<IServiceScopeFactory>(),
Services.GetRequiredService<OidcIssuerOptions>(),
Services.GetRequiredService<OidcKeyMaterial>(),
Services.GetRequiredService<IOptions<OpenIddictServerOptions>>(),
Services.GetRequiredService<IHttpClientFactory>(),
Time,
NullLogger<OidcLogoutReconciler>.Instance);
time ?? Time,
logger ?? NullLogger<OidcLogoutReconciler>.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);
/// <summary>Gives <paramref name="subject"/> one more valid ad hoc authorization of the MAS client.</summary>
public async Task AddAuthorizationAsync(string subject)
{
using var scope = Services.CreateScope();
var applications = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
var authorizations = scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
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
});
}
/// <summary>Revokes every authorization of the subject, as an operator would outside the reconciler.</summary>
public async Task RevokeAllAsync(string subject)
{
using var scope = Services.CreateScope();
var authorizations = scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
await foreach (var authorization in authorizations.FindBySubjectAsync(subject))
{
await authorizations.TryRevokeAsync(authorization);
}
}
/// <summary>How many of the subject's authorizations are still valid.</summary>
public int ValidAuthorizations(string subject)
{