Suspension gaps: bootstrap, demotion, members-page notices, admin-only reason #60
19 changed files with 763 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -821,6 +821,9 @@ namespace Bifrost.Web.Data.Migrations
|
|||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SuspensionReason")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
176
tests/Bifrost.Web.Tests/BootstrapAdminTests.cs
Normal file
176
tests/Bifrost.Web.Tests/BootstrapAdminTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
202
tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs
Normal file
202
tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
50
tests/Bifrost.Web.Tests/OidcLogoutReachWarningTests.cs
Normal file
50
tests/Bifrost.Web.Tests/OidcLogoutReachWarningTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
134
tests/Bifrost.Web.Tests/SuspensionReasonExposureTests.cs
Normal file
134
tests/Bifrost.Web.Tests/SuspensionReasonExposureTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue