Files
Chris Chen 2aa095c158 Task 11: Smoke test fixes (all 5 scenarios pass)
TokenService.GenerateRefreshToken():
  - Switched to URL-safe Base64 (RFC 4648 §5): +→-, /→_, no = padding.
  - Characters are unreserved per RFC 6265, so Response.Cookies.Append
    does NOT percent-encode the value.  Request.Cookies reads back exact value.

AuthController:
  - CookieOptions.Secure = !env.IsDevelopment()
    Plain HTTP in local dev works; HTTPS-only in staging/production.
  - Inject IWebHostEnvironment for environment-aware Secure flag.

TokenServiceTests:
  - Updated GenerateRefreshToken test: 86-char URL-safe Base64 instead
    of 64-byte standard Base64.  16/16 tests pass.

Smoke test results (http://localhost:5209):
  1. POST /api/auth/login       → 200 + rolac_rt cookie + JWT
  2. POST /api/auth/refresh     → 200 + new token (rotation)
  3. POST /api/auth/logout      → 204 + cookie cleared
  4. Refresh with revoked token → 401
  5. Wrong password             → 401

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 19:28:20 -07:00

155 lines
5.5 KiB
C#

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<string, string?>
{
{ "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
// ---------------------------------------------------------------------------
/// <summary>
/// Base64Url-decodes the payload segment of a JWT and returns it as a
/// <see cref="JsonDocument"/>. Caller is responsible for disposing.
/// </summary>
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<string> { "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<string> { 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<string>());
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')));
}
}