tests: add remaining register integration tests

This commit is contained in:
Stevan Freeborn
2026-02-03 21:03:41 -06:00
parent 99db5696ff
commit edc45ae2ff
9 changed files with 350 additions and 31 deletions
@@ -0,0 +1,41 @@
namespace FiscalOS.Infra.Authorization;
internal sealed class ProblemDetailsAuthResultHandler : IAuthorizationMiddlewareResultHandler
{
public Task HandleAsync(
RequestDelegate next,
HttpContext context,
AuthorizationPolicy policy,
PolicyAuthorizationResult authorizeResult
)
{
if (authorizeResult.Succeeded)
{
return next(context);
}
context.Response.StatusCode = authorizeResult.Forbidden
? StatusCodes.Status403Forbidden
: StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/problem+json";
var problemDetails = authorizeResult.Forbidden
? new ProblemDetails
{
Title = "Forbidden",
Status = StatusCodes.Status403Forbidden,
Detail = "You do not have permission to access this resource."
}
: new ProblemDetails
{
Title = "Unauthorized",
Status = StatusCodes.Status401Unauthorized,
Detail = "Authentication is required to access this resource."
};
var json = JsonSerializer.Serialize(problemDetails);
return context.Response.WriteAsync(json);
}
}
@@ -4,6 +4,8 @@ public static class ServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
{
services.AddSingleton<IAuthorizationMiddlewareResultHandler, ProblemDetailsAuthResultHandler>();
services.AddSingleton(TimeProvider.System);
services.ConfigureOptions<JwtOptionsSetup>();
+6
View File
@@ -2,13 +2,19 @@ global using System.IdentityModel.Tokens.Jwt;
global using System.Security.Claims;
global using System.Security.Cryptography;
global using System.Text;
global using System.Text.Json;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity;
global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Authorization;
global using FiscalOS.Infra.Data;
global using Microsoft.AspNetCore.Authorization;
global using Microsoft.AspNetCore.Authorization.Policy;
global using Microsoft.AspNetCore.Http;
global using Microsoft.AspNetCore.Mvc;
global using Microsoft.EntityFrameworkCore;
global using Microsoft.EntityFrameworkCore.Diagnostics;
global using Microsoft.Extensions.Configuration;
@@ -0,0 +1,101 @@
namespace FiscalOS.API.Tests.Assertions;
internal static class HttpResponseExtensions
{
public static HttpResponseMessageAssertions Should(this HttpResponseMessage instance)
{
return new HttpResponseMessageAssertions(instance, AssertionChain.GetOrCreate());
}
}
internal sealed class HttpResponseMessageAssertions(
HttpResponseMessage instance,
AssertionChain assertionChain
) : ReferenceTypeAssertions<HttpResponseMessage, HttpResponseMessageAssertions>(instance, assertionChain)
{
private readonly AssertionChain _chain = assertionChain;
protected override string Identifier => "HttpResponseMessage";
public async Task<AndWhichConstraint<HttpResponseMessageAssertions, T>> BeJsonContentOfType<T>(
HttpStatusCode expectedStatusCode
)
{
_chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/json")
.FailWith(
"Expected response to be application/json, but found {0}",
Subject.Content.Headers.ContentType?.MediaType
);
_chain.ForCondition(Subject.StatusCode == expectedStatusCode)
.FailWith(
"Expected response status code to be {0}, but found {1}",
expectedStatusCode,
Subject.StatusCode
);
var content = await Subject.Content.ReadFromJsonAsync<T>();
_chain.ForCondition(content is not null)
.FailWith($"Expected body to be a valid {typeof(T).Name}, but it could not be deserialized.");
return new AndWhichConstraint<HttpResponseMessageAssertions, T>(this, content!);
}
public AndConstraint<HttpResponseMessageAssertions> HaveSetCookieHeader(string cookieName)
{
_chain.ForCondition(Subject.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders))
.FailWith("Expected response to have 'Set-Cookie' header, but it was not found.");
var hasCookie = setCookieHeaders!.Any(header => header.StartsWith(cookieName + "=", StringComparison.OrdinalIgnoreCase));
_chain.ForCondition(hasCookie)
.FailWith($"Expected response to have 'Set-Cookie' header for cookie '{cookieName}', but it was not found.");
return new AndConstraint<HttpResponseMessageAssertions>(this);
}
public async Task<AndWhichConstraint<HttpResponseMessageAssertions, ProblemDetails>> BeProblemDetails(HttpStatusCode expectedStatusCode)
{
var problem = await ValidateAndDeserialize<ProblemDetails>(expectedStatusCode);
return new AndWhichConstraint<HttpResponseMessageAssertions, ProblemDetails>(this, problem);
}
public async Task<AndWhichConstraint<HttpResponseMessageAssertions, ValidationProblemDetails>> BeValidationProblemDetails(
IDictionary<string, string[]> expectedErrors,
HttpStatusCode expectedStatusCode = HttpStatusCode.BadRequest
)
{
var problem = await ValidateAndDeserialize<ValidationProblemDetails>(expectedStatusCode);
_chain.ForCondition(problem.Errors is not null)
.FailWith("Expected ValidationProblemDetails to contain errors, but the Errors dictionary was null.");
problem.Errors.Should().BeEquivalentTo(expectedErrors, "the validation errors should match the expected dictionary");
return new AndWhichConstraint<HttpResponseMessageAssertions, ValidationProblemDetails>(this, problem);
}
private async Task<T> ValidateAndDeserialize<T>(HttpStatusCode expectedStatusCode) where T : ProblemDetails
{
_chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/problem+json")
.FailWith(
"Expected response to be application/problem+json, but found {0}",
Subject.Content.Headers.ContentType?.MediaType
);
_chain.ForCondition(Subject.StatusCode == expectedStatusCode)
.FailWith(
"Expected response status code to be {0}, but found {1}",
expectedStatusCode,
Subject.StatusCode
);
var problem = await Subject.Content.ReadFromJsonAsync<T>();
_chain.ForCondition(problem is not null)
.FailWith($"Expected body to be a valid {typeof(T).Name}, but it could not be deserialized.");
return problem!;
}
}
@@ -7,7 +7,7 @@ internal sealed class JwtTokenBuilder
private const int ExpiryInMinutes = 5;
private static readonly string Secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
private readonly List<Claim> _claims = [];
private DateTimeOffset _issuedAt = DateTimeOffset.UtcNow;
private DateTimeOffset _expiresAt = DateTimeOffset.UtcNow.AddMinutes(ExpiryInMinutes);
public static JwtOptions DefaultJwtOptions => new()
{
@@ -28,22 +28,23 @@ internal sealed class JwtTokenBuilder
return this;
}
public JwtTokenBuilder IssuedAt(DateTimeOffset issuedAt)
public JwtTokenBuilder WithExpiresAt(DateTimeOffset expiresAt)
{
_issuedAt = issuedAt;
_expiresAt = expiresAt;
return this;
}
public string Build()
{
var tokenHandler = new JwtSecurityTokenHandler();
var expiresAt = _issuedAt.AddMinutes(ExpiryInMinutes);
var issuedAt = _expiresAt.AddMinutes(-ExpiryInMinutes);
var descriptor = new SecurityTokenDescriptor()
{
Subject = new(_claims),
IssuedAt = _issuedAt.UtcDateTime,
Expires = expiresAt.UtcDateTime,
NotBefore = issuedAt.UtcDateTime,
IssuedAt = issuedAt.UtcDateTime,
Expires = _expiresAt.UtcDateTime,
Issuer = Issuer,
Audience = Audience,
SigningCredentials = new(
@@ -52,7 +53,7 @@ internal sealed class JwtTokenBuilder
),
};
var securityToken = tokenHandler.CreateJwtSecurityToken(descriptor);
var securityToken = tokenHandler.CreateToken(descriptor);
var jwtToken = tokenHandler.WriteToken(securityToken);
return jwtToken;
@@ -16,11 +16,7 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
res.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problem = await res.Content.ReadFromJsonAsync<ValidationProblemDetails>(TestContext.Current.CancellationToken);
problem!.Errors.Should().BeEquivalentTo(tc.ExpectedErrors);
await res.Should().BeValidationProblemDetails(tc.ExpectedErrors);
}
[Fact]
@@ -34,7 +30,7 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
[Fact]
@@ -57,7 +53,7 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
[Fact]
@@ -80,18 +76,8 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
res.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await res.Content.ReadFromJsonAsync<Login.Response>(TestContext.Current.CancellationToken);
content!.AccessToken.Should().NotBeNullOrEmpty();
res.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue();
var hasRefreshTokenCookie = cookies!
.Any(static c => c.StartsWith("fiscalos_refresh_cookie=", StringComparison.OrdinalIgnoreCase));
hasRefreshTokenCookie.Should().BeTrue();
res.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
await res.Should().BeJsonContentOfType<Login.Response>(HttpStatusCode.OK);
}
}
@@ -1,4 +1,3 @@
namespace FiscalOS.API.Tests.Integration;
public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
@@ -6,10 +5,189 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
private static readonly Uri RefreshUri = new("/refresh", UriKind.Relative);
[Fact]
public async Task Refresh_WhenCalledWithNoAccessToken_ItShouldReturn401Unauthorized()
public async Task Refresh_WhenCalledWithNoAccessToken_ItShouldReturn401WithProblemDetails()
{
var response = await Client.PostAsync(RefreshUri, null, TestContext.Current.CancellationToken); ;
var response = await Client.PostAsync(RefreshUri, null, TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Refresh_WhenCalledWithNonExistentRefreshToken_ItShouldReturn400WithProblemDetails()
{
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
.Build();
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
request.Headers.Add("Cookie", "fiscalos_refresh_cookie=nonexistenttoken");
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Refresh_WhenCalledWithTokenBelongingToDifferentUser_ItShouldReturn403WithProblemDetails()
{
var (users, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
var user1 = User.From("User1", passwordHasher.Hash("@Password1"));
var user2 = User.From("User2", passwordHasher.Hash("@Password2"));
var refreshToken1 = tokenGenerator.GenerateRefreshToken(user2);
var refreshToken2 = tokenGenerator.GenerateRefreshToken(user2);
user2.AddRefreshToken(refreshToken1);
user2.AddRefreshToken(refreshToken2);
context.Add(user1);
context.Add(user2);
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
return (new User[] { user1, user2 }, refreshToken1);
});
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, users[0].Id.ToString())
.Build();
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.Forbidden);
var unrevokedTokensCountForUser2 = await ExecuteDbContextAsync(
async context => await context.Set<RefreshToken>()
.Include(t => t.User)
.Where(t => t.UserId == users[1].Id && t.Revoked == false)
.CountAsync()
);
unrevokedTokensCountForUser2.Should().Be(0);
}
[Fact]
public async Task Refresh_WhenCalledWithRevokedRefreshToken_ItShouldReturn400WithProblemDetails()
{
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
var user = User.From("User1", passwordHasher.Hash("@Password1"));
var refreshToken = tokenGenerator.GenerateRefreshToken(user);
refreshToken.Revoke();
user.AddRefreshToken(refreshToken);
context.Add(user);
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
return (user, refreshToken);
});
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
.Build();
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Refresh_WhenCalledWithExpiredRefreshToken_ItShouldReturn400WithProblemDetails()
{
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
var timeProvider = sp.GetRequiredService<TimeProvider>();
var user = User.From("User1", passwordHasher.Hash("@Password1"));
var refreshToken = RefreshToken.From(user.Id, "expiredtoken", timeProvider.GetUtcNow().AddHours(-1));
user.AddRefreshToken(refreshToken);
context.Add(user);
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
return (user, refreshToken);
});
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
.Build();
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
}
[Theory]
[InlineData(5)]
[InlineData(-5)]
public async Task Refresh_WhenCalledWithValidRefreshTokenAndExpiredOrNotExpiredAccessToken_ItShouldReturn200WithNewTokensAndSetRefreshCookie(int accessTokenExpiresAtOffset)
{
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
var timeProvider = sp.GetRequiredService<TimeProvider>();
var user = User.From("User1", passwordHasher.Hash("@Password1"));
var refreshToken = tokenGenerator.GenerateRefreshToken(user);
user.AddRefreshToken(refreshToken);
context.Add(user);
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
return (user, refreshToken);
});
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
.WithExpiresAt(DateTime.UtcNow.AddMinutes(accessTokenExpiresAtOffset))
.Build();
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
response.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
await response.Should().BeJsonContentOfType<Refresh.Response>(HttpStatusCode.OK);
var oldRefreshTokenInDb = await ExecuteDbContextAsync(
async context => await context.Set<RefreshToken>()
.Include(t => t.User)
.Where(t => t.UserId == user.Id && t.Token == refreshToken.Token && t.Revoked == true)
.SingleOrDefaultAsync()
);
oldRefreshTokenInDb.Should().NotBeNull();
var newRefreshTokenInDb = await ExecuteDbContextAsync(
async context => await context.Set<RefreshToken>()
.Include(t => t.User)
.Where(t => t.UserId == user.Id && t.Revoked == false && t.Token != refreshToken.Token)
.SingleOrDefaultAsync()
);
newRefreshTokenInDb.Should().NotBeNull();
}
}
+4
View File
@@ -1,9 +1,13 @@
global using System.IdentityModel.Tokens.Jwt;
global using System.Net;
global using System.Net.Http.Headers;
global using System.Net.Http.Json;
global using System.Security.Claims;
global using System.Security.Cryptography;
global using AwesomeAssertions.Execution;
global using AwesomeAssertions.Primitives;
global using FiscalOS.API.Tests.Assertions;
global using FiscalOS.API.Tests.Infra;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Identity;
@@ -1,6 +1,6 @@
namespace FiscalOS.Infra.Tests.Assertions;
internal static class JwtTokenAssertionExtensions
internal static class JwtTokenAssertions
{
public static AndConstraint<string> HaveClaim(
this StringAssertions assertions,