test: add unit, bUnit and Aspire integration tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user