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:
parent
c9e470bef4
commit
af64868326
2 changed files with 276 additions and 17 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue