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:
Stefan
2026-07-16 16:03:58 +02:00
parent 8a0ebeeb1e
commit c812d94d99
9 changed files with 238 additions and 47 deletions
@@ -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("")]
+138
View File
@@ -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()