From f2c1511ac55615cc0c3a8a4327e1158d1fe9c8e9 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Thu, 30 Jul 2026 23:20:48 -0500 Subject: [PATCH] Reject notification settings without a verified email address Email is the only delivery channel for transfer notifications, so a user with no verified email address could enable notifications, see them persisted as enabled, and never receive anything. The client now keeps the toggles disabled until an address is verified, and reads the verified address out of the contact info changed event rather than the requested one. Those two fields carry the same value today, so this is not a visible fix on its own, but the flag now means what its name says before it starts gating the control. Co-Authored-By: Claude Opus 5 --- .../Controllers/UserSettingController.cs | 3 +- .../UpdateNotificationSettingsError.cs | 3 +- .../UpdateNotificationSettingsCommand.cs | 10 +- .../UpdateNotificationSettings_Tests.cs | 125 +++++++++++++++++- .../UserSettingsNotificationSettings.razor | 4 +- .../UserSettingsNotificationSettings.razor.cs | 2 +- 6 files changed, 133 insertions(+), 14 deletions(-) diff --git a/Crypter.API/Controllers/UserSettingController.cs b/Crypter.API/Controllers/UserSettingController.cs index 2457b0df8..5d8690d0e 100644 --- a/Crypter.API/Controllers/UserSettingController.cs +++ b/Crypter.API/Controllers/UserSettingController.cs @@ -229,7 +229,8 @@ IActionResult MakeErrorResponse(UpdateNotificationSettingsError error) #pragma warning disable CS8524 return error switch { - UpdateNotificationSettingsError.UnknownError => MakeErrorResponseBase(HttpStatusCode.InternalServerError, error) + UpdateNotificationSettingsError.UnknownError => MakeErrorResponseBase(HttpStatusCode.InternalServerError, error), + UpdateNotificationSettingsError.MissingNotificationChannel => MakeErrorResponseBase(HttpStatusCode.BadRequest, error) }; #pragma warning restore CS8524 } diff --git a/Crypter.Common/Contracts/Features/UserSettings/NotificationSettings/UpdateNotificationSettingsError.cs b/Crypter.Common/Contracts/Features/UserSettings/NotificationSettings/UpdateNotificationSettingsError.cs index d7e2c279c..77c188c48 100644 --- a/Crypter.Common/Contracts/Features/UserSettings/NotificationSettings/UpdateNotificationSettingsError.cs +++ b/Crypter.Common/Contracts/Features/UserSettings/NotificationSettings/UpdateNotificationSettingsError.cs @@ -28,5 +28,6 @@ namespace Crypter.Common.Contracts.Features.UserSettings.NotificationSettings; public enum UpdateNotificationSettingsError { - UnknownError + UnknownError, + MissingNotificationChannel } diff --git a/Crypter.Core/Features/UserSettings/Commands/UpdateNotificationSettingsCommand.cs b/Crypter.Core/Features/UserSettings/Commands/UpdateNotificationSettingsCommand.cs index 75caf7eb9..f2c378c99 100644 --- a/Crypter.Core/Features/UserSettings/Commands/UpdateNotificationSettingsCommand.cs +++ b/Crypter.Core/Features/UserSettings/Commands/UpdateNotificationSettingsCommand.cs @@ -55,14 +55,20 @@ public async Task> { var userData = await _dataContext.Users .Where(x => x.Id == request.UserId) - .Select(x => new { x.NotificationSetting }) + .Select(x => new { x.EmailAddress, x.NotificationSetting }) .FirstOrDefaultAsync(CancellationToken.None); if (userData is null) { return UpdateNotificationSettingsError.UnknownError; } - + + bool enablingNotifications = request.Request.NotifyOnTransferReceived || request.Request.EmailNotifications; + if (enablingNotifications && string.IsNullOrEmpty(userData.EmailAddress)) + { + return UpdateNotificationSettingsError.MissingNotificationChannel; + } + if (userData.NotificationSetting is null) { UserNotificationSettingEntity newNotificationSettings = diff --git a/Crypter.Test/Integration_Tests/UserSettings_Tests/UpdateNotificationSettings_Tests.cs b/Crypter.Test/Integration_Tests/UserSettings_Tests/UpdateNotificationSettings_Tests.cs index 3992b13dc..cea5c3f39 100644 --- a/Crypter.Test/Integration_Tests/UserSettings_Tests/UpdateNotificationSettings_Tests.cs +++ b/Crypter.Test/Integration_Tests/UserSettings_Tests/UpdateNotificationSettings_Tests.cs @@ -24,14 +24,24 @@ * Contact the current copyright holder to discuss commercial license options. */ +using System.Linq; using System.Threading.Tasks; using Crypter.Common.Client.Interfaces.HttpClients; using Crypter.Common.Client.Interfaces.Repositories; using Crypter.Common.Contracts.Features.UserAuthentication; +using Crypter.Common.Contracts.Features.UserSettings; using Crypter.Common.Contracts.Features.UserSettings.NotificationSettings; using Crypter.Common.Enums; +using Crypter.Common.Infrastructure; +using Crypter.Crypto.Common; +using Crypter.Crypto.Common.DigitalSignature; +using Crypter.Crypto.Providers.Default; +using Crypter.DataAccess; +using Crypter.DataAccess.Entities; using EasyMonads; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; namespace Crypter.Test.Integration_Tests.UserSettings_Tests; @@ -43,10 +53,19 @@ internal class UpdateNotificationSettings_Tests private ICrypterApiClient? _client; private ITokenRepository? _clientTokenRepository; + private DefaultCryptoProvider? _cryptoProvider; + private Ed25519KeyPair? _emailVerificationKeyPair; + [SetUp] public async Task SetupTestAsync() { - _factory = await AssemblySetup.CreateWebApplicationFactoryAsync(); + _cryptoProvider = new DefaultCryptoProvider(); + _emailVerificationKeyPair = _cryptoProvider.DigitalSignature.GenerateKeyPair(); + ICryptoProvider mockCryptoProvider = Mocks.CreateDeterministicCryptoProvider(_emailVerificationKeyPair); + IServiceCollection overrideServices = new ServiceCollection(); + overrideServices.AddSingleton(mockCryptoProvider); + + _factory = await AssemblySetup.CreateWebApplicationFactoryAsync(true, overrideServices); (_client, _clientTokenRepository) = AssemblySetup.SetupCrypterApiClient(_factory.CreateClient()); await AssemblySetup.InitializeRespawnerAsync(); } @@ -56,32 +75,124 @@ public async Task TeardownTestAsync() { if (_factory is not null) { - await _factory.DisposeAsync(); + await _factory.DisposeAsync(); } await AssemblySetup.ResetServerDataAsync(); } [Test] public async Task Update_Notification_Preferences_Works_Async() + { + await RegisterAndLoginAsync(); + + NotificationSettings request = new NotificationSettings(false, false); + Either result = + await _client!.UserSetting.UpdateNotificationSettingsAsync(request); + + Assert.That(result.IsRight, Is.True); + } + + [Test] + public async Task Update_Notification_Preferences_Works_For_Verified_Email_Address_Async() + { + await RegisterAndLoginAsync(TestData.DefaultEmailAdress); + await VerifyEmailAddressAsync(); + + NotificationSettings request = new NotificationSettings(true, true); + Either result = + await _client!.UserSetting.UpdateNotificationSettingsAsync(request); + + Assert.That(result.IsRight, Is.True); + result.DoRight(settings => + { + Assert.That(settings.EmailNotifications, Is.True); + Assert.That(settings.NotifyOnTransferReceived, Is.True); + }); + } + + [Test] + public async Task Update_Notification_Preferences_Fails_Without_Email_Address_Async() + { + await RegisterAndLoginAsync(); + + NotificationSettings request = new NotificationSettings(true, true); + Either result = + await _client!.UserSetting.UpdateNotificationSettingsAsync(request); + + Assert.That(result.IsLeft, Is.True); + Assert.That(result.LeftOrDefault(UpdateNotificationSettingsError.UnknownError), + Is.EqualTo(UpdateNotificationSettingsError.MissingNotificationChannel)); + } + + [Test] + public async Task Update_Notification_Preferences_Fails_For_Unverified_Email_Address_Async() + { + await RegisterAndLoginAsync(TestData.DefaultEmailAdress); + + NotificationSettings request = new NotificationSettings(true, true); + Either result = + await _client!.UserSetting.UpdateNotificationSettingsAsync(request); + + Assert.That(result.IsLeft, Is.True); + Assert.That(result.LeftOrDefault(UpdateNotificationSettingsError.UnknownError), + Is.EqualTo(UpdateNotificationSettingsError.MissingNotificationChannel)); + } + + [Test] + public async Task Disable_Notification_Preferences_Works_Without_Email_Address_Async() + { + await RegisterAndLoginAsync(); + + NotificationSettings request = new NotificationSettings(false, false); + Either result = + await _client!.UserSetting.UpdateNotificationSettingsAsync(request); + + Assert.That(result.IsRight, Is.True); + result.DoRight(settings => + { + Assert.That(settings.EmailNotifications, Is.False); + Assert.That(settings.NotifyOnTransferReceived, Is.False); + }); + } + + private async Task RegisterAndLoginAsync(string? emailAddress = null) { RegistrationRequest registrationRequest = - TestData.GetRegistrationRequest(TestData.DefaultUsername, TestData.DefaultPassword); - Either _ = await _client!.UserAuthentication.RegisterAsync(registrationRequest); + TestData.GetRegistrationRequest(TestData.DefaultUsername, TestData.DefaultPassword, emailAddress); + Either registrationResult = + await _client!.UserAuthentication.RegisterAsync(registrationRequest); + Assert.That(registrationResult.IsRight, Is.True); LoginRequest loginRequest = TestData.GetLoginRequest(TestData.DefaultUsername, TestData.DefaultPassword); Either loginResult = await _client!.UserAuthentication.LoginAsync(loginRequest); + Assert.That(loginResult.IsRight, Is.True); await loginResult.DoRightAsync(async loginResponse => { await _clientTokenRepository!.StoreAuthenticationTokenAsync(loginResponse.AuthenticationToken); await _clientTokenRepository!.StoreRefreshTokenAsync(loginResponse.RefreshToken, TokenType.Session); }); + } - NotificationSettings request = new NotificationSettings(false, false); - Either result = - await _client!.UserSetting.UpdateNotificationSettingsAsync(request); + private async Task VerifyEmailAddressAsync() + { + // Allow the background service to "send" the verification email and save the email verification data + await Task.Delay(5000); + + using IServiceScope scope = _factory!.Services.CreateScope(); + DataContext dataContext = scope.ServiceProvider.GetRequiredService(); + UserEmailChangeEntity changeData = await dataContext.UserEmailChangeRequests + .Where(x => x.User!.Username == TestData.DefaultUsername) + .FirstAsync(); + + string encodedVerificationCode = UrlSafeEncoder.EncodeGuidUrlSafe(changeData.Code!.Value); + byte[] signedVerificationCode = _cryptoProvider!.DigitalSignature.GenerateSignature( + _emailVerificationKeyPair!.PrivateKey, changeData.Code.Value.ToByteArray()); + string encodedSignature = UrlSafeEncoder.EncodeBytesUrlSafe(signedVerificationCode); + VerifyEmailAddressRequest request = new VerifyEmailAddressRequest(encodedVerificationCode, encodedSignature); + Either result = await _client!.UserSetting.VerifyUserEmailAddressAsync(request); Assert.That(result.IsRight, Is.True); } } diff --git a/Crypter.Web/Shared/UserSettings/UserSettingsNotificationSettings.razor b/Crypter.Web/Shared/UserSettings/UserSettingsNotificationSettings.razor index 4cd7a2e99..155959229 100644 --- a/Crypter.Web/Shared/UserSettings/UserSettingsNotificationSettings.razor +++ b/Crypter.Web/Shared/UserSettings/UserSettingsNotificationSettings.razor @@ -29,12 +29,12 @@
- +

Notification Methods

- +
*Alternative notifications methods are not available yet diff --git a/Crypter.Web/Shared/UserSettings/UserSettingsNotificationSettings.razor.cs b/Crypter.Web/Shared/UserSettings/UserSettingsNotificationSettings.razor.cs index 7a351f487..373f391a7 100644 --- a/Crypter.Web/Shared/UserSettings/UserSettingsNotificationSettings.razor.cs +++ b/Crypter.Web/Shared/UserSettings/UserSettingsNotificationSettings.razor.cs @@ -100,7 +100,7 @@ await CrypterApiService.UserSetting.UpdateNotificationSettingsAsync(newNotificat private void OnContactInfoChanged(object? sender, UserContactInfoChangedEventArgs args) { - _emailAddressVerified = args.RequestedEmailAddress; + _emailAddressVerified = args.VerifiedEmailAddress.IsSome; StateHasChanged(); }