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.
98 lines
3.8 KiB
C#
98 lines
3.8 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using YesChef.Api.Auth;
|
|
using YesChef.Api.Entities;
|
|
|
|
namespace YesChef.Api.UnitTests.Auth;
|
|
|
|
public class JwtTokenServiceTests
|
|
{
|
|
private const string Secret = "this-is-a-test-secret-that-is-definitely-long-enough-for-hs256";
|
|
|
|
private static JwtTokenService BuildService(string secret = Secret)
|
|
{
|
|
var config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["Jwt:Secret"] = secret })
|
|
.Build();
|
|
return new JwtTokenService(config);
|
|
}
|
|
|
|
private static JwtSecurityToken Decode(string token) =>
|
|
new JwtSecurityTokenHandler().ReadJwtToken(token);
|
|
|
|
[Test]
|
|
public async Task GenerateToken_includes_user_id_name_and_family_claims()
|
|
{
|
|
var service = BuildService();
|
|
var user = new User { Id = 42, Name = "alice", PasswordHash = "x" };
|
|
|
|
var jwt = Decode(service.GenerateToken(user, familyId: 7, role: FamilyRole.Admin));
|
|
|
|
await Assert.That(jwt.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value).IsEqualTo("42");
|
|
await Assert.That(jwt.Claims.First(c => c.Type == ClaimTypes.Name).Value).IsEqualTo("alice");
|
|
await Assert.That(jwt.Claims.First(c => c.Type == JwtTokenService.FamilyIdClaim).Value).IsEqualTo("7");
|
|
await Assert.That(jwt.Claims.First(c => c.Type == JwtTokenService.FamilyRoleClaim).Value).IsEqualTo("Admin");
|
|
}
|
|
|
|
[Test]
|
|
public async Task GenerateToken_expires_in_about_30_days()
|
|
{
|
|
var service = BuildService();
|
|
var user = new User { Id = 1, Name = "bob", PasswordHash = "x" };
|
|
|
|
var jwt = Decode(service.GenerateToken(user, familyId: 1, role: FamilyRole.Member));
|
|
|
|
var expectedExpiry = DateTime.UtcNow.AddDays(30);
|
|
var delta = (jwt.ValidTo - expectedExpiry).Duration();
|
|
await Assert.That(delta).IsLessThan(TimeSpan.FromMinutes(1));
|
|
}
|
|
|
|
[Test]
|
|
public async Task GenerateToken_signs_with_hs256_using_configured_secret()
|
|
{
|
|
var service = BuildService();
|
|
var user = new User { Id = 7, Name = "carol", PasswordHash = "x" };
|
|
|
|
var token = service.GenerateToken(user, familyId: 1, role: FamilyRole.Member);
|
|
|
|
var validator = new JwtSecurityTokenHandler();
|
|
var parameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = false,
|
|
ValidateAudience = false,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret))
|
|
};
|
|
|
|
var principal = validator.ValidateToken(token, parameters, out var validatedToken);
|
|
|
|
await Assert.That(principal.Identity!.IsAuthenticated).IsTrue();
|
|
await Assert.That(((JwtSecurityToken)validatedToken).SignatureAlgorithm)
|
|
.IsEqualTo(SecurityAlgorithms.HmacSha256);
|
|
}
|
|
|
|
[Test]
|
|
public async Task GenerateToken_with_different_secret_fails_validation()
|
|
{
|
|
var service = BuildService();
|
|
var token = service.GenerateToken(new User { Id = 1, Name = "x", PasswordHash = "x" }, familyId: 1, role: FamilyRole.Member);
|
|
|
|
var validator = new JwtSecurityTokenHandler();
|
|
var parameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = false,
|
|
ValidateAudience = false,
|
|
ValidateLifetime = true,
|
|
IssuerSigningKey = new SymmetricSecurityKey(
|
|
Encoding.UTF8.GetBytes("a-completely-different-secret-of-sufficient-length"))
|
|
};
|
|
|
|
await Assert.That(() => validator.ValidateToken(token, parameters, out _))
|
|
.ThrowsExactly<SecurityTokenSignatureKeyNotFoundException>();
|
|
}
|
|
}
|