using System.Net; using System.Net.Http.Json; using ItWorx.KnowledgeHub.Contracts.Auth; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; namespace ItWorx.KnowledgeHub.IntegrationTests.Auth; [Collection(PostgresTestGroup.Name)] public sealed class LoginLogoutLockoutTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { [Fact] public async Task Login_with_correct_credentials_establishes_an_authenticated_session() { using var client = Factory.CreateClient(); var response = await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var session = await response.Content.ReadFromJsonAsync(); Assert.NotNull(session); Assert.True(session.Authenticated); Assert.Equal(OwnerEmail, session.Email); Assert.Equal("Administrator", session.Role); // The same client (with its session cookie) now sees itself as authenticated // on a fresh request too, not only in the login response body. var sessionAfter = await client.GetFromJsonAsync("/api/v1/auth/session"); Assert.NotNull(sessionAfter); Assert.True(sessionAfter.Authenticated); } [Fact] public async Task Login_with_wrong_password_is_rejected_generically() { using var client = Factory.CreateClient(); var response = await SetupHelper.LoginAsync(client, OwnerEmail, "totally-wrong-password"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); Assert.DoesNotContain(OwnerPassword, body, StringComparison.Ordinal); } [Fact] public async Task Login_for_an_unknown_account_returns_the_same_response_as_a_wrong_password() { using var client = Factory.CreateClient(); var response = await SetupHelper.LoginAsync(client, "does-not-exist@itworx.test", "whatever-password"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Login_rate_limit_is_per_remote_ip_and_ignores_untrusted_forwarded_headers() { using var clientA = Factory.CreateClient(); clientA.DefaultRequestHeaders.Add("X-Test-Remote-Ip", "192.0.2.10"); // Without a configured trusted proxy, these caller-supplied values must not // create fresh rate-limit partitions. All attempts originate from client A's // effective remote address. for (var attempt = 0; attempt < 10; attempt++) { clientA.DefaultRequestHeaders.Remove("X-Forwarded-For"); clientA.DefaultRequestHeaders.Add("X-Forwarded-For", $"203.0.113.{attempt + 1}"); var response = await SetupHelper.LoginAsync( clientA, "unknown-rate-limit-user@itworx.test", "invalid-password"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } clientA.DefaultRequestHeaders.Remove("X-Forwarded-For"); clientA.DefaultRequestHeaders.Add("X-Forwarded-For", "203.0.113.250"); var exhaustedClientA = await SetupHelper.LoginAsync( clientA, "unknown-rate-limit-user@itworx.test", "invalid-password"); Assert.Equal(HttpStatusCode.TooManyRequests, exhaustedClientA.StatusCode); using var clientB = Factory.CreateClient(); clientB.DefaultRequestHeaders.Add("X-Test-Remote-Ip", "192.0.2.11"); clientB.DefaultRequestHeaders.Add("X-Forwarded-For", "203.0.113.251"); var clientBLogin = await SetupHelper.LoginAsync(clientB, OwnerEmail, OwnerPassword); Assert.Equal(HttpStatusCode.OK, clientBLogin.StatusCode); clientA.DefaultRequestHeaders.Remove("X-Forwarded-For"); clientA.DefaultRequestHeaders.Add("X-Forwarded-For", "203.0.113.252"); var stillExhaustedClientA = await SetupHelper.LoginAsync( clientA, "unknown-rate-limit-user@itworx.test", "invalid-password"); Assert.Equal(HttpStatusCode.TooManyRequests, stillExhaustedClientA.StatusCode); } [Fact] public async Task Repeated_failed_logins_lock_the_account() { using var client = Factory.CreateClient(); // Configured lockout threshold is 5 failed attempts. HttpResponseMessage last = null!; for (var attempt = 0; attempt < 6; attempt++) { last = await SetupHelper.LoginAsync(client, OwnerEmail, "wrong-password-attempt"); } Assert.Equal(HttpStatusCode.TooManyRequests, last.StatusCode); // Even the correct password is now rejected while locked out. var withCorrectPassword = await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); Assert.Equal(HttpStatusCode.TooManyRequests, withCorrectPassword.StatusCode); } [Fact] public async Task Logout_ends_the_session() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var logoutResponse = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/auth/logout", null); Assert.Equal(HttpStatusCode.OK, logoutResponse.StatusCode); var session = await client.GetFromJsonAsync("/api/v1/auth/session"); Assert.NotNull(session); Assert.False(session.Authenticated); } }