load the ACL
This commit is contained in:
parent
3a1ce55c0f
commit
0241dd98f0
14 changed files with 438 additions and 174 deletions
|
|
@ -8,8 +8,6 @@
|
|||
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Avalonia packages -->
|
||||
<!-- Important: keep version in sync! -->
|
||||
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
|
||||
|
|
@ -21,16 +19,12 @@
|
|||
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
|
||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
|
||||
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
|
||||
|
||||
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
|
||||
|
||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" />
|
||||
|
||||
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
|
||||
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
|
||||
|
|
|
|||
|
|
@ -166,4 +166,51 @@ public class BlogPostAuthorDtoTests
|
|||
Assert.True(root.TryGetProperty("userName", out _));
|
||||
Assert.True(root.TryGetProperty("avatar", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlogPostDto_deserialises_acl_from_detail_payload()
|
||||
{
|
||||
// Detail payload shape emitted by BlogApiController.GetBlog:
|
||||
// ACL entries are included under "acl"/"ACL".
|
||||
var json = """
|
||||
{
|
||||
"id": 99,
|
||||
"title": "ACL test",
|
||||
"authorId": "u-alice",
|
||||
"acl": [
|
||||
{ "circleId": 12, "blogPostId": 99 },
|
||||
{ "circleId": 34, "blogPostId": 99 }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
|
||||
|
||||
Assert.NotNull(post);
|
||||
var acl = post!.GetACL();
|
||||
Assert.Equal(2, acl.Length);
|
||||
Assert.Contains(acl, a => a.CircleId == 12);
|
||||
Assert.Contains(acl, a => a.CircleId == 34);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlogPostDto_does_not_emit_acl_when_serialized_for_write()
|
||||
{
|
||||
var post = new BlogPostDto
|
||||
{
|
||||
Id = 77,
|
||||
Title = "Write payload"
|
||||
};
|
||||
post.AuthorizeCircle(11);
|
||||
|
||||
// The client should not send ACL through POST/PUT blog payloads.
|
||||
// ACL mutations have their own dedicated /blogacl endpoint.
|
||||
var json = JsonSerializer.Serialize(post,
|
||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
Assert.False(root.TryGetProperty("acl", out _));
|
||||
Assert.False(root.TryGetProperty("wireAcl", out _));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using PostIt.Services;
|
|||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Api.Client.Dtos;
|
||||
using Yavsc.Blogspot;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
|
@ -190,9 +191,8 @@ public class PostAclDialogTests
|
|||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
// Assert: exactly two GETs went out (one to /blogacl,
|
||||
// one to /circle), both from the LoadAsync call.
|
||||
Assert.Equal(2, handler.RequestCount);
|
||||
// Assert: one GET went out (for /circle) from LoadAsync.
|
||||
Assert.Equal(1, handler.RequestCount);
|
||||
|
||||
// And the VM's idempotency gate has flipped.
|
||||
Assert.True(vm.Loaded);
|
||||
|
|
@ -218,7 +218,58 @@ public class PostAclDialogTests
|
|||
await vm.LoadAsync();
|
||||
|
||||
// Assert: the second call short-circuited on _loaded.
|
||||
Assert.Equal(2, handler.RequestCount);
|
||||
Assert.Equal(1, handler.RequestCount);
|
||||
Assert.True(vm.Loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_keeps_acl_from_blogpostdto_and_only_loads_circles()
|
||||
{
|
||||
var post = new BlogPostDto { Id = 42, Title = "ACL hydration" };
|
||||
post.AuthorizeCircle(12);
|
||||
post.AuthorizeCircle(34);
|
||||
|
||||
var api = new StubAclApiClient();
|
||||
var aclClient = new BlogAclApiClient(api, "http://localhost/");
|
||||
var circleClient = new CircleApiClient(api, "http://localhost/");
|
||||
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
|
||||
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal(1, api.CallCount);
|
||||
Assert.Equal(2, vm.AclEntries.Count);
|
||||
Assert.Contains(vm.AclEntries, a => a.CircleId == 12);
|
||||
Assert.Contains(vm.AclEntries, a => a.CircleId == 34);
|
||||
}
|
||||
|
||||
private sealed class StubAclApiClient : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
CallCount++;
|
||||
|
||||
if (typeof(T) == typeof(List<CircleDto>))
|
||||
{
|
||||
var circles = new List<CircleDto>
|
||||
{
|
||||
new() { Id = 12, Name = "A", OwnerId = "owner", Public = false },
|
||||
new() { Id = 34, Name = "B", OwnerId = "owner", Public = false },
|
||||
};
|
||||
return Task.FromResult((T)(object)circles);
|
||||
}
|
||||
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,7 +250,23 @@ public partial class MainViewModel : ViewModelBase
|
|||
StatusMessage = "Select an existing post before managing ACL.";
|
||||
return;
|
||||
}
|
||||
await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
|
||||
|
||||
var postForAcl = SelectedPost;
|
||||
try
|
||||
{
|
||||
var detailed = await BlogClient!.GetPostAsync(SelectedPost.Id).ConfigureAwait(true);
|
||||
if (detailed is not null)
|
||||
{
|
||||
postForAcl = detailed;
|
||||
SelectedPost = detailed;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep the dialog usable even if the detail refresh fails.
|
||||
}
|
||||
|
||||
await ((App)App.Current!).PushPageAsync(GetACLViewModel(postForAcl)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
|
@ -8,6 +10,8 @@ using Yavsc.Blogspot;
|
|||
using Yavsc.Api.Client;
|
||||
using Yavsc.Api.Client.Dtos;
|
||||
using Yavsc.Abstract.BlogSpot;
|
||||
using Yavsc.Abstract.Identity.Security;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
@ -41,7 +45,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
MyCircles { get; set; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<PostAccessControlRulePayload>
|
||||
public partial ObservableCollection<CircleAuthorization>
|
||||
AclEntries { get; set; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
|
|
@ -77,6 +81,12 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
Post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient));
|
||||
_circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient));
|
||||
|
||||
AclEntries = new ObservableCollection<CircleAuthorization>(post.GetACL().Select(a => new CircleAuthorization
|
||||
{
|
||||
CircleId = a.CircleId
|
||||
}));
|
||||
SelectedCircleToAdd = null;
|
||||
}
|
||||
|
||||
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
||||
|
|
@ -90,12 +100,10 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
// Load circles and ACL entries in parallel — both are
|
||||
// independent reads on the same host. The caller's uid
|
||||
// is implicit in both endpoints.
|
||||
// Load circles for the picker. ACL entries come from the
|
||||
// BlogPostDto detail payload (source of truth for initial state).
|
||||
var circlesTask = _circleClient.GetMyCirclesAsync();
|
||||
var aclTask = _aclClient.GetMyAclAsync();
|
||||
await Task.WhenAll(circlesTask, aclTask);
|
||||
await Task.WhenAll(circlesTask);
|
||||
|
||||
var circles = circlesTask.Result ?? new List<CircleDto>();
|
||||
MyCircles = new ObservableCollection<CircleDto>(circles);
|
||||
|
|
@ -126,14 +134,20 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var created = await _aclClient.GrantAsync(new Yavsc.Abstract.BlogSpot.PostAccessControlRulePayload
|
||||
if (AclEntries.Any(a => a.CircleId == SelectedCircleToAdd.Id))
|
||||
{
|
||||
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé";
|
||||
return;
|
||||
}
|
||||
|
||||
var created = await _aclClient.GrantAsync(new PostAccessControlRulePayload
|
||||
{
|
||||
CircleId = SelectedCircleToAdd.Id,
|
||||
BlogPostId = Post.Id
|
||||
});
|
||||
if (created is not null)
|
||||
{
|
||||
AclEntries.Add(created);
|
||||
AclEntries.Add(new CircleAuthorization { CircleId = created.CircleId });
|
||||
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé";
|
||||
}
|
||||
else
|
||||
|
|
@ -141,6 +155,13 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
StatusMessage = "Autorisation refusée par le serveur";
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
// Conflict means the link already exists in backend. Resync
|
||||
// from the dedicated ACL API so the UI reflects server truth.
|
||||
await ReloadAclEntriesFromServerAsync();
|
||||
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Erreur: {ex.Message}";
|
||||
|
|
@ -159,7 +180,9 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
try
|
||||
{
|
||||
await _aclClient.RevokeAsync(acl.CircleId);
|
||||
AclEntries.Remove(acl);
|
||||
var existing = AclEntries.FirstOrDefault(e => e.CircleId == acl.CircleId);
|
||||
if (existing is not null)
|
||||
AclEntries.Remove(existing);
|
||||
StatusMessage = "Autorisation révoquée";
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -171,4 +194,16 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadAclEntriesFromServerAsync()
|
||||
{
|
||||
var allAcl = await _aclClient.GetMyAclAsync();
|
||||
var currentPostAcl = (allAcl ?? new List<PostAccessControlRulePayload>())
|
||||
.Where(a => a.BlogPostId == Post.Id)
|
||||
.Select(a => new CircleAuthorization { CircleId = a.CircleId })
|
||||
.GroupBy(a => a.CircleId)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
AclEntries = new ObservableCollection<CircleAuthorization>(currentPostAcl);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue