Add per-family invite codes and admin roles
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.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user