fix: detect no-op document stores via OUTPUT rowset; sanitise regenerated filename

Follow-up to 99bf533 from code review:

- The AffectedRows == 0 guard in Store{Invoice,Reminder}DocumentFileAsync
  was dead: fds__setInvoiceFile / fds__setReminderFile run SET NOCOUNT ON,
  so ExecuteNonQuery returns -1 whether or not the UPDATE ... WHERE [file]
  IS NULL matched. Both procs already return the updated Id through their
  OUTPUT clause, so read that result set (getSQLDatatable_async) instead:
  empty rowset = no-op store, logged, and the blob upload / FileCreated
  event are skipped rather than reporting unpersisted bytes as success.
- inv/rdoc's regenerate path built the download name from the raw
  DocumentName; a user-typed title with '/' truncated FileInfo.Name.
  SafeDocumentFileName strips editor markup and every invalid filename
  char, and the path no longer goes through FileInfo at all.
- Reminder counterpart tests for the store-file failure branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan
2026-09-16 21:16:49 +02:00
co-authored by Claude Opus 5
parent 99bf5337f8
commit 4cac109283
6 changed files with 203 additions and 25 deletions
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Moq;
using OCORE.security;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Reminder counterpart of <see cref="InvoiceServiceStoreFileTests"/>: every failure branch of
/// <see cref="ReminderService.StoreReminderDocumentFileAsync"/> must be logged individually and
/// must not reach the blob upload or the ReminderFileCreated event (ADR 0025).
/// </summary>
public class ReminderServiceStoreFileTests
{
private sealed class RecordingLogger : ILogger<ReminderService>
{
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
}
private const string UnreachableConnectionString =
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
private static (ReminderService svc, RecordingLogger log, Mock<IBlobStorageService> blob, Mock<IEventService> events)
MakeService(Mock<IPdfService> pdf, string? connectionString = null)
{
var log = new RecordingLogger();
var blob = new Mock<IBlobStorageService>();
var events = new Mock<IEventService>();
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
}).Build();
var svc = new ReminderService(new Fuchs_intranet(config), pdf.Object, blob.Object, events.Object, log);
return (svc, log, blob, events);
}
private static FdsReminderData Reminder() => new()
{
ReminderRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
{
["Id"] = "M1ABC",
["subject"] = "Zahlungserinnerung",
["IsFinal"] = true
}),
IsDraft = false
};
private static Mock<IPdfService> PdfReturning(byte[] bytes)
{
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.ReturnsAsync(new Document());
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<Document>())).Returns(bytes);
return pdf;
}
private static void AssertNothingPersisted(Mock<IBlobStorageService> blob, Mock<IEventService> events)
{
blob.Verify(b => b.UploadReminderPdfAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<byte[]>(),
It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>()), Times.Never);
events.Verify(e => e.ReminderFileCreatedAsync(It.IsAny<FdsReminderData>(), It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task StoreReminderDocumentFileAsync_RenderProducesZeroBytes_ReturnsEmptyAndLogsError()
{
var (svc, log, blob, events) = MakeService(PdfReturning(Array.Empty<byte>()));
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Null(entry.Exception);
Assert.Contains("produced 0 bytes", entry.Message);
Assert.Contains("M1ABC", entry.Message);
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreReminderDocumentFileAsync_RenderThrows_ReturnsEmptyAndLogsExceptionOnce()
{
var pdf = new Mock<IPdfService>();
var boom = new InvalidOperationException("render exploded");
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>())).ThrowsAsync(boom);
var (svc, log, blob, events) = MakeService(pdf);
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Same(boom, entry.Exception);
Assert.Contains("render failed", entry.Message);
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreReminderDocumentFileAsync_RenderOkButDbStoreFails_ReturnsEmptyAndSkipsBlob()
{
var (svc, log, blob, events) = MakeService(PdfReturning(new byte[] { 0x25, 0x50, 0x44, 0x46 }), UnreachableConnectionString);
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("render failed"));
AssertNothingPersisted(blob, events);
}
}
+33
View File
@@ -0,0 +1,33 @@
using Fuchs.Controllers;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <see cref="IntranetController.SafeDocumentFileName"/> names a regenerated invoice document
/// (inv/rdoc, ADR 0025). DocumentName comes from the user-typed invoice title, so it must survive
/// path separators and editor markup without truncating or breaking the download filename.
/// </summary>
public class ReportDocFileNameTests
{
[Theory]
[InlineData("Rechnung R2026-0258.pdf", "Rechnung R2026-0258.pdf")]
[InlineData("Wartung 2025/26 R2026-0001.pdf", "Wartung 2025_26 R2026-0001.pdf")]
[InlineData("Angebot A\\B: \"x\" <y>|z?*.pdf", "Angebot A_B_ _x_ _y__z__.pdf")]
[InlineData("<p>Rechnung R2026-0002.pdf</p>", "Rechnung R2026-0002.pdf")]
[InlineData(" Rechnung R2026-0003.pdf ", "Rechnung R2026-0003.pdf")]
public void SafeDocumentFileName_ReplacesInvalidCharsAndStripsMarkup(string documentName, string expected)
{
Assert.Equal(expected, IntranetController.SafeDocumentFileName(documentName, "Rechnung_X.pdf"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("<p></p>")]
public void SafeDocumentFileName_EmptyDocumentName_UsesFallback(string? documentName)
{
Assert.Equal("Rechnung_W7VAL.pdf", IntranetController.SafeDocumentFileName(documentName!, "Rechnung_W7VAL.pdf"));
}
}
@@ -293,7 +293,13 @@ public partial class IntranetController
_logger.LogDebug("HandleReportDoc reportid={ReportId} typ={Typ} user={User}", reportid, Form("typ"), UserAccountID); _logger.LogDebug("HandleReportDoc reportid={ReportId} typ={Typ} user={User}", reportid, Form("typ"), UserAccountID);
byte[]? content = null; byte[]? content = null;
var file = _mfr.GetReportDoc(ref content, reportid); var file = _mfr.GetReportDoc(ref content, reportid);
if (file == null || content == null || content.Length == 0) string fileName, mimeType;
if (file != null && content is { Length: > 0 })
{
fileName = file.Name;
mimeType = file.MimeType();
}
else
{ {
// Regenerate-on-miss (ADR 0025). Finalised, non-external only: fds__setInvoiceFile // Regenerate-on-miss (ADR 0025). Finalised, non-external only: fds__setInvoiceFile
// writes WHERE [file] IS NULL, so persisting a draft render here would permanently // writes WHERE [file] IS NULL, so persisting a draft render here would permanently
@@ -310,18 +316,32 @@ public partial class IntranetController
_logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID); _logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID);
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" }); return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
} }
string regeneratedName = fdInv.InvoiceRegistration?.getString("DocumentName") fileName = SafeDocumentFileName(
.ne($"Rechnung_{fdInv.Id}.pdf") ?? $"Rechnung_{reportid}.pdf"; fdInv.InvoiceRegistration?.getString("DocumentName") ?? "", $"Rechnung_{fdInv.Id.ne(reportid)}.pdf");
file = new FileInfo(Path.Combine(Path.GetTempPath(), regeneratedName)); mimeType = "application/pdf";
} }
_logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}", _logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}",
reportid, file.Name, file.MimeType(), content?.Length ?? 0, UserAccountID); reportid, fileName, mimeType, content.Length, UserAccountID);
if (Form("typ") != "img") if (Form("typ") != "img")
return await FileContentResultAsync(content!, file.MimeType(), file.Name); return await FileContentResultAsync(content, mimeType, fileName);
return await PdfPreviewAsync("inv/rdoc", reportid, async () => return await PdfPreviewAsync("inv/rdoc", reportid, async () =>
await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) })); await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) }));
} }
/// <summary>
/// Download filename for a regenerated document. DocumentName is derived from the user-typed
/// invoice title, so it can carry path separators ("Wartung 2025/26") and stray editor markup;
/// mirrors the cleanup <c>FdsMfr.GetReportDoc</c> applies to stored documents.
/// </summary>
internal static string SafeDocumentFileName(string documentName, string fallback)
{
string name = (documentName ?? "").Replace("<p>", "").Replace("</p>", "").Trim();
if (name.Length == 0) name = fallback;
foreach (char c in Path.GetInvalidFileNameChars())
name = name.Replace(c, '_');
return name.Length == 0 ? fallback : name;
}
private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code) private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code)
{ {
if (!HasForm("name")) { _logger.LogWarning("HandleReportDocByName: missing 'name' form field user={User}", UserAccountID); return BadRequest400(); } if (!HasForm("name")) { _logger.LogWarning("HandleReportDocByName: missing 'name' form field user={User}", UserAccountID); return BadRequest400(); }
@@ -42,12 +42,13 @@ A read is therefore allowed to write `[file]`, upload the blob and emit
Every failure inside `StoreInvoiceDocumentFileAsync`/`StoreReminderDocumentFileAsync` Every failure inside `StoreInvoiceDocumentFileAsync`/`StoreReminderDocumentFileAsync`
(render threw, render produced no bytes, DB write threw, DB write matched 0 (render threw, render produced no bytes, DB write threw, DB write matched 0
rows) is logged at Error level individually, because the method signals all rows) is logged at Error level individually, because the method signals all
four to callers with the same empty array. The 0-rows case needed switching four to callers with the same empty array. The 0-rows case is detected via
from `setSQLValue_async` (reports success once `ExecuteNonQueryAsync` the procs' existing `OUTPUT inserted.[Id]` result set (read with
completes without throwing, regardless of rows affected) to `getSQLDatatable_async`; empty ⇒ the `UPDATE ... WHERE [file] IS NULL`
`setSQLValue_async_result`, whose `AffectedRows` makes a no-op `UPDATE ... matched nothing). Rows-affected is **not** usable for this: both procs run
WHERE [file] IS NULL` distinguishable from an actual write — this is what `SET NOCOUNT ON`, so `ExecuteNonQuery` returns -1 whether or not the
made the original incident's failure invisible to every log. `UPDATE` matched, which is why `setSQLValue_async` could never distinguish
a no-op from a write and the original incident was invisible to every log.
## Consequences ## Consequences
+7 -8
View File
@@ -447,17 +447,16 @@ public class InvoiceService : IInvoiceService
SQL_VarChar("@Id", invoice.Id), SQL_VarChar("@Id", invoice.Id),
new("@file", SqlDbType.VarBinary) { Value = ba } new("@file", SqlDbType.VarBinary) { Value = ba }
}; };
var setResult = await setSQLValue_async_result( // fds__setInvoiceFile writes WHERE [file] IS NULL and returns the updated Id via its
// OUTPUT clause, so an empty result set means the UPDATE matched nothing (already stored,
// wrong id, invoice gone) without any SQL error. Rows-affected cannot be used for this:
// the proc runs SET NOCOUNT ON, so ExecuteNonQuery reports -1 either way (ADR 0025).
var stored = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;", "EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;",
Conn, pl, Security: dbSec, options: new FIS_SQLOptions()); Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
if (!setResult.success) return Array.Empty<byte>(); if (!string.IsNullOrEmpty(stored.Exception)) return Array.Empty<byte>();
if (setResult.AffectedRows == 0) if (stored.Count == 0)
{ {
// fds__setInvoiceFile writes WHERE [file] IS NULL — 0 rows affected without an
// exception means the UPDATE simply didn't match (already stored, wrong id, or the
// invoice vanished under us), not a SQL failure setSQLValue_async_result would report.
// ExecuteNonQueryAsync returning rows-affected=0 is not an error by itself, so this was
// previously indistinguishable from success — see ADR 0025.
_logger.LogError( _logger.LogError(
"StoreInvoiceDocumentFileAsync: fds__setInvoiceFile updated 0 rows for {Id} (file already stored, or invoice missing), user={User}", "StoreInvoiceDocumentFileAsync: fds__setInvoiceFile updated 0 rows for {Id} (file already stored, or invoice missing), user={User}",
invoice.Id, userAccountId); invoice.Id, userAccountId);
+5 -5
View File
@@ -158,14 +158,14 @@ public class ReminderService : IReminderService
SQL_VarChar("@authuser", userAccountId), SQL_VarChar("@authuser", userAccountId),
new("@file", SqlDbType.VarBinary) { Value = ba } new("@file", SqlDbType.VarBinary) { Value = ba }
}; };
var setResult = await setSQLValue_async_result( // OUTPUT-clause row count, not rows-affected (SET NOCOUNT ON) — see the matching note in
// InvoiceService.StoreInvoiceDocumentFileAsync / ADR 0025.
var stored = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setReminderFile] @Id, @file;", "EXECUTE [dbo].[fds__setReminderFile] @Id, @file;",
Conn, pl, Security: dbSec, options: new FIS_SQLOptions()); Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
if (!setResult.success) return Array.Empty<byte>(); if (!string.IsNullOrEmpty(stored.Exception)) return Array.Empty<byte>();
if (setResult.AffectedRows == 0) if (stored.Count == 0)
{ {
// fds__setReminderFile writes WHERE [file] IS NULL — see the matching comment/ADR 0025
// note in InvoiceService.StoreInvoiceDocumentFileAsync.
_logger.LogError( _logger.LogError(
"StoreReminderDocumentFileAsync: fds__setReminderFile updated 0 rows for {Id} (file already stored, or reminder missing), user={User}", "StoreReminderDocumentFileAsync: fds__setReminderFile updated 0 rows for {Id} (file already stored, or reminder missing), user={User}",
reminder.Id, userAccountId); reminder.Id, userAccountId);