test: add unit, bUnit and Aspire integration tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Opus 5.5 committed 2026-10-09 18:25:10 +02:00
1 parent 6c845615e3
commit 36d64ccf0b
13 files changed
+921

No files matched your search

@@ -0,0 +1,62 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
[assembly: AssemblyFixture(typeof(FamilyPlanner.IntegrationTests.AppFixture))]
namespace FamilyPlanner.IntegrationTests;
/// <summary>
/// Startet die komplette Anwendung über den Aspire-AppHost (SQL-Server-Container, MigrationService, Api, Web)
/// mit einer flüchtigen Datenbank. Voraussetzung: Docker läuft.
/// </summary>
public sealed class AppFixture : IAsyncLifetime
{
public const string AdminUserName = "admin";
public const string InitialAdminPassword = "Integration!Start1";
private static readonly TimeSpan StartupTimeout = TimeSpan.FromMinutes(5);
private DistributedApplication? _app;
public DistributedApplication App => _app ?? throw new InvalidOperationException("App nicht gestartet.");
public async ValueTask InitializeAsync()
{
using var cts = new CancellationTokenSource(StartupTimeout);
var builder = await DistributedApplicationTestingBuilder.CreateAsync<Projects.FamilyPlanner_AppHost>(
[
"FamilyPlanner:EphemeralDatabase=true",
$"Parameters:seed-admin-password={InitialAdminPassword}",
],
(options, _) => options.DisableDashboard = true,
cts.Token);
builder.Services.AddLogging(logging => logging.SetMinimumLevel(LogLevel.Warning));
_app = await builder.BuildAsync(cts.Token);
await _app.StartAsync(cts.Token);
await _app.ResourceNotifications.WaitForResourceHealthyAsync("web", cts.Token);
}
/// <summary>Client mit eigenem Cookie-Container, spricht wie der Browser nur mit dem Web-Host (BFF).</summary>
public HttpClient CreateBrowserClient()
{
var handler = new HttpClientHandler
{
CookieContainer = new System.Net.CookieContainer(),
UseCookies = true,
AllowAutoRedirect = false,
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
return new HttpClient(handler) { BaseAddress = App.GetEndpoint("web", "https") };
}
public async ValueTask DisposeAsync()
{
if (_app is not null)
{
await _app.DisposeAsync();
}
}
}
@@ -0,0 +1,122 @@
using System.Net;
using System.Net.Http.Json;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Auth;
namespace FamilyPlanner.IntegrationTests;
public sealed class AuthFlowTests(AppFixture fixture)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Theory]
[InlineData("/health")]
[InlineData("/alive")]
public async Task Web_health_endpoints_are_healthy(string path)
{
using var client = fixture.CreateBrowserClient();
var response = await client.GetAsync(path, Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Healthy", await response.Content.ReadAsStringAsync(Ct));
}
[Fact]
public async Task App_shell_and_pwa_assets_are_served()
{
using var client = fixture.CreateBrowserClient();
var shell = await client.GetStringAsync("/kalender", Ct);
var manifest = await client.GetAsync("/manifest.webmanifest", Ct);
var serviceWorker = await client.GetAsync("/service-worker.js", Ct);
Assert.Contains("manifest.webmanifest", shell, StringComparison.Ordinal);
Assert.Contains("blazor.web", shell, StringComparison.Ordinal);
Assert.Equal(HttpStatusCode.OK, manifest.StatusCode);
Assert.Equal(HttpStatusCode.OK, serviceWorker.StatusCode);
}
[Fact]
public async Task Anonymous_user_is_not_authenticated()
{
using var client = fixture.CreateBrowserClient();
var response = await client.GetAsync("/api/auth/me", Ct);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Writing_request_without_csrf_header_is_rejected()
{
using var client = fixture.CreateBrowserClient();
var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(AppFixture.AdminUserName, "egal-egal", false), Ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Wrong_password_is_rejected()
{
using var client = CreateApiClient();
var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(AppFixture.AdminUserName, "falsches-passwort", false), Ct);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Seed_admin_must_change_password_before_using_the_app()
{
const string newPassword = "Unser Familienplaner 2026";
using var client = CreateApiClient();
// 1. Login mit Initialpasswort -> Passwortwechsel erforderlich
var login = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(AppFixture.AdminUserName, AppFixture.InitialAdminPassword, true), Ct);
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
var user = await login.Content.ReadFromJsonAsync<CurrentUserDto>(Ct);
Assert.NotNull(user);
Assert.True(user.MustChangePassword);
Assert.Contains("Admin", user.Roles);
Assert.Contains(login.Headers.GetValues("Set-Cookie"), c =>
c.StartsWith("__Host-fp.auth=", StringComparison.Ordinal)
&& c.Contains("httponly", StringComparison.OrdinalIgnoreCase)
&& c.Contains("secure", StringComparison.OrdinalIgnoreCase)
&& c.Contains("samesite=strict", StringComparison.OrdinalIgnoreCase));
// 2. Geschützte Endpunkte sind bis zum Passwortwechsel gesperrt
var hubBefore = await client.PostAsync("/hubs/live/negotiate?negotiateVersion=1", content: null, Ct);
Assert.Equal(HttpStatusCode.Forbidden, hubBefore.StatusCode);
// 3. Passwort ändern
var change = await client.PostAsJsonAsync("/api/auth/change-password", new ChangePasswordRequest(AppFixture.InitialAdminPassword, newPassword), Ct);
Assert.Equal(HttpStatusCode.OK, change.StatusCode);
Assert.False((await change.Content.ReadFromJsonAsync<CurrentUserDto>(Ct))!.MustChangePassword);
// 4. Jetzt ist alles erreichbar
var hubAfter = await client.PostAsync("/hubs/live/negotiate?negotiateVersion=1", content: null, Ct);
Assert.Equal(HttpStatusCode.OK, hubAfter.StatusCode);
// 5. Abmelden
var logout = await client.PostAsync("/api/auth/logout", content: null, Ct);
Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/auth/me", Ct)).StatusCode);
// 6. Altes Passwort gilt nicht mehr, neues schon
var oldLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(AppFixture.AdminUserName, AppFixture.InitialAdminPassword, false), Ct);
Assert.Equal(HttpStatusCode.Unauthorized, oldLogin.StatusCode);
var newLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(AppFixture.AdminUserName, newPassword, false), Ct);
Assert.Equal(HttpStatusCode.OK, newLogin.StatusCode);
var me = await client.GetFromJsonAsync<CurrentUserDto>("/api/auth/me", Ct);
Assert.Equal(AppFixture.AdminUserName, me!.UserName);
}
private HttpClient CreateApiClient()
{
var client = fixture.CreateBrowserClient();
client.DefaultRequestHeaders.Add(ApiConventions.CsrfHeaderName, ApiConventions.CsrfHeaderValue);
return client;
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Testing" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\FamilyPlanner.AppHost\FamilyPlanner.AppHost.csproj" />
<ProjectReference Include="..\..\src\FamilyPlanner.Shared\FamilyPlanner.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Aspire.Hosting" />
<Using Include="Aspire.Hosting.Testing" />
</ItemGroup>
</Project>
@@ -0,0 +1,48 @@
using FamilyPlanner.Api.Features.Auth;
using FamilyPlanner.Shared.Auth;
namespace FamilyPlanner.UnitTests.Auth;
public sealed class AuthValidatorTests
{
private readonly LoginRequestValidator _loginValidator = new();
private readonly ChangePasswordRequestValidator _changePasswordValidator = new();
[Fact]
public void Login_with_name_and_password_is_valid()
{
Assert.True(_loginValidator.Validate(new LoginRequest("anna", "geheim123", false)).IsValid);
}
[Theory]
[InlineData("", "geheim123")]
[InlineData("anna", "")]
[InlineData(" ", "x")]
public void Login_requires_name_and_password(string name, string password)
{
Assert.False(_loginValidator.Validate(new LoginRequest(name, password, false)).IsValid);
}
[Fact]
public void New_password_must_have_minimum_length()
{
var result = _changePasswordValidator.Validate(new ChangePasswordRequest("altesPasswort", "kurz"));
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == nameof(ChangePasswordRequest.NewPassword));
}
[Fact]
public void New_password_must_differ_from_current()
{
var result = _changePasswordValidator.Validate(new ChangePasswordRequest("gleichesPasswort", "gleichesPasswort"));
Assert.False(result.IsValid);
}
[Fact]
public void Valid_password_change_passes()
{
Assert.True(_changePasswordValidator.Validate(new ChangePasswordRequest("altesPasswort", "Sommer im Garten")).IsValid);
}
}
@@ -0,0 +1,76 @@
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Shared;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace FamilyPlanner.UnitTests.Auth;
public sealed class CsrfHeaderMiddlewareTests
{
[Theory]
[InlineData("POST", "/api/auth/login", true)]
[InlineData("PUT", "/api/shopping/items/1", true)]
[InlineData("DELETE", "/api/x", true)]
[InlineData("PATCH", "/api/x", true)]
[InlineData("GET", "/api/auth/me", false)]
[InlineData("HEAD", "/api/auth/me", false)]
[InlineData("POST", "/hubs/live/negotiate", false)]
[InlineData("GET", "/ics/abc.ics", false)]
public void Header_is_required_only_for_writing_api_requests(string method, string path, bool expected)
{
var context = new DefaultHttpContext();
context.Request.Method = method;
context.Request.Path = path;
Assert.Equal(expected, CsrfHeaderMiddleware.RequiresHeader(context.Request));
}
[Fact]
public async Task Missing_header_returns_400_and_stops_pipeline()
{
var nextCalled = false;
var middleware = new CsrfHeaderMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
var context = CreateContext("POST", "/api/auth/login");
await middleware.InvokeAsync(context);
Assert.False(nextCalled);
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
}
[Fact]
public async Task Correct_header_passes()
{
var nextCalled = false;
var middleware = new CsrfHeaderMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
var context = CreateContext("POST", "/api/auth/login");
context.Request.Headers[ApiConventions.CsrfHeaderName] = ApiConventions.CsrfHeaderValue;
await middleware.InvokeAsync(context);
Assert.True(nextCalled);
}
private static DefaultHttpContext CreateContext(string method, string path)
{
var services = new ServiceCollection()
.AddLogging()
.AddProblemDetails();
var context = new DefaultHttpContext
{
RequestServices = services.BuildServiceProvider(),
};
context.Request.Method = method;
context.Request.Path = path;
context.Response.Body = new MemoryStream();
return context;
}
}
@@ -0,0 +1,100 @@
using System.Security.Claims;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
namespace FamilyPlanner.UnitTests.Authorization;
/// <summary>Prüft die tatsächlich in der Api registrierten Policies.</summary>
public sealed class ApiPolicyTests
{
private readonly IAuthorizationService _authorization;
private readonly IAuthorizationPolicyProvider _policyProvider;
public ApiPolicyTests()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddAuthorizationBuilder().AddFamilyPlannerPolicies();
var provider = services.BuildServiceProvider();
_authorization = provider.GetRequiredService<IAuthorizationService>();
_policyProvider = provider.GetRequiredService<IAuthorizationPolicyProvider>();
}
[Fact]
public async Task Anonymous_user_fails_fallback_policy()
{
var fallback = await _policyProvider.GetFallbackPolicyAsync();
Assert.NotNull(fallback);
var result = await _authorization.AuthorizeAsync(new ClaimsPrincipal(new ClaimsIdentity()), fallback);
Assert.False(result.Succeeded);
}
[Fact]
public async Task Signed_in_user_passes_fallback_policy()
{
var fallback = await _policyProvider.GetFallbackPolicyAsync();
var result = await _authorization.AuthorizeAsync(User(Roles.Guest), fallback!);
Assert.True(result.Succeeded);
}
[Fact]
public async Task Pending_password_change_blocks_fallback_and_role_policies()
{
var admin = User(Roles.Admin, mustChangePassword: true);
var fallback = await _policyProvider.GetFallbackPolicyAsync();
Assert.False((await _authorization.AuthorizeAsync(admin, fallback!)).Succeeded);
Assert.False((await _authorization.AuthorizeAsync(admin, Policies.Admin)).Succeeded);
Assert.False((await _authorization.AuthorizeAsync(admin, Policies.PasswordChanged)).Succeeded);
}
[Theory]
[InlineData(Roles.Admin, true)]
[InlineData(Roles.Family, false)]
[InlineData(Roles.Child, false)]
[InlineData(Roles.Guest, false)]
public async Task Admin_policy_requires_admin_role(string role, bool expected)
{
var result = await _authorization.AuthorizeAsync(User(role), Policies.Admin);
Assert.Equal(expected, result.Succeeded);
}
[Fact]
public async Task All_catalog_policies_are_registered_and_match_catalog()
{
foreach (var (policy, allowedRoles) in PolicyCatalog.RoleBasedPolicies)
{
foreach (var role in Roles.All)
{
var result = await _authorization.AuthorizeAsync(User(role), policy);
Assert.True(
result.Succeeded == allowedRoles.Contains(role),
$"Policy {policy}, Rolle {role}: erwartet {allowedRoles.Contains(role)}");
}
}
}
private static ClaimsPrincipal User(string role, bool mustChangePassword = false)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()),
new(ClaimTypes.Name, "test"),
new(ClaimTypes.Role, role),
};
if (mustChangePassword)
{
claims.Add(new Claim(ApiConventions.MustChangePasswordClaim, "true"));
}
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
}
}
@@ -0,0 +1,71 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
namespace FamilyPlanner.UnitTests.Authorization;
public sealed class PolicyCatalogTests
{
// Erwartete Matrix laut Anforderungen (docs/ARCHITECTURE.md §4).
public static TheoryData<string, string, bool> Matrix => new()
{
{ Policies.Admin, Roles.Admin, true },
{ Policies.Admin, Roles.Family, false },
{ Policies.Admin, Roles.Child, false },
{ Policies.Admin, Roles.Guest, false },
{ Policies.Parent, Roles.Admin, true },
{ Policies.Parent, Roles.Family, true },
{ Policies.Parent, Roles.Child, false },
{ Policies.Parent, Roles.Guest, false },
{ Policies.FamilyMember, Roles.Admin, true },
{ Policies.FamilyMember, Roles.Family, true },
{ Policies.FamilyMember, Roles.Child, true },
{ Policies.FamilyMember, Roles.Guest, false },
{ Policies.AnyRole, Roles.Guest, true },
{ Policies.MealPlanEdit, Roles.Family, true },
{ Policies.MealPlanEdit, Roles.Child, false },
{ Policies.MealPlanEdit, Roles.Guest, false },
{ Policies.MealWish, Roles.Child, true },
{ Policies.MealWish, Roles.Guest, false },
{ Policies.TaskAssign, Roles.Admin, true },
{ Policies.TaskAssign, Roles.Family, true },
{ Policies.TaskAssign, Roles.Child, false },
{ Policies.TaskAssign, Roles.Guest, false },
{ Policies.DirectMessages, Roles.Child, true },
{ Policies.DirectMessages, Roles.Guest, false },
};
[Theory]
[MemberData(nameof(Matrix))]
public void Role_satisfies_policy_as_specified(string policy, string role, bool expected)
{
Assert.Equal(expected, PolicyCatalog.IsSatisfiedBy(policy, [role]));
}
[Fact]
public void Every_role_based_policy_only_references_known_roles()
{
foreach (var (_, roles) in PolicyCatalog.RoleBasedPolicies)
{
Assert.All(roles, role => Assert.True(Roles.IsValid(role), role));
}
}
[Fact]
public void No_roles_satisfy_nothing()
{
Assert.False(PolicyCatalog.IsSatisfiedBy(Policies.AnyRole, []));
}
[Fact]
public void Unknown_policy_throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => PolicyCatalog.RolesFor("Gibtsnicht"));
}
}
@@ -0,0 +1,63 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Settings;
using FamilyPlanner.Infrastructure.Identity;
namespace FamilyPlanner.UnitTests.Domain;
public sealed class DomainTests
{
[Fact]
public void There_are_exactly_four_roles_with_german_names()
{
Assert.Equal(["Admin", "Familie", "Kind", "Gast"], Roles.All);
}
[Theory]
[InlineData("Admin", true)]
[InlineData("Kind", true)]
[InlineData("admin", false)]
[InlineData("Superuser", false)]
[InlineData(null, false)]
public void Role_validation(string? role, bool expected)
{
Assert.Equal(expected, Roles.IsValid(role));
}
[Fact]
public void Default_settings_use_berlin_and_two_meals()
{
var settings = FamilySettings.CreateDefault();
Assert.Equal(FamilySettings.SingletonId, settings.Id);
Assert.Equal("Europe/Berlin", settings.TimeZoneId);
Assert.Equal(["Mittagessen", "Abendessen"], settings.MealTypes);
}
[Fact]
public void Family_today_uses_berlin_time_zone()
{
// 23:30 UTC am 31.12. ist in Berlin bereits Neujahr.
var time = new FixedTimeProvider(new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.Zero));
Assert.Equal(new DateOnly(2027, 1, 1), FamilyTime.Today(time));
}
[Fact]
public void Inactive_or_expired_users_cannot_sign_in()
{
var now = new DateTimeOffset(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
Assert.True(new AppUser().CanSignIn(now));
Assert.False(new AppUser { IsActive = false }.CanSignIn(now));
Assert.False(new AppUser { ExpiresAt = now.AddMinutes(-1) }.CanSignIn(now));
Assert.True(new AppUser { ExpiresAt = now.AddDays(1) }.CanSignIn(now));
}
}
internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public DateTimeOffset Now { get; set; } = now;
public override DateTimeOffset GetUtcNow() => Now;
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\FamilyPlanner.Domain\FamilyPlanner.Domain.csproj" />
<ProjectReference Include="..\..\src\FamilyPlanner.Infrastructure\FamilyPlanner.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\FamilyPlanner.Api\FamilyPlanner.Api.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,130 @@
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Infrastructure;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.UnitTests.Domain;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.UnitTests.Persistence;
public sealed class AuditingInterceptorTests : IDisposable
{
private static readonly Guid UserA = Guid.NewGuid();
private static readonly Guid UserB = Guid.NewGuid();
private readonly SqliteConnection _connection = new("DataSource=:memory:");
private readonly FixedTimeProvider _time = new(new DateTimeOffset(2026, 10, 9, 8, 0, 0, TimeSpan.Zero));
private readonly TestCurrentUser _currentUser = new() { UserId = UserA };
public AuditingInterceptorTests()
{
_connection.Open();
using var db = CreateContext();
db.Database.EnsureCreated();
}
[Fact]
public async Task Added_entity_gets_created_and_updated_fields()
{
await using (var db = CreateContext())
{
db.Notes.Add(new Note { Text = "Milch" });
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}
await using var verify = CreateContext();
var note = await verify.Notes.SingleAsync(TestContext.Current.CancellationToken);
Assert.Equal(_time.Now, note.CreatedAt);
Assert.Equal(_time.Now, note.UpdatedAt);
Assert.Equal(UserA, note.CreatedBy);
Assert.Equal(UserA, note.UpdatedBy);
}
[Fact]
public async Task Modified_entity_keeps_created_fields_and_updates_updated_fields()
{
var created = _time.Now;
Guid id;
await using (var db = CreateContext())
{
var note = new Note { Text = "Milch" };
db.Notes.Add(note);
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
id = note.Id;
}
_time.Now = created.AddHours(1);
_currentUser.UserId = UserB;
await using (var db = CreateContext())
{
var note = await db.Notes.SingleAsync(n => n.Id == id, TestContext.Current.CancellationToken);
note.Text = "Hafermilch";
note.CreatedAt = DateTimeOffset.MinValue; // darf nicht übernommen werden
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}
await using var verify = CreateContext();
var updated = await verify.Notes.SingleAsync(TestContext.Current.CancellationToken);
Assert.Equal(created, updated.CreatedAt);
Assert.Equal(UserA, updated.CreatedBy);
Assert.Equal(created.AddHours(1), updated.UpdatedAt);
Assert.Equal(UserB, updated.UpdatedBy);
}
[Fact]
public async Task Deleting_soft_deletable_entity_marks_it_and_hides_it_from_queries()
{
await using (var db = CreateContext())
{
db.Notes.Add(new Note { Text = "Weg damit" });
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
db.Notes.Remove(await db.Notes.SingleAsync(TestContext.Current.CancellationToken));
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}
await using var verify = CreateContext();
Assert.Empty(await verify.Notes.ToListAsync(TestContext.Current.CancellationToken));
var deleted = await verify.Notes.IgnoreQueryFilters().SingleAsync(TestContext.Current.CancellationToken);
Assert.True(deleted.IsDeleted);
Assert.Equal(_time.Now, deleted.DeletedAt);
Assert.Equal(UserA, deleted.DeletedBy);
}
public void Dispose() => _connection.Dispose();
private TestDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<TestDbContext>()
.UseSqlite(_connection)
.AddInterceptors(new AuditingInterceptor(_currentUser, _time))
.Options;
return new TestDbContext(options);
}
private sealed class TestCurrentUser : ICurrentUser
{
public Guid? UserId { get; set; }
}
private sealed class Note : AuditableEntity, ISoftDeletable
{
public string Text { get; set; } = string.Empty;
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
}
private sealed class TestDbContext(DbContextOptions<TestDbContext> options) : DbContext(options)
{
public DbSet<Note> Notes => Set<Note>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Note>();
modelBuilder.ApplySoftDeleteQueryFilters();
}
}
}
@@ -0,0 +1,51 @@
using System.Security.Claims;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Auth;
using FamilyPlanner.Web.Client.Auth;
using FamilyPlanner.Web.Client.Layout;
namespace FamilyPlanner.Web.Tests;
public sealed class AuthenticationStateProviderTests
{
[Fact]
public void Anonymous_principal_without_user()
{
var principal = ApiAuthenticationStateProvider.BuildPrincipal(null);
Assert.False(principal.Identity?.IsAuthenticated);
}
[Fact]
public void Principal_contains_roles_display_name_and_password_flag()
{
var user = new CurrentUserDto(Guid.NewGuid(), "papa", "Papa Bär", "papa@example.org", "#43A047", ["Admin", "Familie"], true);
var principal = ApiAuthenticationStateProvider.BuildPrincipal(user);
Assert.True(principal.Identity?.IsAuthenticated);
Assert.True(principal.IsInRole("Admin"));
Assert.True(principal.IsInRole("Familie"));
Assert.Equal("Papa Bär", principal.FindFirst(ApiConventions.DisplayNameClaim)?.Value);
Assert.True(principal.HasClaim(ApiConventions.MustChangePasswordClaim, "true"));
}
[Theory]
[InlineData("Anna", "A")]
[InlineData("Papa Bär", "PB")]
[InlineData("anna maria schmidt", "AS")]
[InlineData("", "?")]
public void Initials_for_avatar(string name, string expected)
{
Assert.Equal(expected, UserMenu.Initials(name));
}
[Fact]
public void Admin_navigation_is_policy_protected()
{
var admin = Assert.Single(NavItems.All, i => i.Href == "admin");
Assert.Equal(FamilyPlanner.Domain.Authorization.Policies.Admin, admin.Policy);
Assert.All(NavItems.All.Where(i => i.Href != "admin"), i => Assert.Null(i.Policy));
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="bunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\FamilyPlanner.Web.Client\FamilyPlanner.Web.Client.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Bunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,126 @@
using FamilyPlanner.Shared.Auth;
using FamilyPlanner.Web.Client.Auth;
using FamilyPlanner.Web.Client.Public;
using FamilyPlanner.Web.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace FamilyPlanner.Web.Tests;
public sealed class LoginPageTests : BunitContext
{
private readonly FakeAuthService _authService = new();
public LoginPageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();
Services.AddSingleton<IAuthService>(_authService);
Services.AddSingleton<ApiAuthenticationStateProvider>();
AddAuthorization();
}
[Fact]
public void Renders_username_password_remember_me_and_submit()
{
var cut = Render<Login>();
Assert.NotNull(cut.Find("input#username"));
Assert.Equal("password", cut.Find("input#password").GetAttribute("type"));
Assert.Contains("Angemeldet bleiben", cut.Markup);
Assert.Contains("Anmelden", cut.Find("button[type=submit]").TextContent);
}
[Fact]
public void Empty_form_shows_hint_without_calling_api()
{
var cut = Render<Login>();
cut.Find("form").Submit();
Assert.Contains("Bitte Benutzername/E-Mail und Passwort eingeben.", cut.Markup);
Assert.Null(_authService.LastLogin);
}
[Fact]
public void Successful_login_sets_user_and_navigates_to_return_url()
{
_authService.Result = AuthResult.Success(User(mustChangePassword: false));
var navigation = Services.GetRequiredService<NavigationManager>();
navigation.NavigateTo("login?returnUrl=%2Feinkauf");
var cut = Render<Login>();
cut.Find("input#username").Change(" anna ");
cut.Find("input#password").Change("geheim123");
cut.Find("form").Submit();
Assert.Equal(new LoginRequest("anna", "geheim123", false), _authService.LastLogin);
Assert.EndsWith("/einkauf", navigation.Uri, StringComparison.Ordinal);
Assert.Equal("anna", Services.GetRequiredService<ApiAuthenticationStateProvider>().CurrentUser?.UserName);
}
[Fact]
public void Login_with_pending_password_change_navigates_to_change_password()
{
_authService.Result = AuthResult.Success(User(mustChangePassword: true));
var navigation = Services.GetRequiredService<NavigationManager>();
var cut = Render<Login>();
cut.Find("input#username").Change("anna");
cut.Find("input#password").Change("geheim123");
cut.Find("form").Submit();
Assert.EndsWith("/konto/passwort", navigation.Uri, StringComparison.Ordinal);
}
[Fact]
public void Failed_login_shows_server_message_and_clears_password()
{
_authService.Result = AuthResult.Failure("Benutzername/E-Mail oder Passwort ist falsch.");
var cut = Render<Login>();
cut.Find("input#username").Change("anna");
cut.Find("input#password").Change("falsch123");
cut.Find("form").Submit();
Assert.Contains("Benutzername/E-Mail oder Passwort ist falsch.", cut.Find(".fp-login-error").TextContent);
Assert.Equal(string.Empty, cut.Find("input#password").GetAttribute("value") ?? string.Empty);
}
[Theory]
[InlineData(null, "/")]
[InlineData("/einkauf", "/einkauf")]
[InlineData("//evil.example", "/")]
[InlineData("https://evil.example", "/")]
[InlineData("/\\evil.example", "/")]
[InlineData("/login", "/")]
public void Return_url_only_allows_local_paths(string? returnUrl, string expected)
{
Assert.Equal(expected, Login.SafeReturnUrl(returnUrl));
}
private static CurrentUserDto User(bool mustChangePassword) =>
new(Guid.NewGuid(), "anna", "Anna", null, "#E91E63", ["Kind"], mustChangePassword);
private sealed class FakeAuthService : IAuthService
{
public AuthResult Result { get; set; } = AuthResult.Failure("nicht konfiguriert");
public LoginRequest? LastLogin { get; private set; }
public Task<CurrentUserDto?> GetCurrentUserAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<CurrentUserDto?>(null);
public Task<AuthResult> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default)
{
LastLogin = request;
return Task.FromResult(Result);
}
public Task LogoutAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<AuthResult> ChangePasswordAsync(ChangePasswordRequest request, CancellationToken cancellationToken = default) =>
Task.FromResult(Result);
}
}