feat(admin): port site administration to API and PostIt
Some checks failed
Dotnet build and test / build (push) Failing after 13m0s
Forgejo Release / release (push) Successful in 8m1s

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-18 18:47:12 +01:00
commit 04c42d9380
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
29 changed files with 2580 additions and 53 deletions

View file

@ -16,12 +16,13 @@ namespace PostIt.Tests;
public class AdministrationAccessTests
{
[AvaloniaTheory]
[InlineData("Moderator", true)]
[InlineData("Administrator", false)]
[InlineData("Moderator", false)]
[InlineData("Administrator", true)]
[InlineData("administrator", false)]
[InlineData("Performer", false)]
[InlineData("moderator", false)]
[InlineData("", false)]
public async Task Home_administration_visibility_and_command_follow_the_moderator_role(string role, bool allowed)
public async Task Home_administration_visibility_and_command_follow_the_administrator_role(string role, bool allowed)
{
await using var api = new ProfileTransport(role);
var session = new SessionStatusViewModel { Api = api };
@ -35,7 +36,7 @@ public class AdministrationAccessTests
button => Equals(button.Content, "Administration"));
Assert.False(button.IsVisible);
await session.RefreshAsync();
Assert.Equal(allowed, session.IsModerator);
Assert.Equal(allowed, session.IsAdministrator);
Assert.Equal(allowed, button.IsVisible);
Assert.Equal(allowed, vm.OpenAdministration.CanExecute(null));
Assert.DoesNotContain(page.GetVisualDescendants().OfType<Button>(),
@ -55,32 +56,32 @@ public class AdministrationAccessTests
[Fact]
public async Task Late_role_response_cannot_restore_access_after_logout()
{
await using var api = new ProfileTransport("Moderator");
await using var api = new ProfileTransport("Administrator");
var pending = new TaskCompletionSource<Me>();
api.PendingProfile = pending.Task;
var session = new SessionStatusViewModel { Api = api };
var refresh = session.RefreshAsync();
session.Api = null;
session.Refresh();
pending.SetResult(new Me { Roles = ["Moderator"] });
pending.SetResult(new Me { Roles = ["Administrator"] });
await refresh;
Assert.False(session.IsModerator);
Assert.False(session.IsAdministrator);
}
[Fact]
public async Task Role_lookup_failure_denies_access_and_reports_the_error()
{
await using var api = new ProfileTransport("Moderator") { FailProfile = true };
await using var api = new ProfileTransport("Administrator") { FailProfile = true };
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
Assert.False(session.IsModerator);
Assert.False(session.IsAdministrator);
Assert.Contains("Impossible de vérifier les droits", session.SessionLabel);
}
[Fact]
public async Task Administration_loads_users_and_usage_and_matches_by_id()
{
await using var api = new ProfileTransport("Moderator");
await using var api = new ProfileTransport("Administrator");
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
@ -96,7 +97,7 @@ public class AdministrationAccessTests
[AvaloniaFact]
public async Task Administration_displays_both_tabs_and_selected_user_usage()
{
await using var api = new ProfileTransport("Moderator");
await using var api = new ProfileTransport("Administrator");
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
@ -109,7 +110,7 @@ public class AdministrationAccessTests
window.Show();
Dispatcher.UIThread.RunJobs();
var tabs = Assert.Single(page.GetVisualDescendants().OfType<TabControl>());
Assert.Equal(2, tabs.Items.Count);
Assert.Equal(7, tabs.Items.Count);
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(),
text => text.Text == "Appels API : 12");
tabs.SelectedIndex = 1;
@ -126,9 +127,9 @@ public class AdministrationAccessTests
}
[Fact]
public async Task Administration_denies_non_moderators_without_requesting_private_data()
public async Task Administration_denies_moderators_without_requesting_private_data()
{
await using var api = new ProfileTransport("Administrator");
await using var api = new ProfileTransport("Moderator");
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
api.Requests.Clear();
@ -136,13 +137,13 @@ public class AdministrationAccessTests
await vm.RefreshAsync();
Assert.Empty(api.Requests);
Assert.Empty(vm.Users);
Assert.Contains("Moderator", vm.StatusMessage);
Assert.Contains("Administrator", vm.StatusMessage);
}
[Fact]
public async Task Administration_failure_reports_error_without_falling_back_to_personal_usage()
{
await using var api = new ProfileTransport("Moderator") { FailUsage = true };
await using var api = new ProfileTransport("Administrator") { FailUsage = true };
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
@ -179,7 +180,7 @@ public class AdministrationAccessTests
Assert.Contains("Impossible de charger mon usage", vm.UsageError);
}
private sealed class ProfileTransport : YavscApiClient
internal sealed class ProfileTransport : YavscApiClient
{
private readonly string _tokenPath;
private readonly string _role;

View file

@ -0,0 +1,227 @@
using System.ComponentModel.DataAnnotations;
using System.Net.Http;
using System.Text.Json;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Administration;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class AdministrationToolsTests
{
[AvaloniaTheory]
[InlineData(400, 800)]
[InlineData(800, 400)]
public void All_administration_tabs_and_typed_forms_are_accessible_in_portrait_and_landscape(int width, int height)
{
var vm = new AdministrationPageViewModel();
var page = new AdministrationPage { DataContext = vm };
var window = new Window { Content = page, Width = width, Height = height };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var tabs = Assert.Single(page.GetVisualDescendants().OfType<TabControl>());
for (var index = 0; index < tabs.Items.Count; ++index)
{
var tab = Assert.IsType<TabItem>(tabs.ContainerFromIndex(index));
var point = tab.TranslatePoint(new Point(), page);
Assert.NotNull(point);
Assert.True(point.Value.X >= 0 && point.Value.X + tab.Bounds.Width <= page.Bounds.Width + 0.5);
}
tabs.SelectedIndex = 3;
Dispatcher.UIThread.RunJobs();
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(), block => block.Text == "RequirePkce");
Assert.Contains(page.GetVisualDescendants().OfType<TextBox>(),
box => box.DataContext is AdministrationFieldViewModel field && field.Name == "ClientId");
tabs.SelectedIndex = 5;
Dispatcher.UIThread.RunJobs();
Assert.Contains(page.GetVisualDescendants().OfType<ComboBox>(),
box => box.DataContext is AdministrationFieldViewModel field && field.Name == "ToSend");
}
finally { window.Close(); }
}
[Fact]
public void Client_form_retains_every_scalar_field_and_validates_numeric_input()
{
var settings = new ClientSettings { ClientId = "postit", ClientName = "PostIt", AccessTokenLifetime = 900,
ConsentLifetime = null, RequireConsent = true, PairWiseSubjectSalt = null };
var fields = AdministrationFieldViewModel.From(settings);
Assert.Equal(typeof(ClientSettings).GetProperties().Length, fields.Count);
Assert.Equal(settings, AdministrationFieldViewModel.Build<ClientSettings>(fields));
var lifetime = fields.Single(field => field.Name == nameof(ClientSettings.AccessTokenLifetime));
lifetime.Text = "not-a-number";
Assert.Throws<ValidationException>(() => AdministrationFieldViewModel.Build<ClientSettings>(fields));
lifetime.Text = "-1";
Assert.Throws<ValidationException>(() => AdministrationFieldViewModel.Build<ClientSettings>(fields));
}
[Fact]
public void Mailing_form_round_trips_enum_and_multiline_body()
{
var settings = new MailingTemplateSettings { Id = "template", Topic = "Topic", Body = "First\nSecond",
ReplyToAddress = "reply@example.invalid", ToSend = MailPeriodicity.Monthly };
var fields = AdministrationFieldViewModel.From(settings);
Assert.Equal(settings, AdministrationFieldViewModel.Build<MailingTemplateSettings>(fields));
Assert.True(fields.Single(field => field.Name == "Body").IsMultiline);
var periodicity = fields.Single(field => field.Name == "ToSend");
Assert.True(periodicity.IsChoice);
periodicity.Text = "Unknown";
Assert.Throws<ValidationException>(() => AdministrationFieldViewModel.Build<MailingTemplateSettings>(fields));
}
[Fact]
public async Task Client_commands_save_typed_data_and_require_confirmation_for_delete()
{
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
var session = new SessionStatusViewModel { Api = profile };
await session.RefreshAsync();
await using var transport = new AdminTransport();
var vm = new AdministrationPageViewModel(null, session: session,
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1"));
vm.ClientFields.Single(field => field.Name == "ClientId").Text = "new-client";
await vm.SaveClientCommand.ExecuteAsync(null);
Assert.Equal("new-client", transport.SavedClient?.ClientId);
Assert.Equal(1, vm.SelectedClient?.Id);
transport.Paths.Clear();
await vm.DeleteClientCommand.ExecuteAsync(null);
Assert.Empty(transport.Paths);
Assert.Contains("confirmation", vm.StatusMessage);
vm.ConfirmDestructiveAction = true;
await vm.DeleteClientCommand.ExecuteAsync(null);
Assert.Contains("DELETE https://example.invalid/api/v1/administration/clients/1", transport.Paths);
Assert.False(vm.ConfirmDestructiveAction);
Assert.Null(vm.SelectedClient);
}
[Fact]
public async Task Secrets_are_shown_once_and_cleared_on_selection_and_logout_denies_operations()
{
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
var session = new SessionStatusViewModel { Api = profile };
await session.RefreshAsync();
await using var transport = new AdminTransport();
var vm = new AdministrationPageViewModel(null, session: session,
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1/"));
vm.SelectedClient = new AdministrationClient(1, new ClientSettings { ClientId = "client" }, [], []);
await vm.AddSecretCommand.ExecuteAsync(null);
Assert.Equal("generated-test-value", vm.NewSecretValue);
Assert.Equal("", vm.SecretInput);
vm.NewClientCommand.Execute(null);
Assert.Null(vm.NewSecretValue);
session.Api = null;
session.Refresh();
transport.Paths.Clear();
await vm.AddSecretCommand.ExecuteAsync(null);
Assert.Empty(transport.Paths);
Assert.Contains("Administrator", vm.StatusMessage);
}
[Fact]
public async Task Secret_response_received_after_logout_is_discarded()
{
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
var session = new SessionStatusViewModel { Api = profile };
await session.RefreshAsync();
await using var transport = new AdminTransport
{
PendingSecret = new TaskCompletionSource<NewClientSecret>(TaskCreationOptions.RunContinuationsAsynchronously)
};
var vm = new AdministrationPageViewModel(null, session: session,
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1/"));
vm.SelectedClient = new AdministrationClient(1, new ClientSettings { ClientId = "client" }, [], []);
var operation = vm.AddSecretCommand.ExecuteAsync(null);
Assert.True(vm.IsBusy);
session.Api = null;
session.Refresh();
transport.PendingSecret.SetResult(new NewClientSecret(1, "must-not-be-shown", null));
await operation;
Assert.Null(vm.NewSecretValue);
Assert.Empty(vm.Clients);
Assert.Null(vm.SelectedClient);
Assert.Contains("Administrator", vm.StatusMessage);
Assert.Equal(["POST https://example.invalid/api/v1/administration/clients/1/secrets"], transport.Paths);
}
[Fact]
public async Task Refresh_without_administrator_access_clears_previous_administration_data()
{
var vm = new AdministrationPageViewModel();
var client = new AdministrationClient(1, new ClientSettings { ClientId = "previous-client" }, [], []);
vm.Clients.Add(client);
vm.SelectedClient = client;
vm.NewSecretValue = "previous-secret";
await vm.RefreshAsync();
Assert.Empty(vm.Clients);
Assert.Null(vm.SelectedClient);
Assert.Null(vm.NewSecretValue);
Assert.Equal("", vm.ClientFields.Single(field => field.Name == "ClientId").Text);
Assert.Contains("Administrator", vm.StatusMessage);
}
[Fact]
public async Task Import_requires_confirmation_and_uses_only_administration_host()
{
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
var session = new SessionStatusViewModel { Api = profile };
await session.RefreshAsync();
await using var transport = new AdminTransport();
var vm = new AdministrationPageViewModel(null, session: session,
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1/"));
await Assert.ThrowsAsync<InvalidOperationException>(() => vm.ImportTransferAsync(new AdministrationTransfer()));
Assert.Empty(transport.Paths);
vm.ConfirmDestructiveAction = true;
var result = await vm.ImportTransferAsync(new AdministrationTransfer());
Assert.Equal(new ImportResult(0, 0, 0), result);
Assert.False(vm.ConfirmDestructiveAction);
Assert.Equal(["POST https://example.invalid/api/v1/administration/transfer"], transport.Paths);
}
private sealed class AdminTransport : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<string> Paths { get; } = [];
public ClientSettings? SavedClient { get; private set; }
public TaskCompletionSource<NewClientSecret>? PendingSecret { get; init; }
public async Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add($"{method} {path}");
object result;
if (typeof(T) == typeof(AdministrationClient))
{
SavedClient = Assert.IsType<ClientSettings>(body);
result = new AdministrationClient(1, SavedClient, [], []);
}
else if (typeof(T) == typeof(List<AdministrationClient>))
result = new List<AdministrationClient> { new(1, SavedClient ?? new ClientSettings { ClientId = "client" }, [], []) };
else if (typeof(T) == typeof(NewClientSecret))
result = PendingSecret is null
? new NewClientSecret(1, "generated-test-value", null)
: await PendingSecret.Task;
else if (typeof(T) == typeof(ImportResult))
result = new ImportResult(0, 0, 0);
else
throw new NotSupportedException(typeof(T).Name);
return JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(result))!;
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add($"{method} {path}");
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
=> throw new NotSupportedException();
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
}
}