de5c18f3e6
First member into a family becomes Admin; subsequent members default to Member. JWTs carry a family_role claim that is refreshed from the DB on each request so promotions and demotions take effect immediately. New /api/family endpoints let admins view the roster, regenerate the invite code, change roles, and remove members. Last-admin and self-removal guards prevent locking the family out of management. The /family page exposes the same actions in the UI; the bottom nav now links there. Members see the roster but not the invite code. Existing deployments get a one-time backfill at startup: any family with members but no admin gets its earliest-joined member promoted.
186 lines
7.9 KiB
C#
186 lines
7.9 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using YesChef.Api.Entities;
|
|
using YesChef.Api.Features.Families;
|
|
using YesChef.Api.IntegrationTests.Infrastructure;
|
|
|
|
namespace YesChef.Api.IntegrationTests.Features;
|
|
|
|
public class FamilyEndpointsTests : AuthenticatedIntegrationTest
|
|
{
|
|
// The default registered user is the first member of the bootstrap family
|
|
// and therefore the admin. Tests that need a non-admin register a second
|
|
// user via the same invite code to get a Member.
|
|
|
|
[Test]
|
|
public async Task First_registrant_is_admin()
|
|
{
|
|
var family = await Client.GetFromJsonAsync<FamilyEndpoints.FamilyDto>("/api/family");
|
|
|
|
await Assert.That(family!.MyRole).IsEqualTo("Admin");
|
|
await Assert.That(family.Members.Count).IsEqualTo(1);
|
|
await Assert.That(family.Members[0].Role).IsEqualTo("Admin");
|
|
}
|
|
|
|
[Test]
|
|
public async Task Second_registrant_is_member()
|
|
{
|
|
using var second = await Data.RegisterAsync("second-user");
|
|
|
|
var family = await second.Client.GetFromJsonAsync<FamilyEndpoints.FamilyDto>("/api/family");
|
|
|
|
await Assert.That(family!.MyRole).IsEqualTo("Member");
|
|
await Assert.That(family.Members.Count).IsEqualTo(2);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Get_family_hides_invite_code_from_members()
|
|
{
|
|
using var member = await Data.RegisterAsync("member");
|
|
|
|
var asAdmin = await Client.GetFromJsonAsync<FamilyEndpoints.FamilyDto>("/api/family");
|
|
var asMember = await member.Client.GetFromJsonAsync<FamilyEndpoints.FamilyDto>("/api/family");
|
|
|
|
await Assert.That(asAdmin!.InviteCode).IsNotNull();
|
|
await Assert.That(asMember!.InviteCode).IsNull();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Regenerate_invite_code_replaces_code_and_returns_new_one()
|
|
{
|
|
var beforeCode = await UseDbAsync(db => db.Families.Select(f => f.InviteCode).FirstAsync());
|
|
|
|
var response = await Client.PostAsync("/api/family/invite-code/regenerate", content: null);
|
|
|
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
|
var body = await response.Content.ReadFromJsonAsync<FamilyEndpoints.InviteCodeResponse>();
|
|
await Assert.That(body!.InviteCode).IsNotEqualTo(beforeCode);
|
|
await Assert.That(body.InviteCode.Length).IsEqualTo(10);
|
|
var afterCode = await UseDbAsync(db => db.Families.Select(f => f.InviteCode).FirstAsync());
|
|
await Assert.That(afterCode).IsEqualTo(body.InviteCode);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Regenerate_invite_code_forbidden_for_member()
|
|
{
|
|
using var member = await Data.RegisterAsync("member");
|
|
|
|
var response = await member.Client.PostAsync("/api/family/invite-code/regenerate", content: null);
|
|
|
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Update_role_promotes_member_to_admin()
|
|
{
|
|
using var member = await Data.RegisterAsync("member");
|
|
var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync());
|
|
|
|
var response = await Client.PutAsJsonAsync($"/api/family/members/{memberId}/role",
|
|
new FamilyEndpoints.UpdateRoleRequest("Admin"));
|
|
|
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
|
|
var role = await UseDbAsync(db => db.FamilyMemberships
|
|
.Where(m => m.UserId == memberId).Select(m => m.Role).SingleAsync());
|
|
await Assert.That(role).IsEqualTo(FamilyRole.Admin);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Update_role_blocks_demoting_last_admin()
|
|
{
|
|
var response = await Client.PutAsJsonAsync($"/api/family/members/{User.Id}/role",
|
|
new FamilyEndpoints.UpdateRoleRequest("Member"));
|
|
|
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Update_role_allows_demoting_admin_when_other_admin_exists()
|
|
{
|
|
using var member = await Data.RegisterAsync("member");
|
|
var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync());
|
|
// promote so we have two admins
|
|
await Client.PutAsJsonAsync($"/api/family/members/{memberId}/role",
|
|
new FamilyEndpoints.UpdateRoleRequest("Admin"));
|
|
|
|
// demote self — should succeed because the other admin remains
|
|
var response = await Client.PutAsJsonAsync($"/api/family/members/{User.Id}/role",
|
|
new FamilyEndpoints.UpdateRoleRequest("Member"));
|
|
|
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Update_role_forbidden_for_member()
|
|
{
|
|
using var member = await Data.RegisterAsync("member");
|
|
var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync());
|
|
|
|
var response = await member.Client.PutAsJsonAsync($"/api/family/members/{memberId}/role",
|
|
new FamilyEndpoints.UpdateRoleRequest("Admin"));
|
|
|
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Remove_member_deletes_membership()
|
|
{
|
|
using var member = await Data.RegisterAsync("member");
|
|
var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync());
|
|
|
|
var response = await Client.DeleteAsync($"/api/family/members/{memberId}");
|
|
|
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
|
|
var stillMember = await UseDbAsync(db => db.FamilyMemberships.AnyAsync(m => m.UserId == memberId));
|
|
await Assert.That(stillMember).IsFalse();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Remove_member_revokes_their_token()
|
|
{
|
|
using var member = await Data.RegisterAsync("member");
|
|
var memberId = await UseDbAsync(db => db.Users.Where(u => u.Name == "member").Select(u => u.Id).SingleAsync());
|
|
|
|
var removal = await Client.DeleteAsync($"/api/family/members/{memberId}");
|
|
await Assert.That(removal.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
|
|
|
|
// The kicked member's existing JWT must now be rejected.
|
|
var meResponse = await member.Client.GetAsync("/api/auth/me");
|
|
await Assert.That(meResponse.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Remove_self_returns_409()
|
|
{
|
|
var response = await Client.DeleteAsync($"/api/family/members/{User.Id}");
|
|
|
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Member_demoted_loses_admin_access_on_next_request()
|
|
{
|
|
// Promote a second user to admin, then have the original admin demote
|
|
// them. The original admin's already-issued token should still work
|
|
// (still admin); the demoted user's token should no longer pass the
|
|
// admin policy on the next request.
|
|
using var second = await Data.RegisterAsync("second");
|
|
var secondId = await UseDbAsync(db => db.Users.Where(u => u.Name == "second").Select(u => u.Id).SingleAsync());
|
|
await Client.PutAsJsonAsync($"/api/family/members/{secondId}/role",
|
|
new FamilyEndpoints.UpdateRoleRequest("Admin"));
|
|
|
|
// confirm second can now regenerate (admin-only)
|
|
var asAdmin = await second.Client.PostAsync("/api/family/invite-code/regenerate", content: null);
|
|
await Assert.That(asAdmin.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
|
|
|
// demote second
|
|
var demote = await Client.PutAsJsonAsync($"/api/family/members/{secondId}/role",
|
|
new FamilyEndpoints.UpdateRoleRequest("Member"));
|
|
await Assert.That(demote.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
|
|
|
|
// second's same token now fails the admin policy
|
|
var afterDemote = await second.Client.PostAsync("/api/family/invite-code/regenerate", content: null);
|
|
await Assert.That(afterDemote.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
|
|
}
|
|
}
|