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