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