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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user