using System.Net; using System.Net.Http.Json; using System.Text.Json; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Auth; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Shouldly; namespace Cleanuparr.Api.Tests.Features.Auth; /// /// Covers refresh-token rotation, expiry and logout, which all stamp the clock. /// [Collection("Auth Integration Tests")] [TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")] public class AuthControllerTokenLifecycleTests : IClassFixture { private const string Username = "tokenadmin"; private const string Password = "TokenPassword123!"; private readonly CustomWebApplicationFactory _factory; private readonly HttpClient _client; private static string _refreshToken = ""; public AuthControllerTokenLifecycleTests(CustomWebApplicationFactory factory) { _factory = factory; _client = factory.CreateClient(); } [Fact, TestPriority(0)] public async Task Login_IssuesARefreshTokenThatExpiresInAWeek() { HttpResponseMessage createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new { username = Username, password = Password, }); createResponse.StatusCode.ShouldBe(HttpStatusCode.Created); HttpResponseMessage completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { }); completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK); _refreshToken = await Login(); RefreshToken issued = await NewestToken(); issued.CreatedAt.ShouldBe(_factory.Clock.GetUtcNow()); issued.ExpiresAt.ShouldBe(_factory.Clock.GetUtcNow().AddDays(7)); } [Fact, TestPriority(1)] public async Task Refresh_RevokesTheOldTokenAndIssuesANewOne() { HttpResponseMessage response = await _client.PostAsJsonAsync("/api/auth/refresh", new { refreshToken = _refreshToken }); response.StatusCode.ShouldBe(HttpStatusCode.OK); JsonElement body = await response.Content.ReadFromJsonAsync(); string rotated = body.GetProperty("refreshToken").GetString()!; rotated.ShouldNotBe(_refreshToken); await using UsersContext context = Context(out IServiceScope scope); using (scope) { RefreshToken revoked = await context.RefreshTokens.SingleAsync(t => t.RevokedAt != null); revoked.RevokedAt!.Value.ShouldBe(_factory.Clock.GetUtcNow()); } _refreshToken = rotated; } [Fact, TestPriority(2)] public async Task Refresh_WithAnExpiredToken_IsRejected() { await using (UsersContext context = Context(out IServiceScope writeScope)) { using (writeScope) { RefreshToken active = await context.RefreshTokens .Where(t => t.RevokedAt == null) .OrderByDescending(t => t.CreatedAt) .FirstAsync(); active.ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1); await context.SaveChangesAsync(); } } HttpResponseMessage response = await _client.PostAsJsonAsync("/api/auth/refresh", new { refreshToken = _refreshToken }); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } [Fact, TestPriority(3)] public async Task Logout_RevokesTheRefreshToken() { _refreshToken = await Login(); HttpResponseMessage response = await _client.PostAsJsonAsync("/api/auth/logout", new { refreshToken = _refreshToken }); response.StatusCode.ShouldBe(HttpStatusCode.OK); await using UsersContext context = Context(out IServiceScope scope); using (scope) { RefreshToken revoked = await context.RefreshTokens .OrderByDescending(t => t.CreatedAt) .FirstAsync(); revoked.RevokedAt.ShouldNotBeNull(); revoked.RevokedAt!.Value.ShouldBe(_factory.Clock.GetUtcNow()); } } private async Task Login() { HttpResponseMessage response = await _client.PostAsJsonAsync("/api/auth/login", new { username = Username, password = Password, }); response.StatusCode.ShouldBe(HttpStatusCode.OK); JsonElement body = await response.Content.ReadFromJsonAsync(); return body.GetProperty("tokens").GetProperty("refreshToken").GetString()!; } private async Task NewestToken() { await using UsersContext context = Context(out IServiceScope scope); using (scope) { return await context.RefreshTokens .OrderByDescending(t => t.CreatedAt) .FirstAsync(); } } private UsersContext Context(out IServiceScope scope) { scope = _factory.Services.CreateScope(); return scope.ServiceProvider.GetRequiredService(); } }