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).
2026-08-18 14:10:12 +01:00
|
|
|
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;
|
2026-08-20 20:50:52 +01:00
|
|
|
using static Yavsc.Constants;
|
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).
2026-08-18 14:10:12 +01:00
|
|
|
|
|
|
|
|
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<ApplicationUser></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)
|
2026-08-20 20:50:52 +01:00
|
|
|
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/circle/{circleId}/members";
|
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).
2026-08-18 14:10:12 +01:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|