From bbe3779a537f3a765cdcf9d3253d6a5a8dca0784 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:03:41 -0600 Subject: [PATCH] tests: add remaining register integration tests --- ...roblemDetailsAuthorizationResultHandler.cs | 41 ++++ .../ServiceCollectionExtensions.cs | 2 + src/FiscalOS.Infra/Usings.cs | 6 + .../HttpResponseMessageAssertions.cs | 101 ++++++++++ .../Infra/JwtTokenBuilder.cs | 15 +- .../Integration/LoginTests.cs | 24 +-- .../Integration/RefreshTests.cs | 186 +++++++++++++++++- tests/FiscalOS.API.Tests/Usings.cs | 4 + .../Assertions/JwtTokenAssertions.cs | 2 +- 9 files changed, 350 insertions(+), 31 deletions(-) create mode 100644 src/FiscalOS.Infra/Authorization/ProblemDetailsAuthorizationResultHandler.cs create mode 100644 tests/FiscalOS.API.Tests/Assertions/HttpResponseMessageAssertions.cs diff --git a/src/FiscalOS.Infra/Authorization/ProblemDetailsAuthorizationResultHandler.cs b/src/FiscalOS.Infra/Authorization/ProblemDetailsAuthorizationResultHandler.cs new file mode 100644 index 0000000..8d47cca --- /dev/null +++ b/src/FiscalOS.Infra/Authorization/ProblemDetailsAuthorizationResultHandler.cs @@ -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); + } +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/DependencyInjection/ServiceCollectionExtensions.cs b/src/FiscalOS.Infra/DependencyInjection/ServiceCollectionExtensions.cs index 2b5ea40..cae6f96 100644 --- a/src/FiscalOS.Infra/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/FiscalOS.Infra/DependencyInjection/ServiceCollectionExtensions.cs @@ -4,6 +4,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddInfrastructure(this IServiceCollection services) { + services.AddSingleton(); + services.AddSingleton(TimeProvider.System); services.ConfigureOptions(); diff --git a/src/FiscalOS.Infra/Usings.cs b/src/FiscalOS.Infra/Usings.cs index 9a6d388..b428c29 100644 --- a/src/FiscalOS.Infra/Usings.cs +++ b/src/FiscalOS.Infra/Usings.cs @@ -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; diff --git a/tests/FiscalOS.API.Tests/Assertions/HttpResponseMessageAssertions.cs b/tests/FiscalOS.API.Tests/Assertions/HttpResponseMessageAssertions.cs new file mode 100644 index 0000000..48f24d7 --- /dev/null +++ b/tests/FiscalOS.API.Tests/Assertions/HttpResponseMessageAssertions.cs @@ -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(instance, assertionChain) +{ + private readonly AssertionChain _chain = assertionChain; + + protected override string Identifier => "HttpResponseMessage"; + + public async Task> BeJsonContentOfType( + 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(); + + _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(this, content!); + } + + public AndConstraint 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(this); + } + + public async Task> BeProblemDetails(HttpStatusCode expectedStatusCode) + { + var problem = await ValidateAndDeserialize(expectedStatusCode); + return new AndWhichConstraint(this, problem); + } + + public async Task> BeValidationProblemDetails( + IDictionary expectedErrors, + HttpStatusCode expectedStatusCode = HttpStatusCode.BadRequest + ) + { + var problem = await ValidateAndDeserialize(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(this, problem); + } + + private async Task ValidateAndDeserialize(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(); + + _chain.ForCondition(problem is not null) + .FailWith($"Expected body to be a valid {typeof(T).Name}, but it could not be deserialized."); + + return problem!; + } +} \ No newline at end of file diff --git a/tests/FiscalOS.API.Tests/Infra/JwtTokenBuilder.cs b/tests/FiscalOS.API.Tests/Infra/JwtTokenBuilder.cs index 4895428..ab66b45 100644 --- a/tests/FiscalOS.API.Tests/Infra/JwtTokenBuilder.cs +++ b/tests/FiscalOS.API.Tests/Infra/JwtTokenBuilder.cs @@ -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 _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; diff --git a/tests/FiscalOS.API.Tests/Integration/LoginTests.cs b/tests/FiscalOS.API.Tests/Integration/LoginTests.cs index 91acaeb..f9dbc04 100644 --- a/tests/FiscalOS.API.Tests/Integration/LoginTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/LoginTests.cs @@ -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(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(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(HttpStatusCode.OK); } } diff --git a/tests/FiscalOS.API.Tests/Integration/RefreshTests.cs b/tests/FiscalOS.API.Tests/Integration/RefreshTests.cs index f6dd076..2dbf49c 100644 --- a/tests/FiscalOS.API.Tests/Integration/RefreshTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/RefreshTests.cs @@ -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(); + var tokenGenerator = sp.GetRequiredService(); + + 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() + .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(); + var tokenGenerator = sp.GetRequiredService(); + + 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(); + var tokenGenerator = sp.GetRequiredService(); + var timeProvider = sp.GetRequiredService(); + + 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(); + var tokenGenerator = sp.GetRequiredService(); + var timeProvider = sp.GetRequiredService(); + + 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(HttpStatusCode.OK); + + var oldRefreshTokenInDb = await ExecuteDbContextAsync( + async context => await context.Set() + .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() + .Include(t => t.User) + .Where(t => t.UserId == user.Id && t.Revoked == false && t.Token != refreshToken.Token) + .SingleOrDefaultAsync() + ); + + newRefreshTokenInDb.Should().NotBeNull(); } } \ No newline at end of file diff --git a/tests/FiscalOS.API.Tests/Usings.cs b/tests/FiscalOS.API.Tests/Usings.cs index e3c5db9..910a3c1 100644 --- a/tests/FiscalOS.API.Tests/Usings.cs +++ b/tests/FiscalOS.API.Tests/Usings.cs @@ -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; diff --git a/tests/FiscalOS.Infra.Tests/Assertions/JwtTokenAssertions.cs b/tests/FiscalOS.Infra.Tests/Assertions/JwtTokenAssertions.cs index 74ae952..59a3de8 100644 --- a/tests/FiscalOS.Infra.Tests/Assertions/JwtTokenAssertions.cs +++ b/tests/FiscalOS.Infra.Tests/Assertions/JwtTokenAssertions.cs @@ -1,6 +1,6 @@ namespace FiscalOS.Infra.Tests.Assertions; -internal static class JwtTokenAssertionExtensions +internal static class JwtTokenAssertions { public static AndConstraint HaveClaim( this StringAssertions assertions,