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