From 4b3f3dc50a11e394f9ea8e080d985a8c82a1f997 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Fri, 6 Mar 2026 23:06:41 -0500 Subject: [PATCH] Make confirmation idempotent and add re-confirmation coverage --- .../API.Specs/Features/Confirmation.feature | 9 +++++ src/Core/API/API.Specs/Steps/AuthSteps.cs | 19 ++++++++++ .../Auth/AuthRepository.cs | 36 ++++++++++++++++++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/Core/API/API.Specs/Features/Confirmation.feature b/src/Core/API/API.Specs/Features/Confirmation.feature index bd2a203..ac77184 100644 --- a/src/Core/API/API.Specs/Features/Confirmation.feature +++ b/src/Core/API/API.Specs/Features/Confirmation.feature @@ -10,6 +10,15 @@ Feature: User Account Confirmation Then the response has HTTP status 200 And the response JSON should have "message" containing "is confirmed" + Scenario: Re-confirming an already verified account remains successful + Given the API is running + And I have registered a new account + And I have a valid confirmation token for my account + When I submit a confirmation request with the valid token + And I submit the same confirmation request again + Then the response has HTTP status 200 + And the response JSON should have "message" containing "is confirmed" + Scenario: Confirmation fails with invalid token Given the API is running When I submit a confirmation request with an invalid token diff --git a/src/Core/API/API.Specs/Steps/AuthSteps.cs b/src/Core/API/API.Specs/Steps/AuthSteps.cs index 2fe6865..26b26c9 100644 --- a/src/Core/API/API.Specs/Steps/AuthSteps.cs +++ b/src/Core/API/API.Specs/Steps/AuthSteps.cs @@ -599,6 +599,25 @@ public class AuthSteps(ScenarioContext scenario) scenario[ResponseBodyKey] = responseBody; } + [When("I submit the same confirmation request again")] + public async Task WhenISubmitTheSameConfirmationRequestAgain() + { + var client = GetClient(); + var token = scenario.TryGetValue("confirmationToken", out var t) + ? t + : "valid-token"; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" + ); + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + [When("I submit a confirmation request with a malformed token")] public async Task WhenISubmitAConfirmationRequestWithAMalformedToken() { diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs index ce7697d..132499d 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs @@ -2,6 +2,7 @@ using System.Data; using System.Data.Common; using Domain.Entities; using Infrastructure.Repository.Sql; +using Microsoft.Data.SqlClient; namespace Infrastructure.Repository.Auth; @@ -132,6 +133,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) return null; } + // Idempotency: if already verified, treat as successful confirmation. + if (await IsUserVerifiedAsync(userAccountId)) + { + return user; + } + await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); command.CommandText = "USP_CreateUserVerification"; @@ -139,12 +146,39 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) AddParameter(command, "@UserAccountID_", userAccountId); - await command.ExecuteNonQueryAsync(); + try + { + await command.ExecuteNonQueryAsync(); + } + catch (SqlException ex) when (IsDuplicateVerificationViolation(ex)) + { + // A concurrent request verified this user first. Keep behavior idempotent. + } // Fetch and return the updated user return await GetUserByIdAsync(userAccountId); } + private async Task IsUserVerifiedAsync(Guid userAccountId) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = + "SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID"; + command.CommandType = CommandType.Text; + + AddParameter(command, "@UserAccountID", userAccountId); + + var result = await command.ExecuteScalarAsync(); + return result != null && result != DBNull.Value; + } + + private static bool IsDuplicateVerificationViolation(SqlException ex) + { + // 2601/2627 are duplicate key violations in SQL Server. + return ex.Number == 2601 || ex.Number == 2627; + } + /// /// Maps a data reader row to a UserAccount entity.