using System.Net; using System.Net.Http.Json; using System.Text.Json; using Shouldly; namespace Cleanuparr.Api.Tests.Features.Auth; /// /// Integration tests for the authentication flow. /// Uses a single shared factory to avoid static state conflicts. /// Tests are ordered to build on each other: setup → login → protected endpoints. /// [Collection("Auth Integration Tests")] [TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")] public class AuthControllerTests : IClassFixture { private readonly HttpClient _client; public AuthControllerTests(CustomWebApplicationFactory factory) { _client = factory.CreateClient(); } [Fact, TestPriority(0)] public async Task GetStatus_BeforeSetup_ReturnsNotCompleted() { var response = await _client.GetAsync("/api/auth/status"); response.StatusCode.ShouldBe(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(); body.GetProperty("setupCompleted").GetBoolean().ShouldBeFalse(); } [Fact, TestPriority(0)] public async Task AuthEndpoints_AlwaysReturnNoCacheHeaders() { var response = await _client.GetAsync("/api/auth/status"); response.Headers.CacheControl.ShouldNotBeNull(); response.Headers.CacheControl!.NoCache.ShouldBeTrue(); response.Headers.CacheControl!.NoStore.ShouldBeTrue(); } [Fact, TestPriority(1)] public async Task Setup_CreateAccount_ReturnsCreated() { var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new { username = "admin", password = "TestPassword123!" }); response.StatusCode.ShouldBe(HttpStatusCode.Created); var body = await response.Content.ReadFromJsonAsync(); body.GetProperty("userId").GetString().ShouldNotBeNullOrEmpty(); } [Fact, TestPriority(2)] public async Task Setup_CreateDuplicateAccount_ReturnsConflict() { var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new { username = "another", password = "TestPassword123!" }); response.StatusCode.ShouldBe(HttpStatusCode.Conflict); } [Fact, TestPriority(3)] public async Task Setup_Generate2FA_ReturnsSecretAndRecoveryCodes() { var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { }); response.StatusCode.ShouldBe(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(); body.GetProperty("secret").GetString().ShouldNotBeNullOrEmpty(); body.GetProperty("qrCodeUri").GetString().ShouldNotBeNullOrEmpty(); body.GetProperty("recoveryCodes").GetArrayLength().ShouldBeGreaterThan(0); // Store the secret for the next test _totpSecret = body.GetProperty("secret").GetString()!; } [Fact, TestPriority(4)] public async Task Setup_Verify2FA_WithValidCode_Succeeds() { // If we don't have the secret from the previous test, generate it again if (string.IsNullOrEmpty(_totpSecret)) { var genResponse = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { }); var genBody = await genResponse.Content.ReadFromJsonAsync(); _totpSecret = genBody.GetProperty("secret").GetString()!; } var code = GenerateTotpCode(_totpSecret); var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/verify", new { code }); response.StatusCode.ShouldBe(HttpStatusCode.OK); } [Fact, TestPriority(5)] public async Task Setup_Complete_Succeeds() { var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { }); response.StatusCode.ShouldBe(HttpStatusCode.OK); } [Fact, TestPriority(6)] public async Task Login_ValidCredentials_RequiresTwoFactor() { var response = await _client.PostAsJsonAsync("/api/auth/login", new { username = "admin", password = "TestPassword123!" }); response.StatusCode.ShouldBe(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(); body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue(); body.GetProperty("loginToken").GetString().ShouldNotBeNullOrEmpty(); } [Fact, TestPriority(7)] public async Task Login_InvalidCredentials_ReturnsUnauthorized() { var response = await _client.PostAsJsonAsync("/api/auth/login", new { username = "admin", password = "WrongPassword!" }); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } [Fact, TestPriority(8)] public async Task Login_BruteForce_ReturnsRetryAfter() { // Make multiple failed attempts for (int i = 0; i < 3; i++) { await _client.PostAsJsonAsync("/api/auth/login", new { username = "admin", password = "WrongPassword!" }); } var response = await _client.PostAsJsonAsync("/api/auth/login", new { username = "admin", password = "WrongPassword!" }); var body = await response.Content.ReadFromJsonAsync(); if (response.StatusCode == HttpStatusCode.TooManyRequests) { body.GetProperty("retryAfterSeconds").GetInt32().ShouldBeGreaterThan(0); } else { response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); body.TryGetProperty("retryAfterSeconds", out var retry).ShouldBeTrue(); retry.GetInt32().ShouldBeGreaterThan(0); } } [Fact, TestPriority(9)] public async Task ProtectedEndpoint_WithoutAuth_DeniesAccess() { var response = await _client.GetAsync("/api/account"); // 401 (FallbackPolicy) or 403 (SetupGuardMiddleware) - both deny unauthenticated access new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden } .ShouldContain(response.StatusCode); } [Fact, TestPriority(10)] public async Task HealthEndpoint_WithoutAuth_Returns200() { var response = await _client.GetAsync("/health"); response.StatusCode.ShouldBe(HttpStatusCode.OK); } [Fact, TestPriority(11)] public async Task Setup_2FAGenerate_AfterCompletion_IsBlocked() { var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { }); // Blocked by middleware (403) or controller defense-in-depth (409) new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict } .ShouldContain(response.StatusCode); } [Fact, TestPriority(12)] public async Task Setup_PlexPin_AfterCompletion_IsBlocked() { var response = await _client.PostAsync("/api/auth/setup/plex/pin", null); // Blocked by middleware (403) or controller defense-in-depth (409) new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict } .ShouldContain(response.StatusCode); } [Fact, TestPriority(13)] public async Task Setup_Complete_AfterCompletion_IsBlocked() { var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { }); // Blocked by middleware (403) or controller defense-in-depth (409) new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict } .ShouldContain(response.StatusCode); } [Fact, TestPriority(14)] public async Task Login_NotBlockedByMiddleware_AfterSetupEndpointsBlocked() { var response = await _client.PostAsJsonAsync("/api/auth/login", new { username = "admin", password = "TestPassword123!" }); // Login endpoint must NOT be blocked by the middleware (403). // It may return OK (200) or TooManyRequests (429) due to brute force lockout from earlier tests. response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden); } [Fact, TestPriority(15)] public async Task AuthStatus_StillWorks_AfterSetupEndpointsBlocked() { var response = await _client.GetAsync("/api/auth/status"); response.StatusCode.ShouldBe(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(); body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue(); } [Fact, TestPriority(16)] public async Task OidcExchange_WithNonexistentCode_ReturnsNotFound() { var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = "nonexistent-one-time-code" }); response.StatusCode.ShouldBe(HttpStatusCode.NotFound); } [Fact, TestPriority(17)] public async Task AuthStatus_IncludesOidcFields() { var response = await _client.GetAsync("/api/auth/status"); response.StatusCode.ShouldBe(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(); // Verify OIDC fields exist in the response (values depend on shared static DB state) body.TryGetProperty("oidcEnabled", out _).ShouldBeTrue(); body.TryGetProperty("oidcProviderName", out _).ShouldBeTrue(); } private static string _totpSecret = ""; private static string GenerateTotpCode(string base32Secret) => TotpTestHelper.GenerateTotpCode(base32Secret); }