133 lines
5.4 KiB
C#
133 lines
5.4 KiB
C#
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<SessionResponse>();
|
|
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<SessionResponse>("/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<object?>(client, "/api/v1/auth/logout", null);
|
|
Assert.Equal(HttpStatusCode.OK, logoutResponse.StatusCode);
|
|
|
|
var session = await client.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
|
|
Assert.NotNull(session);
|
|
Assert.False(session.Authenticated);
|
|
}
|
|
}
|