Fix invoice-image code-review findings: aspect ratio, whitelist config, stale refs

- MigraDocExtensions: fix a bug where capping an oversized explicit
  width+height image re-derived the other dimension from the image's
  native pixel ratio instead of scaling both sides uniformly, silently
  distorting a deliberately non-native-ratio (e.g. stretched) image.
- InvoiceService: derive the mfr-portal image whitelist prefix from the
  existing Fds:MFR_host config setting (via MFRClientConfig.RestRoot)
  instead of a second hardcoded literal, so it can't drift from the
  actual configured host; parse <img> sources with HtmlAgilityPack
  (already used by the PDF's own HTML converter) instead of a regex, so
  a decoy attribute like data-src can't be picked up in place of the
  real src; dedup fetch URLs with a HashSet instead of O(n^2) List
  scanning; reuse FuchsPdf.MigraDocFilenameFromByteArray instead of
  re-spelling the "base64:" prefix.
- Fix a stale log message and comment left over from the
  GenerateInvoicePdf -> GenerateInvoicePdfAsync rename, and update
  INVOICE_LIFECYCLE.md's references to the old sync method name.
- Add tests for the config-derived whitelist, a decoy-attribute src,
  and the 20-image-per-invoice fetch cap boundary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 21:03:09 +02:00
co-authored by Claude Sonnet 5
parent c182d4fd69
commit 406c6f2860
6 changed files with 146 additions and 47 deletions
+72 -13
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
@@ -17,8 +18,9 @@ namespace Fuchs.Tests;
/// <summary>
/// Exercises <see cref="InvoiceService.ResolveInlineImagesAsync"/> — the whitelist-restricted image
/// fetch that lets invoice-editor picture links (mfr portal only) appear in the rendered PDF
/// (ADR 0018). Covers the whitelist boundary, fetch failure modes (each must just drop that one
/// image, never throw), and the per-invoice fetch cap.
/// (ADR 0018). Covers the whitelist boundary (derived from the <c>Fds:MFR_host</c> config entry,
/// not a second hardcoded host), fetch failure modes (each must just drop that one image, never
/// throw), and the per-invoice fetch cap.
/// </summary>
public class InvoiceInlineImageTests
{
@@ -41,13 +43,20 @@ public class InvoiceInlineImageTests
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
private static InvoiceService MakeService(HttpMessageHandler handler) => new(
private static IConfiguration TestConfig(string mfrHost = "portal.mobilefieldreport.com") =>
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Fds:MFR_host"] = mfrHost
}).Build();
private static InvoiceService MakeService(HttpMessageHandler handler, string mfrHost = "portal.mobilefieldreport.com") => new(
new Fuchs_intranet(new ConfigurationBuilder().Build()),
Mock.Of<IPdfService>(),
Mock.Of<IBlobStorageService>(),
Mock.Of<IEventService>(),
Mock.Of<IERechnungService>(),
new StubFactory(handler),
TestConfig(mfrHost),
NullLogger<InvoiceService>.Instance);
private static FdsInvoiceData InvoiceWithDesc(params string[] descs)
@@ -63,15 +72,25 @@ public class InvoiceInlineImageTests
private static HttpResponseMessage ImageResponse(byte[] bytes) =>
new(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) { Headers = { ContentType = new("image/png") } } };
[Fact]
public void AllowedInlineImagePrefix_DerivedFromConfiguredMfrHost_NotHardcodedSecondTime()
{
var svc = MakeService(new StubHandler(_ => ImageResponse(Array.Empty<byte>())),
mfrHost: "other-mfr-host.example.com");
Assert.Equal("https://other-mfr-host.example.com/mfr/", svc.AllowedInlineImagePrefix);
}
[Fact]
public async Task ResolveInlineImagesAsync_WhitelistedUrl_ReturnsBase64DataForOriginalSrc()
{
byte[] bytes = { 1, 2, 3, 4 };
var handler = new StubHandler(_ => ImageResponse(bytes));
string url = InvoiceService.AllowedInlineImagePrefix + "pic1.png";
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "pic1.png";
var invoice = InvoiceWithDesc($"<p>Before</p><img src=\"{url}\"><p>After</p>");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.True(result.ContainsKey(url));
Assert.Equal("base64:" + Convert.ToBase64String(bytes), result[url]);
@@ -94,6 +113,25 @@ public class InvoiceInlineImageTests
Assert.Empty(handler.RequestedUrls);
}
[Fact]
public async Task ResolveInlineImagesAsync_DecoyAttributeNextToRealSrc_FetchesOnlyRealSrc()
{
// A description containing something like data-src alongside the real src must never let
// the decoy be picked up in place of (or in addition to) the actual src attribute.
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var svc = MakeService(handler);
string real = svc.AllowedInlineImagePrefix + "real.png";
string decoy = svc.AllowedInlineImagePrefix + "decoy.png";
var invoice = InvoiceWithDesc($"<img data-src=\"{decoy}\" src=\"{real}\">");
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Single(result);
Assert.True(result.ContainsKey(real));
Assert.False(result.ContainsKey(decoy));
Assert.DoesNotContain(decoy, handler.RequestedUrls);
}
[Fact]
public async Task ResolveInlineImagesAsync_NoImages_SkipsHttpEntirely()
{
@@ -110,10 +148,11 @@ public class InvoiceInlineImageTests
public async Task ResolveInlineImagesAsync_HttpErrorStatus_DropsImageWithoutThrowing()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
string url = InvoiceService.AllowedInlineImagePrefix + "missing.png";
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "missing.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
}
@@ -125,10 +164,11 @@ public class InvoiceInlineImageTests
{
Content = new ByteArrayContent(new byte[] { 1, 2 }) { Headers = { ContentType = new("text/html") } }
});
string url = InvoiceService.AllowedInlineImagePrefix + "notreally.png";
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "notreally.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
}
@@ -137,10 +177,11 @@ public class InvoiceInlineImageTests
public async Task ResolveInlineImagesAsync_ThrowingHandler_DropsImageWithoutThrowing()
{
var handler = new StubHandler(_ => throw new HttpRequestException("network down"));
string url = InvoiceService.AllowedInlineImagePrefix + "boom.png";
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "boom.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
}
@@ -149,12 +190,30 @@ public class InvoiceInlineImageTests
public async Task ResolveInlineImagesAsync_DuplicateSrcAcrossLines_FetchedOnce()
{
var handler = new StubHandler(_ => ImageResponse(new byte[] { 9 }));
string url = InvoiceService.AllowedInlineImagePrefix + "shared.png";
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "shared.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">", $"<img src=\"{url}\">");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Single(result);
Assert.Single(handler.RequestedUrls);
}
[Fact]
public async Task ResolveInlineImagesAsync_MoreThanCapDistinctImages_StopsAtCap()
{
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var svc = MakeService(handler);
// 25 distinct URLs on one invoice; the 20-image-per-invoice cap must stop fetching at 20.
var descs = Enumerable.Range(1, 25)
.Select(i => $"<img src=\"{svc.AllowedInlineImagePrefix}img{i}.png\">")
.ToArray();
var invoice = InvoiceWithDesc(descs);
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Equal(20, result.Count);
Assert.Equal(20, handler.RequestedUrls.Count);
}
}