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);
byte[]? content = null;
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
// 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);
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
}
string regeneratedName = fdInv.InvoiceRegistration?.getString("DocumentName")
.ne($"Rechnung_{fdInv.Id}.pdf") ?? $"Rechnung_{reportid}.pdf";
file = new FileInfo(Path.Combine(Path.GetTempPath(), regeneratedName));
fileName = SafeDocumentFileName(
fdInv.InvoiceRegistration?.getString("DocumentName") ?? "", $"Rechnung_{fdInv.Id.ne(reportid)}.pdf");
mimeType = "application/pdf";
}
_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")
return await FileContentResultAsync(content!, file.MimeType(), file.Name);
return await FileContentResultAsync(content, mimeType, fileName);
return await PdfPreviewAsync("inv/rdoc", reportid, async () =>
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)
{
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`
(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
four to callers with the same empty array. The 0-rows case needed switching
from `setSQLValue_async` (reports success once `ExecuteNonQueryAsync`
completes without throwing, regardless of rows affected) to
`setSQLValue_async_result`, whose `AffectedRows` makes a no-op `UPDATE ...
WHERE [file] IS NULL` distinguishable from an actual write — this is what
made the original incident's failure invisible to every log.
four to callers with the same empty array. The 0-rows case is detected via
the procs' existing `OUTPUT inserted.[Id]` result set (read with
`getSQLDatatable_async`; empty ⇒ the `UPDATE ... WHERE [file] IS NULL`
matched nothing). Rows-affected is **not** usable for this: both procs run
`SET NOCOUNT ON`, so `ExecuteNonQuery` returns -1 whether or not the
`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
+7 -8
View File
@@ -447,17 +447,16 @@ public class InvoiceService : IInvoiceService
SQL_VarChar("@Id", invoice.Id),
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;",
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
if (!setResult.success) return Array.Empty<byte>();
if (setResult.AffectedRows == 0)
if (!string.IsNullOrEmpty(stored.Exception)) return Array.Empty<byte>();
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(
"StoreInvoiceDocumentFileAsync: fds__setInvoiceFile updated 0 rows for {Id} (file already stored, or invoice missing), user={User}",
invoice.Id, userAccountId);
+5 -5
View File
@@ -158,14 +158,14 @@ public class ReminderService : IReminderService
SQL_VarChar("@authuser", userAccountId),
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;",
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
if (!setResult.success) return Array.Empty<byte>();
if (setResult.AffectedRows == 0)
if (!string.IsNullOrEmpty(stored.Exception)) return Array.Empty<byte>();
if (stored.Count == 0)
{
// fds__setReminderFile writes WHERE [file] IS NULL — see the matching comment/ADR 0025
// note in InvoiceService.StoreInvoiceDocumentFileAsync.
_logger.LogError(
"StoreReminderDocumentFileAsync: fds__setReminderFile updated 0 rows for {Id} (file already stored, or reminder missing), user={User}",
reminder.Id, userAccountId);