diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs index 9a0a57a..55fa91f 100644 --- a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs @@ -22,7 +22,7 @@ public sealed class IndexModel( [BindProperty] public UpdateProfileInput Profile { get; set; } = new(); - /// Why the last suspend or unsuspend did nothing; one of , carried across the redirect. + /// Why the last membership action did nothing; one of , carried across the redirect. [TempData] public string? Notice { get; set; } @@ -34,6 +34,11 @@ public sealed class IndexModel( [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 Users { get; private set; } = []; @@ -118,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; @@ -179,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(); } @@ -193,7 +212,7 @@ 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); diff --git a/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs b/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs index b84c3ce..b4261d8 100644 --- a/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs +++ b/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs @@ -19,7 +19,7 @@ public sealed class MembersPageNoticeTests(TestWebApplicationFactory factory) : HandleCookies = true }); - private async Task SeedMemberAsync(MembershipStatus status) + private async Task SeedMemberAsync(MembershipStatus status, bool withMembership = true) { using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -27,7 +27,7 @@ public sealed class MembersPageNoticeTests(TestWebApplicationFactory factory) : var account = new UserAccount { DisplayName = id, BifrostIdentity = id, NormalizedBifrostIdentity = id, - Membership = new Bifrost.Web.Domain.Membership { Status = status, ApprovedAtUtc = DateTimeOffset.UtcNow } + Membership = withMembership ? new Bifrost.Web.Domain.Membership { Status = status, ApprovedAtUtc = DateTimeOffset.UtcNow } : null }; db.UserAccounts.Add(account); await db.SaveChangesAsync(); @@ -47,7 +47,7 @@ public sealed class MembersPageNoticeTests(TestWebApplicationFactory factory) : return Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value; } - private static async Task PostThenFollowAsync(HttpClient client, string handler, Guid target, string reason) + private static async Task PostThenFollowAsync(HttpClient client, string handler, Guid target, string reason, string? role = null) { var token = await TokenAsync(client); Assert.NotEmpty(token); @@ -55,6 +55,7 @@ public sealed class MembersPageNoticeTests(TestWebApplicationFactory factory) : { ["userAccountId"] = target.ToString(), ["reason"] = reason, + ["role"] = role ?? "Producer", ["__RequestVerificationToken"] = token })); Assert.Equal(HttpStatusCode.Redirect, post.StatusCode); @@ -127,4 +128,75 @@ public sealed class MembersPageNoticeTests(TestWebApplicationFactory factory) : 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().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); + } }