diff --git a/LANCommander.Server.UI.Tests/Tests/FirstTimeSetupAdministratorTests.cs b/LANCommander.Server.UI.Tests/Tests/FirstTimeSetupAdministratorTests.cs new file mode 100644 index 00000000..eb1a1301 --- /dev/null +++ b/LANCommander.Server.UI.Tests/Tests/FirstTimeSetupAdministratorTests.cs @@ -0,0 +1,141 @@ +using LANCommander.Server.Data; +using LANCommander.Server.Data.Models; +using LANCommander.Server.Services; +using LANCommander.Server.Settings.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace LANCommander.Server.UI.Tests.Tests; + +/// +/// Fixture for the first-time-setup administrator flow. Stands up the real server DI container +/// backed by a file-based SQLite database (via ) but, unlike +/// BUnitServerFixture, seeds NO administrator role or user. The provider is marked as +/// configured so queries the database and reports the +/// server as "installed but awaiting an admin" — exactly the state the Administrator wizard step +/// runs in. +/// +public class FirstTimeSetupFixture : IAsyncLifetime +{ + public UITestApplicationFactory Factory { get; private set; } = null!; + + public Task InitializeAsync() + { + Factory = new UITestApplicationFactory(); + // Trigger the factory to build the host and create the SQLite schema. + _ = Factory.Services; + + // Mark the provider as configured (but seed no admin) so the server behaves like a freshly + // installed instance sitting on the final "create administrator" step. + DatabaseContext.Provider = DatabaseProvider.SQLite; + + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + DatabaseContext.Provider = DatabaseProvider.Unknown; + await Factory.DisposeAsync(); + } +} + +/// +/// Isolates the first-time-setup administrator tests in their own collection so the seeded admin +/// they create does not contend with the configured/bUnit collections over the static +/// and shared database. +/// +[CollectionDefinition("FirstTimeSetupAdministrator")] +public class FirstTimeSetupAdministratorCollection : ICollectionFixture +{ +} + +/// +/// Integration tests for the final first-time-setup step (Administrator.razor): creating the +/// initial administrator account. These exercise the exact service calls the page makes against a +/// real SQLite-backed DI container, guarding the install flow that a user can only ever run once. +/// +/// Regression: the admin is created through 's password-policy +/// bypass path, which writes the user directly via DbContext. That path previously left +/// NormalizedUserName null, so ASP.NET Identity's FindByNameAsync (used by +/// ) could not locate the just-created user and the wizard +/// failed with "Value cannot be null. (Parameter 'user')". +/// +[Collection("FirstTimeSetupAdministrator")] +public class FirstTimeSetupAdministratorTests +{ + private readonly FirstTimeSetupFixture _fixture; + + public FirstTimeSetupAdministratorTests(FirstTimeSetupFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task CreateAdministrator_FollowingWizardFlow_ProducesUsableAdminAndCompletesSetup() + { + const string username = "setupadmin"; + const string password = "SetupAdmin123!"; + + using var scope = _fixture.Factory.RealServices.CreateScope(); + var setupService = scope.ServiceProvider.GetRequiredService(); + var roleService = scope.ServiceProvider.GetRequiredService(); + var userService = scope.ServiceProvider.GetRequiredService(); + + // A freshly installed server with no administrator yet is not considered set up. + Assert.False(await setupService.IsSetupInitialized()); + + // Mirror Administrator.razor: ensure the Administrator role exists, then create the admin + // user through the password-policy bypass path and assign the role. + var role = await roleService.GetAsync(RoleService.AdministratorRoleName) + ?? await roleService.AddAsync(new Role { Name = RoleService.AdministratorRoleName }); + Assert.NotNull(role); + + var user = new User + { + UserName = username, + Approved = true, + ApprovedOn = DateTime.UtcNow, + }; + + await userService.AddAsync(user, bypassPasswordPolicy: true, password); + + // The regression point: before NormalizedUserName was populated in the bypass path, the + // FindByNameAsync inside AddToRolesAsync returned null and this threw + // ArgumentNullException ("Value cannot be null. (Parameter 'user')"). + await userService.AddToRolesAsync(user.UserName, new[] { RoleService.AdministratorRoleName }); + + // The new admin is findable by name and carries the administrator role. + var persisted = await userService.GetAsync(username); + Assert.NotNull(persisted); + Assert.True(await userService.IsInRoleAsync(persisted, RoleService.AdministratorRoleName)); + + var administrators = await roleService.GetUsersAsync(RoleService.AdministratorRoleName); + Assert.Contains(administrators, u => u.UserName == username); + + // The password set through the bypass path actually authenticates. + Assert.True(await userService.CheckPassword(username, password)); + + // With an administrator present, setup now reports as complete. + Assert.True(await setupService.IsSetupInitialized()); + } + + [Fact] + public async Task AddAsync_WithPasswordPolicyBypass_PopulatesNormalizedUserName() + { + const string username = "normalizationcheck"; + + using var scope = _fixture.Factory.RealServices.CreateScope(); + var userService = scope.ServiceProvider.GetRequiredService(); + var contextFactory = scope.ServiceProvider.GetRequiredService>(); + + var user = new User { UserName = username }; + + await userService.AddAsync(user, bypassPasswordPolicy: true, "Whatever123!"); + + // The directly-inserted row must carry a normalized user name, otherwise Identity's + // FindByNameAsync (which queries NormalizedUserName) cannot locate the user. + await using var db = await contextFactory.CreateDbContextAsync(); + var stored = await db.Users.AsNoTracking().SingleAsync(u => u.UserName == username); + Assert.Equal(username.ToUpperInvariant(), stored.NormalizedUserName); + } +}