Suspension gaps: bootstrap, demotion, members-page notices, admin-only reason #60

Merged
heimdall merged 7 commits from heimdall/susp-gaps into main 2026-11-11 14:12:47 +00:00
19 changed files with 763 additions and 41 deletions

View file

@ -103,6 +103,10 @@ to 15 minutes; a 400 (token refused) or 404 (unknown provider ULID) is a configu
and retried at the 15 minute cap. Nothing is revoked until MAS answers 2xx. A restart loses the backoff and nothing
else: the authorizations table is the queue, so a later-set URI also catches up on earlier demotions.
Without `BackchannelLogoutUri` none of this runs: suspension stops new sign-ins and token refresh (the authorize, token and userinfo
endpoints refuse a suspended account) but does not end a chat session that is already live, and the issuer logs one warning at
startup saying so. The other lever is MAS admin deactivation of the user (ops, not built here).
Tradeoff: delivery latency is up to one tick, and a member demoted and re-promoted inside one tick keeps their sessions.
MAS verified at v1.28.0 (`crates/handlers/src/upstream_oauth2/backchannel_logout.rs`): the token is checked like an

View file

@ -11,7 +11,7 @@ This page scopes the three that did not exist, says where each lives, and record
| Rate limits and brake | `OidcGuardMiddleware` (`docs/oidc-issuer.md`) | Merged. Covers `/connect/*` only, not the register endpoint. |
| Reserved names | `BifrostIdentityService.IsReserved`, 28 names | Merged, at creation and at authorize. |
| Chat tier, no vote | `Authenticated` / `PendingApproval` accounts chat and open no member or governance surface | Merged. |
| Suspension | `MembershipSuspensionService` | Merged. |
| Suspension | `MembershipSuspensionService` | Merged. The reason is `Membership.SuspensionReason`, written with the status change and its receipt, cleared on unsuspend, projected only to admins on the members page; the receipt (`AuditEvent.Detail`) holds no reason text. Receipts written before the column existed still hold their reasons as history; the member dashboard keeps filtering suspension receipts out. An unsuspend still requires a reason, but nothing stores it. |
| CAPTCHA | `Features/Registration/RegistrationChallengeService` | Built here. |
| Consent receipt | `ConsentReceipt`, `TrainingNotice` | Built here. |
| Email verification | nowhere | Scoped below, not built. |

View file

@ -0,0 +1,25 @@
using Bifrost.Web.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
// Hand-written like ConsentReceiptsOneLivePerVersion: no dotnet-ef on the authoring host; the snapshot was edited to match.
// Reasons already written to AuditEvent.Detail stay there as history; new ones live only in this admin-read column.
[DbContext(typeof(BifrostDbContext))]
[Migration("20261111110000_AddMembershipSuspensionReason")]
public partial class AddMembershipSuspensionReason : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(name: "SuspensionReason", table: "Memberships", type: "text", nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "SuspensionReason", table: "Memberships");
}
}
}

View file

@ -821,6 +821,9 @@ namespace Bifrost.Web.Data.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("SuspensionReason")
.HasColumnType("text");
b.Property<Guid>("UserAccountId")
.HasColumnType("uuid");

View file

@ -334,6 +334,9 @@ public sealed class Membership
public MembershipStatus Status { get; set; } = MembershipStatus.Authenticated;
/// <summary>Why an admin suspended the account; set and cleared only by <c>MembershipSuspensionService</c> and projected only to admins.</summary>
public string? SuspensionReason { get; set; }
public bool IsPlatformAdmin { get; set; }
public bool CanManageProjects { get; set; }

View file

@ -17,7 +17,7 @@ public enum SuspensionOutcome
/// <summary>
/// The one place a membership becomes Suspended or stops being. Only a platform admin may call it, every use leaves an audit
/// receipt naming the admin, the account and the reason, and no sign-in path writes or lifts a suspension (Heimdall and GitHub
/// receipt naming the admin and the account (the reason is kept on the membership row, readable by admins only), and no sign-in path writes or lifts a suspension (Heimdall and GitHub
/// sign-in leave a Suspended membership as it is). Unsuspending restores Active for an account that was once approved,
/// Authenticated for one that never was; a pending request is not remembered.
/// </summary>
@ -65,14 +65,16 @@ public sealed class MembershipSuspensionService(BifrostDbContext dbContext, Audi
membership.Status = suspend
? MembershipStatus.Suspended
: membership.ApprovedAtUtc is null ? MembershipStatus.Authenticated : MembershipStatus.Active;
// The reason is a column on the membership, for admins; the receipt names who and what changed, never why.
var clipped = reason.Trim().Length > MaxReasonLength ? reason.Trim()[..MaxReasonLength] : reason.Trim();
// One save: the status change and its receipt stand or fall together (RecordAsync saves the shared context).
membership.SuspensionReason = suspend ? clipped : null;
// One save: the status change, the reason and the receipt stand or fall together (RecordAsync saves the shared context).
await auditTrailService.RecordAsync(
actor.UserAccount.Id,
nameof(Membership),
membership.Id,
suspend ? "membership.suspended" : "membership.unsuspended",
$"{(suspend ? "Suspended" : "Unsuspended")} {user.DisplayName} ({before} -> {membership.Status}): {clipped}",
$"{(suspend ? "Suspended" : "Unsuspended")} {user.DisplayName} ({before} -> {membership.Status}).",
cancellationToken);
return SuspensionOutcome.Done;
}

View file

@ -12,7 +12,9 @@ public sealed class MembershipSynchronizationService(
BifrostDbContext dbContext,
BifrostIdentityService bifrostIdentityService,
IOptions<BootstrapOptions> bootstrapOptions,
TimeProvider timeProvider)
TimeProvider timeProvider,
AuditTrailService auditTrailService,
ILogger<MembershipSynchronizationService> logger)
{
public async Task SynchronizeAsync(ClaimsPrincipal principal, CancellationToken cancellationToken)
{
@ -109,9 +111,14 @@ public sealed class MembershipSynchronizationService(
invitation.AcceptedAtUtc = now;
}
MembershipStatus? promotedFrom = null;
if (IsBootstrapAdmin(gitHubLogin, bootstrapOptions.Value))
{
PromoteToBootstrapAdmin(userAccount.Membership!, now);
promotedFrom = userAccount.Membership!.Status;
if (!ApplicationBootstrapper.TryPromoteBootstrapAdmin(userAccount, now, logger))
{
promotedFrom = null;
}
}
else if (userAccount.Membership!.Status == MembershipStatus.Active)
{
@ -125,22 +132,16 @@ public sealed class MembershipSynchronizationService(
userAccount.MemberProfile!.UpdatedAtUtc = now;
if (promotedFrom is { } before && before != userAccount.Membership!.Status)
{
// One save: the promotion and its receipt stand or fall together.
await ApplicationBootstrapper.RecordPromotionAsync(auditTrailService, userAccount, before, cancellationToken);
return;
}
await dbContext.SaveChangesAsync(cancellationToken);
}
private static void PromoteToBootstrapAdmin(Bifrost.Web.Domain.Membership membership, DateTimeOffset now)
{
membership.Status = MembershipStatus.Active;
membership.IsPlatformAdmin = true;
membership.CanManageProjects = true;
membership.CanManageLedger = true;
membership.CanModerateMotions = true;
membership.ApprovedAtUtc ??= now;
ApplicationBootstrapper.EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
ApplicationBootstrapper.EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
}
private static bool TryReadGitHubIdentity(
ClaimsPrincipal principal,
out long gitHubUserId,

View file

@ -14,10 +14,17 @@ public sealed class OidcClientSeeder(
IServiceScopeFactory scopeFactory,
OidcIssuerOptions options,
OidcKeyMaterial material,
IOptions<OpenIddictServerOptions> serverOptions) : IHostedService
IOptions<OpenIddictServerOptions> serverOptions,
ILogger<OidcClientSeeder> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri))
{
logger.LogWarning(
"Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri is not set: suspending an account stops new sign-ins and token refresh but does not end its existing chat sessions.");
}
// Reading the server options now runs their configuration, so a bad issuer stops startup rather than the first request.
_ = serverOptions.Value;

View file

@ -10,6 +10,7 @@ public sealed class ApplicationBootstrapper(
BifrostDbContext dbContext,
IOptions<BootstrapOptions> bootstrapOptions,
TimeProvider timeProvider,
AuditTrailService auditTrailService,
ILogger<ApplicationBootstrapper> logger)
{
public async Task RunAsync(CancellationToken cancellationToken)
@ -40,16 +41,11 @@ public sealed class ApplicationBootstrapper(
foreach (var adminUser in adminUsers.Where(x => x.Membership is not null))
{
var membership = adminUser.Membership!;
membership.Status = MembershipStatus.Active;
membership.IsPlatformAdmin = true;
membership.CanManageProjects = true;
membership.CanManageLedger = true;
membership.CanModerateMotions = true;
membership.ApprovedAtUtc ??= now;
EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
var before = adminUser.Membership!.Status;
if (TryPromoteBootstrapAdmin(adminUser, now, logger) && before != adminUser.Membership.Status)
{
await RecordPromotionAsync(auditTrailService, adminUser, before, cancellationToken);
}
}
if (dbContext.ChangeTracker.HasChanges())
@ -58,6 +54,41 @@ public sealed class ApplicationBootstrapper(
}
}
/// <summary>
/// The one bootstrap-admin promotion, used at startup and at sign-in. A Suspended account is left exactly as it is (an admin's
/// decision; only the suspension service lifts it) and the refusal is logged by account id. Returns whether it promoted.
/// </summary>
public static bool TryPromoteBootstrapAdmin(UserAccount account, DateTimeOffset now, ILogger logger)
{
var membership = account.Membership!;
if (membership.Status == MembershipStatus.Suspended)
{
logger.LogWarning("Bootstrap admin promotion skipped for suspended account {UserAccountId}.", account.Id);
return false;
}
membership.Status = MembershipStatus.Active;
membership.IsPlatformAdmin = true;
membership.CanManageProjects = true;
membership.CanManageLedger = true;
membership.CanModerateMotions = true;
membership.ApprovedAtUtc ??= now;
EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
return true;
}
/// <summary>The receipt for a bootstrap promotion that changed membership status; saves together with the promotion.</summary>
public static Task RecordPromotionAsync(AuditTrailService auditTrailService, UserAccount account, MembershipStatus before, CancellationToken cancellationToken) =>
auditTrailService.RecordAsync(
null,
nameof(Bifrost.Web.Domain.Membership),
account.Membership!.Id,
"membership.bootstrap-promoted",
$"Bootstrap admin promotion of {account.DisplayName} ({before} -> {account.Membership.Status}).",
cancellationToken);
public static void EnsureRole(
Bifrost.Web.Domain.Membership membership,
MemberRole role,

View file

@ -92,7 +92,7 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
var recentActivity = await dbContext.AuditEvents
.AsNoTracking()
.Include(x => x.ActorUserAccount)
.Where(x => !x.Action.StartsWith("membership.suspended") && !x.Action.StartsWith("membership.unsuspended")) // a reason is for admins, not the member dashboard
.Where(x => !x.Action.StartsWith("membership.suspended") && !x.Action.StartsWith("membership.unsuspended") && x.Action != "membership.bootstrap-promoted") // suspension reasons and the bootstrap-admin promotion are for admins, not the member dashboard
.OrderByDescending(x => x.OccurredAtUtc)
.Take(8)
.Select(x => new DashboardActivity(

View file

@ -14,6 +14,11 @@
because trust and contributor accountability are product concerns, not just auth concerns.
</p>
@if (!string.IsNullOrEmpty(Model.Notice))
{
<p class="validation-summary-errors" role="alert">@Model.Notice</p>
}
@if (Model.Actor.CanManageMembership)
{
<form method="post" asp-page-handler="Invite">
@ -186,6 +191,10 @@
@if (Model.Actor.CanManageMembership)
{
@if (user.Status == MembershipStatus.Suspended && !string.IsNullOrEmpty(user.SuspensionReason))
{
<div class="muted" style="margin-top: 0.8rem;">Suspended: @user.SuspensionReason</div>
}
<form method="post" asp-page-handler="@(user.Status == MembershipStatus.Suspended ? "Unsuspend" : "Suspend")" style="margin-top: 0.8rem;">
<input type="hidden" name="userAccountId" value="@user.UserAccountId" />
<input type="text" name="reason" maxlength="500" required placeholder="Reason" />

View file

@ -22,6 +22,23 @@ public sealed class IndexModel(
[BindProperty]
public UpdateProfileInput Profile { get; set; } = new();
/// <summary>Why the last membership action did nothing; one of <see cref="RefusalNotices"/>, carried across the redirect.</summary>
[TempData]
public string? Notice { get; set; }
private static readonly IReadOnlyDictionary<SuspensionOutcome, string> RefusalNotices = new Dictionary<SuspensionOutcome, string>
{
[SuspensionOutcome.NoReason] = "Nothing changed: a reason is required to suspend or unsuspend an account.",
[SuspensionOutcome.ProtectedAccount] = "Nothing changed: you cannot suspend yourself or another platform admin.",
[SuspensionOutcome.NoSuchAccount] = "Nothing changed: that account or its membership does not exist.",
[SuspensionOutcome.NotApplicable] = "Nothing changed: the account is already in that state."
};
internal const string ApproveSuspendedNotice = "Nothing changed: a suspended account can only be lifted with Unsuspend.";
internal const string NoMembershipNotice = "Nothing changed: that account has no membership yet.";
internal const string OwnAdminRoleNotice = "Nothing changed: you cannot remove or change your own platform admin role.";
internal const string RoleOnSuspendedNotice = "Nothing changed: a suspended account's roles cannot be changed until it is unsuspended.";
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public IReadOnlyList<MemberDirectoryItem> Users { get; private set; } = [];
@ -106,7 +123,8 @@ public sealed class IndexModel(
if (user.Membership!.Status == MembershipStatus.Suspended)
{
return RedirectToPage(); // only Unsuspend lifts a suspension
Notice = ApproveSuspendedNotice; // only Unsuspend lifts a suspension
return RedirectToPage();
}
user.Membership.Status = MembershipStatus.Active;
@ -143,7 +161,13 @@ public sealed class IndexModel(
var outcome = suspend
? await suspension.SuspendAsync(Actor, userAccountId, reason, cancellationToken)
: await suspension.UnsuspendAsync(Actor, userAccountId, reason, cancellationToken);
return outcome == SuspensionOutcome.NotAdmin ? Forbid() : RedirectToPage();
if (outcome == SuspensionOutcome.NotAdmin)
{
return Forbid();
}
Notice = RefusalNotices.GetValueOrDefault(outcome);
return RedirectToPage();
}
public async Task<IActionResult> OnPostToggleRoleAsync(Guid userAccountId, MemberRole role, CancellationToken cancellationToken)
@ -161,6 +185,19 @@ public sealed class IndexModel(
if (user.Membership is null)
{
Notice = NoMembershipNotice;
return RedirectToPage();
}
if (user.Id == Actor.UserAccount.Id && role == MemberRole.PlatformAdmin)
{
Notice = OwnAdminRoleNotice;
return RedirectToPage();
}
if (user.Membership.Status == MembershipStatus.Suspended)
{
Notice = RoleOnSuspendedNotice;
return RedirectToPage();
}
@ -175,8 +212,10 @@ public sealed class IndexModel(
Notes = "Assigned from the member admin page."
});
}
else if (!(user.Id == Actor.UserAccount.Id && role == MemberRole.PlatformAdmin))
else
{
// Out of the collection too, so the flags below derive from the roles as they will stand after the save.
user.Membership.RoleAssignments.Remove(existingAssignment);
dbContext.RoleAssignments.Remove(existingAssignment);
}
@ -339,6 +378,7 @@ public sealed class IndexModel(
user.MemberProfile?.Skills ?? string.Empty,
user.MemberProfile?.Availability ?? string.Empty,
user.Membership?.Status ?? MembershipStatus.Authenticated,
Actor.CanManageMembership ? user.Membership?.SuspensionReason ?? string.Empty : string.Empty,
user.Membership?.RoleAssignments.Select(x => x.Role).OrderBy(x => x.ToString()).ToArray() ?? [],
patronTier?.Label ?? string.Empty,
patronTier?.Weight ?? 0m,
@ -426,6 +466,7 @@ public sealed class IndexModel(
string Skills,
string Availability,
MembershipStatus Status,
string SuspensionReason,
IReadOnlyList<MemberRole> Roles,
string PatronTierLabel,
decimal PatronTierWeight,

View file

@ -0,0 +1,176 @@
using System.Security.Claims;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Pages.App.Members;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Bifrost.Web.Tests;
/// <summary>Bootstrap promotion never lifts a suspension and leaves a receipt when it changes status; demoting an admin clears the admin flags.</summary>
public sealed class BootstrapAdminTests
{
private const long BootstrapGitHubId = 4242;
private static (Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<Program> Factory, CapturingLoggerProvider Logs) NewFactory()
{
var logs = new CapturingLoggerProvider();
var factory = new TestWebApplicationFactory().WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs))));
return (factory, logs);
}
private static async Task<Guid> SeedBootstrapLoginAsync(IServiceProvider services, MembershipStatus status)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var account = new UserAccount
{
DisplayName = "Test Admin",
GitHubUserId = BootstrapGitHubId,
GitHubLogin = "test-admin",
NormalizedGitHubLogin = "TEST-ADMIN",
BifrostIdentity = "testadmin",
NormalizedBifrostIdentity = "testadmin",
MemberProfile = new MemberProfile { Nickname = "ta" },
Membership = new Bifrost.Web.Domain.Membership { Status = status, ApprovedAtUtc = status == MembershipStatus.Suspended ? DateTimeOffset.UtcNow : null }
};
db.UserAccounts.Add(account);
await db.SaveChangesAsync();
return account.Id;
}
private static async Task<(Bifrost.Web.Domain.Membership Membership, List<AuditEvent> Receipts)> ReadAsync(IServiceProvider services, Guid accountId)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var membership = await db.Memberships.AsNoTracking().Include(x => x.RoleAssignments).SingleAsync(x => x.UserAccountId == accountId);
var receipts = await db.AuditEvents.AsNoTracking().Where(x => x.EntityId == membership.Id.ToString()).ToListAsync();
return (membership, receipts);
}
private static ClaimsPrincipal BootstrapPrincipal() => new(new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, BootstrapGitHubId.ToString()),
new Claim(ClaimTypes.Name, "test-admin")
], "test"));
[Theory]
[InlineData("startup")]
[InlineData("signin")]
public async Task A_suspended_bootstrap_login_stays_suspended_and_unpromoted(string path)
{
var (factory, logs) = NewFactory();
using var _ = factory;
var id = await SeedBootstrapLoginAsync(factory.Services, MembershipStatus.Suspended);
var before = (await ReadAsync(factory.Services, id)).Receipts.Count;
await RunPathAsync(factory.Services, path);
var (membership, receipts) = await ReadAsync(factory.Services, id);
Assert.Equal(MembershipStatus.Suspended, membership.Status);
Assert.False(membership.IsPlatformAdmin);
Assert.Empty(membership.RoleAssignments);
Assert.Equal(before, receipts.Count);
Assert.Contains(logs.Lines, x => x.Contains("suspended account") && x.Contains(id.ToString()));
}
[Theory]
[InlineData("startup")]
[InlineData("signin")]
public async Task A_bootstrap_promotion_that_changes_status_writes_a_receipt(string path)
{
var (factory, _) = NewFactory();
using var _f = factory;
var id = await SeedBootstrapLoginAsync(factory.Services, MembershipStatus.Authenticated);
await RunPathAsync(factory.Services, path);
var (membership, receipts) = await ReadAsync(factory.Services, id);
Assert.Equal(MembershipStatus.Active, membership.Status);
Assert.True(membership.IsPlatformAdmin);
var receipt = Assert.Single(receipts, x => x.Action == "membership.bootstrap-promoted");
Assert.Contains("Authenticated -> Active", receipt.Detail);
await RunPathAsync(factory.Services, path); // already Active: no second receipt
Assert.Single((await ReadAsync(factory.Services, id)).Receipts, x => x.Action == "membership.bootstrap-promoted");
}
private static async Task RunPathAsync(IServiceProvider services, string path)
{
using var scope = services.CreateScope();
if (path == "startup")
{
await scope.ServiceProvider.GetRequiredService<ApplicationBootstrapper>().RunAsync(CancellationToken.None);
}
else
{
await scope.ServiceProvider.GetRequiredService<MembershipSynchronizationService>().SynchronizeAsync(BootstrapPrincipal(), CancellationToken.None);
}
}
private sealed class FixedActor(CurrentBifrostActor actor) : ICurrentBifrostActorAccessor
{
public Task<CurrentBifrostActor> GetAsync(CancellationToken cancellationToken) => Task.FromResult(actor);
}
private static async Task<Guid> SeedAsync(IServiceProvider services, string name, bool admin)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var membership = new Bifrost.Web.Domain.Membership { Status = MembershipStatus.Active, ApprovedAtUtc = DateTimeOffset.UtcNow };
if (admin)
{
membership.IsPlatformAdmin = membership.CanManageProjects = membership.CanManageLedger = membership.CanModerateMotions = true;
membership.RoleAssignments.Add(new RoleAssignment { Role = MemberRole.PlatformAdmin });
}
var account = new UserAccount
{
DisplayName = name,
BifrostIdentity = $"{name}-{Guid.NewGuid():N}"[..20],
NormalizedBifrostIdentity = $"{name}-{Guid.NewGuid():N}"[..20],
Membership = membership
};
db.UserAccounts.Add(account);
await db.SaveChangesAsync();
return account.Id;
}
private static async Task ToggleAsync(IServiceProvider services, Guid by, Guid target, MemberRole role)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var actor = new CurrentBifrostActor(true, await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleAsync(x => x.Id == by));
var page = new IndexModel(db, new FixedActor(actor), scope.ServiceProvider.GetRequiredService<AuditTrailService>(),
scope.ServiceProvider.GetRequiredService<MembershipSuspensionService>(), TimeProvider.System);
Assert.IsType<RedirectToPageResult>(await page.OnPostToggleRoleAsync(target, role, CancellationToken.None));
}
[Fact]
public async Task Demoting_an_admin_through_the_page_clears_the_admin_flags_and_promoting_sets_them()
{
var (factory, _) = NewFactory();
using var _f = factory;
var actingAdmin = await SeedAsync(factory.Services, "acting", admin: true);
var otherAdmin = await SeedAsync(factory.Services, "other", admin: true);
var member = await SeedAsync(factory.Services, "member", admin: false);
await ToggleAsync(factory.Services, actingAdmin, otherAdmin, MemberRole.PlatformAdmin);
var (demoted, _) = await ReadAsync(factory.Services, otherAdmin);
Assert.False(demoted.IsPlatformAdmin);
Assert.False(demoted.CanManageProjects);
Assert.False(demoted.CanManageLedger);
Assert.False(demoted.CanModerateMotions);
Assert.DoesNotContain(demoted.RoleAssignments, x => x.Role == MemberRole.PlatformAdmin);
await ToggleAsync(factory.Services, actingAdmin, member, MemberRole.PlatformAdmin);
var (promoted, _) = await ReadAsync(factory.Services, member);
Assert.True(promoted.IsPlatformAdmin);
Assert.Contains(promoted.RoleAssignments, x => x.Role == MemberRole.PlatformAdmin);
}
}

View file

@ -0,0 +1,202 @@
using System.Net;
using System.Text.RegularExpressions;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests;
/// <summary>A refused suspend or unsuspend tells the admin why on the page the redirect lands on, and shows only a fixed message.</summary>
public sealed class MembersPageNoticeTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
BaseAddress = new Uri("https://localhost"),
HandleCookies = true
});
private async Task<Guid> SeedMemberAsync(MembershipStatus status, bool withMembership = true)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var id = $"notice-{Guid.NewGuid():N}"[..20];
var account = new UserAccount
{
DisplayName = id, BifrostIdentity = id, NormalizedBifrostIdentity = id,
Membership = withMembership ? new Bifrost.Web.Domain.Membership { Status = status, ApprovedAtUtc = DateTimeOffset.UtcNow } : null
};
db.UserAccounts.Add(account);
await db.SaveChangesAsync();
return account.Id;
}
private async Task<Guid> SignedInAdminIdAsync(HttpClient client)
{
using var _ = await client.GetAsync("/App/Members"); // sign-in sync creates and promotes the bootstrap admin
using var scope = factory.Services.CreateScope();
return (await scope.ServiceProvider.GetRequiredService<BifrostDbContext>().UserAccounts.AsNoTracking().SingleAsync(x => x.GitHubUserId == 12345)).Id;
}
private static async Task<string> TokenAsync(HttpClient client)
{
var html = await (await client.GetAsync("/App/Members")).Content.ReadAsStringAsync();
return Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value;
}
private static async Task<string> PostThenFollowAsync(HttpClient client, string handler, Guid target, string reason, string? role = null)
{
var token = await TokenAsync(client);
Assert.NotEmpty(token);
using var post = await client.PostAsync($"/App/Members?handler={handler}", new FormUrlEncodedContent(new Dictionary<string, string>
{
["userAccountId"] = target.ToString(),
["reason"] = reason,
["role"] = role ?? "Producer",
["__RequestVerificationToken"] = token
}));
Assert.Equal(HttpStatusCode.Redirect, post.StatusCode);
using var landed = await client.GetAsync(post.Headers.Location);
Assert.Equal(HttpStatusCode.OK, landed.StatusCode);
return await landed.Content.ReadAsStringAsync();
}
private static readonly Regex Alert = new("<p[^>]*role=\"alert\"[^>]*>(.*?)</p>", RegexOptions.Singleline);
[Fact]
public async Task A_suspend_without_a_reason_says_a_reason_is_required_and_changes_nothing()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
var member = await SeedMemberAsync(MembershipStatus.Active);
var html = await PostThenFollowAsync(client, "Suspend", member, " ");
Assert.Contains("a reason is required", Alert.Match(html).Groups[1].Value);
using var scope = factory.Services.CreateScope();
Assert.Equal(MembershipStatus.Active, (await scope.ServiceProvider.GetRequiredService<BifrostDbContext>().Memberships.AsNoTracking().SingleAsync(x => x.UserAccountId == member)).Status);
}
[Fact]
public async Task A_suspend_of_the_admin_themselves_says_it_is_protected()
{
using var client = Client();
var admin = await SignedInAdminIdAsync(client);
var html = await PostThenFollowAsync(client, "Suspend", admin, "oops");
Assert.Contains("cannot suspend yourself or another platform admin", Alert.Match(html).Groups[1].Value);
}
[Fact]
public async Task A_suspend_of_a_missing_account_says_so_and_never_echoes_the_submitted_text()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
const string canary = "<script>CANARY-notice-77</script>";
var html = await PostThenFollowAsync(client, "Suspend", Guid.NewGuid(), canary);
Assert.Contains("does not exist", Alert.Match(html).Groups[1].Value);
Assert.DoesNotContain("CANARY-notice-77", html);
}
[Fact]
public async Task An_unsuspend_of_an_active_account_says_it_is_already_in_that_state_and_the_notice_shows_once()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
var member = await SeedMemberAsync(MembershipStatus.Active);
var html = await PostThenFollowAsync(client, "Unsuspend", member, "appeal");
Assert.Contains("already in that state", Alert.Match(html).Groups[1].Value);
Assert.DoesNotContain("role=\"alert\"", await (await client.GetAsync("/App/Members")).Content.ReadAsStringAsync());
}
[Fact]
public async Task A_successful_suspend_shows_no_refusal()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
var member = await SeedMemberAsync(MembershipStatus.Active);
var html = await PostThenFollowAsync(client, "Suspend", member, "spam");
Assert.DoesNotContain("role=\"alert\"", html);
}
private async Task<(MembershipStatus Status, int Roles)> StateAsync(Guid user)
{
using var scope = factory.Services.CreateScope();
var m = await scope.ServiceProvider.GetRequiredService<BifrostDbContext>().Memberships.AsNoTracking().Include(x => x.RoleAssignments).SingleAsync(x => x.UserAccountId == user);
return (m.Status, m.RoleAssignments.Count);
}
[Fact]
public async Task Approving_a_suspended_account_says_only_unsuspend_lifts_it_and_leaves_it_suspended()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
var member = await SeedMemberAsync(MembershipStatus.Suspended);
var html = await PostThenFollowAsync(client, "Approve", member, "");
Assert.Contains("only be lifted with Unsuspend", Alert.Match(html).Groups[1].Value);
Assert.Equal(MembershipStatus.Suspended, (await StateAsync(member)).Status);
}
[Fact]
public async Task Toggling_a_role_on_a_suspended_account_is_refused_with_a_notice_and_changes_nothing()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
var member = await SeedMemberAsync(MembershipStatus.Suspended);
var html = await PostThenFollowAsync(client, "ToggleRole", member, "", role: "Producer");
Assert.Contains("roles cannot be changed until it is unsuspended", Alert.Match(html).Groups[1].Value);
Assert.Equal(0, (await StateAsync(member)).Roles);
}
[Fact]
public async Task Toggling_a_role_on_an_account_without_a_membership_says_so()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
var member = await SeedMemberAsync(MembershipStatus.Active, withMembership: false);
var html = await PostThenFollowAsync(client, "ToggleRole", member, "", role: "Producer");
Assert.Contains("has no membership yet", Alert.Match(html).Groups[1].Value);
}
[Fact]
public async Task An_admin_toggling_their_own_platform_admin_role_is_refused_and_keeps_the_role()
{
using var client = Client();
var admin = await SignedInAdminIdAsync(client);
var before = (await StateAsync(admin)).Roles;
var html = await PostThenFollowAsync(client, "ToggleRole", admin, "", role: "PlatformAdmin");
Assert.Contains("cannot remove or change your own platform admin role", Alert.Match(html).Groups[1].Value);
Assert.Equal(before, (await StateAsync(admin)).Roles);
}
[Fact]
public async Task A_role_toggle_on_an_active_account_still_works_without_a_refusal()
{
using var client = Client();
_ = await SignedInAdminIdAsync(client);
var member = await SeedMemberAsync(MembershipStatus.Active);
var html = await PostThenFollowAsync(client, "ToggleRole", member, "", role: "Producer");
Assert.DoesNotContain("role=\"alert\"", html);
Assert.True((await StateAsync(member)).Roles >= 1);
}
}

View file

@ -57,6 +57,12 @@ public sealed class MembershipSuspensionTests : IClassFixture<TestWebApplication
private Task<MembershipStatus> StatusAsync(Guid id) => InScopeAsync(async (_, db) =>
(await db.Memberships.AsNoTracking().SingleAsync(x => x.UserAccountId == id)).Status);
private Task<string?> ReasonAsync(Guid id) => InScopeAsync(async (_, db) =>
(await db.Memberships.AsNoTracking().SingleAsync(x => x.UserAccountId == id)).SuspensionReason);
private Task<Guid> MembershipIdAsync(Guid id) => InScopeAsync(async (_, db) =>
(await db.Memberships.AsNoTracking().SingleAsync(x => x.UserAccountId == id)).Id);
private Task<List<AuditEvent>> AuditAsync(string action) => InScopeAsync((_, db) =>
db.AuditEvents.AsNoTracking().Where(x => x.Action == action).ToListAsync());
@ -78,9 +84,12 @@ public sealed class MembershipSuspensionTests : IClassFixture<TestWebApplication
Assert.Equal(SuspensionOutcome.Done, await SuspendAsync(seed.AdminId, seed.MemberId, "spam in chat"));
Assert.Equal(MembershipStatus.Suspended, await StatusAsync(seed.MemberId));
var receipt = Assert.Single(await AuditAsync("membership.suspended"), x => x.Detail.Contains("spam in chat"));
var membershipId = (await MembershipIdAsync(seed.MemberId)).ToString();
var receipt = Assert.Single(await AuditAsync("membership.suspended"), x => x.EntityId == membershipId);
Assert.Equal(seed.AdminId, receipt.ActorUserAccountId);
Assert.Contains("Active -> Suspended", receipt.Detail);
Assert.DoesNotContain("spam in chat", receipt.Detail);
Assert.Equal("spam in chat", await ReasonAsync(seed.MemberId));
}
[Fact]
@ -95,7 +104,7 @@ public sealed class MembershipSuspensionTests : IClassFixture<TestWebApplication
}
[Fact]
public async Task A_suspension_reason_does_not_reach_the_member_dashboard_snapshot()
public async Task A_suspension_reason_is_on_the_membership_row_not_the_audit_trail_or_the_member_dashboard_snapshot()
{
var seed = await SeedAsync();
await SuspendAsync(seed.AdminId, seed.MemberId, "private-reason-canary");
@ -103,7 +112,8 @@ public sealed class MembershipSuspensionTests : IClassFixture<TestWebApplication
var snapshot = await InScopeAsync(async (sp, _) => await sp.GetRequiredService<DashboardSnapshotService>().GetAsync(seed.OrdinaryId, CancellationToken.None));
Assert.DoesNotContain(snapshot.RecentActivity, x => x.Detail.Contains("private-reason-canary"));
Assert.Contains(await AuditAsync("membership.suspended"), x => x.Detail.Contains("private-reason-canary"));
Assert.DoesNotContain(await AuditAsync("membership.suspended"), x => x.Detail.Contains("private-reason-canary"));
Assert.Equal("private-reason-canary", await ReasonAsync(seed.MemberId));
}
[Fact]
@ -132,9 +142,16 @@ public sealed class MembershipSuspensionTests : IClassFixture<TestWebApplication
Assert.Equal(SuspensionOutcome.NoReason, await UnsuspendAsync(seed.AdminId, seed.MemberId, null));
Assert.Equal(MembershipStatus.Suspended, await StatusAsync(seed.MemberId));
await InScopeAsync(async (_, db) =>
{
(await db.Memberships.SingleAsync(x => x.UserAccountId == seed.MemberId)).SuspensionReason = "old reason";
await db.SaveChangesAsync();
return 0;
});
Assert.Equal(SuspensionOutcome.Done, await UnsuspendAsync(seed.AdminId, seed.MemberId, "appeal upheld"));
Assert.Equal(restored, await StatusAsync(seed.MemberId));
Assert.Contains(await AuditAsync("membership.unsuspended"), x => x.Detail.Contains("appeal upheld") && x.ActorUserAccountId == seed.AdminId);
Assert.Contains(await AuditAsync("membership.unsuspended"), x => x.ActorUserAccountId == seed.AdminId && !x.Detail.Contains("appeal upheld"));
Assert.Null(await ReasonAsync(seed.MemberId));
Assert.Equal(SuspensionOutcome.NotApplicable, await UnsuspendAsync(seed.AdminId, seed.MemberId, "again"));
}

View file

@ -26,6 +26,7 @@ public sealed class MigrationDiscoveryTests
"20261109210000_AddMindKeys",
"20261111090000_AddConsentReceipts",
"20261111100000_ConsentReceiptsOneLivePerVersion",
"20261111110000_AddMembershipSuspensionReason",
];
// Database.GetMigrations() reads assembly metadata only; no connection is opened.
@ -55,7 +56,7 @@ public sealed class MigrationDiscoveryTests
}
[Fact]
public void All_sixteen_migrations_are_listed_in_order()
public void All_migrations_are_listed_in_order()
{
Assert.Equal(ExpectedMigrations, Discovered());
}

View file

@ -0,0 +1,50 @@
using Bifrost.Web.Tests.Support;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Bifrost.Web.Tests;
/// <summary>Without a back-channel logout URL suspension cannot end live chat sessions, and the host says so once at startup.</summary>
public sealed class OidcLogoutReachWarningTests
{
private const string Marker = "does not end its existing chat sessions";
private static int Warnings(Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<Program> factory, CapturingLoggerProvider logs)
{
_ = factory.Services; // starts the host and its hosted services
return logs.Lines.Count(x => x.Contains(Marker));
}
private static Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<Program> WithLogs(Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<Program> factory, CapturingLoggerProvider logs) =>
factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs))));
[Fact]
public void OIDC_configured_without_the_logout_url_warns_exactly_once()
{
var logs = new CapturingLoggerProvider();
using var plain = new OidcWebApplicationFactory();
using var factory = WithLogs(plain, logs);
Assert.Equal(1, Warnings(factory, logs));
}
[Fact]
public void OIDC_with_the_logout_url_does_not_warn()
{
var logs = new CapturingLoggerProvider();
using var plain = new OidcLogoutFactory();
using var factory = WithLogs(plain, logs);
Assert.Equal(0, Warnings(factory, logs));
}
[Fact]
public void OIDC_unconfigured_does_not_warn()
{
var logs = new CapturingLoggerProvider();
using var plain = new TestWebApplicationFactory();
using var factory = WithLogs(plain, logs);
Assert.Equal(0, Warnings(factory, logs));
}
}

View file

@ -359,6 +359,11 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory
public FakeMasHandler Mas { get; } = new();
/// <summary>The raw token response and access token of the last <see cref="GrantAsync"/>.</summary>
public string LastTokenResponse { get; private set; } = string.Empty;
public string LastAccessToken { get; private set; } = string.Empty;
public ManualClock Time { get; } = new();
public OidcLogoutReconciler Reconciler => Services.GetServices<IHostedService>().OfType<OidcLogoutReconciler>().Single();
@ -399,7 +404,7 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory
};
/// <summary>Registers a member, sets <paramref name="status"/>, and completes one authorization-code flow for them.</summary>
public async Task<(string Identity, string IdToken)> GrantAsync(MembershipStatus status = MembershipStatus.Active)
public async Task<(string Identity, string IdToken)> GrantAsync(MembershipStatus status = MembershipStatus.Active, string? suspensionReason = null)
{
using var client = CreateClient(HostClientOptions());
var identity = $"member-{Guid.NewGuid():N}";
@ -409,6 +414,14 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory
}
await SetMembershipAsync(identity, MembershipStatus.Active);
if (suspensionReason is not null)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity);
db.Memberships.Single(x => x.UserAccountId == account.Id).SuspensionReason = suspensionReason;
await db.SaveChangesAsync();
}
var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48));
var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24));
@ -444,7 +457,10 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory
"Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{OidcTestKit.ClientId}:{Kit.ClientSecret}")));
using var tokens = await bare.SendAsync(exchange);
Assert.Equal(HttpStatusCode.OK, tokens.StatusCode);
var idToken = (await tokens.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id_token").GetString()!;
var body = await tokens.Content.ReadFromJsonAsync<JsonElement>();
var idToken = body.GetProperty("id_token").GetString()!;
LastTokenResponse = body.GetRawText();
LastAccessToken = body.GetProperty("access_token").GetString()!;
if (status != MembershipStatus.Active)
{

View file

@ -0,0 +1,134 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests;
/// <summary>The suspension reason is for admins: no member-facing page or the OIDC token and userinfo output carries it.</summary>
public sealed class SuspensionReasonExposureTests
{
private const string Canary = "REASON-CANARY-9d2f-do-not-leak";
private static async Task SeedAsync(IServiceProvider services, string identity, MembershipStatus status, string? reason)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
db.UserAccounts.Add(new UserAccount
{
DisplayName = identity, BifrostIdentity = identity, NormalizedBifrostIdentity = identity,
Membership = new Bifrost.Web.Domain.Membership { Status = status, ApprovedAtUtc = DateTimeOffset.UtcNow, SuspensionReason = reason }
});
await db.SaveChangesAsync();
}
private static async Task<string> GetAsync(HttpClient client, string path, string? identity)
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Add("X-Test-Bifrost-Identity", identity);
using var response = await client.SendAsync(request);
Assert.True(response.StatusCode == HttpStatusCode.OK, path);
return await response.Content.ReadAsStringAsync();
}
private sealed class FixedActor(CurrentBifrostActor actor) : ICurrentBifrostActorAccessor
{
public Task<CurrentBifrostActor> GetAsync(CancellationToken cancellationToken) => Task.FromResult(actor);
}
// Guards the projection on its own: the view's admin gate cannot be the only thing keeping the reason off a non-admin's page model.
[Fact]
public async Task The_members_page_model_carries_no_reason_for_a_non_admin_viewer_and_does_for_an_admin()
{
using var factory = new TestWebApplicationFactory();
await SeedAsync(factory.Services, "suspendedtwo", MembershipStatus.Suspended, Canary);
using var scope = factory.Services.CreateScope();
var sp = scope.ServiceProvider;
var db = sp.GetRequiredService<BifrostDbContext>();
var viewer = await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleAsync(x => x.BifrostIdentity == "suspendedtwo");
async Task<IReadOnlyList<Bifrost.Web.Pages.App.Members.IndexModel.MemberDirectoryItem>> UsersSeenBy(bool admin)
{
viewer.Membership!.IsPlatformAdmin = admin;
var model = new Bifrost.Web.Pages.App.Members.IndexModel(db, new FixedActor(new CurrentBifrostActor(true, viewer)),
sp.GetRequiredService<AuditTrailService>(), sp.GetRequiredService<MembershipSuspensionService>(), TimeProvider.System);
await model.OnGetAsync(CancellationToken.None);
return model.Users;
}
Assert.All(await UsersSeenBy(admin: false), u => Assert.DoesNotContain(Canary, u.SuspensionReason));
Assert.Contains(await UsersSeenBy(admin: true), u => u.SuspensionReason == Canary);
}
[Fact]
public async Task The_member_dashboard_hides_the_bootstrap_promotion_receipt_but_shows_ordinary_activity()
{
using var factory = new TestWebApplicationFactory();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
db.AuditEvents.Add(new AuditEvent { Action = "membership.bootstrap-promoted", Detail = "PROMO-CANARY-41", EntityType = "Membership", EntityId = "x", OccurredAtUtc = DateTimeOffset.UtcNow });
db.AuditEvents.Add(new AuditEvent { Action = "membership.approved", Detail = "ORDINARY-CANARY-41", EntityType = "Membership", EntityId = "x", OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1) });
await db.SaveChangesAsync();
}
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri("https://localhost") });
var html = await client.GetStringAsync("/App");
Assert.Contains("ORDINARY-CANARY-41", html);
Assert.DoesNotContain("PROMO-CANARY-41", html);
}
[Fact]
public async Task Member_facing_pages_never_carry_the_reason_and_the_admin_members_page_does()
{
using var factory = new TestWebApplicationFactory();
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri("https://localhost") });
await SeedAsync(factory.Services, "suspendedone", MembershipStatus.Suspended, Canary);
await SeedAsync(factory.Services, "ordinaryone", MembershipStatus.Active, null);
foreach (var path in new[] { "/App", "/App/Members", "/Membership/Status" })
{
Assert.DoesNotContain(Canary, await GetAsync(client, path, "ordinaryone"));
}
Assert.DoesNotContain(Canary, await GetAsync(client, "/Membership/Status", "suspendedone"));
using var admin = factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri("https://localhost") });
Assert.Contains(Canary, await admin.GetStringAsync("/App/Members")); // the default test sign-in is a bootstrap admin
}
[Fact]
public async Task The_OIDC_token_response_and_id_token_never_carry_the_reason()
{
using var factory = new OidcLogoutFactory();
var (_, idToken) = await factory.GrantAsync(suspensionReason: Canary);
Assert.DoesNotContain(Canary, factory.LastTokenResponse);
var payload = new JsonWebToken(idToken).EncodedPayload;
Assert.DoesNotContain(Canary, Encoding.UTF8.GetString(Base64UrlEncoder.DecodeBytes(payload)));
}
// Userinfo is broken in some environments before this change (OidcIssuerTests' userinfo cases fail identically there).
[Fact]
public async Task OIDC_userinfo_never_carries_the_reason()
{
using var factory = new OidcLogoutFactory();
_ = await factory.GrantAsync(suspensionReason: Canary);
using var client = factory.CreateClient(factory.HostClientOptions());
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", factory.LastAccessToken);
using var userinfo = await client.GetAsync("/connect/userinfo");
Assert.Equal(HttpStatusCode.OK, userinfo.StatusCode);
Assert.DoesNotContain(Canary, await userinfo.Content.ReadAsStringAsync());
}
}