LANCommander/LANCommander.Server.UI.Tests/Tests/FirstTimeSetupAdministratorTests.cs

141 lines
6.3 KiB
C#

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;
/// <summary>
/// Fixture for the first-time-setup administrator flow. Stands up the real server DI container
/// backed by a file-based SQLite database (via <see cref="UITestApplicationFactory"/>) but, unlike
/// <c>BUnitServerFixture</c>, seeds NO administrator role or user. The provider is marked as
/// configured so <see cref="SetupService.IsSetupInitialized"/> queries the database and reports the
/// server as "installed but awaiting an admin" — exactly the state the Administrator wizard step
/// runs in.
/// </summary>
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();
}
}
/// <summary>
/// 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
/// <see cref="DatabaseContext.Provider"/> and shared database.
/// </summary>
[CollectionDefinition("FirstTimeSetupAdministrator")]
public class FirstTimeSetupAdministratorCollection : ICollectionFixture<FirstTimeSetupFixture>
{
}
/// <summary>
/// Integration tests for the final first-time-setup step (<c>Administrator.razor</c>): 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 <see cref="UserService.AddAsync"/>'s password-policy
/// bypass path, which writes the user directly via <c>DbContext</c>. That path previously left
/// <c>NormalizedUserName</c> null, so ASP.NET Identity's <c>FindByNameAsync</c> (used by
/// <see cref="UserService.AddToRolesAsync"/>) could not locate the just-created user and the wizard
/// failed with "Value cannot be null. (Parameter 'user')".
/// </summary>
[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<SetupService>();
var roleService = scope.ServiceProvider.GetRequiredService<RoleService>();
var userService = scope.ServiceProvider.GetRequiredService<UserService>();
// 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<UserService>();
var contextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<DatabaseContext>>();
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);
}
}