using System.Text.Json; using Microsoft.Extensions.Configuration; using ROLAC.API.Entities; using ROLAC.API.Services; using Xunit; namespace ROLAC.API.Tests.Services; public class TokenServiceTests { private readonly TokenService _sut; public TokenServiceTests() { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "Jwt:SecretKey", "test-secret-key-that-is-at-least-32-characters-long!!" }, { "Jwt:Issuer", "rolac-api-test" }, { "Jwt:Audience", "rolac-client-test" }, { "Jwt:AccessTokenExpiryMinutes", "15" }, { "Jwt:RefreshTokenExpiryDays", "30" }, }) .Build(); _sut = new TokenService(config); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// /// Base64Url-decodes the payload segment of a JWT and returns it as a /// . Caller is responsible for disposing. /// private static JsonDocument ReadJwtPayload(string token) { var b64 = token.Split('.')[1]; var mod4 = b64.Length % 4; if (mod4 > 0) b64 += new string('=', 4 - mod4); var bytes = Convert.FromBase64String(b64.Replace('-', '+').Replace('_', '/')); return JsonDocument.Parse(bytes); } // --------------------------------------------------------------------------- // GenerateAccessToken // --------------------------------------------------------------------------- [Fact] public void GenerateAccessToken_ReturnsValidJwt_WithUserIdAndRoles() { var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" }; var roles = new List { "member", "finance" }; var token = _sut.GenerateAccessToken(user, roles); Assert.NotEmpty(token); // Decode payload without touching any Microsoft.IdentityModel API so that // library version mismatches (7.1.2 vs 7.5.2) cannot affect this test. using var doc = ReadJwtPayload(token); var root = doc.RootElement; Assert.Equal("user-123", root.GetProperty("sub").GetString()); Assert.Equal("test@rolac.org", root.GetProperty("email").GetString()); // JwtSecurityTokenHandler's DefaultOutboundClaimTypeMap maps // ClaimTypes.Role → "role", so roles land under the "role" key. // When multiple claims share a type they are serialised as a JSON array. var roleEl = root.GetProperty("role"); var roleClaims = roleEl.ValueKind == JsonValueKind.Array ? roleEl.EnumerateArray().Select(e => e.GetString()!).ToList() : new List { roleEl.GetString()! }; Assert.Contains("member", roleClaims); Assert.Contains("finance", roleClaims); } [Fact] public void GenerateAccessToken_ExpiresIn15Minutes() { var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" }; var token = _sut.GenerateAccessToken(user, new List()); using var doc = ReadJwtPayload(token); var expUnix = doc.RootElement.GetProperty("exp").GetInt64(); var expectedUnix = DateTimeOffset.UtcNow.AddMinutes(15).ToUnixTimeSeconds(); // Allow ±2 s for test execution time. Assert.InRange(expUnix, expectedUnix - 2, expectedUnix + 2); } // --------------------------------------------------------------------------- // GenerateRefreshToken // --------------------------------------------------------------------------- [Fact] public void GenerateRefreshToken_Returns86CharUrlSafeBase64() { var token = _sut.GenerateRefreshToken(); Assert.NotEmpty(token); // 64 bytes → 88 standard Base64 chars → 86 URL-safe chars (no '==' padding, // '+' → '-', '/' → '_'). All chars must be unreserved in RFC 6265 cookie-values. Assert.Equal(86, token.Length); Assert.True( token.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'), "Token must use URL-safe Base64 alphabet (A-Z a-z 0-9 - _)"); } [Fact] public void GenerateRefreshToken_ProducesUniqueTokensEachCall() { var token1 = _sut.GenerateRefreshToken(); var token2 = _sut.GenerateRefreshToken(); Assert.NotEqual(token1, token2); } // --------------------------------------------------------------------------- // HashToken // --------------------------------------------------------------------------- [Fact] public void HashToken_SameInputProducesSameHash() { const string raw = "some-raw-token-value"; var hash1 = _sut.HashToken(raw); var hash2 = _sut.HashToken(raw); Assert.Equal(hash1, hash2); } [Fact] public void HashToken_DifferentInputsProduceDifferentHashes() { var hash1 = _sut.HashToken("token-a"); var hash2 = _sut.HashToken("token-b"); Assert.NotEqual(hash1, hash2); } [Fact] public void HashToken_Returns64CharLowercaseHexString() { var hash = _sut.HashToken("any-token"); Assert.Equal(64, hash.Length); Assert.True(hash.All(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))); } }