using System.Net; using System.Net.Http.Json; using ItWorx.KnowledgeHub.Contracts.Admin; using ItWorx.KnowledgeHub.Contracts.Auth; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; namespace ItWorx.KnowledgeHub.IntegrationTests.Auth; [Collection(PostgresTestGroup.Name)] public sealed class RecoveryTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { [Fact] public async Task Administrator_issued_token_lets_the_user_set_a_new_password_and_old_sessions_stop_working() { using var adminClient = Factory.CreateClient(); await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword); const string email = "vergeten@itworx.test"; const string originalPassword = "original-strong-password"; var createResponse = await SetupHelper.PostWithCsrfAsync( adminClient, "/api/v1/admin/users", new CreateUserRequest("Vergeetachtige Gebruiker", email, originalPassword, "Editor")); var user = await createResponse.Content.ReadFromJsonAsync(); Assert.NotNull(user); // The user logs in once with the original password to prove that session gets // invalidated once the password is reset via recovery. using var userClient = Factory.CreateClient(); await SetupHelper.LoginAsync(userClient, email, originalPassword); var beforeReset = await userClient.GetFromJsonAsync("/api/v1/auth/session"); Assert.NotNull(beforeReset); Assert.True(beforeReset.Authenticated); var issueResponse = await SetupHelper.PostWithCsrfAsync( adminClient, $"/api/v1/admin/users/{user.Id}/recovery-tokens", null); Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode); var issued = await issueResponse.Content.ReadFromJsonAsync(); Assert.NotNull(issued); Assert.False(string.IsNullOrWhiteSpace(issued.Token)); const string newPassword = "brand-new-strong-password"; using var redeemClient = Factory.CreateClient(); var redeemResponse = await redeemClient.PostAsJsonAsync( "/api/v1/auth/recovery/redeem", new RecoveryRedeemRequest(issued.Token, newPassword)); Assert.Equal(HttpStatusCode.OK, redeemResponse.StatusCode); // The pre-reset session must be dead now (security stamp rotated). var afterReset = await userClient.GetFromJsonAsync("/api/v1/auth/session"); Assert.NotNull(afterReset); Assert.False(afterReset.Authenticated); // The old password no longer works... using var oldPasswordClient = Factory.CreateClient(); var oldPasswordAttempt = await SetupHelper.LoginAsync(oldPasswordClient, email, originalPassword); Assert.Equal(HttpStatusCode.Unauthorized, oldPasswordAttempt.StatusCode); // ...but the new one does. using var newPasswordClient = Factory.CreateClient(); var newPasswordAttempt = await SetupHelper.LoginAsync(newPasswordClient, email, newPassword); Assert.Equal(HttpStatusCode.OK, newPasswordAttempt.StatusCode); } [Fact] public async Task Redeeming_the_same_recovery_token_twice_only_works_once() { using var adminClient = Factory.CreateClient(); await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword); const string email = "eenmalig@itworx.test"; var createResponse = await SetupHelper.PostWithCsrfAsync( adminClient, "/api/v1/admin/users", new CreateUserRequest("Eenmalige Gebruiker", email, "starting-strong-password", "Reader")); var user = await createResponse.Content.ReadFromJsonAsync(); Assert.NotNull(user); var issueResponse = await SetupHelper.PostWithCsrfAsync( adminClient, $"/api/v1/admin/users/{user.Id}/recovery-tokens", null); var issued = await issueResponse.Content.ReadFromJsonAsync(); Assert.NotNull(issued); using var client = Factory.CreateClient(); var first = await client.PostAsJsonAsync( "/api/v1/auth/recovery/redeem", new RecoveryRedeemRequest(issued.Token, "first-new-strong-password")); Assert.Equal(HttpStatusCode.OK, first.StatusCode); var second = await client.PostAsJsonAsync( "/api/v1/auth/recovery/redeem", new RecoveryRedeemRequest(issued.Token, "second-new-strong-password")); Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); } [Fact] public async Task Redeeming_an_unknown_token_fails_without_revealing_anything() { using var client = Factory.CreateClient(); var response = await client.PostAsJsonAsync( "/api/v1/auth/recovery/redeem", new RecoveryRedeemRequest("ikhrec_not-a-real-token", "whatever-strong-password")); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } // V2-M7: closes the recovery-token admin UI (issuance) — this authorization // boundary (matching TrashPurgeTests.Purge_is_administrator_only's exact pattern) // had no test at all before, even though the endpoint itself already correctly // requires Administrator via AdminEndpoints' group-level policy. [Fact] public async Task Issuing_a_recovery_token_is_administrator_only() { using var adminClient = Factory.CreateClient(); await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword); var targetEmail = "doelgebruiker-recovery@itworx.test"; var createResponse = await SetupHelper.PostWithCsrfAsync( adminClient, "/api/v1/admin/users", new CreateUserRequest("Doelgebruiker", targetEmail, "starting-strong-password", "Reader")); var target = await createResponse.Content.ReadFromJsonAsync(); Assert.NotNull(target); const string editorEmail = "redacteur-recovery@itworx.test"; const string editorPassword = "correct-horse-battery-staple"; await SetupHelper.PostWithCsrfAsync( adminClient, "/api/v1/admin/users", new CreateUserRequest("Redacteur", editorEmail, editorPassword, "Editor")); using var editorClient = Factory.CreateClient(); await SetupHelper.LoginAsync(editorClient, editorEmail, editorPassword); var response = await SetupHelper.PostWithCsrfAsync( editorClient, $"/api/v1/admin/users/{target.Id}/recovery-tokens", null); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } }