Fix newsletter confirmation: handle already-confirmed idempotently and protect confirmed subscriber preferences#441
Conversation
…protect confirmed subscriber preferences
There was a problem hiding this comment.
Pull request overview
This PR fixes newsletter confirmation being non-idempotent (breaking in Blazor SSR prerender + hydration) and closes a security gap where re-subscribing could overwrite preferences for already-confirmed subscribers.
Changes:
- Introduces
ConfirmSubscriptionResult(Confirmed / AlreadyConfirmed / InvalidToken) and propagates it through Core interfaces, Infrastructure service/repository, and API confirm endpoint. - Makes confirmation idempotent at the repository level by distinguishing “already confirmed” vs “invalid token/not found”.
- Prevents overwriting
display_name/preferencesfor confirmed subscribers onUpsertSubscriberAsync, and updates Web/API tests accordingly.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/TechHub.Infrastructure.Tests/Repositories/NewsletterSubscriberRepositoryTests.cs | Adds coverage for new confirm result states and confirmed-subscriber no-overwrite behavior. |
| tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs | Adds integration tests for confirm responses/messages and preference overwrite protection via subscribe endpoint. |
| src/TechHub.Web/Services/TechHubApiClient.cs | Updates confirm call to return the API-provided message. |
| src/TechHub.Web/Services/ITechHubApiClient.cs | Updates interface to return the confirmation message string. |
| src/TechHub.Web/Components/Pages/NewsletterConfirm.razor | Displays API-provided confirmation message instead of a hardcoded one. |
| src/TechHub.Infrastructure/Services/Newsletter/NewsletterService.cs | Changes confirm method to return ConfirmSubscriptionResult and propagates invalid token/secret cases. |
| src/TechHub.Infrastructure/Repositories/NewsletterSubscriberRepository.cs | Makes confirm idempotent and guards UPSERT updates so confirmed subscribers aren’t overwritten. |
| src/TechHub.Core/Models/ConfirmSubscriptionResult.cs | Adds new enum modeling the tri-state confirmation result. |
| src/TechHub.Core/Interfaces/INewsletterSubscriberRepository.cs | Updates confirm contract to return ConfirmSubscriptionResult. |
| src/TechHub.Core/Interfaces/INewsletterService.cs | Updates confirm contract to return ConfirmSubscriptionResult. |
| src/TechHub.Api/Endpoints/NewsletterEndpoints.cs | Returns 200 for both Confirmed/AlreadyConfirmed with distinct messages; 400 for InvalidToken. |
| try | ||
| { | ||
| await Api.ConfirmNewsletterAsync(Email, Token); | ||
| _message = "Your subscription is confirmed. Welcome to TechHub newsletters!"; | ||
| _message = await Api.ConfirmNewsletterAsync(Email, Token); | ||
| _success = true; |
There was a problem hiding this comment.
Done in the latest commit. NewsletterConfirm.razor now uses PersistentComponentState: during SSR prerender the result (_message + _success) is persisted to the HTML payload, and on interactive hydration the new component instance restores from that state in OnInitializedAsync instead of calling the API a second time. The old _handled guard and OnParametersSetAsync are replaced with the standard OnInitializedAsync + IDisposable pattern used by other pages in the project.
⏰ PR Preview Environment — Nightly TeardownThe preview environment was automatically torn down as part of the nightly cleanup (23:00 Brussels time). To resume testing:
|
…API call on hydration
| protected override async Task OnInitializedAsync() | ||
| { | ||
| if (_handled) | ||
| _persistSubscription = ApplicationState.RegisterOnPersisting(PersistState); |
Confirmation links showed "Invalid or expired" after a valid click due to Blazor SSR pre-rendering the page (calling the API) and then the interactive client calling it again on a fresh component instance. Also, anyone could silently overwrite a confirmed subscriber's preferences by POSTing to
/api/newsletter/subscribewith their email.Changes
ConfirmSubscriptionResultenum (new)Confirmed/AlreadyConfirmed/InvalidTokenboolreturn acrossINewsletterSubscriberRepository,INewsletterService, and the endpointRepository —
ConfirmSubscriberAsyncUPDATE … WHERE is_confirmed = FALSEmatches 0 rows, does a follow-upSELECTto distinguishAlreadyConfirmedfromInvalidToken(subscriber not found / unsubscribed)API endpoint —
ConfirmAsyncConfirmedandAlreadyConfirmedboth return HTTP 200 with distinct messages;InvalidTokenreturns 400Repository —
UpsertSubscriberAsyncDO UPDATE SETnow usesCASE WHEN is_confirmed = FALSEguards so a re-subscribe request never overwrites an already-confirmed subscriber'sdisplay_nameorpreferencesWeb client / Blazor page
ConfirmNewsletterAsyncreturnsTask<string>(the message from the response body) instead ofTaskNewsletterConfirm.razordisplays the API-provided message rather than a hardcoded string