From be4ce93535205c5f7a97c0ebba18cc6c4354fe67 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 12 Jul 2026 05:50:36 -0300 Subject: [PATCH 1/2] fix(api): strip email PII from remaining email/marketing log sites (#243) Removes recipient email addresses and raw provider response bodies from the log templates that merged PR #325 left untouched, keeping only non-PII diagnostics (subject, HTTP status, a user/correlation id, error): - VerifyCodeCommand: LogWelcomeEmailFailed logs {UserId} instead of {Email}. - ResendEmailService: LogEmailSent/LogEmailFailed/LogEmailSendException/ LogSkippingTestEmail/LogMarketingRetry drop the recipient {To}; LogEmailFailed also drops the raw response {Body} (which can echo submitted PII) and keeps the HTTP status. The two now-orphaned response-body reads are removed. - SendMarketingBroadcastCommand: LogPreviewSent logs {Subject} instead of the {TestEmail}; LogBroadcastQueued drops the redundant {QueuedAtUtc} timestamp (the logging framework already stamps each entry, and it tripped the tz rule). - AuthController: the Information-level LogVerificationCodeSent drops {Email} (RequestId already correlates) and LogUserLoggedInViaCode logs {UserId}. Adds capturing-logger tests asserting the email address and response body never reach the rendered log line while status/subject/user id survive. Refs thomasluizon/orbit-ui-mobile#243 Co-Authored-By: Claude Opus 4.8 --- src/Orbit.Api/Controllers/AuthController.cs | 12 +- .../Auth/Commands/VerifyCodeCommand.cs | 10 +- .../Commands/SendMarketingBroadcastCommand.cs | 12 +- .../Services/ResendEmailService.cs | 40 +++--- ...ndMarketingBroadcastCommandHandlerTests.cs | 35 +++++ .../Controllers/AuthControllerTests.cs | 57 +++++++- .../ResendEmailServicePiiScrubbingTests.cs | 127 ++++++++++++++++++ 7 files changed, 254 insertions(+), 39 deletions(-) create mode 100644 tests/Orbit.Infrastructure.Tests/Services/ResendEmailServicePiiScrubbingTests.cs diff --git a/src/Orbit.Api/Controllers/AuthController.cs b/src/Orbit.Api/Controllers/AuthController.cs index d28547e5..132cba1a 100644 --- a/src/Orbit.Api/Controllers/AuthController.cs +++ b/src/Orbit.Api/Controllers/AuthController.cs @@ -40,7 +40,7 @@ public async Task SendCode( if (result.IsSuccess) { - LogVerificationCodeSent(logger, request.Email, HttpContext.GetRequestId()); + LogVerificationCodeSent(logger, HttpContext.GetRequestId()); return Ok(new { message = "Verification code sent" }); } @@ -89,7 +89,7 @@ public async Task VerifyCode( if (result.IsSuccess) { - LogUserLoggedInViaCode(logger, request.Email, HttpContext.GetRequestId()); + LogUserLoggedInViaCode(logger, result.Value.UserId, HttpContext.GetRequestId()); return Ok(result.Value); } @@ -350,14 +350,14 @@ public async Task ConfirmDeletion( return result.ToErrorResult(); } - [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Verification code sent to {Email}. RequestId={RequestId}")] - private static partial void LogVerificationCodeSent(ILogger logger, string email, string requestId); + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Verification code sent. RequestId={RequestId}")] + private static partial void LogVerificationCodeSent(ILogger logger, string requestId); [LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "Failed to send code: {Error}. RequestId={RequestId}")] private static partial void LogFailedToSendCode(ILogger logger, string? error, string requestId); - [LoggerMessage(EventId = 3, Level = LogLevel.Information, Message = "User logged in via code {Email}. RequestId={RequestId}")] - private static partial void LogUserLoggedInViaCode(ILogger logger, string email, string requestId); + [LoggerMessage(EventId = 3, Level = LogLevel.Information, Message = "User logged in via code. UserId={UserId} RequestId={RequestId}")] + private static partial void LogUserLoggedInViaCode(ILogger logger, Guid userId, string requestId); [LoggerMessage(EventId = 4, Level = LogLevel.Warning, Message = "Code verification failed: {Error}. RequestId={RequestId}")] private static partial void LogCodeVerificationFailed(ILogger logger, string? error, string requestId); diff --git a/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs b/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs index a4b71627..ae856fd9 100644 --- a/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs +++ b/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs @@ -143,7 +143,7 @@ private async Task HandlePostLoginAsync( } if (isNewUser) - SendWelcomeEmailInBackground(user.Email, user.Name, request.Language); + SendWelcomeEmailInBackground(user.Id, user.Email, user.Name, request.Language); return wasReactivated; } @@ -160,7 +160,7 @@ private async Task ProcessReferralSafeAsync(Guid userId, string referralCode, Ca } } - private void SendWelcomeEmailInBackground(string email, string name, string language) + private void SendWelcomeEmailInBackground(Guid userId, string email, string name, string language) { _ = Task.Run(async () => { @@ -170,7 +170,7 @@ private void SendWelcomeEmailInBackground(string email, string name, string lang } catch (Exception ex) when (ex is not OperationCanceledException) { - LogWelcomeEmailFailed(logger, ex, email); + LogWelcomeEmailFailed(logger, ex, userId); } }, CancellationToken.None); } @@ -178,6 +178,6 @@ private void SendWelcomeEmailInBackground(string email, string name, string lang [LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Referral processing failed for user {UserId}")] private static partial void LogReferralProcessingFailed(ILogger logger, Exception ex, Guid userId); - [LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "Welcome email failed for user {Email}")] - private static partial void LogWelcomeEmailFailed(ILogger logger, Exception ex, string email); + [LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "Welcome email failed for user {UserId}")] + private static partial void LogWelcomeEmailFailed(ILogger logger, Exception ex, Guid userId); } diff --git a/src/Orbit.Application/Marketing/Commands/SendMarketingBroadcastCommand.cs b/src/Orbit.Application/Marketing/Commands/SendMarketingBroadcastCommand.cs index 278cfaee..5ed555bd 100644 --- a/src/Orbit.Application/Marketing/Commands/SendMarketingBroadcastCommand.cs +++ b/src/Orbit.Application/Marketing/Commands/SendMarketingBroadcastCommand.cs @@ -39,7 +39,7 @@ public async Task> Handle( await emailService.SendMarketingEmailAsync( request.TestEmail, preview.Subject, preview.BodyHtml, "en", previewUrl, cancellationToken); - LogPreviewSent(logger, request.TestEmail); + LogPreviewSent(logger, request.SubjectEn); return Result.Success(new MarketingBroadcastResult(RecipientCount: 1, WasTest: true)); } @@ -50,7 +50,7 @@ await emailService.SendMarketingEmailAsync( .Select(user => new MarketingRecipient(user.Id, user.Email, user.Language ?? "en")) .ToList(); - LogBroadcastQueued(logger, recipients.Count, request.SubjectEn, DateTime.UtcNow); + LogBroadcastQueued(logger, recipients.Count, request.SubjectEn); FanOutInBackground(request, recipients); return Result.Success(new MarketingBroadcastResult(recipients.Count, WasTest: false)); @@ -109,11 +109,11 @@ private sealed record MarketingRecipient(Guid UserId, string Email, string Langu private sealed record RenderedContent(string Subject, string BodyHtml); - [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Marketing broadcast preview sent to {TestEmail}")] - private static partial void LogPreviewSent(ILogger logger, string testEmail); + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Marketing broadcast preview sent; subject={Subject}")] + private static partial void LogPreviewSent(ILogger logger, string subject); - [LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "Marketing broadcast queued: {RecipientCount} recipients, subject={Subject}, at {QueuedAtUtc:o}")] - private static partial void LogBroadcastQueued(ILogger logger, int recipientCount, string subject, DateTime queuedAtUtc); + [LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "Marketing broadcast queued: {RecipientCount} recipients, subject={Subject}")] + private static partial void LogBroadcastQueued(ILogger logger, int recipientCount, string subject); [LoggerMessage(EventId = 3, Level = LogLevel.Information, Message = "Marketing broadcast fan-out completed: {SentCount} sent, {FailedCount} failed")] private static partial void LogBroadcastCompleted(ILogger logger, int sentCount, int failedCount); diff --git a/src/Orbit.Infrastructure/Services/ResendEmailService.cs b/src/Orbit.Infrastructure/Services/ResendEmailService.cs index 00ef61eb..027ffca4 100644 --- a/src/Orbit.Infrastructure/Services/ResendEmailService.cs +++ b/src/Orbit.Infrastructure/Services/ResendEmailService.cs @@ -150,7 +150,7 @@ private async Task SendMarketingWithBackoffAsync(string to, string subject, stri if (IsTestAccount(to)) { if (logger.IsEnabled(LogLevel.Information)) - LogSkippingTestEmail(logger, to, subject); + LogSkippingTestEmail(logger, subject); return; } @@ -170,7 +170,7 @@ private async Task SendMarketingWithBackoffAsync(string to, string subject, stri } catch (Exception ex) { - LogEmailSendException(logger, ex, to); + LogEmailSendException(logger, ex); return; } @@ -179,23 +179,22 @@ private async Task SendMarketingWithBackoffAsync(string to, string subject, stri if (response.IsSuccessStatusCode) { if (logger.IsEnabled(LogLevel.Information)) - LogEmailSent(logger, to, subject); + LogEmailSent(logger, subject); return; } var isRetriable = response.StatusCode == HttpStatusCode.TooManyRequests || (int)response.StatusCode >= 500; if (!isRetriable || attempt == MaxMarketingRetries) { - var body = await response.Content.ReadAsStringAsync(cancellationToken); if (logger.IsEnabled(LogLevel.Error)) - LogEmailFailed(logger, to, response.StatusCode, body); + LogEmailFailed(logger, subject, response.StatusCode); return; } } var backoff = TimeSpan.FromMilliseconds(_settings.MarketingRetryBaseDelayMs * Math.Pow(2, attempt)); if (logger.IsEnabled(LogLevel.Warning)) - LogMarketingRetry(logger, to, attempt + 1, backoff.TotalMilliseconds); + LogMarketingRetry(logger, attempt + 1, backoff.TotalMilliseconds); await Task.Delay(backoff, cancellationToken); } } @@ -247,7 +246,7 @@ private async Task SendEmailAsync(string to, string subject, string html, string if (IsTestAccount(to)) { if (logger.IsEnabled(LogLevel.Information)) - LogSkippingTestEmail(logger, to, subject); + LogSkippingTestEmail(logger, subject); return; } @@ -268,13 +267,12 @@ private async Task SendEmailAsync(string to, string subject, string html, string if (response.IsSuccessStatusCode) { if (logger.IsEnabled(LogLevel.Information)) - LogEmailSent(logger, to, subject); + LogEmailSent(logger, subject); } else { - var body = await response.Content.ReadAsStringAsync(cancellationToken); if (logger.IsEnabled(LogLevel.Error)) - LogEmailFailed(logger, to, response.StatusCode, body); + LogEmailFailed(logger, subject, response.StatusCode); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -283,7 +281,7 @@ private async Task SendEmailAsync(string to, string subject, string html, string } catch (Exception ex) { - LogEmailSendException(logger, ex, to); + LogEmailSendException(logger, ex); } } @@ -304,19 +302,19 @@ private static bool IsTestAccount(string to) return false; } - [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Skipping email to test account {To} subject={Subject}")] - private static partial void LogSkippingTestEmail(ILogger logger, string to, string subject); + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Skipping email to test account; subject={Subject}")] + private static partial void LogSkippingTestEmail(ILogger logger, string subject); - [LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "Email sent to {To} subject={Subject}")] - private static partial void LogEmailSent(ILogger logger, string to, string subject); + [LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "Email sent; subject={Subject}")] + private static partial void LogEmailSent(ILogger logger, string subject); - [LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Email failed to {To} status={Status} body={Body}")] - private static partial void LogEmailFailed(ILogger logger, string to, System.Net.HttpStatusCode status, string body); + [LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Email failed; subject={Subject} status={Status}")] + private static partial void LogEmailFailed(ILogger logger, string subject, System.Net.HttpStatusCode status); - [LoggerMessage(EventId = 4, Level = LogLevel.Error, Message = "Email send exception to {To}")] - private static partial void LogEmailSendException(ILogger logger, Exception ex, string to); + [LoggerMessage(EventId = 4, Level = LogLevel.Error, Message = "Email send exception")] + private static partial void LogEmailSendException(ILogger logger, Exception ex); - [LoggerMessage(EventId = 5, Level = LogLevel.Warning, Message = "Marketing email to {To} rate-limited; retry {Attempt} after {BackoffMs}ms")] - private static partial void LogMarketingRetry(ILogger logger, string to, int attempt, double backoffMs); + [LoggerMessage(EventId = 5, Level = LogLevel.Warning, Message = "Marketing email rate-limited; retry {Attempt} after {BackoffMs}ms")] + private static partial void LogMarketingRetry(ILogger logger, int attempt, double backoffMs); } diff --git a/tests/Orbit.Application.Tests/Commands/Marketing/SendMarketingBroadcastCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Marketing/SendMarketingBroadcastCommandHandlerTests.cs index 2c104f75..5ce72193 100644 --- a/tests/Orbit.Application.Tests/Commands/Marketing/SendMarketingBroadcastCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Marketing/SendMarketingBroadcastCommandHandlerTests.cs @@ -131,6 +131,25 @@ public async Task Handle_NoConsentingUsers_QueuesZeroAndSendsNothing() _emailService.MarketingSends.Should().BeEmpty(); } + [Fact] + public async Task Handle_TestEmail_PreviewLogDoesNotLeakTestEmail() + { + var logger = new CollectingLogger(); + var handler = new SendMarketingBroadcastCommandHandler( + _userRepo, _emailService, _tokenService, Substitute.For(), + Options.Create(new MarketingSettings { ApiBaseUrl = "https://api.useorbit.org", SendDelayMilliseconds = 0 }), + logger); + + var command = new SendMarketingBroadcastCommand( + "EN Subject", "PT Assunto", "

EN body

", "

PT corpo

", "preview-pii@example.com"); + await handler.Handle(command, CancellationToken.None); + + var previewLog = logger.Entries.Should() + .ContainSingle(entry => entry.Contains("preview sent")).Subject; + previewLog.Should().NotContain("preview-pii@example.com"); + previewLog.Should().Contain("EN Subject"); + } + [Fact] public async Task Handle_OneRecipientSendFails_ContinuesWithTheRest() { @@ -146,6 +165,22 @@ public async Task Handle_OneRecipientSendFails_ContinuesWithTheRest() _emailService.MarketingSends.Should().ContainSingle(send => send.To == "second@example.com"); } + private sealed class CollectingLogger : ILogger + { + public List Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => Entries.Add(formatter(state, exception)); + } + private sealed class RecordingEmailService : IEmailService { public ConcurrentBag<(string To, string Subject, string Body, string Language, string UnsubscribeUrl)> MarketingSends { get; } = []; diff --git a/tests/Orbit.Infrastructure.Tests/Controllers/AuthControllerTests.cs b/tests/Orbit.Infrastructure.Tests/Controllers/AuthControllerTests.cs index 45382d6d..92c1754f 100644 --- a/tests/Orbit.Infrastructure.Tests/Controllers/AuthControllerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Controllers/AuthControllerTests.cs @@ -66,7 +66,7 @@ public async Task SendCode_Failure_ReturnsBadRequest() public async Task VerifyCode_Success_ReturnsOk() { _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(default(LoginResponse)!)); + .Returns(Result.Success(new LoginResponse(UserId, "token", "Name", "test@example.com", false, "refresh"))); var request = new AuthController.VerifyCodeRequest("test@example.com", "123456"); var result = await _controller.VerifyCode(request, CancellationToken.None); @@ -351,4 +351,59 @@ public async Task LogoutOperation_Failure_ReturnsUnauthorized() result.Should().BeOfType(); } + + [Fact] + public async Task SendCode_Success_LogDoesNotLeakEmail() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success()); + var (controller, logger) = BuildWithCapturingLogger(); + + await controller.SendCode(new AuthController.SendCodeRequest("pii-user@example.com"), CancellationToken.None); + + logger.Entries.Should().NotBeEmpty(); + logger.Entries.Should().OnlyContain(entry => !entry.Contains("pii-user@example.com")); + } + + [Fact] + public async Task VerifyCode_Success_LogsUserIdInsteadOfEmail() + { + var userId = Guid.NewGuid(); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success(new LoginResponse(userId, "token", "Name", "pii-user@example.com", false, "refresh"))); + var (controller, logger) = BuildWithCapturingLogger(); + + await controller.VerifyCode( + new AuthController.VerifyCodeRequest("pii-user@example.com", "123456"), CancellationToken.None); + + var loginLog = logger.Entries.Should().ContainSingle(entry => entry.Contains("logged in via code")).Subject; + loginLog.Should().NotContain("pii-user@example.com"); + loginLog.Should().Contain(userId.ToString()); + } + + private (AuthController Controller, CollectingLogger Logger) BuildWithCapturingLogger() + { + var logger = new CollectingLogger(); + var controller = new AuthController(_mediator, _auditService, logger) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + return (controller, logger); + } + + private sealed class CollectingLogger : ILogger + { + public List Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => Entries.Add(formatter(state, exception)); + } } diff --git a/tests/Orbit.Infrastructure.Tests/Services/ResendEmailServicePiiScrubbingTests.cs b/tests/Orbit.Infrastructure.Tests/Services/ResendEmailServicePiiScrubbingTests.cs new file mode 100644 index 00000000..aed6b00e --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/ResendEmailServicePiiScrubbingTests.cs @@ -0,0 +1,127 @@ +using System.Net; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Infrastructure.Configuration; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +public class ResendEmailServicePiiScrubbingTests +{ + private const string RecipientEmail = "secret.user@example.com"; + + private readonly CollectingLogger _logger = new(); + private readonly FakeHttpMessageHandler _handler = new(); + private readonly ResendEmailService _sut; + + public ResendEmailServicePiiScrubbingTests() + { + var httpClient = new HttpClient(_handler) { BaseAddress = new Uri("https://api.resend.com") }; + var factory = Substitute.For(); + factory.CreateClient("Resend").Returns(httpClient); + + var settings = Options.Create(new ResendSettings + { + ApiKey = "re_test_key", + FromEmail = "noreply@useorbit.org", + SupportEmail = "contact@useorbit.org", + MarketingFromEmail = "news@useorbit.org", + MarketingRetryBaseDelayMs = 1, + }); + var frontend = Options.Create(new FrontendSettings { BaseUrl = "https://app.useorbit.org" }); + + _sut = new ResendEmailService(factory, settings, frontend, _logger); + } + + [Fact] + public async Task SuccessfulSend_InfoLog_OmitsRecipientEmail() + { + _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); + + await _sut.SendVerificationCodeAsync(RecipientEmail, "123456"); + + _logger.Entries.Should().NotBeEmpty(); + _logger.Entries.Should().OnlyContain(entry => !entry.Contains(RecipientEmail)); + } + + [Fact] + public async Task FailedSend_ErrorLog_OmitsEmailAndResponseBody_ButKeepsStatus() + { + _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent($"{{\"message\":\"invalid recipient {RecipientEmail}\"}}"), + }; + + await _sut.SendVerificationCodeAsync(RecipientEmail, "123456"); + + var errorLog = _logger.Entries.Should() + .ContainSingle(entry => entry.Contains("Email failed")).Subject; + errorLog.Should().NotContain(RecipientEmail); + errorLog.Should().NotContain("invalid recipient"); + errorLog.Should().Contain("BadRequest"); + } + + [Fact] + public async Task SendException_ErrorLog_OmitsRecipientEmail() + { + _handler.ExceptionToThrow = new HttpRequestException("Connection reset"); + + await _sut.SendVerificationCodeAsync(RecipientEmail, "123456"); + + _logger.Entries.Should().Contain(entry => entry.Contains("Email send exception")); + _logger.Entries.Should().OnlyContain(entry => !entry.Contains(RecipientEmail)); + } + + [Fact] + public async Task MarketingFailure_ErrorLog_OmitsEmailAndResponseBody_ButKeepsStatus() + { + _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent($"{{\"message\":\"blocked {RecipientEmail}\"}}"), + }; + + await _sut.SendMarketingEmailAsync( + RecipientEmail, "Product news", "

hi

", "en", + "https://api.useorbit.org/api/marketing/unsubscribe?token=t"); + + var errorLog = _logger.Entries.Should() + .ContainSingle(entry => entry.Contains("Email failed")).Subject; + errorLog.Should().NotContain(RecipientEmail); + errorLog.Should().NotContain("blocked"); + errorLog.Should().Contain("BadRequest"); + } + + private sealed class FakeHttpMessageHandler : HttpMessageHandler + { + public HttpResponseMessage ResponseToReturn { get; set; } = new(HttpStatusCode.OK); + public Exception? ExceptionToThrow { get; set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (ExceptionToThrow is not null) + throw ExceptionToThrow; + + return Task.FromResult(ResponseToReturn); + } + } + + private sealed class CollectingLogger : ILogger + { + public List Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => Entries.Add(formatter(state, exception)); + } +} From 418b7ba3018fb0606d5ec2f191d8c6ef88488916 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 12 Jul 2026 06:02:00 -0300 Subject: [PATCH 2/2] test(api): cover marketing/skip/exception log paths for PII scrub (#243) Lifts new-code coverage over the SonarCloud gate by exercising the IsEnabled-guarded log lines that existing NullLogger tests skip. Each new case asserts the recipient email never reaches the rendered marketing log line (success, retry/rate-limit, send exception, and both test-account skip paths), completing the PII contract across every ResendEmailService log site. Refs thomasluizon/orbit-ui-mobile#243 Co-Authored-By: Claude Opus 4.8 --- .../ResendEmailServicePiiScrubbingTests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/Orbit.Infrastructure.Tests/Services/ResendEmailServicePiiScrubbingTests.cs b/tests/Orbit.Infrastructure.Tests/Services/ResendEmailServicePiiScrubbingTests.cs index aed6b00e..ea275837 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/ResendEmailServicePiiScrubbingTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/ResendEmailServicePiiScrubbingTests.cs @@ -94,9 +94,81 @@ await _sut.SendMarketingEmailAsync( errorLog.Should().Contain("BadRequest"); } + [Fact] + public async Task MarketingSuccess_InfoLog_OmitsRecipientEmail() + { + _handler.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK); + + await SendMarketing(); + + _logger.Entries.Should().Contain(entry => entry.Contains("Email sent")); + _logger.Entries.Should().OnlyContain(entry => !entry.Contains(RecipientEmail)); + } + + [Fact] + public async Task MarketingRetry_WarningLog_OmitsRecipientEmail() + { + _handler.StatusSequence = new Queue([HttpStatusCode.TooManyRequests, HttpStatusCode.OK]); + + await SendMarketing(); + + _logger.Entries.Should().Contain(entry => entry.Contains("rate-limited")); + _logger.Entries.Should().OnlyContain(entry => !entry.Contains(RecipientEmail)); + } + + [Fact] + public async Task MarketingSendException_ErrorLog_OmitsRecipientEmail() + { + _handler.ExceptionToThrow = new HttpRequestException("Connection reset"); + + await SendMarketing(); + + _logger.Entries.Should().Contain(entry => entry.Contains("Email send exception")); + _logger.Entries.Should().OnlyContain(entry => !entry.Contains(RecipientEmail)); + } + + [Fact] + public async Task MarketingTestAccount_SkipLog_OmitsRecipientEmail() => + await WithTestAccountAsync(async () => + { + await SendMarketing(); + + _logger.Entries.Should().Contain(entry => entry.Contains("Skipping email")); + _logger.Entries.Should().OnlyContain(entry => !entry.Contains(RecipientEmail)); + }); + + [Fact] + public async Task TransactionalTestAccount_SkipLog_OmitsRecipientEmail() => + await WithTestAccountAsync(async () => + { + await _sut.SendVerificationCodeAsync(RecipientEmail, "123456"); + + _logger.Entries.Should().Contain(entry => entry.Contains("Skipping email")); + _logger.Entries.Should().OnlyContain(entry => !entry.Contains(RecipientEmail)); + }); + + private Task SendMarketing() => + _sut.SendMarketingEmailAsync( + RecipientEmail, "Product news", "

hi

", "en", + "https://api.useorbit.org/api/marketing/unsubscribe?token=t"); + + private static async Task WithTestAccountAsync(Func action) + { + Environment.SetEnvironmentVariable("TEST_ACCOUNTS", $"{RecipientEmail}:123456"); + try + { + await action(); + } + finally + { + Environment.SetEnvironmentVariable("TEST_ACCOUNTS", null); + } + } + private sealed class FakeHttpMessageHandler : HttpMessageHandler { public HttpResponseMessage ResponseToReturn { get; set; } = new(HttpStatusCode.OK); + public Queue? StatusSequence { get; set; } public Exception? ExceptionToThrow { get; set; } protected override Task SendAsync( @@ -105,6 +177,12 @@ protected override Task SendAsync( if (ExceptionToThrow is not null) throw ExceptionToThrow; + if (StatusSequence is { Count: > 0 }) + { + var status = StatusSequence.Count > 1 ? StatusSequence.Dequeue() : StatusSequence.Peek(); + return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent("{}") }); + } + return Task.FromResult(ResponseToReturn); } }