Improve audit logging, error handling, and metadata encoding
- Ensure all email/SMS send attempts are audit-logged, including API errors, exceptions, invalid addresses, and disabled service cases; logs now include raw API responses or exception details for better diagnostics. - Fix audit log parameter builder to never pass NULL for NOT NULL columns (`config`, `DateSent`). - Add unit tests covering all send paths and audit log correctness. - Percent-encode non-ASCII/control chars in `DocumentMetadataBuilder` for safe HTTP header metadata; add tests. - Simplify admin test mail dialog JS and improve success handler robustness. - Set `charset=utf-8` in admin controller JSON responses for OCORE consistency. - Update minified JS to match new admin dialog logic.
This commit is contained in:
@@ -110,6 +110,20 @@ public class DocumentMetadataBuilderTests
|
||||
Assert.Equal(guid.ToString(), metadata["file_guid"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Zahlungserinnerung für Müller", "Zahlungserinnerung f%C3%BCr M%C3%BCller")]
|
||||
[InlineData("Rechnung\r\nKunde", "Rechnung%0D%0AKunde")]
|
||||
public void Build_NonAsciiOrControlCharacters_PercentEncodesUtf8ForHttpHeaders(
|
||||
string value, string expected)
|
||||
{
|
||||
var row = new Dictionary<string, object?> { ["DocumentName"] = value };
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, new[] { "DocumentName" });
|
||||
|
||||
Assert.Equal(expected, metadata["DocumentName"]);
|
||||
Assert.All(metadata["DocumentName"], c => Assert.InRange(c, '\u0020', '\u007e'));
|
||||
}
|
||||
|
||||
// ── Field-list edge cases ────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
@@ -48,6 +49,12 @@ public class ProcessWebComServiceTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> throw new HttpRequestException("simulated transport failure");
|
||||
}
|
||||
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
@@ -55,6 +62,41 @@ public class ProcessWebComServiceTests
|
||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures what would be written to <c>fds__logEmail</c> without hitting a database, by
|
||||
/// overriding the audit-log write. Lets tests assert that every send path is logged and that
|
||||
/// failures carry the service response / exception detail.
|
||||
/// </summary>
|
||||
private sealed class CapturingComService : ProcessWebComService
|
||||
{
|
||||
public readonly List<(bool success, List<string> log)> Entries = new();
|
||||
|
||||
public CapturingComService(
|
||||
IOptions<ProcessWebComSettings> settings,
|
||||
IOptions<FuchsEmailSettings> emailSettings,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
: base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, httpClientFactory)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task WriteAuditLogAsync(string reference, string guid, string config,
|
||||
DateTime sent, bool success, IEnumerable<string> errors)
|
||||
{
|
||||
Entries.Add((success, errors.ToList()));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static CapturingComService CreateCapturing(HttpMessageHandler handler, bool enabled = true, string? overrideRecipient = null)
|
||||
{
|
||||
var settings = Options.Create(new ProcessWebComSettings
|
||||
{
|
||||
Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok"
|
||||
});
|
||||
var emailSettings = Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient });
|
||||
return new CapturingComService(settings, emailSettings, new StubHttpClientFactory(handler));
|
||||
}
|
||||
|
||||
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true, string? overrideRecipient = null)
|
||||
{
|
||||
var settings = Options.Create(new ProcessWebComSettings
|
||||
@@ -129,6 +171,102 @@ public class ProcessWebComServiceTests
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
// ── Audit logging: every attempt is logged; failures carry the service response ─
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Success_LogsSuccessfulAttempt()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK));
|
||||
|
||||
await svc.SendEmailAsync("inv_log_ok", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.True(entry.success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ApiError_LogsFailureIncludingServiceResponse()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.InternalServerError, "mailer rejected: quota exceeded"));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_api", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded")); // raw service response
|
||||
Assert.Contains(entry.log, l => l.Contains("500")); // HTTP status
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_TransportException_LogsFailureIncludingExceptionDetail()
|
||||
{
|
||||
var svc = CreateCapturing(new ThrowingHandler());
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_ex", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("Exception while sending"));
|
||||
Assert.Contains(entry.log, l => l.Contains("simulated transport failure"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-an-email")]
|
||||
[InlineData("")]
|
||||
public async Task SendEmailAsync_InvalidEmail_StillLogsFailedAttempt(string badEmail)
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_bad", "S", "<p>x</p>", badEmail, "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("Invalid recipient email address"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Disabled_LogsFailedAttempt()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), enabled: false);
|
||||
|
||||
await svc.SendEmailAsync("inv_log_dis", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.ToLowerInvariant().Contains("disabled"));
|
||||
}
|
||||
|
||||
// ── Audit-log parameters honour the NOT NULL columns of fds__emaillog ──────
|
||||
[Fact]
|
||||
public void BuildAuditLogParameters_FailedAttempt_NeverPassesNullForNotNullColumns()
|
||||
{
|
||||
// fds__emaillog.config and .DateSent are NOT NULL; a failed/unset send must not send NULL
|
||||
// (regression: it previously did, so every audit write failed and no email was ever logged).
|
||||
var pl = ProcessWebComService.BuildAuditLogParameters(
|
||||
"admin_test", guid: "", config: "", sent: default, success: false, errors: new[] { "err" });
|
||||
|
||||
object? config = pl.Single(p => p.ParameterName == "@config").Value;
|
||||
object? dateSent = pl.Single(p => p.ParameterName == "@DateSent").Value;
|
||||
|
||||
Assert.Equal("", config);
|
||||
Assert.NotEqual(DBNull.Value, config);
|
||||
Assert.IsType<DateTime>(dateSent);
|
||||
Assert.NotEqual((object)DBNull.Value, dateSent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAuditLogParameters_SuccessfulSend_KeepsProvidedSentTimestamp()
|
||||
{
|
||||
var when = new DateTime(2026, 7, 16, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var pl = ProcessWebComService.BuildAuditLogParameters(
|
||||
"inv_1", guid: "g1", config: "", sent: when, success: true, errors: Array.Empty<string>());
|
||||
|
||||
Assert.Equal(when, pl.Single(p => p.ParameterName == "@DateSent").Value);
|
||||
}
|
||||
|
||||
// ── Dev/test recipient override safety net ─────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
|
||||
|
||||
Reference in New Issue
Block a user