test: cover permission matrix, admin rules and admin endpoints
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b465d2e551
commit
045ca38931
12 files changed
+937
-51
No files matched your search
@@ -0,0 +1,278 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Authorization;
|
||||
using FamilyPlanner.Shared;
|
||||
using FamilyPlanner.Shared.Admin;
|
||||
using FamilyPlanner.Shared.Auth;
|
||||
|
||||
namespace FamilyPlanner.IntegrationTests;
|
||||
|
||||
public sealed class AdminTests(AppFixture fixture)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Theory]
|
||||
[InlineData(Roles.Family)]
|
||||
[InlineData(Roles.Child)]
|
||||
[InlineData(Roles.Guest)]
|
||||
public async Task Non_admins_get_403_on_all_admin_endpoints(string role)
|
||||
{
|
||||
var (user, client) = await fixture.CreateSignedInUserAsync(role, Ct);
|
||||
using var _ = client;
|
||||
var settings = new FamilySettingsDto("Hack", "Europe/Berlin", 7, ["Essen"], [], true, false, true, true);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/admin/users", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsJsonAsync("/api/admin/users",
|
||||
new CreateUserRequest("eindringling", "X", null, Roles.Admin, "#000000", "Passwort123", null), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await client.DeleteAsync($"/api/admin/users/{user.Id}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync($"/api/admin/users/{user.Id}/unlock", null, Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await client.PutAsJsonAsync("/api/admin/settings", settings, Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/admin/audit", Ct)).StatusCode);
|
||||
|
||||
// Lesen der Einstellungen und des Verzeichnisses ist für alle angemeldeten Benutzer erlaubt.
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/settings", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/users", Ct)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_manages_user_lifecycle()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var created = await fixture.CreateUserAsync(Roles.Child, Ct);
|
||||
Assert.True(created.MustChangePassword);
|
||||
|
||||
// Bearbeiten: Rolle und Anzeigename
|
||||
var update = await admin.PutAsJsonAsync($"/api/admin/users/{created.Id}",
|
||||
new UpdateUserRequest("Großes Kind", null, Roles.Family, "#FB8C00", true, null), Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, update.StatusCode);
|
||||
var updated = await update.Content.ReadFromJsonAsync<UserAdminDto>(Ct);
|
||||
Assert.Equal(Roles.Family, updated!.Role);
|
||||
Assert.Equal("Großes Kind", updated.DisplayName);
|
||||
|
||||
// Doppelter Benutzername
|
||||
var duplicate = await admin.PostAsJsonAsync("/api/admin/users",
|
||||
new CreateUserRequest(created.UserName, "Doppelt", null, Roles.Child, "#FB8C00", AppFixture.StartPassword, null), Ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
|
||||
Assert.Contains("UserName", await duplicate.Content.ReadAsStringAsync(Ct));
|
||||
|
||||
// Ungültige Eingaben
|
||||
var invalid = await admin.PostAsJsonAsync("/api/admin/users",
|
||||
new CreateUserRequest("mit leerzeichen", "", null, "Chef", "blau", "kurz", null), Ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
|
||||
|
||||
// Passwort zurücksetzen -> erzwungener Wechsel
|
||||
var reset = await admin.PostAsJsonAsync($"/api/admin/users/{created.Id}/reset-password", new ResetPasswordRequest("Neues Start Passwort"), Ct);
|
||||
Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode);
|
||||
using (var userClient = fixture.CreateApiClient())
|
||||
{
|
||||
var me = await AppFixture.LoginAsync(userClient, created.UserName, "Neues Start Passwort", Ct);
|
||||
Assert.True(me.MustChangePassword);
|
||||
}
|
||||
|
||||
// Sperre: 5 Fehlversuche -> gesperrt -> Admin hebt auf
|
||||
using (var attacker = fixture.CreateApiClient())
|
||||
{
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
await attacker.PostAsJsonAsync("/api/auth/login", new LoginRequest(created.UserName, "falsch-falsch", false), Ct);
|
||||
}
|
||||
|
||||
var locked = await attacker.PostAsJsonAsync("/api/auth/login", new LoginRequest(created.UserName, "Neues Start Passwort", false), Ct);
|
||||
Assert.Equal(HttpStatusCode.Locked, locked.StatusCode);
|
||||
}
|
||||
|
||||
var list = await admin.GetFromJsonAsync<List<UserAdminDto>>("/api/admin/users", Ct);
|
||||
Assert.True(list!.Single(u => u.Id == created.Id).IsLockedOut);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await admin.PostAsync($"/api/admin/users/{created.Id}/unlock", null, Ct)).StatusCode);
|
||||
using (var userClient = fixture.CreateApiClient())
|
||||
{
|
||||
await AppFixture.LoginAsync(userClient, created.UserName, "Neues Start Passwort", Ct);
|
||||
}
|
||||
|
||||
// Deaktivieren -> Login verweigert
|
||||
await admin.PutAsJsonAsync($"/api/admin/users/{created.Id}",
|
||||
new UpdateUserRequest("Großes Kind", null, Roles.Family, "#FB8C00", false, null), Ct);
|
||||
using (var userClient = fixture.CreateApiClient())
|
||||
{
|
||||
var denied = await userClient.PostAsJsonAsync("/api/auth/login", new LoginRequest(created.UserName, "Neues Start Passwort", false), Ct);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
|
||||
}
|
||||
|
||||
// Löschen (Soft Delete) -> verschwindet aus Liste und Verzeichnis, Login unmöglich
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/admin/users/{created.Id}", Ct)).StatusCode);
|
||||
list = await admin.GetFromJsonAsync<List<UserAdminDto>>("/api/admin/users", Ct);
|
||||
Assert.DoesNotContain(list!, u => u.Id == created.Id);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await admin.GetAsync($"/api/admin/users/{created.Id}", Ct)).StatusCode);
|
||||
using (var userClient = fixture.CreateApiClient())
|
||||
{
|
||||
var gone = await userClient.PostAsJsonAsync("/api/auth/login", new LoginRequest(created.UserName, "Neues Start Passwort", false), Ct);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, gone.StatusCode);
|
||||
}
|
||||
|
||||
// Benutzername ist wieder frei
|
||||
var again = await admin.PostAsJsonAsync("/api/admin/users",
|
||||
new CreateUserRequest(created.UserName, "Neu", null, Roles.Child, "#FB8C00", AppFixture.StartPassword, null), Ct);
|
||||
Assert.Equal(HttpStatusCode.Created, again.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Last_active_admin_cannot_be_removed()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var me = await admin.GetFromJsonAsync<CurrentUserDto>("/api/auth/me", Ct);
|
||||
|
||||
// Andere Tests können zusätzliche Admins anlegen – hier nur prüfen, solange es genau einen gibt.
|
||||
var admins = (await admin.GetFromJsonAsync<List<UserAdminDto>>("/api/admin/users", Ct))!
|
||||
.Where(u => u.Role == Roles.Admin && u.IsActive).ToList();
|
||||
Assert.Single(admins);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await admin.DeleteAsync($"/api/admin/users/{me!.Id}", Ct)).StatusCode);
|
||||
var demote = await admin.PutAsJsonAsync($"/api/admin/users/{me.Id}",
|
||||
new UpdateUserRequest(me.DisplayName, null, Roles.Family, me.Color, true, null), Ct);
|
||||
Assert.Equal(HttpStatusCode.Conflict, demote.StatusCode);
|
||||
var deactivate = await admin.PutAsJsonAsync($"/api/admin/users/{me.Id}",
|
||||
new UpdateUserRequest(me.DisplayName, null, Roles.Admin, me.Color, false, null), Ct);
|
||||
Assert.Equal(HttpStatusCode.Conflict, deactivate.StatusCode);
|
||||
Assert.Contains("letzte aktive Administrator", await deactivate.Content.ReadAsStringAsync(Ct));
|
||||
|
||||
// Änderungen ohne Rechteverlust sind erlaubt
|
||||
var rename = await admin.PutAsJsonAsync($"/api/admin/users/{me.Id}",
|
||||
new UpdateUserRequest("Chef-Admin", null, Roles.Admin, me.Color, true, null), Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, rename.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expired_guest_cannot_sign_in()
|
||||
{
|
||||
var expired = await fixture.CreateUserAsync(Roles.Guest, Ct, expiresAt: DateTimeOffset.UtcNow.AddMinutes(-5));
|
||||
var valid = await fixture.CreateUserAsync(Roles.Guest, Ct, expiresAt: DateTimeOffset.UtcNow.AddDays(3));
|
||||
using var client = fixture.CreateApiClient();
|
||||
|
||||
var denied = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(expired.UserName, AppFixture.StartPassword, false), Ct);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
|
||||
Assert.Contains("abgelaufen", await denied.Content.ReadAsStringAsync(Ct));
|
||||
|
||||
var me = await AppFixture.LoginAsync(client, valid.UserName, AppFixture.StartPassword, Ct);
|
||||
Assert.NotNull(me.ExpiresAt);
|
||||
|
||||
// Ablaufdatum nur für Gäste
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var invalid = await admin.PostAsJsonAsync("/api/admin/users",
|
||||
new CreateUserRequest("kind-mit-ablauf", "X", null, Roles.Child, "#000000", AppFixture.StartPassword, DateTimeOffset.UtcNow.AddDays(1)), Ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Avatar_upload_is_validated_and_served()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var user = await fixture.CreateUserAsync(Roles.Child, Ct);
|
||||
|
||||
var bad = await admin.PutAsync($"/api/admin/users/{user.Id}/avatar", File("text/plain", "kein bild"u8.ToArray()), Ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||
|
||||
var tooBig = await admin.PutAsync($"/api/admin/users/{user.Id}/avatar", File("image/png", [.. Png, .. new byte[1024 * 1024]]), Ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, tooBig.StatusCode);
|
||||
|
||||
var ok = await admin.PutAsync($"/api/admin/users/{user.Id}/avatar", File("image/jpeg", Png), Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
var dto = await ok.Content.ReadFromJsonAsync<UserAdminDto>(Ct);
|
||||
Assert.StartsWith($"/api/users/{user.Id}/avatar?v=", dto!.AvatarUrl, StringComparison.Ordinal);
|
||||
|
||||
var image = await admin.GetAsync(dto.AvatarUrl, Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, image.StatusCode);
|
||||
Assert.Equal("image/png", image.Content.Headers.ContentType?.MediaType); // Typ aus Signatur, nicht aus Client-Angabe
|
||||
|
||||
var directory = await admin.GetFromJsonAsync<List<UserDirectoryEntry>>("/api/users", Ct);
|
||||
Assert.Equal(dto.AvatarUrl, directory!.Single(u => u.Id == user.Id).AvatarUrl);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/admin/users/{user.Id}/avatar", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await admin.GetAsync($"/api/users/{user.Id}/avatar", Ct)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pin_can_only_be_set_for_children()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var child = await fixture.CreateUserAsync(Roles.Child, Ct);
|
||||
var parent = await fixture.CreateUserAsync(Roles.Family, Ct);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await admin.PutAsJsonAsync($"/api/admin/users/{child.Id}/pin", new SetPinRequest("2468"), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PutAsJsonAsync($"/api/admin/users/{child.Id}/pin", new SetPinRequest("12"), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PutAsJsonAsync($"/api/admin/users/{parent.Id}/pin", new SetPinRequest("2468"), Ct)).StatusCode);
|
||||
|
||||
var list = await admin.GetFromJsonAsync<List<UserAdminDto>>("/api/admin/users", Ct);
|
||||
Assert.True(list!.Single(u => u.Id == child.Id).HasPin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Settings_are_validated_saved_and_change_guest_permissions()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
|
||||
using var __ = guest;
|
||||
var original = await admin.GetFromJsonAsync<FamilySettingsDto>("/api/settings", Ct);
|
||||
|
||||
var invalid = await admin.PutAsJsonAsync("/api/admin/settings", original! with { MealPlanDays = 99, TimeZoneId = "Mars/Olympus" }, Ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
|
||||
|
||||
var guestBefore = await guest.GetFromJsonAsync<CurrentUserDto>("/api/auth/me", Ct);
|
||||
Assert.False(guestBefore!.Can(Permission.ShoppingWrite));
|
||||
|
||||
var changed = original with
|
||||
{
|
||||
FamilyName = "Familie Integration",
|
||||
MealPlanDays = 10,
|
||||
MealTypes = ["Frühstück", "Mittagessen", "Abendessen"],
|
||||
TaskCategories = ["Haushalt", "Garten"],
|
||||
GuestsCanEditShoppingList = true,
|
||||
};
|
||||
var saved = await admin.PutAsJsonAsync("/api/admin/settings", changed, Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, saved.StatusCode);
|
||||
|
||||
try
|
||||
{
|
||||
var reloaded = await guest.GetFromJsonAsync<FamilySettingsDto>("/api/settings", Ct);
|
||||
Assert.Equal("Familie Integration", reloaded!.FamilyName);
|
||||
Assert.Equal(["Frühstück", "Mittagessen", "Abendessen"], reloaded.MealTypes);
|
||||
|
||||
var guestAfter = await guest.GetFromJsonAsync<CurrentUserDto>("/api/auth/me", Ct);
|
||||
Assert.True(guestAfter!.Can(Permission.ShoppingWrite));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await admin.PutAsJsonAsync("/api/admin/settings", original, Ct);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_actions_are_written_to_the_paged_audit_log()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var user = await fixture.CreateUserAsync(Roles.Child, Ct);
|
||||
await admin.PostAsync($"/api/admin/users/{user.Id}/unlock", null, Ct);
|
||||
|
||||
var page1 = await admin.GetFromJsonAsync<PagedResult<AuditLogEntryDto>>("/api/admin/audit?page=1&pageSize=2", Ct);
|
||||
Assert.NotNull(page1);
|
||||
Assert.Equal(2, page1.Items.Count);
|
||||
Assert.True(page1.TotalCount >= 2);
|
||||
Assert.True(page1.Items[0].At >= page1.Items[1].At);
|
||||
|
||||
var all = await admin.GetFromJsonAsync<PagedResult<AuditLogEntryDto>>("/api/admin/audit?page=1&pageSize=100", Ct);
|
||||
var forUser = all!.Items.Where(e => e.TargetName == user.DisplayName).Select(e => e.Action).ToList();
|
||||
Assert.Contains("user.created", forUser);
|
||||
Assert.Contains("user.unlocked", forUser);
|
||||
Assert.All(all.Items, e => Assert.False(string.IsNullOrEmpty(e.ActorName)));
|
||||
}
|
||||
|
||||
private static readonly byte[] Png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52];
|
||||
|
||||
private static MultipartFormDataContent File(string contentType, byte[] data)
|
||||
{
|
||||
var content = new ByteArrayContent(data);
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||
return new MultipartFormDataContent { { content, "file", "bild.png" } };
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FamilyPlanner.Shared;
|
||||
using FamilyPlanner.Shared.Admin;
|
||||
using FamilyPlanner.Shared.Auth;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -8,11 +13,15 @@ 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.
|
||||
/// Der Seed-Admin ändert beim Start einmalig sein Passwort, damit alle Tests ihn nutzen können.
|
||||
/// </summary>
|
||||
public sealed class AppFixture : IAsyncLifetime
|
||||
{
|
||||
public const string AdminUserName = "admin";
|
||||
public const string InitialAdminPassword = "Integration!Start1";
|
||||
public const string AdminPassword = "Integration Admin 2026";
|
||||
public const string StartPassword = "Startpasswort 123";
|
||||
public const string UserPassword = "Eigenes Passwort 456";
|
||||
|
||||
private static readonly TimeSpan StartupTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
@@ -20,6 +29,9 @@ public sealed class AppFixture : IAsyncLifetime
|
||||
|
||||
public DistributedApplication App => _app ?? throw new InvalidOperationException("App nicht gestartet.");
|
||||
|
||||
/// <summary>Ergebnis des ersten Seed-Admin-Logins (vor dem Passwortwechsel).</summary>
|
||||
public CurrentUserDto? SeedAdminFirstLogin { get; private set; }
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(StartupTimeout);
|
||||
@@ -37,6 +49,11 @@ public sealed class AppFixture : IAsyncLifetime
|
||||
_app = await builder.BuildAsync(cts.Token);
|
||||
await _app.StartAsync(cts.Token);
|
||||
await _app.ResourceNotifications.WaitForResourceHealthyAsync("web", cts.Token);
|
||||
|
||||
using var client = CreateApiClient();
|
||||
SeedAdminFirstLogin = await LoginAsync(client, AdminUserName, InitialAdminPassword, cts.Token);
|
||||
var change = await client.PostAsJsonAsync("/api/auth/change-password", new ChangePasswordRequest(InitialAdminPassword, AdminPassword), cts.Token);
|
||||
change.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>Client mit eigenem Cookie-Container, spricht wie der Browser nur mit dem Web-Host (BFF).</summary>
|
||||
@@ -44,7 +61,7 @@ public sealed class AppFixture : IAsyncLifetime
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
CookieContainer = new System.Net.CookieContainer(),
|
||||
CookieContainer = new CookieContainer(),
|
||||
UseCookies = true,
|
||||
AllowAutoRedirect = false,
|
||||
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
|
||||
@@ -52,6 +69,49 @@ public sealed class AppFixture : IAsyncLifetime
|
||||
return new HttpClient(handler) { BaseAddress = App.GetEndpoint("web", "https") };
|
||||
}
|
||||
|
||||
/// <summary>Browser-Client inkl. CSRF-Header.</summary>
|
||||
public HttpClient CreateApiClient()
|
||||
{
|
||||
var client = CreateBrowserClient();
|
||||
client.DefaultRequestHeaders.Add(ApiConventions.CsrfHeaderName, ApiConventions.CsrfHeaderValue);
|
||||
return client;
|
||||
}
|
||||
|
||||
public async Task<HttpClient> CreateAdminClientAsync(CancellationToken ct)
|
||||
{
|
||||
var client = CreateApiClient();
|
||||
await LoginAsync(client, AdminUserName, AdminPassword, ct);
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>Legt einen Benutzer an (Admin), meldet ihn an und erledigt den Pflicht-Passwortwechsel.</summary>
|
||||
public async Task<(UserAdminDto User, HttpClient Client)> CreateSignedInUserAsync(string role, CancellationToken ct, DateTimeOffset? expiresAt = null)
|
||||
{
|
||||
var user = await CreateUserAsync(role, ct, expiresAt);
|
||||
var client = CreateApiClient();
|
||||
await LoginAsync(client, user.UserName, StartPassword, ct);
|
||||
var change = await client.PostAsJsonAsync("/api/auth/change-password", new ChangePasswordRequest(StartPassword, UserPassword), ct);
|
||||
change.EnsureSuccessStatusCode();
|
||||
return (user, client);
|
||||
}
|
||||
|
||||
public async Task<UserAdminDto> CreateUserAsync(string role, CancellationToken ct, DateTimeOffset? expiresAt = null, string? userName = null)
|
||||
{
|
||||
using var admin = await CreateAdminClientAsync(ct);
|
||||
userName ??= $"{role.ToLowerInvariant()}-{Guid.NewGuid():N}"[..20];
|
||||
var response = await admin.PostAsJsonAsync("/api/admin/users",
|
||||
new CreateUserRequest(userName, $"Test {role}", null, role, "#43A047", StartPassword, expiresAt), ct);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
return (await response.Content.ReadFromJsonAsync<UserAdminDto>(ct))!;
|
||||
}
|
||||
|
||||
public static async Task<CurrentUserDto> LoginAsync(HttpClient client, string userName, string password, CancellationToken ct)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(userName, password, false), ct);
|
||||
Assert.True(response.IsSuccessStatusCode, $"Login {userName}: {(int)response.StatusCode} {await response.Content.ReadAsStringAsync(ct)}");
|
||||
return (await response.Content.ReadFromJsonAsync<CurrentUserDto>(ct))!;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_app is not null)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FamilyPlanner.Shared;
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Authorization;
|
||||
using FamilyPlanner.Shared.Auth;
|
||||
|
||||
namespace FamilyPlanner.IntegrationTests;
|
||||
@@ -60,7 +61,7 @@ public sealed class AuthFlowTests(AppFixture fixture)
|
||||
[Fact]
|
||||
public async Task Wrong_password_is_rejected()
|
||||
{
|
||||
using var client = CreateApiClient();
|
||||
using var client = fixture.CreateApiClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(AppFixture.AdminUserName, "falsches-passwort", false), Ct);
|
||||
|
||||
@@ -68,18 +69,27 @@ public sealed class AuthFlowTests(AppFixture fixture)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Seed_admin_must_change_password_before_using_the_app()
|
||||
public void Seed_admin_had_to_change_the_initial_password()
|
||||
{
|
||||
const string newPassword = "Unser Familienplaner 2026";
|
||||
using var client = CreateApiClient();
|
||||
var first = fixture.SeedAdminFirstLogin;
|
||||
|
||||
// 1. Login mit Initialpasswort -> Passwortwechsel erforderlich
|
||||
var login = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(AppFixture.AdminUserName, AppFixture.InitialAdminPassword, true), Ct);
|
||||
Assert.NotNull(first);
|
||||
Assert.True(first.MustChangePassword);
|
||||
Assert.Contains(Roles.Admin, first.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task New_user_must_change_password_before_using_the_app()
|
||||
{
|
||||
var created = await fixture.CreateUserAsync(Roles.Family, Ct);
|
||||
using var client = fixture.CreateApiClient();
|
||||
|
||||
// 1. Login mit Startpasswort -> Passwortwechsel erforderlich
|
||||
var login = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(created.UserName, AppFixture.StartPassword, 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)
|
||||
@@ -87,36 +97,39 @@ public sealed class AuthFlowTests(AppFixture fixture)
|
||||
&& 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);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync("/hubs/live/negotiate?negotiateVersion=1", null, Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/users", Ct)).StatusCode);
|
||||
|
||||
// 3. Passwort ändern
|
||||
var change = await client.PostAsJsonAsync("/api/auth/change-password", new ChangePasswordRequest(AppFixture.InitialAdminPassword, newPassword), Ct);
|
||||
var change = await client.PostAsJsonAsync("/api/auth/change-password", new ChangePasswordRequest(AppFixture.StartPassword, AppFixture.UserPassword), Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, change.StatusCode);
|
||||
Assert.False((await change.Content.ReadFromJsonAsync<CurrentUserDto>(Ct))!.MustChangePassword);
|
||||
var changed = await change.Content.ReadFromJsonAsync<CurrentUserDto>(Ct);
|
||||
Assert.False(changed!.MustChangePassword);
|
||||
Assert.True(changed.Can(Permission.ShoppingWrite));
|
||||
Assert.False(changed.Can(Permission.UserManagement));
|
||||
|
||||
// 4. Jetzt ist alles erreichbar
|
||||
var hubAfter = await client.PostAsync("/hubs/live/negotiate?negotiateVersion=1", content: null, Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, hubAfter.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PostAsync("/hubs/live/negotiate?negotiateVersion=1", null, Ct)).StatusCode);
|
||||
|
||||
// 5. Abmelden
|
||||
var logout = await client.PostAsync("/api/auth/logout", content: null, Ct);
|
||||
Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await client.PostAsync("/api/auth/logout", null, Ct)).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);
|
||||
var oldLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(created.UserName, AppFixture.StartPassword, 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);
|
||||
await AppFixture.LoginAsync(client, created.UserName, AppFixture.UserPassword, Ct);
|
||||
var me = await client.GetFromJsonAsync<CurrentUserDto>("/api/auth/me", Ct);
|
||||
Assert.Equal(AppFixture.AdminUserName, me!.UserName);
|
||||
Assert.Equal(created.UserName, me!.UserName);
|
||||
}
|
||||
|
||||
private HttpClient CreateApiClient()
|
||||
[Fact]
|
||||
public async Task Pin_login_is_disabled_by_default()
|
||||
{
|
||||
var client = fixture.CreateBrowserClient();
|
||||
client.DefaultRequestHeaders.Add(ApiConventions.CsrfHeaderName, ApiConventions.CsrfHeaderValue);
|
||||
return client;
|
||||
using var client = fixture.CreateApiClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/auth/pin-login", new PinLoginRequest("kind", "1234"), Ct);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text;
|
||||
using FamilyPlanner.Api.Features.Admin;
|
||||
using FamilyPlanner.Domain.Common;
|
||||
using FamilyPlanner.Domain.Users;
|
||||
|
||||
namespace FamilyPlanner.UnitTests.Admin;
|
||||
|
||||
public sealed class AdminRulesTests
|
||||
{
|
||||
[Theory]
|
||||
// activeAdmins, targetIsActiveAdmin, remainsActiveAdmin, expectedBlocked
|
||||
[InlineData(1, true, false, true)] // letzter Admin wird gelöscht/deaktiviert/herabgestuft
|
||||
[InlineData(1, true, true, false)] // letzter Admin bleibt Admin (z. B. Namensänderung)
|
||||
[InlineData(2, true, false, false)] // es gibt noch einen weiteren Admin
|
||||
[InlineData(1, false, false, false)] // betroffener Benutzer ist kein (aktiver) Admin
|
||||
[InlineData(0, false, true, false)] // jemand wird Admin
|
||||
public void Last_active_admin_is_protected(int activeAdmins, bool targetIsActiveAdmin, bool remains, bool expectedBlocked)
|
||||
{
|
||||
Assert.Equal(expectedBlocked, AdminGuard.WouldRemoveLastActiveAdmin(activeAdmins, targetIsActiveAdmin, remains));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1234", true)]
|
||||
[InlineData("123456", true)]
|
||||
[InlineData("123", false)]
|
||||
[InlineData("1234567", false)]
|
||||
[InlineData("12a4", false)]
|
||||
[InlineData("١٢٣٤", false)] // nicht-ASCII-Ziffern
|
||||
[InlineData(null, false)]
|
||||
public void Pin_format(string? pin, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, UserRules.IsValidPin(pin));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("#1E88E5", true)]
|
||||
[InlineData("#abcdef", true)]
|
||||
[InlineData("1E88E5", false)]
|
||||
[InlineData("#1E88E", false)]
|
||||
[InlineData("#GGGGGG", false)]
|
||||
[InlineData("red", false)]
|
||||
public void Color_format(string color, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, UserRules.IsValidColor(color));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Avatar_type_is_detected_from_magic_bytes()
|
||||
{
|
||||
Assert.Equal("image/png", UserAvatar.DetectContentType(Png()));
|
||||
Assert.Equal("image/jpeg", UserAvatar.DetectContentType([0xFF, 0xD8, 0xFF, 0xE0, 0x00]));
|
||||
Assert.Equal("image/webp", UserAvatar.DetectContentType([.. "RIFF"u8, 0, 0, 0, 0, .. "WEBP"u8]));
|
||||
Assert.Null(UserAvatar.DetectContentType(Encoding.UTF8.GetBytes("<svg xmlns=\"http://www.w3.org/2000/svg\"/>")));
|
||||
Assert.Null(UserAvatar.DetectContentType([]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Avatar_validation_rejects_wrong_type_and_oversize()
|
||||
{
|
||||
Assert.Null(AvatarValidation.Validate(Png()).Error);
|
||||
Assert.NotNull(AvatarValidation.Validate(Encoding.UTF8.GetBytes("GIF89a....")).Error);
|
||||
Assert.NotNull(AvatarValidation.Validate([.. Png(), .. new byte[UserAvatar.MaxBytes]]).Error);
|
||||
Assert.NotNull(AvatarValidation.Validate([]).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void End_of_day_in_berlin_respects_daylight_saving()
|
||||
{
|
||||
// Sommerzeit (UTC+2) und Winterzeit (UTC+1)
|
||||
Assert.Equal(new DateTimeOffset(2026, 7, 1, 21, 59, 59, TimeSpan.Zero), FamilyTime.EndOfDayUtc(new DateOnly(2026, 7, 1)));
|
||||
Assert.Equal(new DateTimeOffset(2026, 12, 1, 22, 59, 59, TimeSpan.Zero), FamilyTime.EndOfDayUtc(new DateOnly(2026, 12, 1)));
|
||||
Assert.Equal(new DateOnly(2026, 12, 1), FamilyTime.LocalDate(FamilyTime.EndOfDayUtc(new DateOnly(2026, 12, 1))));
|
||||
}
|
||||
|
||||
internal static byte[] Png() => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D];
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Shared.Admin;
|
||||
|
||||
namespace FamilyPlanner.UnitTests.Admin;
|
||||
|
||||
public sealed class AdminValidatorTests
|
||||
{
|
||||
private static readonly DateTimeOffset Tomorrow = DateTimeOffset.UtcNow.AddDays(1);
|
||||
|
||||
private static CreateUserRequest ValidCreate() =>
|
||||
new("anna", "Anna", null, Roles.Child, "#E53935", "Startpasswort1", null);
|
||||
|
||||
[Fact]
|
||||
public void Valid_create_request_passes()
|
||||
{
|
||||
Assert.True(new CreateUserRequestValidator().Validate(ValidCreate()).IsValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "UserName")]
|
||||
[InlineData("anna maria", "UserName")]
|
||||
[InlineData("anna/1", "UserName")]
|
||||
public void Invalid_user_names_are_rejected(string userName, string field)
|
||||
{
|
||||
var result = new CreateUserRequestValidator().Validate(ValidCreate() with { UserName = userName });
|
||||
|
||||
Assert.Contains(result.Errors, e => e.PropertyName == field);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Umlauts_in_user_name_are_allowed()
|
||||
{
|
||||
Assert.True(new CreateUserRequestValidator().Validate(ValidCreate() with { UserName = "jürgen.müller" }).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_rejects_bad_fields()
|
||||
{
|
||||
var result = new CreateUserRequestValidator().Validate(
|
||||
new CreateUserRequest("anna", "", "keine-mail", "Superuser", "rot", "kurz", null));
|
||||
|
||||
var fields = result.Errors.Select(e => e.PropertyName).ToHashSet();
|
||||
Assert.Superset(new HashSet<string> { "DisplayName", "Email", "Role", "Color", "Password" }, fields);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expiry_date_only_for_guests()
|
||||
{
|
||||
var validator = new CreateUserRequestValidator();
|
||||
|
||||
Assert.False(validator.Validate(ValidCreate() with { ExpiresAt = Tomorrow }).IsValid);
|
||||
Assert.True(validator.Validate(ValidCreate() with { Role = Roles.Guest, ExpiresAt = Tomorrow }).IsValid);
|
||||
Assert.False(new UpdateUserRequestValidator().Validate(new UpdateUserRequest("Oma", null, Roles.Family, "#43A047", true, Tomorrow)).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_request_validation()
|
||||
{
|
||||
var validator = new UpdateUserRequestValidator();
|
||||
|
||||
Assert.True(validator.Validate(new UpdateUserRequest("Oma", "oma@example.org", Roles.Guest, "#43A047", true, Tomorrow)).IsValid);
|
||||
Assert.False(validator.Validate(new UpdateUserRequest(new string('x', 101), null, Roles.Guest, "#43A047", true, null)).IsValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1234", true)]
|
||||
[InlineData(null, true)]
|
||||
[InlineData("12", false)]
|
||||
public void Pin_request_validation(string? pin, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, new SetPinRequestValidator().Validate(new SetPinRequest(pin)).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_password_requires_minimum_length()
|
||||
{
|
||||
Assert.False(new ResetPasswordRequestValidator().Validate(new ResetPasswordRequest("kurz")).IsValid);
|
||||
Assert.True(new ResetPasswordRequestValidator().Validate(new ResetPasswordRequest("lang genug")).IsValid);
|
||||
}
|
||||
|
||||
private static FamilySettingsDto ValidSettings() =>
|
||||
new("Familie Muster", "Europe/Berlin", 7, ["Mittagessen", "Abendessen"], ["Haushalt"], true, false, false, false);
|
||||
|
||||
[Fact]
|
||||
public void Valid_settings_pass()
|
||||
{
|
||||
Assert.True(new FamilySettingsDtoValidator().Validate(ValidSettings()).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_validation_rejects_invalid_values()
|
||||
{
|
||||
var validator = new FamilySettingsDtoValidator();
|
||||
|
||||
Assert.False(validator.Validate(ValidSettings() with { FamilyName = " " }).IsValid);
|
||||
Assert.False(validator.Validate(ValidSettings() with { TimeZoneId = "Mars/Olympus" }).IsValid);
|
||||
Assert.False(validator.Validate(ValidSettings() with { MealPlanDays = 2 }).IsValid);
|
||||
Assert.False(validator.Validate(ValidSettings() with { MealPlanDays = 15 }).IsValid);
|
||||
Assert.False(validator.Validate(ValidSettings() with { MealTypes = [] }).IsValid);
|
||||
Assert.False(validator.Validate(ValidSettings() with { MealTypes = ["Mittag", "mittag"] }).IsValid);
|
||||
Assert.False(validator.Validate(ValidSettings() with { TaskCategories = [new string('k', 41)] }).IsValid);
|
||||
Assert.True(validator.Validate(ValidSettings() with { TaskCategories = [] }).IsValid);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using FamilyPlanner.Api.Infrastructure;
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Authorization;
|
||||
using FamilyPlanner.Domain.Settings;
|
||||
using FamilyPlanner.Infrastructure.Settings;
|
||||
using FamilyPlanner.Shared;
|
||||
using FamilyPlanner.UnitTests.Domain;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace FamilyPlanner.UnitTests.Authorization;
|
||||
|
||||
/// <summary>Prüft die tatsächlich in der Api registrierten Policies.</summary>
|
||||
/// <summary>Prüft die tatsächlich in der Api registrierten Policies inkl. Handler.</summary>
|
||||
public sealed class ApiPolicyTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 10, 9, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly FakeSettingsProvider _settings = new();
|
||||
private readonly IAuthorizationService _authorization;
|
||||
private readonly IAuthorizationPolicyProvider _policyProvider;
|
||||
|
||||
@@ -18,6 +25,9 @@ public sealed class ApiPolicyTests
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<TimeProvider>(new FixedTimeProvider(Now));
|
||||
services.AddSingleton<ISettingsProvider>(_settings);
|
||||
services.AddFamilyPlannerAuthorizationHandlers();
|
||||
services.AddAuthorizationBuilder().AddFamilyPlannerPolicies();
|
||||
var provider = services.BuildServiceProvider();
|
||||
_authorization = provider.GetRequiredService<IAuthorizationService>();
|
||||
@@ -45,14 +55,23 @@ public sealed class ApiPolicyTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pending_password_change_blocks_fallback_and_role_policies()
|
||||
public async Task Pending_password_change_blocks_fallback_role_and_permission_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);
|
||||
Assert.False((await _authorization.AuthorizeAsync(admin, Policies.For(Permission.UserManagement))).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expired_guest_is_rejected_by_fallback_policy()
|
||||
{
|
||||
var fallback = await _policyProvider.GetFallbackPolicyAsync();
|
||||
|
||||
Assert.False((await _authorization.AuthorizeAsync(User(Roles.Guest, expiresAt: Now.AddMinutes(-1)), fallback!)).Succeeded);
|
||||
Assert.True((await _authorization.AuthorizeAsync(User(Roles.Guest, expiresAt: Now.AddDays(1)), fallback!)).Succeeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -60,29 +79,55 @@ public sealed class ApiPolicyTests
|
||||
[InlineData(Roles.Family, false)]
|
||||
[InlineData(Roles.Child, false)]
|
||||
[InlineData(Roles.Guest, false)]
|
||||
public async Task Admin_policy_requires_admin_role(string role, bool expected)
|
||||
public async Task User_management_requires_admin(string role, bool expected)
|
||||
{
|
||||
var result = await _authorization.AuthorizeAsync(User(role), Policies.Admin);
|
||||
var result = await _authorization.AuthorizeAsync(User(role), Policies.For(Permission.UserManagement));
|
||||
|
||||
Assert.Equal(expected, result.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task All_catalog_policies_are_registered_and_match_catalog()
|
||||
public async Task All_permission_policies_match_the_matrix()
|
||||
{
|
||||
foreach (var permission in PermissionMatrix.All)
|
||||
{
|
||||
foreach (var role in Roles.All)
|
||||
{
|
||||
var expected = PermissionMatrix.For(permission, role) != AccessLevel.None;
|
||||
var result = await _authorization.AuthorizeAsync(User(role), Policies.For(permission));
|
||||
Assert.True(result.Succeeded == expected, $"{permission}/{role}: erwartet {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Guest_exceptions_follow_family_settings()
|
||||
{
|
||||
var guest = User(Roles.Guest);
|
||||
Assert.False((await _authorization.AuthorizeAsync(guest, Policies.For(Permission.ShoppingWrite))).Succeeded);
|
||||
Assert.False((await _authorization.AuthorizeAsync(guest, Policies.For(Permission.FamilyChat))).Succeeded);
|
||||
|
||||
_settings.Settings.GuestsCanEditShoppingList = true;
|
||||
_settings.Settings.GuestChatEnabled = true;
|
||||
|
||||
Assert.True((await _authorization.AuthorizeAsync(guest, Policies.For(Permission.ShoppingWrite))).Succeeded);
|
||||
Assert.True((await _authorization.AuthorizeAsync(guest, Policies.For(Permission.FamilyChat))).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task All_role_policies_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)}");
|
||||
Assert.True(result.Succeeded == allowedRoles.Contains(role), $"Policy {policy}, Rolle {role}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal User(string role, bool mustChangePassword = false)
|
||||
private static ClaimsPrincipal User(string role, bool mustChangePassword = false, DateTimeOffset? expiresAt = null)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
@@ -95,6 +140,22 @@ public sealed class ApiPolicyTests
|
||||
claims.Add(new Claim(ApiConventions.MustChangePasswordClaim, "true"));
|
||||
}
|
||||
|
||||
if (expiresAt is { } e)
|
||||
{
|
||||
claims.Add(new Claim(ApiConventions.ExpiresAtClaim, e.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
|
||||
}
|
||||
|
||||
private sealed class FakeSettingsProvider : ISettingsProvider
|
||||
{
|
||||
public FamilySettings Settings { get; } = FamilySettings.CreateDefault();
|
||||
|
||||
public Task<FamilySettings> GetAsync(CancellationToken cancellationToken = default) => Task.FromResult(Settings);
|
||||
|
||||
public void Invalidate()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Authorization;
|
||||
|
||||
namespace FamilyPlanner.UnitTests.Authorization;
|
||||
|
||||
/// <summary>Die vollständige Rollenmatrix aus den Anforderungen (Abschnitt „Rollen und Berechtigungen“).</summary>
|
||||
public sealed class PermissionMatrixTests
|
||||
{
|
||||
private const AccessLevel N = AccessLevel.None;
|
||||
private const AccessLevel R = AccessLevel.Restricted;
|
||||
private const AccessLevel F = AccessLevel.Full;
|
||||
|
||||
// Erwartung je Zeile der Anforderungstabelle: Admin Familie Kind Gast
|
||||
public static TheoryData<Permission, AccessLevel, AccessLevel, AccessLevel, AccessLevel> Requirements => new()
|
||||
{
|
||||
{ Permission.DashboardView, F, F, F, R }, // Gast: eingeschränkt
|
||||
{ Permission.ShoppingRead, F, F, F, F },
|
||||
{ Permission.ShoppingWrite, F, F, F, N }, // Gast: nein (konfigurierbar)
|
||||
{ Permission.ShoppingDelete, F, F, R, N }, // Kind: nur eigene
|
||||
{ Permission.MealPlanRead, F, F, F, F },
|
||||
{ Permission.MealPlanEdit, F, F, R, N }, // Kind: nur Wünsche vorschlagen
|
||||
{ Permission.TasksRead, F, F, R, R }, // Kind: eigene + Familie, Gast: nur zugewiesene
|
||||
{ Permission.TasksEdit, F, F, R, N }, // Kind: nur eigene, private Aufgaben
|
||||
{ Permission.TasksAssign, F, F, N, N },
|
||||
{ Permission.TasksCompleteAssigned, F, F, F, F },
|
||||
{ Permission.CalendarRead, F, F, F, R }, // Gast: nur freigegebene Kalender
|
||||
{ Permission.EventsEdit, F, F, R, N }, // Kind: nur eigene Termine
|
||||
{ Permission.FamilyChat, F, F, F, N }, // Gast: nur wenn freigegeben
|
||||
{ Permission.DirectMessages, F, F, R, N }, // Kind: nur an Familienmitglieder
|
||||
{ Permission.UserManagement, F, N, N, N },
|
||||
{ Permission.CalendarManagement, F, N, N, N },
|
||||
{ Permission.SettingsManagement, F, N, N, N },
|
||||
{ Permission.AuditLogView, F, N, N, N },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Requirements))]
|
||||
public void Matrix_matches_requirements(Permission permission, AccessLevel admin, AccessLevel family, AccessLevel child, AccessLevel guest)
|
||||
{
|
||||
Assert.Equal(admin, PermissionMatrix.For(permission, Roles.Admin));
|
||||
Assert.Equal(family, PermissionMatrix.For(permission, Roles.Family));
|
||||
Assert.Equal(child, PermissionMatrix.For(permission, Roles.Child));
|
||||
Assert.Equal(guest, PermissionMatrix.For(permission, Roles.Guest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_permission_is_covered_by_the_requirement_table()
|
||||
{
|
||||
var covered = Requirements.Select(row => row.Data.Item1).ToHashSet();
|
||||
|
||||
Assert.Equal(PermissionMatrix.All.ToHashSet(), covered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guest_shopping_write_can_be_enabled_in_settings()
|
||||
{
|
||||
var options = new PermissionOptions(GuestsCanEditShoppingList: true);
|
||||
|
||||
Assert.Equal(F, PermissionMatrix.For(Permission.ShoppingWrite, Roles.Guest, options));
|
||||
Assert.Equal(N, PermissionMatrix.For(Permission.ShoppingDelete, Roles.Guest, options));
|
||||
Assert.Equal(N, PermissionMatrix.For(Permission.FamilyChat, Roles.Guest, options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guest_family_chat_can_be_enabled_in_settings()
|
||||
{
|
||||
var options = new PermissionOptions(GuestChatEnabled: true);
|
||||
|
||||
Assert.Equal(F, PermissionMatrix.For(Permission.FamilyChat, Roles.Guest, options));
|
||||
Assert.Equal(N, PermissionMatrix.For(Permission.DirectMessages, Roles.Guest, options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_never_restrict_other_roles()
|
||||
{
|
||||
var options = new PermissionOptions(GuestsCanEditShoppingList: false, GuestChatEnabled: false);
|
||||
|
||||
Assert.Equal(F, PermissionMatrix.For(Permission.ShoppingWrite, Roles.Child, options));
|
||||
Assert.Equal(F, PermissionMatrix.For(Permission.FamilyChat, Roles.Child, options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Highest_level_over_multiple_roles_wins()
|
||||
{
|
||||
Assert.Equal(F, PermissionMatrix.For(Permission.TasksRead, [Roles.Guest, Roles.Family]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_or_missing_role_has_no_access()
|
||||
{
|
||||
Assert.Equal(N, PermissionMatrix.For(Permission.ShoppingRead, "Hacker"));
|
||||
Assert.Equal(N, PermissionMatrix.For(Permission.ShoppingRead, Array.Empty<string>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Effective_permissions_contain_only_granted_entries()
|
||||
{
|
||||
var effective = PermissionMatrix.Effective([Roles.Guest]);
|
||||
|
||||
Assert.DoesNotContain(Permission.UserManagement, effective.Keys);
|
||||
Assert.Equal(R, effective[Permission.TasksRead]);
|
||||
Assert.All(effective.Values, level => Assert.NotEqual(N, level));
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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 },
|
||||
@@ -24,21 +23,6 @@ public sealed class PolicyCatalogTests
|
||||
{ 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]
|
||||
@@ -68,4 +52,16 @@ public sealed class PolicyCatalogTests
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => PolicyCatalog.RolesFor("Gibtsnicht"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Permission_policy_names_round_trip()
|
||||
{
|
||||
foreach (var permission in PermissionMatrix.All)
|
||||
{
|
||||
Assert.True(Policies.TryParsePermission(Policies.For(permission), out var parsed));
|
||||
Assert.Equal(permission, parsed);
|
||||
}
|
||||
|
||||
Assert.False(Policies.TryParsePermission(Policies.Admin, out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using FamilyPlanner.Shared;
|
||||
using FamilyPlanner.Shared.Admin;
|
||||
using FamilyPlanner.Web.Client.Services;
|
||||
|
||||
namespace FamilyPlanner.Web.Tests.Admin;
|
||||
|
||||
internal sealed class FakeAdminApi : IAdminApi
|
||||
{
|
||||
public List<UserAdminDto> Users { get; } = [];
|
||||
|
||||
public List<CreateUserRequest> Created { get; } = [];
|
||||
|
||||
public List<(Guid Id, UpdateUserRequest Request)> Updated { get; } = [];
|
||||
|
||||
public ApiProblem? NextProblem { get; set; }
|
||||
|
||||
public Task<ApiResult<List<UserAdminDto>>> GetUsersAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(ApiResult<List<UserAdminDto>>.Ok([.. Users]));
|
||||
|
||||
public Task<ApiResult<UserAdminDto>> CreateUserAsync(CreateUserRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (TakeProblem() is { } problem)
|
||||
{
|
||||
return Task.FromResult(ApiResult<UserAdminDto>.Fail(problem));
|
||||
}
|
||||
|
||||
Created.Add(request);
|
||||
var user = Dto(Guid.NewGuid(), request.UserName, request.DisplayName, request.Role, request.Color) with { MustChangePassword = true };
|
||||
Users.Add(user);
|
||||
return Task.FromResult(ApiResult<UserAdminDto>.Ok(user));
|
||||
}
|
||||
|
||||
public Task<ApiResult<UserAdminDto>> UpdateUserAsync(Guid id, UpdateUserRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (TakeProblem() is { } problem)
|
||||
{
|
||||
return Task.FromResult(ApiResult<UserAdminDto>.Fail(problem));
|
||||
}
|
||||
|
||||
Updated.Add((id, request));
|
||||
var index = Users.FindIndex(u => u.Id == id);
|
||||
Users[index] = Users[index] with { DisplayName = request.DisplayName, Role = request.Role, IsActive = request.IsActive };
|
||||
return Task.FromResult(ApiResult<UserAdminDto>.Ok(Users[index]));
|
||||
}
|
||||
|
||||
public Task<ApiResult<bool>> DeleteUserAsync(Guid id, CancellationToken ct = default) => Ok();
|
||||
|
||||
public Task<ApiResult<bool>> ResetPasswordAsync(Guid id, ResetPasswordRequest request, CancellationToken ct = default) => Ok();
|
||||
|
||||
public Task<ApiResult<bool>> UnlockAsync(Guid id, CancellationToken ct = default) => Ok();
|
||||
|
||||
public Task<ApiResult<bool>> SetPinAsync(Guid id, SetPinRequest request, CancellationToken ct = default) => Ok();
|
||||
|
||||
public Task<ApiResult<UserAdminDto>> UploadAvatarAsync(Guid id, Stream content, string fileName, string contentType, CancellationToken ct = default) =>
|
||||
Task.FromResult(ApiResult<UserAdminDto>.Ok(Users.Single(u => u.Id == id)));
|
||||
|
||||
public Task<ApiResult<bool>> DeleteAvatarAsync(Guid id, CancellationToken ct = default) => Ok();
|
||||
|
||||
public Task<ApiResult<FamilySettingsDto>> GetSettingsAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(ApiResult<FamilySettingsDto>.Ok(
|
||||
new FamilySettingsDto("Familie Test", "Europe/Berlin", 7, ["Mittagessen"], ["Haushalt"], true, false, false, false)));
|
||||
|
||||
public Task<ApiResult<FamilySettingsDto>> UpdateSettingsAsync(FamilySettingsDto settings, CancellationToken ct = default) =>
|
||||
Task.FromResult(ApiResult<FamilySettingsDto>.Ok(settings));
|
||||
|
||||
public Task<ApiResult<PagedResult<AuditLogEntryDto>>> GetAuditLogAsync(int page, int pageSize, CancellationToken ct = default) =>
|
||||
Task.FromResult(ApiResult<PagedResult<AuditLogEntryDto>>.Ok(new PagedResult<AuditLogEntryDto>([], 0, page, pageSize)));
|
||||
|
||||
public static UserAdminDto Dto(Guid id, string userName, string displayName, string role, string color = "#1E88E5") =>
|
||||
new(id, userName, displayName, null, role, color, null, true, null, false, null, false, false, DateTimeOffset.UtcNow, null);
|
||||
|
||||
private ApiProblem? TakeProblem()
|
||||
{
|
||||
var problem = NextProblem;
|
||||
NextProblem = null;
|
||||
return problem;
|
||||
}
|
||||
|
||||
private static Task<ApiResult<bool>> Ok() => Task.FromResult(ApiResult<bool>.Ok(true));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@* Testhülle: MudBlazor-Provider + Benutzerverwaltung, damit Menüs und Dialoge gerendert werden. *@
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
<UserManagement />
|
||||
@@ -0,0 +1,107 @@
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Web.Client.Components.Admin;
|
||||
using FamilyPlanner.Web.Client.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MudBlazor.Services;
|
||||
|
||||
namespace FamilyPlanner.Web.Tests.Admin;
|
||||
|
||||
public sealed class UserManagementTests : BunitContext
|
||||
{
|
||||
private readonly FakeAdminApi _api = new();
|
||||
|
||||
public UserManagementTests()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
Services.AddMudServices();
|
||||
Services.AddSingleton<IAdminApi>(_api);
|
||||
|
||||
_api.Users.Add(FakeAdminApi.Dto(Guid.NewGuid(), "papa", "Papa", Roles.Admin));
|
||||
_api.Users.Add(FakeAdminApi.Dto(Guid.NewGuid(), "anna", "Anna", Roles.Child) with { IsLockedOut = true, HasPin = true });
|
||||
_api.Users.Add(FakeAdminApi.Dto(Guid.NewGuid(), "oma", "Oma", Roles.Guest) with { ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1) });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lists_all_users_with_role_and_status()
|
||||
{
|
||||
var cut = Render<UserManagementHost>();
|
||||
|
||||
var names = cut.FindAll(".fp-user-name").Select(e => e.TextContent).ToList();
|
||||
Assert.Equal(["Papa", "Anna", "Oma"], names);
|
||||
Assert.Contains("Administrator", cut.Markup);
|
||||
Assert.Contains("Kind", cut.Markup);
|
||||
Assert.Contains("Gesperrt", cut.Markup);
|
||||
Assert.Contains("Abgelaufen", cut.Markup);
|
||||
Assert.Contains("PIN", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_dialog_validates_before_calling_the_api()
|
||||
{
|
||||
var cut = Render<UserManagementHost>();
|
||||
|
||||
cut.Find("button.fp-add-user").Click();
|
||||
cut.WaitForElement("#user-edit-form");
|
||||
cut.Find("#user-edit-form").Submit();
|
||||
|
||||
Assert.Contains("Bitte einen Anzeigenamen eingeben.", cut.Markup);
|
||||
Assert.Contains("Bitte einen Benutzernamen eingeben.", cut.Markup);
|
||||
Assert.Empty(_api.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_dialog_sends_request_and_refreshes_list()
|
||||
{
|
||||
var cut = Render<UserManagementHost>();
|
||||
|
||||
cut.Find("button.fp-add-user").Click();
|
||||
cut.WaitForElement("#user-edit-form");
|
||||
cut.Find("#display-name").Change("Max");
|
||||
cut.Find("#user-name").Change("max");
|
||||
cut.Find("#password").Change("Startpasswort1");
|
||||
cut.Find("#user-edit-form").Submit();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("Max", cut.FindAll(".fp-user-name").Select(e => e.TextContent)));
|
||||
var request = Assert.Single(_api.Created);
|
||||
Assert.Equal("max", request.UserName);
|
||||
Assert.Equal(Roles.Family, request.Role);
|
||||
Assert.Null(request.ExpiresAt);
|
||||
Assert.Empty(cut.FindAll("#user-edit-form"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_validation_errors_are_shown_in_the_dialog()
|
||||
{
|
||||
_api.NextProblem = new ApiProblem
|
||||
{
|
||||
Status = 400,
|
||||
Errors = new Dictionary<string, string[]> { ["UserName"] = ["Der Benutzername „max“ ist bereits vergeben."] },
|
||||
};
|
||||
var cut = Render<UserManagementHost>();
|
||||
|
||||
cut.Find("button.fp-add-user").Click();
|
||||
cut.WaitForElement("#user-edit-form");
|
||||
cut.Find("#display-name").Change("Max");
|
||||
cut.Find("#user-name").Change("max");
|
||||
cut.Find("#password").Change("Startpasswort1");
|
||||
cut.Find("#user-edit-form").Submit();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("bereits vergeben", cut.Markup));
|
||||
Assert.NotEmpty(cut.FindAll("#user-edit-form"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_chips_cover_all_states()
|
||||
{
|
||||
var user = FakeAdminApi.Dto(Guid.NewGuid(), "x", "X", Roles.Guest) with
|
||||
{
|
||||
IsActive = false,
|
||||
IsLockedOut = true,
|
||||
MustChangePassword = true,
|
||||
};
|
||||
|
||||
var texts = UserManagement.StatusChips(user).Select(c => c.Text).ToList();
|
||||
|
||||
Assert.Equal(["Deaktiviert", "Gesperrt", "Passwortwechsel offen"], texts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@using MudBlazor
|
||||
@using FamilyPlanner.Web.Client.Components.Admin
|
||||
Reference in new issue
Block a user