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; /// Bootstrap promotion never lifts a suspension and leaves a receipt when it changes status; demoting an admin clears the admin flags. public sealed class BootstrapAdminTests { private const long BootstrapGitHubId = 4242; private static (Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory 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 SeedBootstrapLoginAsync(IServiceProvider services, MembershipStatus status) { using var scope = services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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 Receipts)> ReadAsync(IServiceProvider services, Guid accountId) { using var scope = services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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().RunAsync(CancellationToken.None); } else { await scope.ServiceProvider.GetRequiredService().SynchronizeAsync(BootstrapPrincipal(), CancellationToken.None); } } private sealed class FixedActor(CurrentBifrostActor actor) : ICurrentBifrostActorAccessor { public Task GetAsync(CancellationToken cancellationToken) => Task.FromResult(actor); } private static async Task SeedAsync(IServiceProvider services, string name, bool admin) { using var scope = services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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(); 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(), scope.ServiceProvider.GetRequiredService(), TimeProvider.System); Assert.IsType(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); } }