Make confirmation idempotent and add re-confirmation coverage

This commit is contained in:
Aaron Po
2026-03-06 23:06:41 -05:00
parent 7c97825f91
commit 4b3f3dc50a
3 changed files with 63 additions and 1 deletions

View File

@@ -10,6 +10,15 @@ Feature: User Account Confirmation
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed" 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 Scenario: Confirmation fails with invalid token
Given the API is running Given the API is running
When I submit a confirmation request with an invalid token When I submit a confirmation request with an invalid token

View File

@@ -599,6 +599,25 @@ public class AuthSteps(ScenarioContext scenario)
scenario[ResponseBodyKey] = responseBody; scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit the same confirmation request again")]
public async Task WhenISubmitTheSameConfirmationRequestAgain()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("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")] [When("I submit a confirmation request with a malformed token")]
public async Task WhenISubmitAConfirmationRequestWithAMalformedToken() public async Task WhenISubmitAConfirmationRequestWithAMalformedToken()
{ {

View File

@@ -2,6 +2,7 @@ using System.Data;
using System.Data.Common; using System.Data.Common;
using Domain.Entities; using Domain.Entities;
using Infrastructure.Repository.Sql; using Infrastructure.Repository.Sql;
using Microsoft.Data.SqlClient;
namespace Infrastructure.Repository.Auth; namespace Infrastructure.Repository.Auth;
@@ -132,6 +133,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return null; 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 connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
command.CommandText = "USP_CreateUserVerification"; command.CommandText = "USP_CreateUserVerification";
@@ -139,12 +146,39 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
AddParameter(command, "@UserAccountID_", userAccountId); 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 // Fetch and return the updated user
return await GetUserByIdAsync(userAccountId); return await GetUserByIdAsync(userAccountId);
} }
private async Task<bool> 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;
}
/// <summary> /// <summary>
/// Maps a data reader row to a UserAccount entity. /// Maps a data reader row to a UserAccount entity.