Add Azure Blob Storage archive & email safety net

- Add AzureBlobStorageService, DocumentArchiveSyncService, and related config for secondary PDF archiving of invoices/reminders
- Add SQL procs and schema changes for archive backfill
- Update invoice/reminder services to upload PDFs to blob storage
- Add telemetry counters and unit tests for blob storage/archive logic
- Add Fuchs:Email:OverrideRecipient config and enforce dev/test email redirect in ProcessWebComService, with tests
- Improve JS date parsing (German formats), stricter JSON date detection
- Increase widget SQL timeouts, update dependencies, docs, and project files
This commit is contained in:
Stefan
2026-07-03 10:15:04 +02:00
parent 3035ef1719
commit c3395bc83c
47 changed files with 1723 additions and 101 deletions
+175
View File
@@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Fuchs.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Tests for the Azure Blob Storage secondary-archive service. Covers the
/// disabled/unconfigured path (no client), the empty-content guard, successful
/// uploads (routing to the correct per-category container and blob name), an
/// upload that throws (must be swallowed, never break invoice/reminder storage),
/// and the emitted telemetry counters.
///
/// Azure.Storage.Blobs clients (BlobServiceClient/BlobContainerClient/BlobClient)
/// expose a protected parameterless constructor and virtual members specifically
/// to support mocking with Moq — see Azure SDK unit-testing guidance.
/// </summary>
public class AzureBlobStorageServiceTests
{
private const string FuchsMeterName = "Fuchs.Intranet";
private static AzureBlobStorageSettings CreateSettings() => new()
{
Enabled = true,
InvoiceContainer = "test-invoices",
ReminderContainer = "test-reminders"
};
private static (Mock<BlobServiceClient> service, Mock<BlobContainerClient> container, Mock<BlobClient> blob)
CreateMockedClientChain()
{
var blobClientMock = new Mock<BlobClient>();
var containerClientMock = new Mock<BlobContainerClient>();
containerClientMock.Setup(c => c.GetBlobClient(It.IsAny<string>())).Returns(blobClientMock.Object);
containerClientMock
.Setup(c => c.CreateIfNotExistsAsync(
It.IsAny<PublicAccessType>(), It.IsAny<IDictionary<string, string>>(),
It.IsAny<BlobContainerEncryptionScopeOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Response<BlobContainerInfo>)null!);
var serviceClientMock = new Mock<BlobServiceClient>();
serviceClientMock.Setup(s => s.GetBlobContainerClient(It.IsAny<string>())).Returns(containerClientMock.Object);
return (serviceClientMock, containerClientMock, blobClientMock);
}
// ── Disabled / unconfigured (feature-flag-off) ─────────────────────────────
[Fact]
public async Task UploadInvoicePdfAsync_NoClient_ReturnsNullWithoutThrowing()
{
var svc = new AzureBlobStorageService(client: null, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.UploadInvoicePdfAsync("INV1", "Rechnung_INV1.pdf", new byte[] { 1, 2, 3 });
Assert.Null(result);
}
// ── Boundary condition: empty content ──────────────────────────────────────
[Fact]
public async Task UploadReminderPdfAsync_EmptyContent_ReturnsNullWithoutCallingClient()
{
var (service, container, blob) = CreateMockedClientChain();
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.UploadReminderPdfAsync("REM1", "Zahlungserinnerung_REM1.pdf", Array.Empty<byte>());
Assert.Null(result);
container.Verify(c => c.CreateIfNotExistsAsync(
It.IsAny<PublicAccessType>(), It.IsAny<IDictionary<string, string>>(),
It.IsAny<BlobContainerEncryptionScopeOptions>(), It.IsAny<CancellationToken>()), Times.Never);
blob.Verify(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
}
// ── Success path: invoice routing + counter ─────────────────────────────────
[Fact]
public async Task UploadInvoicePdfAsync_ClientSucceeds_ReturnsBlobUriAndUsesInvoiceContainer()
{
var expectedUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf");
var (service, container, blob) = CreateMockedClientChain();
blob.Setup(b => b.Uri).Returns(expectedUri);
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Response<BlobContentInfo>)null!);
long delta = 0;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.uploads")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
listener.Start();
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.UploadInvoicePdfAsync("INV1", "Rechnung_INV1.pdf", new byte[] { 1, 2, 3, 4 });
Assert.Equal(expectedUri, result);
service.Verify(s => s.GetBlobContainerClient("test-invoices"), Times.Once);
container.Verify(c => c.GetBlobClient("INV1/Rechnung_INV1.pdf"), Times.Once);
blob.Verify(b => b.UploadAsync(It.IsAny<Stream>(), true, It.IsAny<CancellationToken>()), Times.Once);
Assert.True(delta >= 1, "fuchs.blobstorage.uploads counter should have been incremented on a successful upload.");
}
// ── Success path: reminder routing (different container) ──────────────────
[Fact]
public async Task UploadReminderPdfAsync_ClientSucceeds_UsesReminderContainer()
{
var (service, container, blob) = CreateMockedClientChain();
blob.Setup(b => b.Uri).Returns(new Uri("https://test.blob.core.windows.net/test-reminders/REM2/Zahlungserinnerung_REM2.pdf"));
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Response<BlobContentInfo>)null!);
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.UploadReminderPdfAsync("REM2", "Zahlungserinnerung_REM2.pdf", new byte[] { 5, 6 });
Assert.NotNull(result);
service.Verify(s => s.GetBlobContainerClient("test-reminders"), Times.Once);
container.Verify(c => c.GetBlobClient("REM2/Zahlungserinnerung_REM2.pdf"), Times.Once);
}
// ── Boundary condition: blank filename falls back to "{id}.pdf" ───────────
[Fact]
public async Task UploadInvoicePdfAsync_BlankFileName_FallsBackToIdPdfBlobName()
{
var (service, container, blob) = CreateMockedClientChain();
blob.Setup(b => b.Uri).Returns(new Uri("https://test.blob.core.windows.net/test-invoices/INV3/INV3.pdf"));
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Response<BlobContentInfo>)null!);
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.UploadInvoicePdfAsync("INV3", " ", new byte[] { 7 });
Assert.NotNull(result);
container.Verify(c => c.GetBlobClient("INV3/INV3.pdf"), Times.Once);
}
// ── Failure path: client throws, must not propagate ────────────────────────
[Fact]
public async Task UploadReminderPdfAsync_ClientThrows_ReturnsNullAndIncrementsFailedCounter()
{
var (service, container, blob) = CreateMockedClientChain();
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException("simulated storage failure"));
long delta = 0;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.uploads.failed")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
listener.Start();
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.UploadReminderPdfAsync("REM3", "Zahlungserinnerung_REM3.pdf", new byte[] { 9, 9 });
Assert.Null(result);
Assert.True(delta >= 1, "fuchs.blobstorage.uploads.failed counter should have been incremented on a failed upload.");
}
}
@@ -0,0 +1,59 @@
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Tests for <see cref="DocumentArchiveSyncService"/> — the one-shot startup backfill for the
/// Azure Blob Storage secondary archive. Only the disabled/feature-flag-off fast path is
/// unit-testable without a real SQL Server connection (the enabled path drives
/// fds__getInvoiceFiles_ForBlobArchive / fds__getReminderFiles_ForBlobArchive against
/// fds__invoices / fds__reminder); the enabled path is exercised manually/in integration
/// environments, consistent with InvoiceService/ReminderService which are likewise DB-bound
/// and have no unit tests of their SQL-calling members.
/// </summary>
public class DocumentArchiveSyncServiceTests
{
private static Fuchs_intranet CreateIntranet() =>
new(new ConfigurationBuilder().Build());
[Fact]
public async Task ExecuteAsync_FeatureDisabled_NeverTouchesBlobStorageAndCompletesImmediately()
{
var blobStorage = new Mock<IBlobStorageService>(MockBehavior.Strict);
var settings = Options.Create(new AzureBlobStorageSettings { Enabled = false });
using var service = new DocumentArchiveSyncService(
CreateIntranet(), blobStorage.Object, settings, NullLogger<DocumentArchiveSyncService>.Instance);
await service.StartAsync(CancellationToken.None);
await service.StopAsync(CancellationToken.None);
blobStorage.VerifyNoOtherCalls();
}
[Fact]
public async Task ExecuteAsync_FeatureDisabled_DoesNotThrowEvenWithUnconfiguredIntranet()
{
// The disabled fast path must return before any SQL access is attempted, so an
// intranet instance with no real connection string configured is still safe to use.
var blobStorage = new Mock<IBlobStorageService>(MockBehavior.Strict);
var settings = Options.Create(new AzureBlobStorageSettings { Enabled = false });
using var service = new DocumentArchiveSyncService(
CreateIntranet(), blobStorage.Object, settings, NullLogger<DocumentArchiveSyncService>.Instance);
var exception = await Record.ExceptionAsync(async () =>
{
await service.StartAsync(CancellationToken.None);
await service.StopAsync(CancellationToken.None);
});
Assert.Null(exception);
}
}
+206
View File
@@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using Fuchs.Services;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Tests for <see cref="DocumentMetadataBuilder"/> — the per-blob metadata projection used by the
/// Azure Blob Storage archive (see <see cref="AzureBlobStorageService"/> and
/// <see cref="DocumentArchiveSyncService"/>). Covers the "skip if absent from the row, keep empty
/// string if present-but-empty" contract, case-insensitive column lookup (SQL rows are frequently
/// lower-cased), and value stringification (DateTime round-trip formatting, DBNull/null handling).
/// </summary>
public class DocumentMetadataBuilderTests
{
// ── Field presence contract ──────────────────────────────────────────────
[Fact]
public void Build_FieldPresentWithValue_IncludesStringifiedValue()
{
var row = new Dictionary<string, object?> { ["InvoiceId"] = "INV-42" };
var metadata = DocumentMetadataBuilder.Build(row, new[] { "InvoiceId" });
Assert.Equal("INV-42", metadata["InvoiceId"]);
}
[Fact]
public void Build_FieldAbsentFromRow_SkipsFieldEntirely()
{
// Reminders have no file_guid column — must be skipped, not stored as empty.
var row = new Dictionary<string, object?> { ["Id"] = "REM1" };
var metadata = DocumentMetadataBuilder.Build(row, new[] { "Id", "file_guid" });
Assert.True(metadata.ContainsKey("Id"));
Assert.False(metadata.ContainsKey("file_guid"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void Build_FieldPresentButNullOrEmpty_StoresEmptyStringRatherThanSkipping(object? value)
{
var row = new Dictionary<string, object?> { ["InvoiceTitle"] = value };
var metadata = DocumentMetadataBuilder.Build(row, new[] { "InvoiceTitle" });
Assert.True(metadata.ContainsKey("InvoiceTitle"));
Assert.Equal("", metadata["InvoiceTitle"]);
}
[Fact]
public void Build_FieldPresentButDBNull_StoresEmptyString()
{
var row = new Dictionary<string, object?> { ["InvoiceTitle"] = DBNull.Value };
var metadata = DocumentMetadataBuilder.Build(row, new[] { "InvoiceTitle" });
Assert.True(metadata.ContainsKey("InvoiceTitle"));
Assert.Equal("", metadata["InvoiceTitle"]);
}
// ── Case-insensitive lookup ──────────────────────────────────────────────
[Theory]
[InlineData("invoiceid")]
[InlineData("INVOICEID")]
[InlineData("InVoIcEId")]
public void Build_CaseInsensitiveRowKey_MatchesConfiguredFieldName(string rowKey)
{
var row = new Dictionary<string, object?> { [rowKey] = "INV-7" };
var metadata = DocumentMetadataBuilder.Build(row, new[] { "InvoiceId" });
Assert.Equal("INV-7", metadata["InvoiceId"]);
}
// ── Value stringification ────────────────────────────────────────────────
[Fact]
public void Build_DateTimeValue_FormatsAsRoundTripString()
{
var dt = new DateTime(2026, 3, 14, 9, 30, 0, DateTimeKind.Utc);
var row = new Dictionary<string, object?> { ["DateCreated"] = dt };
var metadata = DocumentMetadataBuilder.Build(row, new[] { "DateCreated" });
Assert.Equal(dt.ToString("O"), metadata["DateCreated"]);
}
[Theory]
[InlineData(42, "42")]
[InlineData(true, "True")]
public void Build_NonStringValue_UsesToString(object value, string expected)
{
var row = new Dictionary<string, object?> { ["Version"] = value };
var metadata = DocumentMetadataBuilder.Build(row, new[] { "Version" });
Assert.Equal(expected, metadata["Version"]);
}
[Fact]
public void Build_GuidValue_UsesDefaultGuidFormat()
{
var guid = Guid.Parse("11111111-1111-1111-1111-111111111111");
var row = new Dictionary<string, object?> { ["file_guid"] = guid };
var metadata = DocumentMetadataBuilder.Build(row, new[] { "file_guid" });
Assert.Equal(guid.ToString(), metadata["file_guid"]);
}
// ── Field-list edge cases ────────────────────────────────────────────────
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Build_WhitespaceFieldName_IsSkipped(string field)
{
var row = new Dictionary<string, object?> { ["Id"] = "X1" };
var metadata = DocumentMetadataBuilder.Build(row, new[] { field, "Id" });
Assert.Single(metadata);
Assert.Equal("X1", metadata["Id"]);
}
[Fact]
public void Build_EmptyFieldsList_ReturnsEmptyDictionary()
{
var row = new Dictionary<string, object?> { ["Id"] = "X1" };
var metadata = DocumentMetadataBuilder.Build(row, Array.Empty<string>());
Assert.Empty(metadata);
}
[Fact]
public void Build_EmptyRow_SkipsAllConfiguredFields()
{
var row = new Dictionary<string, object?>();
var metadata = DocumentMetadataBuilder.Build(row, new[] { "Id", "Version", "InvoiceId" });
Assert.Empty(metadata);
}
[Fact]
public void Build_DuplicateFieldNamesInFieldList_ProducesSingleEntry()
{
var row = new Dictionary<string, object?> { ["Id"] = "X1" };
var metadata = DocumentMetadataBuilder.Build(row, new[] { "Id", "Id" });
Assert.Single(metadata);
Assert.Equal("X1", metadata["Id"]);
}
// ── Realistic per-document-type row shapes ───────────────────────────────
[Fact]
public void Build_InvoiceRowShape_ProjectsAllConfiguredInvoiceFieldsAndSkipsReminderOnlyColumn()
{
var row = new Dictionary<string, object?>
{
["Id"] = "INV1",
["Version"] = 2,
["InvoiceId"] = "R-2026-001",
["InvoiceTitle"] = "Rechnung",
["DocumentName"] = "Rechnung_INV1.pdf",
["file_guid"] = Guid.Parse("11111111-1111-1111-1111-111111111111")
};
var fields = new[] { "Id", "Version", "InvoiceId", "InvoiceTitle", "InvId", "DocumentName", "file_guid" };
var metadata = DocumentMetadataBuilder.Build(row, fields);
Assert.Equal("INV1", metadata["Id"]);
Assert.Equal("2", metadata["Version"]);
Assert.Equal("R-2026-001", metadata["InvoiceId"]);
Assert.Equal("Rechnung", metadata["InvoiceTitle"]);
Assert.Equal("Rechnung_INV1.pdf", metadata["DocumentName"]);
Assert.Equal("11111111-1111-1111-1111-111111111111", metadata["file_guid"]);
Assert.False(metadata.ContainsKey("InvId"), "Invoices have no InvId column — must be skipped, not empty.");
}
[Fact]
public void Build_ReminderRowShape_SkipsInvoiceOnlyColumns()
{
var row = new Dictionary<string, object?>
{
["Id"] = "REM1",
["Version"] = 0,
["InvId"] = "INV1",
["DocumentName"] = "Zahlungserinnerung_REM1.pdf"
};
var fields = new[] { "Id", "Version", "InvoiceId", "InvoiceTitle", "InvId", "DocumentName", "file_guid" };
var metadata = DocumentMetadataBuilder.Build(row, fields);
Assert.Equal("REM1", metadata["Id"]);
Assert.Equal("0", metadata["Version"]);
Assert.Equal("INV1", metadata["InvId"]);
Assert.Equal("Zahlungserinnerung_REM1.pdf", metadata["DocumentName"]);
Assert.False(metadata.ContainsKey("InvoiceId"));
Assert.False(metadata.ContainsKey("InvoiceTitle"));
Assert.False(metadata.ContainsKey("file_guid"));
}
}
+1 -1
View File
@@ -9,7 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+132 -2
View File
@@ -1,11 +1,13 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Newtonsoft.Json.Linq;
@@ -53,7 +55,7 @@ public class ProcessWebComServiceTests
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true)
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true, string? overrideRecipient = null)
{
var settings = Options.Create(new ProcessWebComSettings
{
@@ -62,10 +64,15 @@ public class ProcessWebComServiceTests
AccountId = "acct",
Token = "tok"
});
var emailSettings = Options.Create(new FuchsEmailSettings
{
OverrideRecipient = overrideRecipient
});
return new ProcessWebComService(
NullLogger<ProcessWebComService>.Instance,
intranet: null!,
settings,
emailSettings,
new StubHttpClientFactory(handler));
}
@@ -122,6 +129,129 @@ public class ProcessWebComServiceTests
Assert.Equal(0, handler.CallCount);
}
// ── Dev/test recipient override safety net ─────────────────────────────────
[Fact]
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
{
var handler = new StubHandler(HttpStatusCode.OK);
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
bool result = await svc.SendEmailAsync("inv_ov1", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
Assert.True(result);
Assert.Equal(1, handler.CallCount);
var json = JObject.Parse(handler.LastRequestBody!);
Assert.Equal("dev-inbox@example.test", json["recipient"]!.ToString());
}
[Fact]
public async Task SendEmailAsync_OverrideRecipientSet_SubjectRetainsOriginalRecipientForTraceability()
{
var handler = new StubHandler(HttpStatusCode.OK);
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "<p>hi</p>", "realcustomer@example.de", "Kunde");
var json = JObject.Parse(handler.LastRequestBody!);
string subject = json["subject"]!.ToString();
Assert.Contains("realcustomer@example.de", subject);
Assert.Contains("Rechnung 123", subject);
}
[Fact]
public async Task SendEmailAsync_OverrideRecipientSet_InvalidOriginalAddressIsStillRedirected()
{
var handler = new StubHandler(HttpStatusCode.OK);
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
bool result = await svc.SendEmailAsync("inv_ov3", "Subject", "<p>hi</p>", "not-an-email", "Kunde");
Assert.True(result);
Assert.Equal(1, handler.CallCount);
var json = JObject.Parse(handler.LastRequestBody!);
Assert.Equal("dev-inbox@example.test", json["recipient"]!.ToString());
}
[Fact]
public async Task SendEmailAsync_NoOverrideConfigured_SendsToOriginalRecipient()
{
var handler = new StubHandler(HttpStatusCode.OK);
var svc = CreateService(handler);
await svc.SendEmailAsync("inv_ov4", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
var json = JObject.Parse(handler.LastRequestBody!);
Assert.Equal("realcustomer@example.de", json["recipient"]!.ToString());
}
// ── Override enforcement across every real appsettings*.json environment ───
// Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core
// layers them (base file + optional environment-specific overlay). These files
// are copied into this test assembly's output directory via the Fuchs project
// reference (Microsoft.NET.Sdk.Web auto-includes appsettings*.json as Content
// with CopyToOutputDirectory). This proves the safety net holds for whichever
// environment/appsettings file ends up "active" at runtime - not just a
// hand-typed literal - so a future edit that silently breaks the override key
// or its value in any appsettings*.json file would fail this test.
//
// Note: this API has a single "recipient" field - there is no distinct
// to/cc/bcc concept anywhere in the codebase (see ProcessWebComService.
// SendEmailAsync / payload.recipient). "to/cc/bcc cleared" is therefore fully
// satisfied by asserting that field no longer carries the original recipient
// once an override is configured.
public static IEnumerable<object[]> AppSettingsEnvironments()
{
yield return new object[] { "" }; // base appsettings.json only ("Production"-like default)
foreach (var file in Directory.EnumerateFiles(AppContext.BaseDirectory, "appsettings.*.json"))
{
string[] parts = Path.GetFileName(file).Split('.');
if (parts.Length == 3 && parts[0] == "appsettings" && parts[2] == "json")
yield return new object[] { parts[1] };
}
}
private static string ResolveOverrideRecipientForEnvironment(string environmentName)
{
var builder = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false);
if (!string.IsNullOrEmpty(environmentName))
builder.AddJsonFile($"appsettings.{environmentName}.json", optional: true, reloadOnChange: false);
return builder.Build()["Fuchs:Email:OverrideRecipient"] ?? "";
}
[Theory]
[MemberData(nameof(AppSettingsEnvironments))]
public async Task SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured(string environmentName)
{
string overrideRecipient = ResolveOverrideRecipientForEnvironment(environmentName);
var handler = new StubHandler(HttpStatusCode.OK);
var svc = CreateService(handler, overrideRecipient: overrideRecipient);
const string originalRecipient = "realcustomer@tenant-owner.example";
bool result = await svc.SendEmailAsync(
"env_" + (string.IsNullOrEmpty(environmentName) ? "base" : environmentName),
"Subject", "<p>hi</p>", originalRecipient, "Kunde");
Assert.True(result);
Assert.Equal(1, handler.CallCount);
var json = JObject.Parse(handler.LastRequestBody!);
string sentRecipient = json["recipient"]!.ToString();
if (!string.IsNullOrWhiteSpace(overrideRecipient))
{
// Override configured for this environment: the real recipient (to/cc/bcc)
// must be fully cleared and replaced by the override - never leaked.
Assert.Equal(overrideRecipient, sentRecipient);
Assert.NotEqual(originalRecipient, sentRecipient);
}
else
{
// No override configured for this environment: real recipient is used normally.
Assert.Equal(originalRecipient, sentRecipient);
}
}
// ── Attachment payload contract ────────────────────────────────────────────
[Fact]
public async Task SendEmailAsync_WithAttachment_EmbedsBase64InPayload()