mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-12 21:37:53 -04:00
275 lines
9.4 KiB
C#
275 lines
9.4 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using Shouldly;
|
|
|
|
namespace Cleanuparr.Api.Tests.Features.Auth;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Collection("Auth Integration Tests")]
|
|
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
|
public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
|
{
|
|
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<JsonElement>();
|
|
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<JsonElement>();
|
|
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<JsonElement>();
|
|
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<JsonElement>();
|
|
_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<JsonElement>();
|
|
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<JsonElement>();
|
|
|
|
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<JsonElement>();
|
|
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<JsonElement>();
|
|
// 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);
|
|
}
|