feat(acl): server endpoints + client + UI for circle membership

Round-trip out a long-standing hole in the ACL feature: until
this commit a circle on Yavsc was an empty named bucket. You
could create 'Famille' and grant it on a post, but the circle
carried no members, so 'Famille' authorised no one. The MVC
admin controller (Yavsc.Org.Controllers.CircleMembersController)
existed but had no REST counterpart, so PostIt — which only
talks to the Yavsc.Blogs API — had no way to manage membership
at all.

This commit closes that gap end-to-end:

Server (Yavsc.Blogs)
- GET    /api/circle/{id}/members           list members
- POST   /api/circle/{id}/members           add a user (body { userId })
- DELETE /api/circle/{id}/members/{userId}  remove a user
  All three are scoped to caller == circle.OwnerId; non-owned
  circles return 404 (not 403) to avoid leaking existence, in
  line with the rest of the controller.
- Two new DTOs (CircleMemberDto, AddCircleMemberDto) for the
  wire shapes. CircleMemberDto mirrors UserSearchResultDto
  minus Email — membership UI doesn't need contact details.

Tests (Yavsc.Blogs.Tests)
- CircleMembersApiTests: 5 [Fact] covering empty list,
  add+get, duplicate add → 409, remove, and cross-owner 404.
  Test users (alice, bob) are seeded directly through the
  in-memory DbContext — the Blogs fixture doesn't stand up
  UserManager<ApplicationUser>.

Client (Yavsc.Api.Client)
- CircleMemberDto + 3 methods on CircleApiClient:
  GetMembersAsync, AddMemberAsync, RemoveMemberAsync.
  All match the server's contract: 404 flattens to null,
  409 surfaces as an exception (callers can dedupe beforehand
  if they want idempotent behaviour).

UI (PostIt)
- CirclesPageViewModel gains a Members ObservableCollection
  that auto-loads on SelectedCircle change (via the partial
  setter generated by [ObservableProperty]). Commands:
  LoadMembersAsync, OpenAddMember (raises an event the view
  subscribes to), OnAddMemberConfirmedAsync (called by the
  view when the dialog confirms a selection), RemoveMemberAsync.
  409 (already a member) is detected from the exception
  message and surfaced as a friendly status rather than an
  error — a likely race when the same user gets added twice
  through two UI paths.

- CirclesPage layout is now two-pane (circles + editor on the
  left, members of the selected circle on the right). The
  member pane has an 'Ajouter un membre' button that opens
  AddCircleMemberDialog. Code-behind wires the dialog's
  Confirmed event back into the VM via an async lambda
  wrapper (EventHandler<T> wants void, the VM method is
  async Task).

- AddCircleMemberDialog is a ContentPage (light modal, same
  pattern as PostAclDialog). Its ViewModel consumes
  IUserDirectory — the abstraction introduced by 04a31709
  to fix the 'user search should not be IContactService'
  confusion. The dialog raises Confirmed with the picked
  UserSummary; the host (CirclesPage) is responsible for
  calling CircleApiClient.AddMemberAsync.

Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: all visible text still hard-coded French.
- XAML accessibility audit of pre-existing pages.
- Avalonia.Headless UI tests of the new navigation flow.

Tests: 51/51 PostIt.Tests green, 20/20 Yavsc.Blogs.Tests
green (was 15; +5 for CircleMembersApiTests), 44/44
Yavsc.Org.Tests green (no regression).
This commit is contained in:
Paul Schneider 2026-08-18 14:10:12 +01:00
commit 5e3d361f88
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
10 changed files with 923 additions and 38 deletions

View file

@ -0,0 +1,199 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Behavioural tests for the circle-members endpoints on
/// <c>CircleApiController</c>:
/// <c>GET /api/circle/{id}/members</c>,
/// <c>POST /api/circle/{id}/members</c>,
/// <c>DELETE /api/circle/{id}/members/{userId}</c>.
///
/// <para>Same fixture as <see cref="BlogApiTests"/>:
/// <see cref="BlogsWebServerFixture"/> provides an in-memory
/// <c>ApplicationDbContext</c>, JWT bearer auth with HS256,
/// and the production <c>BlogScope</c> policy. Tests use
/// <c>TestTokenIssuer</c> to mint tokens whose <c>sub</c>
/// claim identifies the caller.</para>
///
/// <para>Test users (<c>alice</c>, <c>bob</c>) are seeded
/// directly via <see cref="ApplicationDbContext.Users"/>:
/// the Blogs fixture doesn't stand up
/// <c>UserManager&lt;ApplicationUser&gt;</c>, so we go
/// through the DbContext the same way the production code
/// would.</para>
/// </summary>
[Collection("JwtClaimMapping")]
public sealed class CircleMembersApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public CircleMembersApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
/// <summary>Reset the in-memory database and seed
/// <c>alice</c> + <c>bob</c>. <c>UseInMemoryDatabase</c>
/// shares its store across the fixture lifetime, so each
/// test starts from a clean slate.</summary>
private void ResetDatabaseWithUsers()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
db.Users.Add(new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.com",
EmailConfirmed = true,
FullName = "Alice Dupont",
Avatar = "/avatars/alice.png",
});
db.Users.Add(new ApplicationUser
{
Id = "bob",
UserName = "bob",
Email = "bob@example.com",
EmailConfirmed = true,
FullName = "Bob Martin",
Avatar = "/avatars/bob.png",
});
db.SaveChanges();
}
/// <summary>Create a circle owned by <paramref name="ownerId"/>
/// directly in the in-memory store and return its server-assigned
/// id. The tests below use this to bypass the controller's POST
/// (which is already covered by other tests on the branch);
/// the focus here is the members endpoints.</summary>
private long SeedCircle(string ownerId, string name)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var circle = new Circle { OwnerId = ownerId, Name = name };
db.Circle.Add(circle);
db.SaveChanges();
return circle.Id;
}
private string MembersUrl(long circleId)
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/api/circle/{circleId}/members";
private HttpClient NewClient(string subject)
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", TestTokenIssuer.Issue(subject));
return http;
}
[Fact]
public async Task GetMembers_returns_200_with_empty_list_when_no_members()
{
ResetDatabaseWithUsers();
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("alice");
var response = await http.GetAsync(MembersUrl(circleId));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
[Fact]
public async Task PostMember_returns_201_then_Get_returns_the_member()
{
ResetDatabaseWithUsers();
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("alice");
var postResponse = await http.PostAsJsonAsync(
MembersUrl(circleId),
new { userId = "bob" });
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var getResponse = await http.GetAsync(MembersUrl(circleId));
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync());
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(1, doc.RootElement.GetArrayLength());
var member = doc.RootElement[0];
Assert.Equal("bob", member.GetProperty("id").GetString());
Assert.Equal("bob", member.GetProperty("userName").GetString());
Assert.Equal("Bob Martin", member.GetProperty("fullName").GetString());
}
[Fact]
public async Task PostMember_returns_409_when_user_already_in_circle()
{
ResetDatabaseWithUsers();
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("alice");
var first = await http.PostAsJsonAsync(
MembersUrl(circleId),
new { userId = "bob" });
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
var second = await http.PostAsJsonAsync(
MembersUrl(circleId),
new { userId = "bob" });
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
}
[Fact]
public async Task DeleteMember_returns_200_then_Get_does_not_include_member()
{
ResetDatabaseWithUsers();
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("alice");
await http.PostAsJsonAsync(MembersUrl(circleId), new { userId = "bob" });
var deleteResponse = await http.DeleteAsync(
$"{MembersUrl(circleId)}/bob");
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
var getResponse = await http.GetAsync(MembersUrl(circleId));
using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync());
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
[Fact]
public async Task GetMembers_returns_404_when_circle_not_owned_by_caller()
{
ResetDatabaseWithUsers();
// Alice's circle, Bob tries to read its members.
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("bob");
var response = await http.GetAsync(MembersUrl(circleId));
// 404, not 403 — the controller deliberately avoids leaking
// the existence of someone else's circle.
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}